pax_global_header00006660000000000000000000000064152735570520014525gustar00rootroot0000000000000052 comment=3e18b9376f7a79733eeddf68f555d8a6c8ba8c77 pipx-1.18.16/000077500000000000000000000000001527355705200126635ustar00rootroot00000000000000pipx-1.18.16/.deepsource.toml000066400000000000000000000002701527355705200157730ustar00rootroot00000000000000version = 1 test_patterns = ["tests/**"] [[analyzers]] name = "python" enabled = true [analyzers.meta] runtime_version = "3.x.x" [[transformers]] name = "black" enabled = true pipx-1.18.16/.github/000077500000000000000000000000001527355705200142235ustar00rootroot00000000000000pipx-1.18.16/.github/CONTRIBUTING.md000066400000000000000000000006131527355705200164540ustar00rootroot00000000000000# Contributing to `pipx` Thank you for your interest in contributing to pipx! We expect all contributors to follow the [PSF Code of Conduct][coc]. ## Development Documentation Our [development documentation](https://pipx.pypa.io/latest/contributing.html) explains how to start contributing to `pipx` and how we develop it. [coc]: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md pipx-1.18.16/.github/FUNDING.yaml000066400000000000000000000001071527355705200161770ustar00rootroot00000000000000github: gaborbernat tidelift: "pypi/pipx" thanks_dev: u/gh/gaborbernat pipx-1.18.16/.github/ISSUE_TEMPLATE/000077500000000000000000000000001527355705200164065ustar00rootroot00000000000000pipx-1.18.16/.github/ISSUE_TEMPLATE/bug.md000066400000000000000000000005241527355705200175060ustar00rootroot00000000000000--- name: Bug report about: Report a bug in pipx title: '' labels: bug assignees: '' --- **Issue** Describe the behavior you expect and what you observe instead. **Environment** Provide at least: - OS: - Shell: - Python version and path: - `pipx --version` output: **Output of the failing command** Run the command with `--verbose`: pipx-1.18.16/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000012611527355705200203760ustar00rootroot00000000000000# Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser blank_issues_enabled: true # default contact_links: - name: "๐Ÿ’ฌ pypa/pipx @ Discord" url: https://discord.gg/pypa about: Chat with the devs - name: ๐Ÿคท๐Ÿ’ป๐Ÿคฆ Discourse url: https://discuss.python.org/c/packaging about: | Please ask typical Q&A here: general ideas for Python packaging, questions about structuring projects and so on - name: ๐Ÿ“ PSF Code of Conduct url: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md about: โค Be nice to other members of the community. โ˜ฎ Behave. pipx-1.18.16/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000011551527355705200221350ustar00rootroot00000000000000--- name: Feature request about: Suggest an enhancement for this project title: '' labels: enhancement assignees: '' --- **What's the problem this feature will solve?** **Describe the solution you'd like** **Alternative Solutions** **Additional context** pipx-1.18.16/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000012371527355705200200270ustar00rootroot00000000000000### Thanks for contributing! Work through this checklist before review (the [development documentation](https://pipx.pypa.io/latest/contributing.html) explains how) - [ ] ran the repository checks (`prek run --all-files`) - [ ] wrote descriptive pull request text - [ ] ensured there are test(s) validating the fix - [ ] added a `changelog.d/+topic.type.rst` fragment for a user-facing change, or applied `skip news` for maintenance work - [ ] updated/extended the documentation - [ ] ran tests on Python 3.11 and 3.15 (`tox run -e 3.11,3.15`) - [ ] linked upstream issues beside any temporary interpreter overrides - [ ] kept the PR in draft until CI passes pipx-1.18.16/.github/SECURITY.md000066400000000000000000000057211527355705200160210ustar00rootroot00000000000000# Security Policy ## Supported Versions | Version | Supported | | ------- | ------------------ | | 1.7.0+ | :white_check_mark: | | < 1.7.0 | :x: | ## Reporting a Vulnerability **Do not open a public issue for a security problem.** Use [GitHub's private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) through [the advisory form](https://github.com/pypa/pipx/security/advisories/new), or the [Tidelift security contact](https://tidelift.com/security), which coordinates the fix and disclosure with us. ## Scope pipx turns what its user asks for into installer and interpreter runs: package specs, recorded metadata, manifests and configuration become [`pip`](https://pip.pypa.io), [`uv`](https://docs.astral.sh/uv/) and `python` invocations, and the resulting environments expose commands on `PATH`. The boundary this policy cares about is between the user who runs pipx and everyone else. pipx trusts the invoking user and everything they control or choose: the command line, environment variables, pipx, pip and uv configuration, `PIPX_HOME` and the metadata in it, the packages, URLs, manifests, snapshots and lock files they pass, the package indexes they configure, and the interpreters they select. Packages run code while they build and after they install; that is how Python packaging works and not a pipx vulnerability. **In scope** - Files another local account can write, such as in a shared `/tmp`, changing what pipx installs, runs or exposes when the user did not name them. pipx does not look up the current directory or its parents for configuration or tool selection (see [Project scope](../docs/explanation/scope.rst)), so a lookup there is a bug in pipx. - pipx dropping or replacing the index, source or backend the user recorded, so a later install resolves the package somewhere the user did not choose. - Files and directories pipx creates with wider permissions than intended, for example logs or metadata that hold credentials. - Documented instructions that, followed as written, let another local account run code as the user. **Out of scope** - An attacker who already controls the user's command line, environment, configuration, `PIPX_HOME` or executables. - A malicious or compromised package, package index, manifest or repository the user chose to use. Report malware on [PyPI](https://pypi.org) to [PyPI security](https://pypi.org/security/). - Behavior that `pip`, `uv` or [CPython](https://github.com/python/cpython) document and that pipx uses as documented. Report those to the project that owns them; pipx mitigates where it can. - Credentials that appear only in the user's own terminal output. - Test code, unreleased code on `main`, and Python versions pipx no longer supports. If you cannot tell which side a finding falls on, report it privately and we will assess it. pipx-1.18.16/.github/config.yml000066400000000000000000000000251527355705200162100ustar00rootroot00000000000000rtd: project: pipx pipx-1.18.16/.github/dependabot.yaml000066400000000000000000000011031527355705200172070ustar00rootroot00000000000000version: 2 multi-ecosystem-groups: dependencies: schedule: interval: weekly day: tuesday labels: - dependencies - skip news updates: - package-ecosystem: github-actions directory: / patterns: - '*' multi-ecosystem-group: dependencies labels: - dependencies - skip news cooldown: default-days: 7 - package-ecosystem: pre-commit directory: / patterns: - '*' multi-ecosystem-group: dependencies labels: - dependencies - skip news cooldown: default-days: 7 pipx-1.18.16/.github/release.yml000066400000000000000000000001621527355705200163650ustar00rootroot00000000000000changelog: exclude: authors: - dependabot[bot] - pre-commit-ci[bot] - github-actions[bot] pipx-1.18.16/.github/towncrier-template.rst000066400000000000000000000023561527355705200206100ustar00rootroot00000000000000{% if render_title %} .. _release-{{ versiondata.version|replace('.', '-') }}: {% set title = '`' ~ versiondata.version ~ ' `_ - ' ~ versiondata.date %} {{ title }} {{ top_underline * (title|length) }} {% endif %} {% for section, _ in sections.items() %} {% set underline = underlines[0] %}{% if section %}{{section}} {{ underline * section|length }}{% set underline = underlines[1] %} {% endif %} {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section]%} .. _release-{{ versiondata.version|replace('.', '-') }}-{{ definitions[category]['name']|lower|replace(' ', '-') }}: {{ definitions[category]['name'] }} {{ underline * definitions[category]['name']|length }} {% for text, values in sections[section][category].items() %} {% set visible_values = values|reject('in', text|replace(':pr:', ':issue:'))|list %} - {% if text %}{{ text }}{% if visible_values %} ({{ visible_values|join(', ') }}){% endif %}{% else %}{{ visible_values|join(', ') }}{% endif %} {% endfor %} {% if sections[section][category]|length == 0 %} No user-facing changes. {% else %} {% endif %} {% endfor %} {% else %} No user-facing changes. {% endif %} {% endfor %} pipx-1.18.16/.github/workflows/000077500000000000000000000000001527355705200162605ustar00rootroot00000000000000pipx-1.18.16/.github/workflows/codeql.yaml000066400000000000000000000013411527355705200204120ustar00rootroot00000000000000name: ๐Ÿ›ก๏ธ CodeQL on: workflow_dispatch: push: branches: - "main" pull_request: schedule: - cron: "0 9 * * 2" concurrency: group: codeql-${{ github.ref }} cancel-in-progress: true permissions: {} jobs: analyze: name: ๐Ÿ›ก๏ธ Analyze uses: tox-dev/github-workflows/.github/workflows/codeql.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 permissions: contents: read security-events: write with: languages: '["actions","python"]' paths-ignore: '["docs","scripts/check_distribution_manpage.py","scripts/generate_man.py","scripts/list_test_packages.py","scripts/release.py","scripts/test_packages_support.py","scripts/update_package_cache.py","testdata","tests"]' pipx-1.18.16/.github/workflows/news.yaml000066400000000000000000000025341527355705200201240ustar00rootroot00000000000000name: ๐Ÿ“ Changelog on: pull_request: types: [opened, reopened, synchronize, labeled, unlabeled] permissions: contents: read jobs: changelog: name: ๐Ÿ“ News fragment runs-on: ubuntu-26.04 permissions: contents: read pull-requests: read steps: - name: ๐Ÿ“ฅ Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: ๐Ÿ Install uv uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: version: latest-known python-version: "3.15" enable-cache: false - name: ๐Ÿ“ฆ Install locked release tools uses: tox-dev/github-workflows/install-locked-tools@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 with: groups: release-news venv: ${{ runner.temp }}/tools - name: ๐Ÿ” Validate news fragments uses: tox-dev/github-workflows/validate-news@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 with: additional-lock: pylock.zipapp.toml additional-lock-selection: project additional-matrix-python: ">=3.11,<3.12" additional-matrix-platforms: linux_x86_64 windows_amd64 macos_arm64 additional-matrix-implementations: cpython pipx-1.18.16/.github/workflows/pre-release.yml000066400000000000000000000015521527355705200212120ustar00rootroot00000000000000name: ๐Ÿท๏ธ Pre-release on: workflow_dispatch: inputs: bump: description: Version bump type required: true type: choice options: [auto, major, minor, patch] default: auto workflow_call: inputs: dry-run: description: Assemble the release without pushing, for the pull request gate type: boolean required: true permissions: {} jobs: prepare: name: ๐Ÿท๏ธ Prepare Towncrier release uses: tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 permissions: contents: read pull-requests: read with: bump: ${{ inputs.bump || 'auto' }} dry-run: ${{ inputs.dry-run == true }} annotated: true secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} pipx-1.18.16/.github/workflows/release.yml000066400000000000000000000206661527355705200204350ustar00rootroot00000000000000name: ๐Ÿš€ Release on: push: tags: ["*.*.*"] concurrency: group: release-${{ github.ref }} cancel-in-progress: false permissions: {} jobs: build-package: name: ๐Ÿ—๏ธ Build pipx package runs-on: ubuntu-26.04 permissions: contents: read outputs: artifact-id: ${{ steps.upload.outputs.artifact-id }} steps: - name: ๐Ÿ“ฅ Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: โš™๏ธ Set source timestamp run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >>"$GITHUB_ENV" - name: ๐Ÿ Install uv uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: version: latest-known python-version: "3.15" enable-cache: false - name: ๐Ÿ“ฆ Select locked build backend id: constraints uses: tox-dev/github-workflows/export-build-constraints@13381289334b15375958be7f7438f3c3d860e575 # 6.5.5 - name: ๐Ÿ—๏ธ Build sdist and wheel env: BUILD_CONSTRAINTS: ${{ steps.constraints.outputs.path }} run: uv build --sdist --wheel --out-dir dist --build-constraints "$BUILD_CONSTRAINTS" --require-build-hashes - name: ๐Ÿ“ค Store distributions id: upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dist path: dist/ if-no-files-found: error zipapp: name: ๐Ÿ—๏ธ Build pipx zipapp needs: build-package runs-on: ubuntu-26.04 permissions: contents: read outputs: artifact-id: ${{ steps.upload.outputs.artifact-id }} steps: - name: ๐Ÿ“ฅ Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: โš™๏ธ Set source timestamp run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >>"$GITHUB_ENV" - name: ๐Ÿ“ฉ Download distributions uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.build-package.outputs.artifact-id }} path: ${{ runner.temp }}/dist - name: ๐Ÿ Install uv uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: version: latest-known python-version: "3.15" enable-cache: false - name: ๐Ÿ“ฆ Install locked zipapp tools uses: tox-dev/github-workflows/install-locked-tools@13381289334b15375958be7f7438f3c3d860e575 # 6.5.5 with: groups: zipapp venv: ${{ runner.temp }}/tools # shiv's pip would resolve the runtime dependencies fresh and drop colorama on Linux, so install the hashed runtime # selection that pylock.zipapp.toml locks for each zipapp target, and the built wheel. The lock's environments name # Windows as AMD64, while uv's Windows target reports x86_64, so the copy drops them and uv checks the package # markers and requires-python against each target alone. - name: ๐Ÿ“ฆ Install locked runtime packages env: SITE: ${{ runner.temp }}/zipapp-site TAG: ${{ github.ref_name }} DIST: ${{ runner.temp }}/dist LOCK: ${{ runner.temp }}/zipapp-lock/pylock.zipapp.toml run: | set -euo pipefail unset UV_EXCLUDE_NEWER UV_EXCLUDE_NEWER_PACKAGE mkdir "$(dirname "$LOCK")" sed '/^environments = \[$/,/^\]$/d' pylock.zipapp.toml >"$LOCK" for platform in x86_64-unknown-linux-gnu x86_64-pc-windows-msvc aarch64-apple-darwin; do uv pip install --no-config --preview-features pylock --target "$SITE" --python 3.15 --python-version 3.11 \ --python-platform "$platform" --group project-runtime --only-binary :all: --require-hashes -r "$LOCK" done uv pip install --no-config --target "$SITE" --python 3.15 --no-deps --no-index "$DIST/pipx-$TAG-py3-none-any.whl" - name: ๐Ÿ—๏ธ Build zipapp env: SITE: ${{ runner.temp }}/zipapp-site run: | mkdir "$RUNNER_TEMP/zipapp" shiv --site-packages "$SITE" -c pipx -o "$RUNNER_TEMP/zipapp/pipx.pyz" --reproducible - name: ๐Ÿ“ค Store zipapp id: upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-zipapp path: ${{ runner.temp }}/zipapp/pipx.pyz if-no-files-found: error zipapp-test: name: ๐Ÿงช Test pipx zipapp on ${{ matrix.os }} needs: zipapp runs-on: ${{ matrix.os }} permissions: {} strategy: fail-fast: false matrix: os: [ubuntu-26.04, windows-2025] steps: - name: ๐Ÿ Set up Python 3.11 uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 env: UV_VENV_SEED: "true" with: version: latest-known enable-cache: false activate-environment: true python-version: "3.11" ignore-empty-workdir: true - name: ๐Ÿ“ฉ Download zipapp uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.zipapp.outputs.artifact-id }} path: ${{ runner.temp }}/zipapp - name: ๐Ÿงช Test zipapp by installing black shell: bash run: | set -euo pipefail python "$RUNNER_TEMP/zipapp/pipx.pyz" install black notes: name: ๐Ÿ“ Render GitHub Release notes uses: tox-dev/github-workflows/.github/workflows/release-notes.yaml@13381289334b15375958be7f7438f3c3d860e575 # 6.5.5 permissions: contents: read with: tag: ${{ github.ref_name }} changelog: docs/changelog.rst artifact-url: https://pypi.org/project/pipx/${{ github.ref_name }}/ pypi-publish: name: ๐Ÿš€ Publish to PyPI needs: [build-package, zipapp-test, notes] runs-on: ubuntu-26.04 environment: name: release url: https://pypi.org/project/pipx/${{ github.ref_name }}/ permissions: id-token: write steps: - name: ๐Ÿ“ฉ Download distributions uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.build-package.outputs.artifact-id }} path: dist/ - name: ๐Ÿš€ Publish to PyPI uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: packages-dir: dist release: name: ๐Ÿš€ Publish GitHub Release needs: [build-package, notes, zipapp, pypi-publish] runs-on: ubuntu-26.04 environment: release permissions: contents: write steps: - name: ๐Ÿ Install uv uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: version: latest-known python-version: "3.15" enable-cache: false ignore-empty-workdir: true - name: ๐Ÿ“ฉ Download distributions uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.build-package.outputs.artifact-id }} path: dist/ - name: ๐Ÿ“ฉ Download release notes uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.notes.outputs.artifact-id }} path: ${{ runner.temp }}/release-notes - name: ๐Ÿ“ฉ Download zipapp uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.zipapp.outputs.artifact-id }} path: ${{ runner.temp }}/zipapp - name: ๐Ÿ” Verify PyPI artifacts uses: tox-dev/github-workflows/reconcile-pypi-artifacts@13381289334b15375958be7f7438f3c3d860e575 # 6.5.5 with: project: pipx version: ${{ github.ref_name }} dist: dist - name: ๐Ÿš€ Publish GitHub release uses: tox-dev/github-workflows/publish-github-release@13381289334b15375958be7f7438f3c3d860e575 # 6.5.5 env: GH_TOKEN: ${{ github.token }} with: tag: ${{ github.ref_name }} commit: ${{ github.sha }} title: ${{ github.ref_name }} notes-file: ${{ runner.temp }}/release-notes/release-notes.md assets-directory: ${{ runner.temp }}/zipapp pipx-1.18.16/.github/workflows/scorecard.yaml000066400000000000000000000020001527355705200211010ustar00rootroot00000000000000name: ๐Ÿ›ก๏ธ scorecard on: branch_protection_rule: schedule: - cron: "30 7 * * 2" push: branches: ["main"] workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: {} jobs: analysis: name: ๐Ÿ›ก๏ธ Scorecard analysis if: github.repository == 'pypa/pipx' permissions: actions: read # read workflow runs for the Packaging check checks: read # read check runs for the CI-Tests and SAST checks contents: read # check out and scan the repository id-token: write # authenticate the results published to the scorecard API issues: read # read issue activity for the Maintained check pull-requests: read # read reviews for the Code-Review check security-events: write # upload the SARIF results to code scanning statuses: read # read commit statuses for the CI-Tests check uses: tox-dev/github-workflows/.github/workflows/scorecard.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 pipx-1.18.16/.github/workflows/tests.yml000066400000000000000000000163121527355705200201500ustar00rootroot00000000000000name: ๐Ÿงช tests on: workflow_dispatch: push: branches: - "main" pull_request: types: [opened, synchronize, reopened] schedule: - cron: "43 9 * * *" concurrency: group: tests-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: test: name: ๐Ÿงช test ${{ matrix.py }} - ${{ matrix.os }} runs-on: ${{ matrix.os }} timeout-minutes: 40 strategy: fail-fast: false matrix: os: [ubuntu-26.04] py: ["3.15", "3.15t", "3.14", "3.13", "3.12", "3.11"] include: - os: windows-2025 py: "3.15" - os: xcode-27 py: "3.15" steps: - name: ๐Ÿ“ฅ Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: ๐Ÿ Install uv uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: version: latest-known python-version: "3.15" - name: ๐Ÿ Set up Python ${{ matrix.py }} uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 env: UV_VENV_SEED: "true" with: version: latest-known enable-cache: false activate-environment: true python-version: ${{ matrix.py }} - name: ๐Ÿ Register Python with the Windows launcher if: runner.os == 'Windows' shell: pwsh run: | uv python install "${{ matrix.py }}" $pythonPath = uv python find "${{ matrix.py }}" --system $registryPath = "HKCU:\Software\Python\PythonCore\${{ matrix.py }}" New-Item "$registryPath\InstallPath" -Force -Value (Split-Path $pythonPath) | Out-Null New-ItemProperty "$registryPath\InstallPath" -Name ExecutablePath -Value $pythonPath -Force | Out-Null New-ItemProperty $registryPath -Name SysVersion -Value "${{ matrix.py }}" -Force | Out-Null New-ItemProperty $registryPath -Name SysArchitecture -Value "64bit" -Force | Out-Null py -${{ matrix.py }} -c "import sys; print(sys.executable)" - name: ๐Ÿ“ฆ Install tox run: uv tool install --python-preference only-managed --python 3.15 "tox>=4.45" --with tox-uv - name: ๐Ÿ’พ Cache .pipx_tests/package_cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ github.workspace }}/.pipx_tests/package_cache/${{ matrix.py }} # actions/cache never re-saves an existing key, so key on the package lists to pick up new packages key: pipx-tests-package-cache-${{ runner.os }}-${{ matrix.py }}-${{ hashFiles('testdata/tests_packages/*.txt') }} restore-keys: pipx-tests-package-cache-${{ runner.os }}-${{ matrix.py }}- - name: ๐Ÿ“ฆ Set up test suite run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.py }} - name: ๐Ÿ“ฆ Seed package cache run: tox exec -e ${{ matrix.py }} -- python scripts/update_package_cache.py testdata/tests_packages .pipx_tests/package_cache/ - name: ๐Ÿงช Run test suite run: tox run --skip-pkg-install -e ${{ matrix.py }} timeout-minutes: 30 env: PYTEST_ADDOPTS: "-vv --durations=20" check: name: ๐Ÿ” tox env uses: tox-dev/github-workflows/.github/workflows/check.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 permissions: contents: read with: include: | [ {"env": "type"}, {"env": "pkg_meta"} ] tox-install: tox>=4.45 --with tox-uv man: name: ๐Ÿ“– Build man page runs-on: ubuntu-26.04 steps: - name: ๐Ÿ“ฅ Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: ๐Ÿ Install uv uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: version: latest-known python-version: "3.15" - name: ๐Ÿ“ฆ Install tox run: uv tool install --python-preference only-managed --python 3.15 "tox>=4.45" --with tox-uv - name: ๐Ÿ“– Build man page run: tox run -e man - name: ๐Ÿ“– Show man page run: man -l build/man/pipx.1 zipapp: name: ๐Ÿ—๏ธ Build zipapp runs-on: ubuntu-26.04 steps: - name: ๐Ÿ“ฅ Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # the oldest supported Python consumes the artifact while tox builds it with the same # interpreter as the release workflow, so this job reproduces the release scenario - name: ๐Ÿ Set up Python 3.11 uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 env: UV_VENV_SEED: "true" with: version: latest-known enable-cache: false activate-environment: true python-version: "3.11" - name: ๐Ÿ Install uv uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: version: latest-known python-version: "3.15" - name: ๐Ÿ“ฆ Install tox run: uv tool install --python-preference only-managed --python 3.15 "tox>=4.45" --with tox-uv - name: ๐Ÿ—๏ธ Build zipapp run: tox run -e zipapp - name: ๐Ÿงช Test zipapp by installing black run: | python ./pipx.pyz --version python ./pipx.pyz install black - name: ๐Ÿ“ค Store zipapp uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pipx.pyz path: pipx.pyz zipapp-windows: name: ๐Ÿงช Test zipapp on Windows needs: zipapp runs-on: windows-2025 steps: - name: ๐Ÿ Set up Python 3.11 uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 env: UV_VENV_SEED: "true" with: version: latest-known enable-cache: false activate-environment: true python-version: "3.11" ignore-empty-workdir: true - name: ๐Ÿ“ฉ Download zipapp uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: pipx.pyz - name: ๐Ÿงช Test zipapp by installing black run: | python .\pipx.pyz --version python .\pipx.pyz install black release: name: ๐Ÿท๏ธ Release dry run uses: $/.github/workflows/pre-release.yml permissions: contents: read pull-requests: read with: dry-run: true hooks: name: ๐Ÿงน Hooks under the cooldown uses: tox-dev/github-workflows/.github/workflows/cooled-hooks.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2 permissions: contents: read gate: name: ๐Ÿšฆ Required checks pass if: always() needs: [test, check, man, zipapp, zipapp-windows, release, hooks] runs-on: ubuntu-26.04 steps: - name: ๐Ÿšฆ Require all needed jobs to pass uses: tox-dev/github-workflows/required-checks@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 with: needs: ${{ toJSON(needs) }} pipx-1.18.16/.github/workflows/upgrade-release-tools.yaml000066400000000000000000000013551527355705200233530ustar00rootroot00000000000000name: ๐Ÿ”’ Upgrade release tools on: workflow_dispatch: schedule: - cron: "0 8 * * 2" permissions: {} concurrency: group: upgrade-release-tools cancel-in-progress: false jobs: upgrade: name: ๐Ÿ”€ Upgrade release tools uses: tox-dev/github-workflows/.github/workflows/upgrade-release-tools.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 permissions: contents: read secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: additional-lock: pylock.zipapp.toml additional-lock-selection: project additional-matrix-python: ">=3.11,<3.12" additional-matrix-platforms: linux_x86_64 windows_amd64 macos_arm64 additional-matrix-implementations: cpython pipx-1.18.16/.github/workflows/zizmor.yml000066400000000000000000000011761527355705200203420ustar00rootroot00000000000000name: ๐Ÿ›ก๏ธ GitHub Actions Security Analysis with zizmor on: push: branches: ["main"] pull_request: branches: ["**"] permissions: {} jobs: zizmor: name: ๐Ÿ›ก๏ธ Run zizmor runs-on: ubuntu-26.04 permissions: security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. steps: - name: ๐Ÿ“ฅ Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: ๐Ÿ›ก๏ธ Run zizmor uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0 pipx-1.18.16/.gitignore000066400000000000000000000003551527355705200146560ustar00rootroot00000000000000/.*_cache /build /dist /src/pipx/version.py /noxfile.py /.nox /.tox *.py[co] __pycache__ /site /.coverage* /.pipx_tests /testdata/tests_packages/*.txt /pipx.pyz *.egg-info build *.whl /pipx.1 /docs/_draft_changelog.md /.vscode/ /uv.lock pipx-1.18.16/.pre-commit-config.yaml000066400000000000000000000051141527355705200171450ustar00rootroot00000000000000default_language_version: python: python3.15 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - id: end-of-file-fixer - id: check-added-large-files - id: trailing-whitespace - id: check-merge-conflict - id: check-case-conflict - repo: https://github.com/google/yamlfmt rev: 21ca5323a9c87ee37a434e0ca908efc0a89daa07 # frozen: v0.21.0 hooks: - id: yamlfmt - repo: https://github.com/python-jsonschema/check-jsonschema rev: a76b92851fc9eede9f9bc4073eb55202f34eb0df # frozen: 0.39.0 hooks: - id: check-github-workflows - repo: https://github.com/hukkin/mdformat rev: 82912cdaea4fb830f751504486a7879c70526547 # frozen: 1.0.0 hooks: - id: mdformat args: ["--wrap", "120"] additional_dependencies: - mdformat-mkdocs==5.3.0 - mdformat-gfm==1.0.0 - mdformat-gfm-alerts==2.1.0 - mdformat-footnote==0.1.3 - mdformat-config==0.2.1 - mdformat-front-matters==2.0.0 exclude: ^(docs/changelog\.md|testdata) - repo: https://github.com/tox-dev/pyproject-fmt rev: 50e0f3aee9dc5747a5f173f79a7417403c18dda6 # frozen: v2.30.3 hooks: - id: pyproject-fmt - repo: https://github.com/astral-sh/ruff-pre-commit rev: 25bcf95026b96e2dd3c0aec3090c3d6ef6ca2e6c # frozen: v0.17.3 hooks: - id: ruff-check args: ["--fix", "--unsafe-fixes", "--show-fixes", "--exit-non-zero-on-fix"] - id: ruff-format - repo: https://github.com/henryiii/flake8-lazy rev: 2db69f40494cc912ede966160695ede82eb6ebac # frozen: v0.10.1 hooks: - id: flake8-lazy args: ["--apply=list"] files: ^src/ # ty runs in the `type` tox environment via GitHub Actions, not here: pre-commit.ci cannot install its # heavier dependency set (e.g. GitPython) in its sandbox. - repo: https://github.com/LilSpazJoekp/docstrfmt rev: c1813841673cdcd6cf602dde1edb78a5d5e45235 # frozen: v2.2.1 hooks: - id: docstrfmt files: | (?x) ^docs/changelog[.]rst$ # Only the generated release history. args: ["-l", "120", "--preserve-adornments"] additional_dependencies: ["sphinx==9.2.0"] # codespell reads its configuration from pyproject.toml - repo: https://github.com/codespell-project/codespell rev: 57b21406f092110c18776e39b0bda50d37c945c8 # frozen: v2.4.3 hooks: - id: codespell additional_dependencies: - tomli==2.5.0 exclude: ^testdata ci: autoupdate_schedule: quarterly pipx-1.18.16/.pre-commit-hooks.yaml000066400000000000000000000001721527355705200170220ustar00rootroot00000000000000- id: pipx name: pipx entry: pipx run require_serial: true language: python minimum_pre_commit_version: '2.9.2' pipx-1.18.16/.readthedocs.yml000066400000000000000000000003441527355705200157520ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: {} commands: - curl -LsSf https://astral.sh/uv/install.sh | sh - ~/.local/bin/uv tool install tox --with tox-uv -p 3.15 --managed-python - ~/.local/bin/tox run -e docs pipx-1.18.16/LICENSE000066400000000000000000000020741527355705200136730ustar00rootroot00000000000000MIT License Copyright (c) 2018 Chad Smith and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pipx-1.18.16/changelog.d/000077500000000000000000000000001527355705200150345ustar00rootroot00000000000000pipx-1.18.16/changelog.d/.gitignore000066400000000000000000000000141527355705200170170ustar00rootroot00000000000000!.gitignore pipx-1.18.16/docs/000077500000000000000000000000001527355705200136135ustar00rootroot00000000000000pipx-1.18.16/docs/README.md000066400000000000000000000066261527355705200151040ustar00rootroot00000000000000

# pipx โ€” Install and Run Python Applications in Isolated Environments

image PyPI version OpenSSF Scorecard OpenSSF Best Practices

**Documentation**: **Source Code**: _For comparison to other tools including [pipsi](https://github.com/mitsuhiko/pipsi), see [Comparison to Other Tools](https://pipx.pypa.io/stable/explanation/comparisons.html)._ ## Overview: What is `pipx`? pipx installs and runs end-user applications written in Python. It's roughly similar to macOS's [brew](https://brew.sh), JavaScript's [npx](https://medium.com/@maybekatz/introducing-npx-an-npm-package-runner-55f7d4bd282b), and Linux's [apt](https://wiki.debian.org/Apt). It's closely related to [pip](https://pip.pypa.io). It installs with [uv](https://docs.astral.sh/uv/) when available and `pip` otherwise, but focuses on installing and managing Python packages you run directly from the command line as applications. ### Features `pipx` enables you to - expose CLI entrypoints of packages ("apps") installed to isolated environments with the `install` command, guaranteeing no dependency conflicts and clean uninstalls; - list, upgrade, and uninstall packages you installed with pipx; and - run the latest version of a Python application in a temporary environment with the `run` command. pipx runs with regular user permissions and never calls `sudo pip install`. ## Install pipx ### On macOS ``` brew install pipx pipx ensurepath ``` ### On Linux Install pipx with your distribution's package manager. See the [Linux installation instructions](https://pipx.pypa.io/stable/how-to/install-pipx.html) for commands and alternatives. ``` pipx ensurepath ``` ### On Windows ``` scoop install pipx pipx ensurepath ``` For more detailed installation instructions, see the [full documentation](https://pipx.pypa.io/stable/how-to/install-pipx.html). ## Quick Start Install an application for your user: ``` pipx install pycowsay pycowsay mooo ``` Run an application without installing: ``` pipx run pycowsay moo ``` See the [full documentation](https://pipx.pypa.io/stable/) for more details. ## Contributing Issues and pull requests are welcome. Check out [Contributing](https://pipx.pypa.io/stable/contributing.html) to get started. We expect everyone who interacts with the pipx project via codebase, issue tracker, chat rooms, or otherwise to follow the [PSF Code of Conduct](https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md). pipx-1.18.16/docs/_ext/000077500000000000000000000000001527355705200145525ustar00rootroot00000000000000pipx-1.18.16/docs/_ext/__init__.py000066400000000000000000000000471527355705200166640ustar00rootroot00000000000000"""Project-local Sphinx extensions.""" pipx-1.18.16/docs/_ext/llms_txt.py000066400000000000000000000121721527355705200167750ustar00rootroot00000000000000"""Generate ``llms.txt`` and ``llms-full.txt`` from the documentation tree at build time.""" from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING, Final, Protocol from docutils import nodes if TYPE_CHECKING: from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment from sphinx.util.typing import ExtensionMetadata # (docname prefix, heading) in the order the sections read; a docname's section is its first path segment, so # ``reference`` and ``reference/cli`` both fall under Reference. Pages outside these prefixes (changelog, contributing) # are left out of both files. _SECTIONS: Final[tuple[tuple[str, str], ...]] = ( ("tutorial", "Tutorials"), ("how-to", "How-to guides"), ("reference", "Reference"), ("explanation", "Explanation"), ) def setup(app: Sphinx) -> ExtensionMetadata: app.connect("build-finished", _emit_llms_txt) return {"parallel_read_safe": True, "parallel_write_safe": True} def _emit_llms_txt(app: Sphinx, exception: Exception | None) -> None: # https://llmstxt.org asks a site for a Markdown map of itself; kept by hand the two files drift from the pages they # list, so both come from the built tree, grouped by Diรกtaxis section in nav order if exception is not None or app.builder.name != "html": return env: Final = app.env root: Final[str] = env.config.root_doc base: Final[str] = app.config.html_baseurl.rstrip("/") order: Final[list[str]] = _ordered_docnames(env, root) titles: Final[dict[str, str]] = { docname: env.titles[docname].astext() for docname in [root, *order] if docname in env.titles } summaries: Final[dict[str, str]] = {docname: _summary(env, docname) for docname in titles} def link(docname: str, label: str | None = None) -> str: return f"- [{label or titles[docname]}]({base}/{docname}.html): {summaries.get(docname, '')}" grouped: Final[dict[str, list[str]]] = {key: [] for key, _ in _SECTIONS} # the section landing pages go under "Start here", not among their section's own pages index_pages: Final[set[str]] = {f"{key}/index" for key, _ in _SECTIONS} for docname in order: if (section := docname.split("/", 1)[0]) in grouped and docname not in index_pages: grouped[section].append(docname) project_summary: Final[str] = summaries.get(root, "") out: Final[Path] = Path(app.outdir) (out / "llms.txt").write_text(_render_curated(titles, grouped, project_summary, link), encoding="utf-8") (out / "llms-full.txt").write_text(_render_full(titles, grouped, project_summary, link), encoding="utf-8") def _ordered_docnames(env: BuildEnvironment, root: str) -> list[str]: seen: Final[set[str]] = {root} order: Final[list[str]] = [] stack: Final[list[str]] = list(reversed(env.toctree_includes.get(root, []))) while stack: docname = stack.pop() if docname in seen: continue seen.add(docname) order.append(docname) stack.extend(reversed(env.toctree_includes.get(docname, []))) return order def _summary(env: BuildEnvironment, docname: str) -> str: for paragraph in env.get_doctree(docname).findall(nodes.paragraph): if text := " ".join(paragraph.astext().split()): return text return "" def _render_curated(titles: dict[str, str], grouped: dict[str, list[str]], project_summary: str, link: _Link) -> str: lines: Final[list[str]] = [ f"# {titles.get('index', 'pipx')}", "", f"> {project_summary}", "", ( "The documentation follows the Diรกtaxis framework: tutorials to learn pipx by doing, how-to guides for " "specific tasks, a reference for the CLI and environment, and explanation for how pipx works and how it " "compares to other tools." ), "", "## Start here", ] lines.extend( link(docname, label) for docname, label in ( ("index", "pipx overview"), ("tutorial/index", "Tutorials"), ("how-to/index", "How-to guides"), ("reference/index", "Reference"), ("explanation/index", "Explanation"), ) if docname in titles ) for key, heading in _SECTIONS: lines.extend(["", f"## {heading}", *[link(docname) for docname in grouped[key]]]) return "".join(f"{line}\n" for line in lines) class _Link(Protocol): def __call__(self, docname: str, label: str | None = None) -> str: ... def _render_full(titles: dict[str, str], grouped: dict[str, list[str]], project_summary: str, link: _Link) -> str: lines: Final[list[str]] = [ f"# {titles.get('index', 'pipx')} โ€” full documentation map", "", f"> {project_summary}", "", ( "Every documentation page, grouped by section, with a one-line description. Fetch a page's URL for its " "full content." ), ] for key, heading in _SECTIONS: if pages := grouped[key]: lines.extend(["", f"## {heading}", *[link(docname) for docname in pages]]) return "".join(f"{line}\n" for line in lines) pipx-1.18.16/docs/_static/000077500000000000000000000000001527355705200152415ustar00rootroot00000000000000pipx-1.18.16/docs/_static/custom.css000066400000000000000000000053671527355705200173000ustar00rootroot00000000000000/* Widen furo's content column by ~50% (46em -> 69em). Furo fixes the readable width with the SASS variable $content-width (variables/_layout.scss), compiled into `.content { width: 46em }`, and derives $full-width = $content-width + 2*$content-padding + 2*$sidebar-width = 82em, which centers the layout via the .sidebar-drawer calc and drives the responsive breakpoints. None of these are CSS custom properties, so the only override is to re-set the compiled rules here with the new widths: content-width 69em, content-padded-width 75em, sidebar-width 15em, full-width 105em. */ .content { width: 69em; } /* keep the block centered: .sidebar-drawer eats the left margin around the new $full-width (105em) */ .sidebar-drawer { width: calc((100% - 105em) / 2 + 15em); } /* below the new full width, fold the table of contents into its icon and re-centre (furo did this at 82em) */ @media (max-width: 105em) { .toc-content-icon { display: flex; } .toc-drawer { position: fixed; height: 100vh; top: 0; right: -15em; border-left: 1px solid var(--color-background-muted); } .sidebar-drawer { width: calc((100% - 90em) / 2 + 15em); } } /* center the page once the content column no longer fits beside the navigation (furo did this at 67em) */ @media (max-width: 90em) { .content { margin-left: auto; margin-right: auto; } } /* Furo swaps to the header TOC toggle at 67em. The 105em rule above keeps .toc-content-icon as display:flex at every narrower width, so both icons appear together on mobile. Restore the single header button. */ @media (max-width: 67em) { .toc-content-icon { display: none; } } /* Console prompts render as an accented "โฏ". The reStructuredText source keeps Pygments' literal "$ " prompt so the command after it still gets shell highlighting and sphinx-copybutton can strip it on copy; only the shown glyph and its colour change here. font-size:0 collapses the "$ " text so ::before can stand in; the glyph is sized in rem rather than furo's --code-font-size, whose 81.25% would resolve against the zeroed parent to 0. */ .highlight .gp { font-size: 0; user-select: none; } .highlight .gp::before { content: "โฏ "; font-size: 0.8125rem; } /* Accent the prompt in the pipx brand colour (::before inherits it). furo's pygments sheet colours the prompt grey through higher-specificity "body[data-theme=โ€ฆ]" selectors that also fire in OS-default light, so mirror them to win the accent across explicit-light, explicit-dark, and no-preference states. */ .highlight .gp, body[data-theme="dark"] .highlight .gp, body:not([data-theme="light"]) .highlight .gp { color: var(--color-brand-content); } pipx-1.18.16/docs/_static/logo.svg000066400000000000000000002003701527355705200167240ustar00rootroot00000000000000 pipx-1.18.16/docs/changelog.rst000066400000000000000000004467261527355705200163170ustar00rootroot00000000000000########### Changelog ########### All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog `_, and this project adheres to `Semantic Versioning `_. Towncrier assembles released sections from ``changelog.d`` fragments. Pull request documentation builds show pending fragments below; the tracked released history changes only when the release is prepared. .. towncrier-draft-entries:: Unreleased .. towncrier release notes start .. _release-1-18-16: `1.18.16 `_ - 2026-11-07 =================================================================== .. _release-1-18-16-changes: Changes ------- - ``pipx ensurepath`` no longer claims it added the binary directories to PATH when they were already there; the summary now says so and points at ``--force`` for adding them again (:pr:`2431`) by :user:`AP24110011695` and :user:`gaborbernat`. .. _release-1-18-16-fixed: Fixed ----- - Rebuild shared pip environments created with Python older than 3.10, and refresh existing applications' references to the rebuilt environment before running their installers (:pr:`2437`) by :user:`gaborbernat`. (:issue:`2435`) .. _release-1-18-15: `1.18.15 `_ - 2026-11-06 =================================================================== .. _release-1-18-15-fixed: Fixed ----- - ``pipx pin --skip`` accepts case and punctuation variants of injected package names, with or without their suffix (:pr:`2425`) by :user:`kowellaloo9698-afk` and :user:`gaborbernat`. - ``pipx repair`` now refuses an environment whose ``pipx_metadata.json`` a killed install lost or a newer pipx wrote, as ``pipx reinstall`` and ``pipx reinstall-all`` do, instead of reporting it healthy (:pr:`2423`) by :user:`AP24110011695` and :user:`gaborbernat`. .. _release-1-18-14: `1.18.14 `_ - 2026-11-05 =================================================================== .. _release-1-18-14-fixed: Fixed ----- - ``pipx list --short --include-injected`` now lists injected packages, as ``--pinned --include-injected`` already does (:pr:`2415`) by :user:`Pixeless001` and :user:`gaborbernat`. - ``pipx uninject`` matches package names in any spelling, so ``mixed-case`` finds a package that pip recorded as ``Mixed_Case``, and it refuses the main package however the name is spelled (:pr:`2420`) by :user:`guransh0925` and :user:`gaborbernat`. .. _release-1-18-13: `1.18.13 `_ - 2026-11-04 =================================================================== No user-facing changes. .. _release-1-18-12: `1.18.12 `_ - 2026-11-03 =================================================================== .. _release-1-18-12-security: Security -------- - ``pipx run`` now treats requirements with a version, extras, a marker or a direct URL as packages, and script URLs as downloads, even when the working directory contains a matching file (:pr:`2408`) by :user:`gaborbernat`. .. _release-1-18-11: `1.18.11 `_ - 2026-10-30 =================================================================== .. _release-1-18-11-fixed: Fixed ----- - Fetch fresh standalone Python release data when the cached index is invalid or unreadable (:pr:`2404`) by :user:`hemcharan710-afk` and :user:`gaborbernat`. - ``pipx pin`` now lists the packages it pins without ``--injected-only`` or ``--skip`` (:pr:`2405`) by :user:`hemcharan710-afk` and :user:`gaborbernat`. .. _release-1-18-10: `1.18.10 `_ - 2026-10-29 =================================================================== .. _release-1-18-10-changes: Changes ------- - Error, warning, and help messages now state what pipx found and what to run next, without hedges such as "already seems to be installed" or "Perhaps". Update scripts that match the old message text (:pr:`2376`) by :user:`gaborbernat`. .. _release-1-18-10-fixed: Fixed ----- - A pipx that changed its own environment, through ``pipx upgrade pipx`` or ``pipx reinstall pipx`` for example, no longer crashes with an ``ImportError`` when another pipx command empties the trash while it is still running, such as during the rest of ``pipx upgrade-all``. The copy of its environment it imports from now stays in the trash until the process exits (:pr:`2397`) by :user:`gaborbernat`. - Interrupting a parallel ``pipx reinstall-all`` with Ctrl-C now gives a restored environment back the shared app, man page or completion links another environment took while the rebuild had it in the trash, as a failed rebuild already did (:pr:`2386`) by :user:`gaborbernat`. - On Windows under the uv backend, ``pipx run --refresh`` or ``--no-cache`` of a local script no longer deletes the cached environment while another run of the same script is still executing in it, and neither does the expiry sweep. Such a run builds a throwaway environment for itself instead, and after ``--refresh`` the next run to find the cached one idle rebuilds it (:pr:`2387`) by :user:`gaborbernat`. - On Windows under the uv backend, ``pipx upgrade pipx`` on a pipx that manages itself no longer fails with ``Access is denied`` and rolls back. uv deletes the files it replaces, which Windows refuses for the running ``pipx.exe``, so pipx now moves that launcher to its trash first, where the next pipx command deletes it (:pr:`2396`) by :user:`gaborbernat`. - Where pipx copies launchers instead of linking them, as on Windows without symlink rights, ``pipx reinstall`` and ``pipx install --force`` no longer warn ``Overwriting file`` for the package's own launcher. Windows ``.exe`` launchers embed their build time, so a rebuilt one never matched its old copy byte for byte and every reinstall warned. pipx now recognises its own copy by the interpreter it names and replaces it without a warning; a file another venv or the user put there still gets one (:pr:`2394`) by :user:`gaborbernat`. - With the uv backend and ``UV_LINK_MODE=symlink``, ``pipx uninstall`` now removes the man page and shell completion links it exposed, and ``pipx list`` no longer reports those man pages as missing. uv makes the environment's own copies symlinks into its cache, and pipx followed them there instead of stopping at the environment (:pr:`2380`) by :user:`gaborbernat`. - ``--fetch-python`` and ``pipx interpreter upgrade`` now download and unpack the standalone Python build next to the interpreter directory instead of in the system temp dir, so a tmpfs ``/tmp`` or a ``/tmp`` on another btrfs subvolume no longer forces a full copy of the build into the pipx home or splits its hard links into separate files (:pr:`2403`) by :user:`gaborbernat`. - ``pipx inject pipx`` without ``--include-apps`` and ``pipx runpip pipx`` on a pipx that manages itself no longer mix old and new pipx code when they change the pipx package. Like ``pipx upgrade pipx``, they now load late imports from a copy of the environment, which the next pipx command deletes from the trash (:pr:`2393`) by :user:`gaborbernat`. - ``pipx list --json`` no longer exports the interpreter a self-managed pipx recorded when that path no longer leads to the interpreter its environment runs on, such as a removed bootstrap environment's ``python3``. ``pipx install-all`` then builds that environment on the default interpreter instead of whatever appears at the old path (:pr:`2389`) by :user:`gaborbernat`. - ``pipx reinstall pipx`` on a pipx that manages itself no longer crashes with an ``ImportError`` when it installs a different pipx version. Modules the running pipx imported late came from the version it was installing, so old and new code mixed. It now loads them from its own moved environment, which the next pipx command deletes from the trash (:pr:`2377`) by :user:`gaborbernat`. - ``pipx upgrade pipx``, ``pipx upgrade-all`` and ``pipx install --force pipx`` on a pipx that manages itself no longer crash with an ``ImportError`` when they install a different pipx version. The running pipx now loads its late imports from a copy of its environment, which the next pipx command deletes from the trash (:pr:`2388`) by :user:`gaborbernat`. - pipx now recognises the app copies it made when the environment's interpreter path is too long for a shebang line or contains a space. pip and uv start such launchers with a ``#!/bin/sh`` line that hands over to the interpreter, and pipx took ``/bin/sh`` for the interpreter, so ``pipx reinstall`` kept a stale copy and warned "File exists ... Not modifying." (:pr:`2392`) by :user:`gaborbernat`. .. _release-1-18-10-security: Security -------- - ``pipx run`` now names its cached environments with an HMAC keyed by a random per-user secret, stored with mode ``0600`` in ``.run-key`` inside the run cache. Before, the name was a plain hash of inputs that include tokens and proxy passwords from the environment, so another account that could list the cache could check guesses at those values against it. Existing cache entries are not reused and expire as usual (:pr:`2370`) by :user:`gaborbernat`. - pipx no longer records an abbreviation of a one-shot pip option that the current pip finds ambiguous but an older pip accepts, such as ``--requirem FILE``, which pip 24.0 reads as ``--requirement FILE``. With such a shared pip, a later ``pipx upgrade`` or ``pipx reinstall`` used to replay it and install from whatever another account had put at that path. An existing record drops it when read, and an abbreviation that could name a source option such as ``--constraint`` goes through the same replay check as that option (:pr:`2390`) by :user:`gaborbernat`. - pipx no longer records an interpreter that lives inside another virtual environment, such as the ``python3`` of an activated bootstrap environment or of one made with ``python3 -m venv --copies``. It records the base interpreter named in that environment's ``pyvenv.cfg``, which outlives the bootstrap. ``pipx install-all`` also stops building on an exported interpreter that sits inside a virtual environment or that another account can change, and uses the default Python with a message instead, so a snapshot taken before the bootstrap was removed no longer runs whatever another account later puts at its old path (:pr:`2402`) by :user:`gaborbernat`. .. _release-1-18-9: `1.18.9 `_ - 2026-10-29 ================================================================= .. _release-1-18-9-fixed: Fixed ----- - Extend the 1.18.8 handling of recorded pip arguments to uv's path options and to ``--group``. pipx no longer records ``--group``, which names a ``pyproject.toml`` to read packages from, like ``-r``. On POSIX, a recorded ``--config-file``, ``--project``, ``--directory``, ``--overrides``/``--override`` or ``--excludes``/``--exclude`` that points at a path another account could change stops ``pipx upgrade``, ``pipx reinstall`` and the other replaying commands with the same error as ``--find-links``. ``pipx list --outdated`` runs the same check and reports such an environment as failed instead of querying the recorded location. ``pipx runpip`` reads its install arguments with the same parser, so it records an install written as ``-qe ./project`` or ``-e./project`` as editable (:pr:`2363`) by :user:`gaborbernat`. - When a rebuild fails during a parallel ``pipx reinstall-all``, the restored environment gets back the shared app, man page or completion links another environment took while the rebuild had it in the trash, unless something outside this run has replaced them since (:pr:`2371`) by :user:`gaborbernat`. - ``pipx run`` no longer reuses a cached environment after a change to a variable outside ``PIP_*`` and ``UV_*`` that decides which config file pip or uv reads, which proxy or index answers, which certificate is trusted or which credentials are sent, such as ``XDG_CONFIG_HOME``, ``SSL_CERT_FILE``, ``HTTPS_PROXY`` or ``NETRC``. The values only feed the cache key's hash, so secrets in them stay out of cache paths and logs (:pr:`2364`) by :user:`gaborbernat`. .. _release-1-18-8: `1.18.8 `_ - 2026-10-29 ================================================================= .. _release-1-18-8-fixed: Fixed ----- - A ``pipx run`` that names an app the package does not provide, such as ``pipx run --spec black blackk``, no longer expires the cached environment, so the corrected command reuses it instead of rebuilding. A failed venv creation or install still expires the entry (:pr:`2354`) by :user:`gaborbernat`. - A second Ctrl-C during ``pipx reinstall-all``, ``upgrade-all`` or ``install-all`` no longer hangs pipx. A signal handled just after a worker took the shared lock could leave it held, so every other task waited forever (:pr:`2348`) by :user:`gaborbernat`. - A self-managed pipx no longer adopts a recorded default interpreter that no longer backs its own environment, such as the ``python3`` of a bootstrap environment removed after ``pipx install pipx --python python3``. pipx failed every command with a ``PIPX_DEFAULT_PYTHON`` error, or built new environments on whatever appeared at that path later (:pr:`2245`) by :user:`gaborbernat`. - Apply ``--cooldown`` and ``PIPX_COOLDOWN`` to the shared pip that the pip backend installs or refreshes during the command. pipx used to fetch the newest pip, even one uploaded minutes earlier, while it held back the package itself. A new shared environment now starts from the oldest pip pipx supports and upgrades only to a release older than the cooldown (:pr:`2311`) by :user:`gaborbernat`. - Finish restoring an environment when Ctrl-C, ``SIGTERM`` or ``SIGHUP`` arrives while a failed or interrupted ``pipx reinstall``, ``install --force``, ``inject``, ``upgrade`` or ``manifest sync`` rolls it back. The rollback deletes the half-built environment and then moves the backup in, and a signal between the two steps left the environment deleted and its only copy in the trash, which the next pipx command emptied. pipx now holds such a signal until the backup is back in place and acts on it afterwards (:pr:`2329`) by :user:`gaborbernat`. - For an editable install, pipx now copies only the man pages that resolve inside the project directory. Before, it followed absolute paths, ``..`` and symlinks, and a wheel installed by name could ship its own editable ``direct_url.json`` to make pipx copy any readable file into the environment and link it as a man page (:pr:`2268`) by :user:`gaborbernat`. - Ignore apps, man pages and completions whose name is not a plain file name, such as ``..``, and never delete a directory to make room for an app link, so ``pipx reinstall`` can no longer remove the parent of ``PIPX_BIN_DIR`` (:pr:`2228`) by :user:`gaborbernat`. - Ignore recorded ``venv_args`` other than ``--system-site-packages``, with a warning, when reading a snapshot or ``pipx_metadata.json``, so ``pipx install-all`` and ``pipx reinstall`` no longer pass an edited ``--clear `` or an extra directory to ``python -m venv`` (:pr:`2239`) by :user:`gaborbernat`. - Installing an editable project whose man page or project directory is a looping symlink no longer crashes pipx on Python 3.11 and 3.12; pipx skips that man page (:pr:`2334`) by :user:`gaborbernat`. - Keep ``pipx reinstall-all``, ``upgrade-all``, and ``install-all`` waiting for the environments they change when pipx can no longer write their output, as with ``pipx reinstall-all 2>&1 | head`` or a closed stderr. pipx used to log a traceback and return while it still rebuilt environments in the background, so an interrupt then skipped the rollback, and an environment waiting for its turn hung (:pr:`2290`) by :user:`gaborbernat`. - Keep a command, man page or completion link that another package took over while ``pipx reinstall``, ``reinstall-all``, ``uninject`` or ``uninstall-all`` was running. These commands gathered the environment's links first and deleted them by name later, so a name another package claimed in between, for example in the same parallel ``reinstall-all``, disappeared without a message. pipx now deletes a link only if it still points into the environment by Bernรกt Gรกbor. - Keep pipx's ``pipx_metadata.json`` when the installer replaces it with a wheel's data file of the same name. A failed ``pipx install --force``, ``pipx upgrade`` or ``pipx inject``, or a ``pipx runpip APP install`` of a dependency, used to leave the wheel's copy in place, and later commands read its index arguments, spec and suffix as pipx's own. pipx now puts its record back after every installer run (:pr:`2269`) by :user:`gaborbernat`. - Keep standalone Python archive members inside the unpack directory on Python 3.11.0 to 3.11.3, which lack ``tarfile.data_filter``. pipx's own extractor checked member names as text, so a member whose path went through a symlink unpacked earlier from the same archive was written wherever that link led. It now resolves each path through the links already on disk, extracts under normalized names like the data filter does, and refuses a hard link to a symlink (:pr:`2279`) by :user:`gaborbernat`. - Keep the apps, manual pages, and completions of injected packages exposed when ``pipx manifest sync`` reinstalls an existing environment, and expose the environment again when the sync is interrupted. pipx used to drop those links without a message, so another package could take the freed names without ``--force``, and an interrupted sync left the rolled-back environment unexposed (:pr:`2273`) by :user:`gaborbernat`. - Keep the environment when ``pipx install --force`` or ``pipx install-all --force`` cannot move it aside before a ``Requires-Python`` rebuild. On Windows the move fails while any file inside the environment is open, and pipx then deleted the environment and reported a ``FileNotFoundError`` instead of the real error. pipx now retries the move the way ``pipx reinstall`` does, and if it still fails it leaves the environment in place and reports the original error (:pr:`2350`) by :user:`gaborbernat`. - Keep the recorded source of a local wheel when ``pipx runpip install`` abbreviates a value-taking pip option whose value names the package, such as ``--only-bin `` for ``--only-binary ``, and record an install through an abbreviated ``--editable``, such as ``--edit ``, as editable (:pr:`2225`) by :user:`gaborbernat`. - Keep the rollback backup of a running ``pipx reinstall``, ``reinstall-all``, ``install --force``, ``upgrade`` or ``inject`` when another pipx command starts. Every command used to empty the trash at startup, so a ``pipx list`` run meanwhile deleted the backup, and when the first command then failed or was interrupted it lost the environment with a ``FileNotFoundError``. pipx now skips a backup while its environment's lock is held and removes it only once orphaned (:pr:`2298`) by :user:`gaborbernat`. - Leave a command, man page or completion that the environment did not expose before with its owner when ``pipx reinstall``, ``reinstall-all``, ``interpreter upgrade``, the repair step of ``pipx upgrade``, or a repeated ``pipx manifest sync`` rebuilds the environment. These used to replace such a file as if ``--force`` had been passed, deleting a user's own script or repointing another environment's command, although ``pipx install`` had left it alone with a warning. A name two environments both declare now stays with the one that held it, instead of going to whichever ``reinstall-all`` rebuilt last (:pr:`2321`) by :user:`gaborbernat`. - Leave an existing environment alone in ``pipx install-all`` without ``--force``. pipx said it was not modifying the environment, then injected the snapshot's ``injected_packages`` into it anyway. Pass ``--force`` to bring existing environments in line with the snapshot (:pr:`2333`) by :user:`gaborbernat`. - Let an environment take over an app, manual page, or completion name whose link points to a file that another installed environment no longer ships. pipx before 1.18.0 left such links behind when an upgrade dropped an app, and pipx kept them as collisions that only ``--force`` cleared. A link counts as left over when the environment it points into no longer lists the file in its metadata. The collision warning for a link whose target is missing now says so (:pr:`2356`) by :user:`gaborbernat`. - Mask URL passwords and tokens in the remaining places that printed or logged them as typed: the ``Injecting packages`` log line, the ``--output json`` error fields of ``inject``, ``install`` and ``uninject``, the progress lines of ``pipx run`` script dependencies and ``--preinstall``, the local source that ``pipx upgrade`` reports, invalid requirement errors from manifests and inline script metadata, ``--pip-args`` rejected by the uv backend of ``pipx run``, the "looks like a path" error, and installer output that pipx captures and logs (:pr:`2252`) by :user:`gaborbernat`. - Mask a URL password that contains a literal space or tab, such as ``--index-url "http://alice:correct horse@host/simple"``, in the command log, ``--verbose`` output and messages; pipx now masks each argument of a command line before joining it (:pr:`2241`) by :user:`gaborbernat`. - Mask the user part of URL credentials that contain a colon, so a token written as ``https://TOKEN:@host`` or ``TOKEN:x-oauth-basic@`` no longer shows in the command log, ``--verbose`` output and messages. Such URLs now show as ``****:****@``, which also hides a plain user name such as ``alice`` (:pr:`2244`) by :user:`gaborbernat`. - On Windows under the uv backend, ``pipx run script.py`` now releases the run-cache lock before the script starts. pipx held it until the script exited, so a second run of the same script waited without a message, and a script that ran itself through pipx never finished (:pr:`2352`) by :user:`gaborbernat`. - Refuse ``pipx install --lock`` and ``pipx manifest sync`` with a lock under the pip backend when the environment's pip is older than 26.1. That pip read ``pylock.toml`` as a requirements file: a real lock failed with a confusing ``Invalid requirement`` error, and a file that was not a lock had its lines applied as pip options. The error now names the pip version and suggests ``pipx upgrade-shared`` (:pr:`2289`) by :user:`gaborbernat`. - Refuse a URL spec whose installed ``METADATA`` names a different project than its ``.dist-info`` directory. A wheel could rewrite that name through its data files and make ``pipx install --upgrade URL`` install it into another application's environment (:pr:`2236`) by :user:`gaborbernat`. - Refuse a ``--python`` value that is not a version before ``--fetch-python=missing`` or ``--fetch-python=always`` looks in the standalone interpreter cache. pipx joined the raw value onto ``PIPX_HOME/py`` and deleted the result as a failed earlier download, so a directory path, ``..``, ``.`` or a bare ``python`` removed that directory, ``PIPX_HOME`` or the whole interpreter cache (:pr:`2262`) by :user:`gaborbernat`. - Refuse an empty or whitespace-only ``--index-url`` instead of silently installing from the default index (:pr:`2217`) by :user:`Svastikkka`. - Refuse an empty or whitespace-only index option inside ``--pip-args`` (``--index-url``, ``-i``, ``--extra-index-url``, ``--default-index``, ``--index``), and under the uv backend an empty index recorded by an earlier install, instead of letting uv install from the default index (:pr:`2253`) by :user:`gaborbernat`. - Refuse an environment name that is empty or a path instead of acting outside ``$PIPX_HOME/venvs``. ``pipx uninstall ""``, for example from an unset shell variable, used to remove every environment and exit 0, and an ``install-all`` snapshot, manifest tool name or reported package name holding a path built the environment at that path. pipx now stops with ``Invalid environment name`` (:pr:`2331`) by :user:`gaborbernat`. - Refuse to overwrite ``/etc/profile.d/pipx.sh`` or ``/etc/paths.d/pipx`` in ``pipx ensurepath --global`` when the file holds content pipx did not write, such as an administrator's ``PIPX_GLOBAL_HOME`` or ``PIP_INDEX_URL`` exports. pipx used to replace the whole file with its ``PATH`` entry and report success. It now leaves the file and its mode alone and exits with an error that names ``--force``, which still replaces the file. pipx replaces a file that holds only an earlier pipx entry, as before (:pr:`2315`) by :user:`gaborbernat`. - Refuse to run ``pipx manifest lock`` when a tool's ``lock`` path resolves outside the manifest directory, instead of replacing a ``pylock.toml`` elsewhere on disk and discarding its previous contents. ``pipx manifest sync`` still reads such locks (:pr:`2223`) by :user:`gaborbernat`. - Refuse to run pip or uv in an environment whose ``pyvenv.cfg`` is gone, and suggest ``pipx reinstall``. Without that file the environment's ``python`` link starts the interpreter it was created from, so a ``pipx upgrade`` after a wheel had shipped and then dropped ``pyvenv.cfg`` installed the package into that interpreter's prefix and reported success (:pr:`2326`) by :user:`gaborbernat`. - Reject ``--global``, ``--skip-maintenance``, ``--verbose`` and ``--quiet`` placed between ``manifest``, ``interpreter`` or ``cache`` and their subcommand, as in ``pipx manifest --global sync``. pipx accepted the option there and then dropped it, so ``pipx manifest --global sync --prune`` synced and pruned the per-user store with exit 0. Put the option after the subcommand, as in ``pipx manifest sync --global`` (:pr:`2320`) by :user:`gaborbernat`. - Report an environment whose ``pipx_metadata.json`` the current user cannot read as unreadable in ``pipx list``, instead of as missing metadata from a pipx older than 0.15 that needs reinstalling. A non-root user sees this for every environment of ``pipx list --global``, because pipx keeps the file owner-only: it can hold index credentials. ``pipx list`` still lists the environments the user can read (:pr:`2323`) by :user:`gaborbernat`. - Resolve every relative path given to a pip option, such as ``--cert ca.pem``, ``-r requirements.txt``, ``--log`` or ``--cache-dir``, against the directory pipx was started in, also when the option is clustered or abbreviated, as in ``-qr requirements.txt`` or ``--find-l wheels``. The shared pip refresh runs inside the shared libraries directory, so ``pipx upgrade-shared --pip-args='--cert=ca.pem'`` failed with ``invalid path: ca.pem``; installs, upgrades and ``pipx run`` now also resolve these values against the starting directory (:pr:`2351`) by :user:`gaborbernat`. - Restore every earlier lock when ``pipx manifest lock`` fails because a directory sits at a later tool's lock path. pipx used to stop rolling back at that directory, keep the new locks of the tools before it, and delete their previous versions (:pr:`2260`) by :user:`gaborbernat`. - Restore the existing environment when ``pipx install --force`` or ``pipx install-all --force`` rebuilds it on another interpreter because of the package's ``Requires-Python`` and the rebuild fails. pipx used to delete the environment first and leave behind an empty one without its app, injected packages, or ``pipx_metadata.json``, while it reported it had kept the environment. It now moves the environment to its trash before the rebuild and moves it back on failure (:pr:`2229`) by :user:`gaborbernat`. - Stop ``pipx ensurepath --global --force`` from crashing with ``UnicodeDecodeError`` when ``/etc/profile.d/pipx.sh`` or ``/etc/paths.d/pipx`` holds bytes that are not UTF-8. ``--force`` now replaces the file without reading it, and without ``--force`` such a file gets the same error that names ``--force`` as any other file pipx did not write (:pr:`2355`) by :user:`gaborbernat`. - Stop ``pipx interpreter upgrade`` from moving an environment to another Python found on ``PATH`` when the newest release of its package has a ``Requires-Python`` that rules out the new standalone build. The command reported the patch upgrade as done while the environment now ran on, for example, ``/usr/bin/python3.11``. It now fails, restores the environment on its standalone build, and exits 1 (:pr:`2301`) by :user:`gaborbernat`. - Stop ``pipx upgrade-all`` from moving an app, manual page, or completion that two environments expose to the other environment while the installer of its owner is replacing the linked file. pipx treated any link whose target was missing as left over and replaced it without ``--force``; a missing target inside another environment that still exists now counts as a collision (:pr:`2303`) by :user:`gaborbernat`. - Stop a self-managed pipx (``pipx install pipx``) started through its own environment's script from taking that environment's ``bin`` directory for ``PIPX_BIN_DIR`` when the path to the script holds ``..`` or a symlinked directory, such as ``../share/pipx/venvs/pipx/bin/pipx`` from ``~/.local/bin``. Such a command linked the apps it installed into pipx's own environment, off ``PATH`` and onto pipx's own import path. pipx now recognizes the exposed link by real locations, which also works when you reach the pipx home through a symlink (:pr:`2309`) by :user:`gaborbernat`. - Stop pipx from importing modules out of the directory of its own launcher script, or out of the working directory under ``python -m pipx``, once ``pipx.__main__`` starts. After ``pip install --user pipx`` that directory is ``~/.local/bin``, where pipx links apps, so pipx imported a ``json.py`` or ``shlex.py`` script a package shipped there in place of the standard library module, and an exposed ``json.py`` broke every later pipx command, ``pipx uninstall`` included. For modules pip's launcher imports before that point, such as ``re`` and ``enum``, pipx refuses to expose apps named after standard library modules. Programs that import ``pipx.__main__`` no longer see their script directory on ``sys.path`` either (:pr:`2316`) by :user:`gaborbernat`. - Stop recording pip options that name a one-shot input or an output location, such as ``-r requirements.txt``, ``--log``, ``--report``, ``--cache-dir`` or ``--target``: they apply to the command they were given on, and a later ``pipx upgrade`` or ``pipx reinstall`` no longer reads or writes those paths. pipx reads pip arguments as pip does, so a clustered short option such as ``-qr requirements.txt`` or an abbreviation such as ``--log-f pip.log`` counts too. On POSIX, ``pipx upgrade``, ``pipx reinstall`` and the other commands that replay recorded arguments stop with an error, before the installer runs and without changing the record, when a recorded ``--find-links``, ``--constraint``, ``--build-constraint``, ``--cert``, ``--client-cert``, or ``file://`` or local-path index URL points at a path another account could change. Pass the options again with ``--pip-args`` to use them for that run (:pr:`2359`) by :user:`gaborbernat`. - Stop two pipx commands that fetch the same standalone Python at once from leaving an interpreter with part of its standard library missing in ``PIPX_HOME/py``. Both staged the build in one fixed directory and each deleted the other's half-copied tree as a leftover; pipx now holds a per-version lock while it fetches, so the second command waits and reuses the first one's build. ``pipx interpreter list``, ``prune`` and ``upgrade`` also skip the staging and backup directories of a fetch in progress instead of treating them as interpreters. An interpreter that an earlier fetch already left incomplete still needs deleting by hand (:pr:`2293`) by :user:`gaborbernat`. - The pip backend now removes ``pip.conf`` (``pip.ini`` on Windows) from the root of an environment before it starts pip there. A wheel could ship that file as a data file, and pip read it as its site configuration, so a ``target`` or index in it made a later ``pipx upgrade`` or ``pipx inject`` install outside the environment or from another index (:pr:`2264`) by :user:`gaborbernat`. - Under the uv backend, ``--index-url`` (``-i``) now reaches uv as ``--default-index``. uv let ``UV_DEFAULT_INDEX`` or a default ``[[index]]`` entry override a command-line ``--index-url``, so ``pipx run``, ``install``, ``upgrade`` and ``outdated`` used the default index instead of the one named on the command line (:pr:`2247`) by :user:`gaborbernat`. - Under the uv backend, ``pipx run script.py`` now keeps the script's environment in pipx's run cache, keyed to the script's path, the interpreter, ``--pip-args`` and the cooldown. uv kept one environment per script path and reused it while it satisfied the script, so a changed ``--index-url``, ``--pip-args`` or ``--cooldown``, or ``--refresh``, still ran what an earlier run installed. ``pipx cache purge`` and the 14-day expiry now cover these environments (:pr:`2274`) by :user:`gaborbernat`. - Under the uv backend, ``pipx run`` now puts ``--`` before the app name it hands to ``uv tool run``. An app word that started with a single dash, such as ``pipx run -- -ihttp://host/simple demodep``, was read by uv as one of its own options, and uv installed and ran the next word as the package (:pr:`2299`) by :user:`gaborbernat`. - Under the uv backend, ``pipx upgrade``, ``upgrade-all`` and ``install --upgrade`` now pass uv ``--upgrade-package NAME`` instead of ``--upgrade``, so they upgrade only the named package and leave its dependencies alone unless the new release needs others, as pip's default ``only-if-needed`` strategy does. Before, uv upgraded every dependency, replacing packages injected with ``pipx inject --force`` and pinned with ``pipx pin --injected-only`` (:pr:`2248`) by :user:`gaborbernat`. - When ``pipx install`` or ``pipx run --spec`` under the pip backend has to install a URL, VCS or sdist spec to learn its name, the shared pip it installs along the way now comes from the command's ``--index-url`` and ``--pip-args`` instead of pip's default index (:pr:`2275`) by :user:`gaborbernat`. - When an environment holds two ``.dist-info`` records with the same name, pipx now reads the first one on ``sys.path``, the one the environment's interpreter uses. Since 1.15.1 it read the last one, so ``--system-site-packages`` with a different version of the package in the base interpreter reported that version and exposed its commands (:pr:`2258`) by :user:`gaborbernat`. - ``pipx install --preinstall`` and ``pipx run --with`` now refuse a value that starts with ``-``. Written as ``--with=--find-links=DIR`` or ``--preinstall=-rFILE``, such a value reached pip or ``uv pip install`` as an installer option and changed where packages came from, instead of failing like the main package spec does. Pass installer options with ``--pip-args`` (:pr:`2307`) by :user:`gaborbernat`. - ``pipx install-all --backend uv`` now translates index options that a pip recording spells as an abbreviation pip accepts, such as ``--index-u URL``, instead of dropping them and installing from the default index. A recorded pip ``--index URL`` now becomes uv ``--default-index URL``, matching how pip read it (:pr:`2232`) by :user:`gaborbernat`. - ``pipx install`` with several packages no longer builds every environment on the Python it picked for one package whose ``Requires-Python`` rules out the default interpreter; the other packages keep the default Python (:pr:`2222`) by :user:`gaborbernat`. - ``pipx list --outdated`` and ``pipx upgrade-all`` now check uv environments against the index recorded with uv's option names (``--default-index``, ``--index``, ``--allow-insecure-host``, ``--index-strategy``, ``--keyring-provider``) instead of uv's default index (:pr:`2220`) by :user:`gaborbernat`. - ``pipx reinstall``, ``pipx reinstall-all`` and ``pipx repair`` now refuse an environment whose ``pipx_metadata.json`` is unreadable or was lost to a killed install, instead of rebuilding it from the default index under the directory name. ``pipx install`` marks the environment unfinished until it writes the record, and ``pipx uninstall`` moves the environment to its trash before deleting it, so a killed uninstall no longer leaves a venv without its record (:pr:`2234`) by :user:`gaborbernat`. - ``pipx run --with`` now installs the ``--with`` packages in the same installer call as the app. pipx skipped a ``--with`` naming a package the app already depends on, such as ``--with 'dep==1.0'``, as already installed, so the app ran with the newest release on the pip backend. A ``--with`` naming the app with an extra no longer links the cached environment's commands into ``PIPX_BIN_DIR`` (:pr:`2266`) by :user:`gaborbernat`. - ``pipx run script.py`` now expires its cache entry when creating the environment fails or is interrupted, not only when installing the script's dependencies does. A Ctrl-C during ``python -m venv`` left an empty cache directory, and every later run of the script reused it and failed with ``FileNotFoundError`` until the entry expired (:pr:`2294`) by :user:`gaborbernat`. - ``pipx run`` and ``pipx install`` now fail when a script download ends before its ``Content-Length``. Before, pipx ran or installed the part that arrived and could exit 0 (:pr:`2251`) by :user:`gaborbernat`. - ``pipx run`` and ``pipx install`` now refuse an ``http`` or ``https`` script URL with credentials, naming it with the password masked. Before, a URL such as ``https://user:password@host/tool.py`` failed with an error that printed the password in clear text and wrote it to the log. ``pipx install`` also reports a malformed script URL as an error instead of a traceback (:pr:`2249`) by :user:`gaborbernat`. - ``pipx run`` no longer keys its cache on the ``PIP_*`` and ``UV_*`` variables that cannot change what the installer resolves or installs: timeouts, retries, parallelism, verbosity, progress and color output, cache directories, and the ``UV_RUN_RECURSION_DEPTH`` that ``uv run`` sets for its children. Setting one of them rebuilt the environment for an unchanged command. Every other variable with those prefixes stays in the key, whatever its case or ``-``/``_`` spelling, since pip reads ``PIP_index_url`` the same as ``PIP_INDEX_URL`` (:pr:`2353`) by :user:`gaborbernat`. - ``pipx run`` now includes the installer's environment variables (``PIP_*``, or ``UV_*`` under the uv backend) in its cache key. A run after changing ``PIP_INDEX_URL``, ``PIP_FIND_LINKS`` or ``UV_INDEX_URL`` reused the environment built from the previous index for up to 14 days (:pr:`2282`) by :user:`gaborbernat`. - ``pipx run`` now keys its cache on the absolute path of a local ``--spec``, ``--with`` or ``--find-links`` value. The same relative path typed in two directories, such as ``--spec ./dist/tool-1.0-py3-none-any.whl``, shared one environment, so the second directory ran the first directory's build by Bernรกt Gรกbor. - ``pipx run`` now names the script URL, with credentials masked, when a download fails, as ``pipx install`` already did. ``pipx install`` of a script URL whose body does not decode in its declared charset, or declares an unknown one, now reports an error instead of a traceback, as ``pipx run`` already did (:pr:`2255`) by :user:`gaborbernat`. - ``pipx runpip install`` now records a relative ``--find-links`` (``-f``) path resolved against the directory you ran it from. Before, a later ``pipx upgrade`` or ``pipx reinstall`` read it against the environment's directory, failed with ``Failed to read --find-links directory`` or ``No matching distribution found``, and rolled back (:pr:`2349`) by :user:`gaborbernat`. - ``pipx runpip`` starts pip or uv in your working directory again instead of the environment's root, so relative paths such as ``-r requirements.txt``, ``-c constraints.txt`` or ``./dist/app.whl`` name your files. Since 1.12.0 the installer looked them up in the environment, where they were missing or named a data file an installed wheel had put there (:pr:`2318`) by :user:`gaborbernat`. - ``pipx uninject`` no longer removes apps, man pages and completions that the main package or another injected package of the environment still exposes, such as a command of a dependency both packages share under ``--include-deps`` (:pr:`2296`) by :user:`gaborbernat`. - ``pipx uninject`` now ignores installed distributions whose metadata ``Name`` is not a valid project name when it looks for orphaned dependencies to remove. A name such as ``-t/some/dir`` or ``-rFILE`` reached ``uv pip uninstall`` or ``pip uninstall`` as an option, and with uv could delete packages from a directory outside pipx (:pr:`2306`) by :user:`gaborbernat`. - pipx no longer crashes on Python 3.11 and 3.12 when a package's ``installed-files.txt`` lists a looping symlink, and skips that entry instead (:pr:`2336`) by :user:`gaborbernat`. - pipx no longer exposes an app whose name is a standard library module or ``pipx`` plus an import suffix, such as ``enum.py``, ``re.pyc`` or ``pipx.py``, or a directory named after one, and warns instead. Python searches the directory of the script it starts first, so in ``~/.local/bin`` such an app replaced the module for pip's ``pipx`` launcher, which imports ``re`` and then ``pipx`` before pipx can drop that directory from ``sys.path``, and for every other Python script installed there (:pr:`2342`) by :user:`gaborbernat`. - pipx no longer reads ``installed-files.txt`` from a distribution that has a ``RECORD``, matching ``importlib.metadata``. A dependency, or a package injected without ``--include-apps``, could ship that file into the main package's ``.dist-info`` and get its own apps, man pages and completions exposed without ``--include-deps`` (:pr:`2277`) by :user:`gaborbernat`. - pipx now adds ``-E`` to an app script's shebang by writing a new file and renaming it over the script, instead of writing into the existing file. With the uv backend the script can be a hard link or symlink into uv's cache, so the in-place write also changed the cache entry and the same script in every other environment uv had installed that wheel into (:pr:`2285`) by :user:`gaborbernat`. - pipx now reads a package's ``entry_points.txt`` only when that package's ``RECORD`` lists it, or when the package has no ``RECORD``. A dependency could ship that file into the ``.dist-info`` of a main package that has no entry points of its own and get its apps exposed without ``--include-deps`` (:pr:`2340`) by :user:`gaborbernat`. .. _release-1-18-8-documentation: Documentation ------------- - Document what the security policy covers: which inputs pipx trusts, and which reports count as vulnerabilities (:pr:`2198`) by :user:`gaborbernat`. .. _release-1-18-8-security: Security -------- - A bare package name such as ``pipx install pycowsay`` now always means the package on the index. A file of that name without a suffix in the working directory no longer gets installed as a PEP 723 script; pass a path such as ``./pycowsay`` to install one. URL specs no longer pick up a script at the matching relative path below the working directory either (:pr:`2243`) by :user:`gaborbernat`. - Ask the Python Launcher for Unix for a ``--python`` value only when it is an ``X`` or ``X.Y`` version. The launcher ran any other value, such as ``3.14.8`` or ``3.13t``, on the first interpreter its search found, which could be a ``.venv`` in a parent of the current directory (:pr:`2242`) by :user:`gaborbernat`. - Create the self-managed bootstrap environment in the installation guide with ``mktemp -d`` instead of the fixed ``/tmp/bootstrap`` path, which another local user could create first and plant code in that ran as the reader, and as root at the ``sudo`` step (:pr:`2219`) by :user:`gaborbernat`. - Look up a command name in ``PIPX_DEFAULT_PYTHON``, such as ``python3``, on ``PATH`` only: pipx no longer uses a file of that name in the current directory as the default interpreter. Write ``./python3`` to pick that file on purpose (:pr:`2226`) by :user:`gaborbernat`. - Pass the absolute path ``PATH`` lookup found for ``--python python3.11`` on to the backend instead of the bare name. uv read the bare name as a version request and could pick a ``.venv`` interpreter from a parent of the current directory. A file in the current directory named like the value, such as ``./3.11``, no longer counts as a path either (:pr:`2240`) by :user:`gaborbernat`. - Read a package name from a local ``pyproject.toml`` or wheel only for local path specs. A URL spec such as ``https://host/app.whl`` no longer takes its name, and with it the target environment, from a file at the matching relative path below the working directory (:pr:`2238`) by :user:`gaborbernat`. - Record relative ``--find-links`` and ``--constraint`` values passed to ``pipx upgrade --pip-args`` and ``pipx run`` scripts as absolute paths, resolve relative values already in ``pipx_metadata.json`` against the environment's directory, and run the shared pip refresh inside the shared libraries directory, so a later ``pipx upgrade`` or ``pipx reinstall`` no longer reads wheels from whatever directory you start it in (:pr:`2233`) by :user:`gaborbernat`. - Record the index options of a ``pipx runpip install`` that replaces the main package, so a later ``pipx upgrade`` or ``pipx reinstall`` asks the same index instead of resolving the bare name on the default one (:pr:`2231`) by :user:`gaborbernat`. - Start scripts read from a URL or a named pipe, ``pipx.run`` entry points, and apps run with ``--python-args`` with ``python -P`` so a module in the current directory, such as a planted ``json.py``, no longer shadows what the program imports (:pr:`2221`) by :user:`gaborbernat`. - Stop ``pipx reinstall`` from replacing the main package while it replays injected packages. The replay passed ``--force-reinstall``, so an injected package that depends on the main package made the installer fetch the main package again by name from the injected package's index and install that release over the recorded source. ``pipx inject --force`` now passes ``--force-reinstall`` only when the package is already injected, and under the uv backend reinstalls just that package with ``--reinstall-package`` (:pr:`2227`) by :user:`gaborbernat`. - Stop ``pipx run`` from retrying a failed uv install with pip. A venv directory without ``pipx_metadata.json`` no longer counts as recorded with the pip backend, so it resolves its backend like a new venv, and a ``pipx run`` whose install fails now expires its cache entry so the next run rebuilds it. pip ignores ``UV_INDEX_URL``, so the retry fetched the package from pip's default index instead of the private one (:pr:`2235`) by :user:`gaborbernat`. - The uv backend no longer applies a ``uv.toml`` or ``pyproject.toml`` found in the directory uv runs in, the script's directory, or their parents, such as one another local account wrote to a shared ``/tmp``. pipx now points uv's project-config search at ``PIPX_HOME`` through ``UV_PROJECT``, and through ``--project`` for ``uv run --script``. Your user and system ``uv.toml`` and ``UV_CONFIG_FILE`` still apply. Keep ``PIPX_HOME`` out of directories other accounts can write (:pr:`2246`) by :user:`gaborbernat`. - When ``pipx install --force`` or ``pipx manifest sync`` rebuilds an existing environment, either on another Python because the release's ``Requires-Python`` rules out the current one or to apply a lock file, keep the backend recorded for that environment. The rebuild used to pick the backend as for a new environment, so a pip-backed environment could move to uv and install from uv's default index instead of the one set in ``PIP_INDEX_URL`` or ``pip.conf`` (:pr:`2237`) by :user:`gaborbernat`. - ``pipx inject ENV 'APP[extra]'`` now adds the extra to the main package's recorded source instead of replacing it with the bare name ``APP[extra]``. An app installed from a wheel, a directory or a URL no longer turns into the index's project of the same name on the next ``pipx upgrade`` or ``pipx reinstall``. Extras accumulate with the ones already recorded, and a main-package spec that changes the version, path or URL needs ``--force`` (:pr:`2250`) by :user:`gaborbernat`. - ``pipx manifest sync`` now keeps the installer and venv options recorded for an environment that already exists, such as ``--index-url``. It used to reinstall the tool from the default index and erase the recorded options (:pr:`2224`) by :user:`gaborbernat`. - pipx now creates its log directory readable by its owner only (``0700``), tightens an existing one, and writes ``cmd_*.log`` and ``cmd_*_{pip,uv}_errors.log`` as ``0600``. The logs record the full command line, including the arguments ``pipx run`` and ``pipx exec`` pass to the application, and in a ``--global`` store under ``/opt/pipx/logs`` any local account could read them (:pr:`2230`) by :user:`gaborbernat`. .. _release-1-18-7: `1.18.7 `_ - 2026-10-28 ================================================================= .. _release-1-18-7-security: Security -------- - Require pip 26.2.2 or later in the shared libraries, and replace an older shared pip on the next pipx command instead of waiting for the 30-day refresh. Older pip releases bundle a certifi that still trusts the compromised eMudhra/emSign root CA (:pr:`2197`) by :user:`gaborbernat`. .. _release-1-18-6: `1.18.6 `_ - 2026-10-28 ================================================================= .. _release-1-18-6-fixed: Fixed ----- - Keep the extras on a local archive named without ``./``, so ``pipx install "tool-1.0-py3-none-any.whl[cow]"`` installs the ``cow`` extra like ``./tool-1.0-py3-none-any.whl[cow]`` does. A version specifier, URL or environment marker after such a name now fails with an error instead of being silently dropped (:pr:`2174`) by :user:`Cyber-Marty` and :user:`gaborbernat`. - Keep the recorded source of a local wheel when ``pipx runpip install`` passes an option value that names the package, such as ``--only-binary ``, ``--no-binary ``, or uv's ``--upgrade-package `` (:pr:`2166`) by :user:`dailyun` and :user:`gaborbernat`. - Read the full inline script metadata block when a TOML multiline string in it contains a ``# ///`` line, instead of failing with ``Unterminated string``. As the inline script metadata specification requires, the block now ends at the last ``# ///`` line of its comment run (:pr:`2172`) by :user:`zurrll` and :user:`gaborbernat`. - Retain commands enabled by additional dependency extras with ``--include-deps`` (:pr:`2185`) by :user:`JackyHsu-X` and :user:`gaborbernat`. - ``pipx install pkg-1.0.tar.xz`` and other bare filenames ending in an archive extension pip accepts (``.tar.bz2``, ``.tgz``, ``.tar`` and the rest, in any letter case) now install the local file instead of looking up a mangled project name on PyPI (:pr:`2177`) by :user:`ShahabAhmed01` and :user:`gaborbernat`. - ``pipx upgrade-all --pip-args=--index-url``, or any other index option given without a value, now fails with an error naming the option instead of a ``StopIteration`` traceback, and ``--output json`` reports it in the usual error envelope (:pr:`2182`) by :user:`jdotc1` and :user:`gaborbernat`. .. _release-1-18-6-documentation: Documentation ------------- - Fix the dead PythonBytes episode link on the docs home page (:pr:`2170`) by :user:`qodelicious` and :user:`gaborbernat`. - Use a current black commit in the install-from-commit example (:pr:`2181`) by :user:`dcadolph` and :user:`gaborbernat`. .. _release-1-18-5: `1.18.5 `_ - 2026-10-27 ================================================================= .. _release-1-18-5-packaging: Packaging --------- - Releases now run on the shared tox-dev/github-workflows pipeline, with a locked release toolchain and generated GitHub Release notes (:pr:`2190`) by :user:`gaborbernat`. .. _release-1-18-4: `1.18.4 `_ - 2026-10-18 ================================================================= .. _release-1-18-4-fixed: Fixed ----- - `pipx reset` now cancels instead of crashing when the confirmation prompt reads EOF (Ctrl-D on POSIX, Ctrl-Z then Enter on Windows). (:pr:`2160`) by :user:`XXXXXQ-0206` and :user:`gaborbernat` .. _release-1-18-3: `1.18.3 `_ - 2026-10-17 ================================================================= .. _release-1-18-3-fixed: Fixed ----- - Allow selecting dependencies that ship only shell completions. (:pr:`2157`) by :user:`AryanRG` and :user:`gaborbernat` .. _release-1-18-2: `1.18.2 `_ - 2026-10-16 ================================================================= .. _release-1-18-2-fixed: Fixed ----- - Accept absolute `file:///` URLs as package specs, such as `pipx install file:///wheels/tool-1-py3-none-any.whl`. (:pr:`2150`) by :user:`srijan2607` and :user:`gaborbernat` - In a terminal, show the pipx spinner again during installs and upgrades instead of the full `pip` or `uv` log, which `--verbose` still streams. Installer errors are still printed on failure. (:issue:`2151`) - Print the installer error once when a `pipx upgrade --verbose` or pip `pipx install --verbose` fails, instead of repeating it after the live output. (:pr:`2153`) by :user:`gaborbernat` - Stop an `-all` command on Ctrl-C even when the interpreter drops the interrupt, which happens when the signal handler runs inside a weakref callback or a finalizer. (:pr:`2154`) by :user:`gaborbernat` .. _release-1-18-1: `1.18.1 `_ - 2026-10-15 ================================================================= .. _release-1-18-1-fixed: Fixed ----- - Stop pip and uv when Ctrl-C lands early, instead of hanging. (:pr:`2146`) by :user:`gaborbernat` .. _release-1-18-0: `1.18.0 `_ - 2026-10-15 ================================================================= .. _release-1-18-0-added: Added ----- - Put `--suffix` last, except before a Windows file extension. Apps exposed by 1.17.14 keep their old name until pipx next exposes them, which renames them. (:pr:`2098`) by :user:`gaborbernat` - `upgrade` and `reinstall-all` refresh shared pip only after 30 days. (:pr:`2116`) by :user:`gaborbernat` - Run `pipx upgrade-shared` to refresh shared pip sooner. (:pr:`2116`) by :user:`gaborbernat` - Set `PYTHON_LAZY_IMPORTS=normal` to opt out; the entry point is `pipx.__main__`. (:pr:`2132`) by :user:`gaborbernat` - Defer every import, dependencies included, on Python 3.15 for faster startup. (:pr:`2132`) by :user:`gaborbernat` - Run `reinstall-all`, `upgrade-all` and `install-all` across venvs at once. (:pr:`2137`) by :user:`gaborbernat` - Let running upgrades finish on Ctrl-C in `upgrade-all`; a second Ctrl-C stops them, and the next `upgrade` repairs what was left half-upgraded. (:pr:`2144`) by :user:`gaborbernat` - Retry uv-backend installs on a Python matching requires-python, as pip does. (:pr:`2144`) by :user:`gaborbernat` - Translate recorded index options when `install-all --backend` switches between pip and uv. (:pr:`2144`) by :user:`gaborbernat` .. _release-1-18-0-fixed: Fixed ----- - Warn that `--system-site-packages` is ignored by `inject` and `upgrade-all`. (:pr:`2089`) by :user:`gaborbernat` - Fall back to pip with a warning when the auto-detected uv is older than 0.9.17. (:pr:`2092`) by :user:`gaborbernat` - Use the default Python for scripts when it satisfies `requires-python`. (:pr:`2093`) by :user:`gaborbernat` - Make `interpreter upgrade` move venvs off a patch-pinned standalone Python. (:pr:`2094`) by :user:`gaborbernat` - Keep pins of injected packages through `reinstall`, `install-all` and `--force`. (:pr:`2095`) by :user:`gaborbernat` - Restore recorded venv and pip args in `install-all` and reject bad spec files. (:pr:`2096`) by :user:`gaborbernat` - Remove apps, man pages and completions an upgraded package no longer ships. (:pr:`2097`) by :user:`gaborbernat` - Keep the interpreter on `install --force --python`. (:pr:`2098`) by :user:`gaborbernat` - Make `pipx expose` relink resources missing after a collision or a deletion. (:pr:`2099`) by :user:`gaborbernat` - Print only the envelope on `--output json` stdout and fix `health` JSON status. (:pr:`2101`) by :user:`gaborbernat` - Keep users of a global pipx on their own paths and explain unwritable homes. (:pr:`2102`) by :user:`gaborbernat` - Keep `PIPX_MAX_LOGS` logs, not one extra, and rotate uv error logs too. (:pr:`2103`) by :user:`gaborbernat` - Keep the last output of a child process streamed to a terminal on macOS. (:pr:`2111`) by :user:`gaborbernat` - Report apps whose Python is missing in `upgrade-all` instead of crashing. (:pr:`2141`) by :user:`gaborbernat` - Show child output that is not valid UTF-8 instead of crashing. (:pr:`2144`) by :user:`gaborbernat` - Mask passwords in index and package URLs in pipx's output and log files. (:pr:`2144`) by :user:`gaborbernat` .. _release-1-18-0-performance: Performance ----------- - Read a local project's name from `pyproject.toml` instead of building it twice. (:pr:`2108`) by :user:`gaborbernat` - Start every pipx command 50 to 130 ms faster by importing only what it uses. (:pr:`2113`) by :user:`gaborbernat` - Create the shared libraries 35 to 50% faster by skipping a redundant pip run. (:pr:`2114`) by :user:`gaborbernat` - Start half as many helper processes per install by reusing what pipx knows. (:pr:`2118`) by :user:`gaborbernat` - Skip a WMI query on Windows at startup by detecting the OS with `sys.platform`. (:pr:`2125`) by :user:`gaborbernat` - Uninject orphaned dependencies in one pip call, reported on one line. (:pr:`2126`) by :user:`gaborbernat` - Check only pipx-managed packages for updates, not every installed dependency. (:pr:`2127`) by :user:`gaborbernat` - Start pipx commands up to 30 ms faster by loading more modules on first use. (:pr:`2128`) by :user:`gaborbernat` - Start pipx 10 to 50 ms faster on Python 3.15 through lazy imports. (:pr:`2129`) by :user:`gaborbernat` - Check shared pip by its files instead of starting Python on every command. (:pr:`2131`) by :user:`gaborbernat` - Read a local wheel's package name from its metadata instead of a probe venv. (:pr:`2134`) by :user:`gaborbernat` - Scan exposed app and man page dirs once per `list` and `uninstall-all`. (:pr:`2138`) by :user:`gaborbernat` - Require filelock 4.2, whose lighter import speeds up most pipx commands. (:pr:`2142`) by :user:`gaborbernat` .. _release-1-18-0-documentation: Documentation ------------- - Correct the log directory in the platformdirs migration table. (:issue:`2086`) - Correct docs that described pip-only behavior or outdated defaults and paths. (:pr:`2088`) by :user:`gaborbernat` - Match help text, troubleshooting and samples to how pipx behaves today. (:pr:`2105`) by :user:`gaborbernat` .. _release-1-17-14: `1.17.14 `_ - 2026-10-12 =================================================================== .. _release-1-17-14-fixed: Fixed ----- - Build pipx wheels with Docutils 0.21.2 and later, including environments pinned to Docutils 0.22. (:pr:`2084`) by :user:`bfs2021` and :user:`gaborbernat` .. _release-1-17-14-documentation: Documentation ------------- - Say that pipx rejects `--global` before the subcommand instead of ignoring it. (:pr:`2082`) by :user:`Ronak-Mahidharia` and :user:`gaborbernat` .. _release-1-17-13: `1.17.13 `_ - 2026-10-10 =================================================================== .. _release-1-17-13-fixed: Fixed ----- - Accept bare and suffixed injected package names in `pipx pin --skip`. (:pr:`2079`) by :user:`VedantBhayani` and :user:`gaborbernat` - Use each snapshot's recorded interpreter when `pipx install-all` rebuilds an environment unless the user passes `--python`. (:pr:`2081`) by :user:`feiiiiii5` and :user:`gaborbernat` .. _release-1-17-12: `1.17.12 `_ - 2026-10-07 =================================================================== .. _release-1-17-12-fixed: Fixed ----- - Stop `pipx reinstall` from printing a traceback when the reinstall fails for an expected reason. (:pr:`2070`) by :user:`ParamTanna` - Stop `pipx install` printing `WARNING: Skipping setuptools as it is not installed.` when the shared libraries are created on Python 3.12+, where setuptools is already absent. (:pr:`2071`) by :user:`officialpm` and :user:`gaborbernat` - Stop an injected package in a suffixed environment from being renamed every time its record is rewritten: `pipx upgrade`, `pipx pin` and `pipx reinstall` no longer append the environment suffix again, so the package keeps its name in `pipx list --include-injected` and stays addressable by `pipx uninject`. (:pr:`2072`) by :user:`feiiiiii5` and :user:`gaborbernat` .. _release-1-17-12-documentation: Documentation ------------- - Update the pipx logo author credit. (:issue:`2073`) .. _release-1-17-11: `1.17.11 `_ - 2026-10-03 =================================================================== .. _release-1-17-11-documentation: Documentation ------------- - Hide the extra documentation TOC toggle on narrow screens. (:issue:`2064`) .. _release-1-17-10: `1.17.10 `_ - 2026-10-02 =================================================================== .. _release-1-17-10-fixed: Fixed ----- - Remove an environment that has no pipx metadata and an interpreter that no longer runs, instead of crashing `pipx uninstall` and `pipx uninstall-all`. (:pr:`2059`) by :user:`hemcharan710-afk` and :user:`gaborbernat` - Derive local script command names from the filename when parent directories contain `#`. A script installed by an earlier release under its directory's name no longer upgrades; uninstall it and install it again. (:issue:`2060`) - Install a local PEP 723 script from its file when the path reads as a PyPI name (`pipx install bare.py`), contains `[`, or starts with a quoted `~`, and stop the upgrade hint from suggesting a PyPI reinstall for a script. (:pr:`2062`) by :user:`gaborbernat` .. _release-1-17-9: `1.17.9 `_ - 2026-10-01 ================================================================= .. _release-1-17-9-fixed: Fixed ----- - Stop adding ` -E` to an app script's shebang when the longer line would exceed the kernel's limit, which made the script fail on macOS with `Exec format error`. (:pr:`2058`) by :user:`feiiiiii5`, :user:`OpenCode` and :user:`gaborbernat` .. _release-1-17-8: `1.17.8 `_ - 2026-09-29 ================================================================= .. _release-1-17-8-fixed: Fixed ----- - Remove leftover shell completion scripts when running `pipx uninject`. (:issue:`2055`) .. _release-1-17-7: `1.17.7 `_ - 2026-09-28 ================================================================= .. _release-1-17-7-fixed: Fixed ----- - Prevent `pipx run` from sharing cached environments across different requirement lists or argument boundaries. This changes cache keys; run `pipx cache purge` to remove old entries. (:pr:`2053`) by :user:`januththedev` and :user:`gaborbernat` .. _release-1-17-6: `1.17.6 `_ - 2026-09-22 ================================================================= .. _release-1-17-6-fixed: Fixed ----- - Record each tool's dependency group as its lock's default so `pipx manifest sync` installs what `pipx manifest lock` wrote. Run `pipx manifest lock` again to fix existing locks. (:issue:`2049`) .. _release-1-17-6-documentation: Documentation ------------- - Fix the argument order in the combined inject command example. (:pr:`2044`) by :user:`agammann` - Use direct URL requirements for the Git install example with extras. (:issue:`2045`) .. _release-1-17-5: `1.17.5 `_ - 2026-09-20 ================================================================= .. _release-1-17-5-fixed: Fixed ----- - Report failed environments when running `pipx reinstall-all`. (:issue:`2042`) .. _release-1-17-4: `1.17.4 `_ - 2026-09-18 ================================================================= .. _release-1-17-4-fixed: Fixed ----- - `pipx upgrade` of a package installed from a local path, wheel, or VCS URL no longer reports "already at latest version". It now says the package is unchanged because it is installed from a local source and that no package index was checked, and suggests reinstalling by package name to track index releases. (:issue:`2040`) .. _release-1-17-3: `1.17.3 `_ - 2026-09-16 ================================================================= .. _release-1-17-3-fixed: Fixed ----- - Report a clear error when PIPX_MAX_LOGS is not a non-negative integer, instead of crashing on every pipx command. Empty or whitespace-only values fall back to the default everywhere, including log setup. (:pr:`2029`) by :user:`hktitof` - Split `--pip-args` the way a shell would, so a quoted path with spaces such as `--find-links '/tmp/foo bar'` reaches pip intact instead of failing to parse. On Windows pipx now drops the quotes around each argument and keeps backslashes as path separators. (:pr:`2033`) by :user:`r3wretrhy` - Stop `pipx runpip` from printing uv DEBUG logs on the uv backend. (:pr:`2035`) by :user:`anandghegde` .. _release-1-17-3-documentation: Documentation ------------- - List every `--pip-args` flag the uv backend translates instead of only four. (:pr:`2037`) by :user:`hxperl` and :user:`claude` .. _release-1-17-2: `1.17.2 `_ - 2026-09-01 ================================================================= .. _release-1-17-2-fixed: Fixed ----- - Fix `KeyError` stderr noise when installing packages in Python3.15. (:pr:`2026`) by :user:`Cycloctane` .. _release-1-17-1: `1.17.1 `_ - 2026-08-30 ================================================================= .. _release-1-17-1-fixed: Fixed ----- - `pipx environment` now reports `PIPX_MAX_LOGS`, and `pipx environment --value PIPX_MAX_LOGS` no longer exits with `invalid choice`. (:pr:`2024`) by :user:`dylanpulver` and :user:`gaborbernat` .. _release-1-17-0: `1.17.0 `_ - 2026-08-29 ================================================================= .. _release-1-17-0-added: Added ----- - Add `PIPX_COOLDOWN`, a default for `--cooldown` on `install`, `install-all`, `inject`, `upgrade`, `upgrade-all` and `run`, so one release-age policy covers every command. An explicit `--cooldown` still wins, `--cooldown 0` opts a single command out, and a locked install ignores the variable rather than failing. (:issue:`2014`) .. _release-1-17-0-fixed: Fixed ----- - Require `packaging>=26`. Earlier releases serialize a direct reference as `name@ url` rather than PEP 508's `name @ url`, so the specifier pipx recorded in `pipx_metadata.json` and passed to the backend depended on which `packaging` happened to be installed. (:issue:`2015`) .. _release-1-16-8: `1.16.8 `_ - 2026-08-25 ================================================================= .. _release-1-16-8-fixed: Fixed ----- - `pipx run --spec ` on the uv backend builds a pipx-managed venv when `` differs from the spec's normalized distribution name, or when the spec carries no name at all, so pipx honors the package's `[pipx.run]` entry point. An `` that matches the name still runs through `uv tool run`, which sees console scripts only. (:issue:`2004`) .. _release-1-16-8-documentation: Documentation ------------- - The CLI reference in the documentation now shows `pipx run` instead of `sphinx-build run`. (:issue:`2005`) .. _release-1-16-7: `1.16.7 `_ - 2026-08-13 ================================================================= .. _release-1-16-7-fixed: Fixed ----- - Fix pipx not being able to discover packages in separated platlib directories. (:pr:`1999`) by :user:`Cycloctane` and :user:`gaborbernat` - Retry transient failures when fetching the python-build-standalone release index and downloading interpreter archives, resuming an interrupted archive download from the bytes already received instead of starting over. (:pr:`2001`) by :user:`gaborbernat` .. _release-1-16-7-documentation: Documentation ------------- - Correct the documented Windows default for `PIPX_HOME`, which resolves to `%LOCALAPPDATA%\pipx\pipx` rather than `%LOCALAPPDATA%\pipx`. (:pr:`2002`) by :user:`aroh3006` .. _release-1-16-6: `1.16.6 `_ - 2026-08-04 ================================================================= .. _release-1-16-6-fixed: Fixed ----- - Stop appending a second `.exe` on Windows to the app name `pipx run` infers. (:issue:`1189`) - Run the console script a package declares when `pipx run` infers the app name from a VCS URL, a local path, or a name it normalized, instead of passing a guess to `uv tool run`. Reusing that venv no longer reinstalls the package on every run. (:issue:`1994`) .. _release-1-16-5: `1.16.5 `_ - 2026-07-29 ================================================================= .. _release-1-16-5-fixed: Fixed ----- - Honor the version specifier when `pipx upgrade --install` installs a missing package. (:issue:`1987`) .. _release-1-16-4: `1.16.4 `_ - 2026-07-29 ================================================================= .. _release-1-16-4-fixed: Fixed ----- - Bundle `colorama` and `tomli` in the zipapp so the single `pipx.pyz` works on Windows and on Python 3.10. (:issue:`1978`, :issue:`1984`) - Fix the Release Notes link published in package metadata. (:issue:`1980`) - `PIPX_MAX_LOGS=0` now prunes the log files instead of keeping all of them. (:pr:`1988`) by :user:`marko1olo` .. _release-1-16-4-documentation: Documentation ------------- - Fix the documentation links that 404 after the Sphinx move, including the PyPI Release Notes link. (:issue:`1980`) .. _release-1-16-3: `1.16.3 `_ - 2026-07-26 ================================================================= .. _release-1-16-3-fixed: Fixed ----- - Allow read-only global list commands when venv lock files are inaccessible. (:issue:`1974`) .. _release-1-16-3-documentation: Documentation ------------- - Direct Linux users to PEP 668-compatible installation methods. (:issue:`1976`) .. _release-1-16-2: `1.16.2 `_ - 2026-07-21 ================================================================= .. _release-1-16-2-fixed: Fixed ----- - Stop venv inspection from crashing on Python 3.15 when a `dist-info` directory has no `Name` metadata field. `importlib.metadata` now raises `KeyError` for missing metadata keys instead of returning `None`; such distributions are skipped again, as on earlier Python versions. (:pr:`1971`) by :user:`gaborbernat` .. _release-1-16-1: `1.16.1 `_ - 2026-07-20 ================================================================= .. _release-1-16-1-fixed: Fixed ----- - Stop `install` and `list` from crashing when the pipx bin directory contains an executable pipx did not create. Scanning for launcher ownership decoded arbitrary binary content as an interpreter path, which raised `UnicodeDecodeError` on Windows, or `ValueError` for a path holding a NUL byte. Such a file is now treated as not owned by the venv. (:issue:`1965`) .. _release-1-16-0: `1.16.0 `_ - 2026-07-15 ================================================================= .. _release-1-16-0-added: Added ----- - Add `pipx list --outdated` and JSON output. (:issue:`149`) - Honour the `requires-python` a package declares. When the default interpreter fails it, pipx looks for one on the system that satisfies it and builds the environment there, so `pipx install mytool` no longer needs the user to work out the right `--python`. Pass `--fetch-python=missing` to let pipx download a matching interpreter when the system carries none, and name `--python` yourself to keep the last word. (:issue:`387`) - Normalize inferred `pipx run` package names. (:issue:`618`) - Allow `pipx list` to select installed packages by name. (:issue:`640`) - Add `--output` to structured commands. (:issue:`828`) - Freeze the structured result envelope at `pipx_result_version` 1. Every `--output json` document now carries the command as a list of CLI tokens, a `status` of `success`, `partial`, or `error`, the process `exit_code`, the command `data`, and a top-level `errors` list whose entries each have a stable `code` and `message` alongside the `environment` and `package` they concern. A failure that stops a command before it builds a result renders the same envelope. (:issue:`828`) - Add quiet and JSON output for package maintenance. (:issue:`828`) - Give `pipx reinstall` and `pipx reinstall-all` the structured output the other commands carry. They printed straight to the terminal, so `--quiet` left `uninstalled NAME!` behind and no `--output json` existed to read the result from a script. Both now report through the shared renderer. (:issue:`828`) - Give `pipx cache`, `pipx interpreter`, `pipx upgrade-shared`, `pipx manifest lock` and `pipx manifest sync` the structured output the other commands carry, so `--quiet` silences them and `--output json` hands a script the result. The `cache` and `interpreter` subcommands take `--quiet` and `--verbose` for the first time, since the shared options had passed them by. `pipx ensurepath` goes on printing its guidance, because its job is to tell a person what to add to a shell profile. (:issue:`828`) - Add `--output json` to `pipx inject` and `pipx uninject`. (:issue:`828`) - Infer the app name when running packages from VCS URLs. (:issue:`854`) - Stream installer output during verbose installs. (:issue:`1069`) - Draw the download progress bar of `pip` and `uv` during a plain `pipx install`, in place of the pipx spinner. pipx hands the installer a pseudo-terminal, which is the only way `uv` reports progress, and asks `uv` for its default output rather than the quiet one. A redirected stream keeps the previous behaviour. (:issue:`1069`) - Allow commands to skip shared-library maintenance. (:issue:`1278`) - Install PEP 723 scripts as persistent applications. (:issue:`1388`) - Point library install errors to `inject` and `--preinstall`. (:issue:`1411`) - Add commands to diagnose and repair broken environment interpreters. (:issue:`1442`) - Add explicit tool manifests with separate PEP 751 locks. (:issue:`1506`) - Add `pipx install --upgrade` to reconcile apps with package specs. (:issue:`1572`) - Expose the shell completion scripts a package ships, the way pipx already exposes its man pages. A wheel that installs into `share/bash-completion/completions`, `share/zsh/site-functions` or `share/fish/vendor_completions.d` gets those files linked under `PIPX_COMPLETION_DIR`, which defaults to `~/.local/share`. `bash` and `fish` read those locations on their own; `zsh` needs the site-functions directory on its `fpath`. Uninstalling the package takes the links away. (:issue:`1604`) - Add `pipx reset`, which uninstalls every pipx-managed package and removes the shared libraries, the caches, the standalone interpreters, the logs and the trash, so pipx starts over from a fresh install. It asks before it removes anything, `--yes` answers for a script, and `--dry-run` lists what would go. (:issue:`1606`) - Add Python interpreter arguments to `pipx run`. (:issue:`1613`) - Add commands to hide and restore resources exposed from an environment. (:issue:`1645`) - Install the generated pipx manual page from wheels. (:issue:`1657`) - Add `pipx cache` and `pipx run --refresh`. (:issue:`1681`) - Add `--include-apps-from` for selected dependencies. (:issue:`1725`) - Add `pipx exec ENVIRONMENT APPLICATION` for installed environments. (:issue:`1726`) - Support injecting extras into a main package. (:issue:`1805`) - Install from PEP 751 lock files. (:issue:`1807`) - Add dependency cooldowns. (:issue:`1811`) - Validate requested apps and restore prior environments on failure. (:issue:`1908`) - Default `PIPX_HOME` to the platform data directory on macOS and Windows again, which restores `~/Library/Application Support/pipx` on macOS. `platformdirs` reads `XDG_DATA_HOME` and `XDG_CACHE_HOME` on macOS, so exporting those moves the pipx directories. A pipx home from an earlier release keeps working as a fallback. (:issue:`1946`) - Group the manifest commands under `pipx manifest`: use `pipx manifest lock` and `pipx manifest sync` in place of the top-level `pipx lock` and `pipx sync`. Keeping both verbs under one noun matches `pipx cache` and `pipx interpreter` and leaves the top-level command list about the manifest rather than two loose verbs. - Keep `--json` only on `pipx list`, where it prints the versioned package snapshot that `install-all` reads back. Every other command dropped the `--json` alias in favour of `--output json`, so the snapshot format and the result envelope no longer share a flag. - Name the existing-environment argument `ENVIRONMENT` across `inject`, `uninject`, `expose`, `unexpose`, `pin`, `unpin`, `uninstall`, `reinstall`, and `exec`, and fix the `uninject` help, which described the argument as the environment to inject into rather than uninject from. - Record the interpreter and backend in the structured `install`, `upgrade`, and `upgrade-all` results, list `PIPX_GLOBAL_COMPLETION_DIR` in `pipx environment`, and show the shell-completion directory in `pipx list`. Completions had a global override that pipx honoured but never surfaced, and the records omitted which Python and backend produced a venv. - Rename `--include-apps-from` to `--include-resources-from` and the manifest key to match. The option always exposed manual pages and shell completions alongside apps, so the new name describes what it does; `--include-apps` keeps its name. A venv recorded under the old key is migrated on read. - Support standalone Python downloads on Windows ARM64. (:issue:`1856`) .. _release-1-16-0-fixed: Fixed ----- - Check a candidate interpreter's real patch version against `requires-python`. pipx matched only on the minor version, so it rejected an installed `3.13.7` for `>=3.13.5` and accepted `3.14.6` for `<3.14.1`. It now queries each resolved interpreter and keeps the first whose full version satisfies the constraint. (:issue:`387`) - Keep existing exposed files when copy-based environments provide the same resource name. (:issue:`540`) - Recover custom paths when pipx manages its own installation on Unix. (:issue:`774`) - Keep install failures in JSON with the correct environment. (:issue:`828`) - Show injected apps in `pipx list` on systems that copy launchers. (:issue:`997`) - Serialize shared-library maintenance across pipx processes. (:issue:`1056`) - Wait for an installation before listing its environment. (:issue:`1056`) - Draw the installer progress bar while the download runs. pipx reads the installer output over a pipe, which hid the terminal from `pip`, so `pip` held its bar back and printed a finished one once the download had ended. (:issue:`1069`) - Preserve symlinks in `PIPX_HOME` when creating environments. (:issue:`1327`) - Create Windows launchers for PEP 723 scripts. (:issue:`1388`) - Preserve the terminal encoding for applications started by `pipx run`. (:issue:`1423`) - Make `ensurepath --global` update the system PATH configuration instead of root's shell profiles. (:issue:`1452`) - Refresh `pipx_metadata.json` after `pipx runpip ... install` replaces the main package, so future `upgrade` and `reinstall` commands stop following a stale editable source path. (:issue:`1473`) - Accept bare local Git and Mercurial URLs such as `git+file:///path/to/repository@revision`. (:issue:`1583`) - Recreate the shared environment when pip cannot run. (:issue:`1606`) - Resolve local `--find-links` paths before pipx changes the working directory. (:issue:`1637`) - Keep pipx startup usable when locked files prevent trash cleanup. (:issue:`1847`) - Fix `pipx run --with` being silently dropped for PEP 723 scripts on the pip backend; the extra packages are now installed into the temporary venv. (:issue:`1850`) - Fix `pipx uninject` leaving the injected package's man page symlinks behind. (:issue:`1853`) - Preserve `--editable` when installing a local package after a non-local package. (:issue:`1856`) - Emit path warnings after logging setup for the active local or global context. (:issue:`1856`) - Treat empty values for pipx path environment variables as unset. (:issue:`1856`) - Apply a 30-second timeout to standalone Python index and archive network requests. (:issue:`1856`) - Enforce pip's minimum supported version when shared-library upgrades include a user pin. (:issue:`1856`) - Remove exposed apps that use a venv suffix when uninjecting packages. (:issue:`1856`) - Report corrupt venv metadata as missing without aborting list and bulk commands. (:issue:`1856`) - Return user-set values for the advertised `pipx environment --value` choices. (:issue:`1856`) - Keep existing venv metadata when pipx cannot write its replacement. (:issue:`1856`) - Keep trash cleanup non-fatal during directory races and failed moves. (:issue:`1856`) - List only packages whose pin state changed during `pipx unpin`. (:issue:`1856`) - Reject both Windows path separators in package names. (:issue:`1856`) - Resolve relative paths in attached `-c` and `-f` pip arguments before invoking pip. (:issue:`1856`) - Avoid import crashes when stderr or its encoding is unavailable. (:issue:`1856`) - Keep Windows uninject working while an exposed app runs. (:issue:`1856`) - Translate pip binary and trusted-host options to valid uv run arguments, and reject `--no-deps` before invoking uv. (:issue:`1856`) - Validate members before extracting a standalone Python archive on Python 3.10.0-3.10.11, which predate tarfile's data filter. pipx now refuses any entry whose path or link target would escape the download directory instead of falling back to an unfiltered extraction. (:issue:`1856`) - Return a resolution error when standalone Python lacks a build for the current platform. (:issue:`1856`) - Prevent `install --force` from saving its internal `--force-reinstall` option in package metadata. (:issue:`1856`) - Continue bulk interpreter upgrades after finding a corrupt standalone interpreter. (:issue:`1856`) - Skip shared-library maintenance for no-op installs. (:issue:`1856`) - Compare metadata versions by parsed release components when selecting shared libraries. (:issue:`1856`) - Wait for the animation thread to stop before clearing its final frame. (:issue:`1856`) - Preserve standalone archive extraction on Python 3.10 releases without the tar filter API. (:issue:`1856`) - Preserve noncanonical package prefixes when completing names of installed virtual environments. (:issue:`1856`) - Preserve stored pip arguments for injected packages during `pipx upgrade --include-injected`. (:issue:`1856`) - Preserve caller-owned pip arguments when creating the shared-library environment. (:issue:`1856`) - Use tar's data filter when extracting standalone Python archives for consistent safety. (:issue:`1856`) - Skip cache tag files when removing expired `pipx run` environments. (:issue:`1856`) - Allow forced installs in existing uv environments. (:issue:`1924`) - Store the venv cache and the logs in the platform cache and log directories on every platform, and keep them out of a pipx home that only exists for backwards compatibility. Setting `PIPX_HOME` still keeps both inside it. (:issue:`1946`) - Retry fetching a standalone Python build when GitHub's release CDN returns a transient error, so a momentary `5xx` or rate-limit response no longer fails the install. (:pr:`1960`) by :user:`gaborbernat` - Apply `pipx manifest lock` as all-or-nothing. When a manifest declares locks for several tools, pipx wrote the regenerated files one at a time, so a failure partway left some locks new and others old. Each target is now backed up before it is replaced and every applied lock is rolled back if any replacement fails. - Apply a 30-second timeout and a 10 MiB size limit when downloading a remote `pipx run` or `pipx install` script, so a stalled or oversized URL can no longer hang pipx or exhaust memory. `pipx run` also accepts a script URL with a query string, such as `tool.py?raw=1`, by matching on the URL path. - Honour a script's PEP 723 `requires-python` on the pip-backed `pipx run`. Without an explicit `--python`, pipx now picks an interpreter that satisfies the constraint, matching what the uv backend already does; with an explicit `--python` that fails the constraint it reports a clear error instead of running on an unsupported interpreter. A malformed inline metadata block now produces a readable message rather than a traceback. - Include `pipx run --with` packages in the run-cache key. Because they were absent, `pipx run TOOL` and `pipx run --with EXTRA TOOL` shared one cached environment, so extras injected by one invocation leaked into later runs that asked for none. Each distinct set of extras now gets its own environment, and the extras are injected only when that environment is first built. - Keep the backups that `install --force` and `reinstall` take while replacing an environment in pipx's trash rather than beside the live environments. A backup placed in the venvs directory was picked up as a broken environment by a concurrent `list` or `reinstall-all`; the trash shares the home's filesystem, so restoring a backup is still an atomic rename. - Make a standalone Python upgrade atomic. pipx used to delete the existing interpreter before downloading its replacement, so a failed download or unpack left no interpreter at all. The new build is now staged beside the old one and swapped in only once it is complete, and a failed swap restores the previous build. - Make the expired-cache sweep that runs before `pipx run` non-blocking. pipx used to wait for a lock on every other cached environment, so a concurrent `pipx run` whose install held one of those locks stalled the new run, a stall that lasted the whole install on Windows. The sweep now skips any environment another run is holding. - Probe a `uv` found on `PATH` before using it, and bound every `uv --version` call with a timeout. A broken or hanging `uv` on `PATH` previously slipped past discovery and stalled the next command; pipx now skips such a binary and reports a clear error instead of hanging. - Reject a `--suffix` that contains anything but letters, digits, and the characters `.` `_` `-` `+` `@`. A suffix is spliced straight into the names of the links pipx writes into `PIPX_BIN_DIR` and `PIPX_MAN_DIR`, so a value such as `/../../owned` could previously place those links outside pipx's own directories. - Remove a dropped app or man page during upgrade only when pipx still owns the exposed file, so a command a user or another package replaced is left in place. - Report `pipx expose` as a partial or failed operation when a resource cannot be linked because a foreign file already occupies its target name. The command previously claimed success while silently leaving those apps or manual pages unexposed; it now lists each conflict in the structured result and exits non-zero. - Stop forcing `UV_NO_PROGRESS=1` on every uv call. The override silenced uv's progress bar even when pipx asked to draw one, and it ignored a `UV_NO_PROGRESS` already set in the environment. pipx now suppresses the bar only in quiet, JSON, and non-interactive runs, and defers to the caller's `UV_NO_PROGRESS` when it does want progress shown. - Track exposed shell completions through the full environment lifecycle. Stale-resource cleanup during install, upgrade, and reinstall, the repair relink check, and the ownership filter now include completions alongside apps and manual pages, so a completion a package stops shipping is removed instead of lingering. .. _release-1-16-0-performance: Performance ----------- - Check upgrades in parallel and skip installs for current packages. (:issue:`1353`) - Index installed distributions during environment inspection. (:issue:`1856`) - Cache each managed environment's site-packages path. (:issue:`1856`) - Read environment metadata once per interpreter upgrade. (:issue:`1856`) - Cache shared-library validity checks within each pipx process. (:issue:`1856`) - List managed environments only when shell completion requests them. (:issue:`1856`) - Cache resolved pipx context paths. (:issue:`1856`) - Compute only the requested `pipx environment --value` entry. (:issue:`1856`) - Reuse the `pipx run` PATH lookup when warning about an existing app. (:issue:`1856`) - Resolve the default Python interpreter on first use instead of import. (:issue:`1856`) - Inspect legacy environments once during uninstall and reinstall. (:issue:`1856`) .. _release-1-16-0-documentation: Documentation ------------- - Document the pipx application scope and feature boundaries. (:issue:`177`) .. _release-1-16-0-removed: Removed ------- - Remove the `PIPX_HOME_ALLOW_SPACE` environment variable and the warning about spaces in the pipx home path. `pip` and `uv` both write a `/bin/sh` wrapper as the shebang when the interpreter path contains a space, so applications installed into such a path run. (:issue:`1946`) .. _release-1-15-2: `1.15.2 `_ - 2026-07-15 ================================================================= .. _release-1-15-1: `1.15.1 `_ - 2026-07-15 ================================================================= .. _release-1-15-1-fixed: Fixed ----- - Retry fetching a standalone Python build when GitHub's release CDN returns a transient error, so a momentary ``5xx`` or rate-limit response no longer fails the install. (:pr:`1960`) by :user:`gaborbernat` .. _release-1-15-0: `1.15.0 `_ - 2026-06-24 ================================================================= .. _release-1-15-0-added: Added ----- - Add a ``--dry-run`` flag to ``pipx ensurepath`` that reports which directories would be added to ``PATH`` without modifying ``PATH`` or any shell configuration file. (:issue:`1014`) .. _release-1-15-0-fixed: Fixed ----- - Fix ``pipx uninject`` for uv-backed environments, where uv lacks pip's ``list --not-required`` option. Both backends now derive the not-required set from installed distribution metadata, so they behave identically, and a dependency pulled in only by another package's extra is kept instead of being treated as removable. (:issue:`1841`) .. _release-1-14-1: `1.14.1 `_ - 2026-06-17 ================================================================= .. _release-1-14-1-fixed: Fixed ----- - Restore the original package if ``pipx reinstall-all`` is interrupted during reinstall. (:issue:`966`) - Allow importing ``pipx.util`` before ``pipx.paths``. (:issue:`1333`) - Ensure ``pipx inject --force`` reinstalls injected packages without persisting the force-reinstall option in package metadata. (:issue:`1545`) - Preserve installer errors when package-name detection fails during local package installs. (:issue:`1567`) - Expose manual pages for editable local package installs. (:issue:`1793`) - Fixed ``pipx pin --injected-only`` so it can pin injected packages when the main app is already pinned. (:pr:`1835`) by :user:`marko1olo` .. _release-1-14-0: `1.14.0 `_ - 2026-06-04 ================================================================= .. _release-1-14-0-added: Added ----- - Add ``--no-path-check`` to ``pipx run`` to skip the warning when the app is already on ``PATH``. Useful for shim scripts that wrap ``pipx run `` under the same name as the app. (:issue:`1824`) .. _release-1-14-0-documentation: Documentation ------------- - Use the bootstrap pipx executable when adding PATH in self-managed installs. (:issue:`1481`) .. _release-1-13-0: `1.13.0 `_ - 2026-05-30 ================================================================= .. _release-1-13-0-added: Added ----- - Add ``PIPX_DISABLE_SHARED_LIBS_AUTO_UPGRADE`` to skip automatic shared library upgrades. (:issue:`1650`) - Update the error message in get-pipx.py; it is obsoleted, not deprecated. (:pr:`1813`) by :user:`Spitfire1900` .. _release-1-13-0-fixed: Fixed ----- - Install app scripts with shebangs that ignore ``PYTHONPATH``. (:issue:`1584`) - Refresh cached standalone Python indexes written by older pipx versions before using ``--fetch-missing-python``. (:issue:`1774`) .. _release-1-13-0-documentation: Documentation ------------- - Document how to clear cache warnings from ``runpip``. (:issue:`1802`) .. _release-1-12-0: `1.12.0 `_ - 2026-05-06 ================================================================= .. _release-1-12-0-added: Added ----- - Add ``--fetch-python`` and ``PIPX_FETCH_PYTHON`` to control standalone Python downloads, with values ``always``, ``missing``, or ``never``. (:pr:`1663`) by :user:`13steinj` and :user:`gaborbernat` - Add an opt-in ``uv`` backend. Install ``pipx[uv]`` (or put ``uv`` on ``PATH``) and pipx will create venvs with ``uv venv``, manage packages with ``uv pip``, and run ephemeral apps via ``uv tool run``. **Default change:** when ``uv`` is reachable, pipx uses it for **new** venvs by default. Existing venvs keep their recorded backend; flipping a venv requires ``pipx reinstall NAME --backend X``. Set ``PIPX_DEFAULT_BACKEND=pip`` to force pip even when uv is available, or pass ``--backend pip`` per command. ``pipx install pip`` always picks the pip backend (uv venvs have no pip). Selection precedence: ``--backend`` > recorded venv metadata > ``PIPX_DEFAULT_BACKEND`` > auto-detect. The env var sits *below* metadata so setting ``PIPX_DEFAULT_BACKEND=uv`` does not silently retarget existing pip-backed venvs; the recorded backend wins until you flip it explicitly. ``pipx environment`` exposes the resolved backend and its source as ``PIPX_RESOLVED_BACKEND`` and ``PIPX_BACKEND_SOURCE``. **Metadata version bump:** the venv metadata file format moves from ``0.5`` to ``0.6`` to record the chosen backend. Running this pipx version against an existing venv rewrites its metadata to ``0.6`` on the next install/upgrade. If you downgrade pipx after that, the older version raises ``Unknown metadata version 0.6`` and you'll need to reinstall the affected venvs from the older pipx (``pipx reinstall NAME``). (:issue:`1808`) .. _release-1-12-0-deprecated: Deprecated ---------- - Deprecate ``--fetch-missing-python`` and ``PIPX_FETCH_MISSING_PYTHON``; both are now aliases for ``--fetch-python=missing`` / ``PIPX_FETCH_PYTHON=missing``. ``PIPX_FETCH_MISSING_PYTHON=0`` (and other falsy values) now disables fetching, where previously any value enabled it. The ``pipx.interpreter.find_python_interpreter`` keyword ``fetch_missing_python: bool`` is replaced by ``fetch_python: FetchPythonOptions``. (:pr:`1663`) by :user:`13steinj` and :user:`gaborbernat` .. _release-1-11-2: `1.11.2 `_ - 2026-05-05 ================================================================= .. _release-1-11-2-added: Added ----- - Add ``pipx help`` and ``pipx help `` aliases for existing help output. (:issue:`1535`) - Drop support for Python 3.9. (:pr:`1786`) by :user:`henryiii` - Replace if/elif command dispatch in main.py with argparse set_defaults. (:issue:`1794`) .. _release-1-11-1: `1.11.1 `_ - 2026-03-31 ================================================================= .. _release-1-11-1-fixed: Fixed ----- - Ignore ``PIP_TARGET`` environment variable to prevent pip from installing outside the venv. (:issue:`735`) - Fix ``pipx install`` failing on Windows when the username contains non-Latin characters (e.g. cyrillic, Chinese). (:issue:`780`) - Show installed version and suggest ``upgrade`` when ``install`` detects an existing installation. (:issue:`795`) - ``--verbose`` and ``--quiet`` flags before the subcommand are no longer silently ignored. (:issue:`1282`) - Remove dependency app symlinks when uninjecting a package that was injected with ``--include-deps``. (:issue:`1364`) - Remove ``setuptools`` from shared libs to prevent version conflicts when app venvs use a different Python. (:issue:`1539`) - Prevent ``uninject`` from removing dependencies still needed by other packages in the venv. (:issue:`1672`) .. _release-1-11-0: `1.11.0 `_ - 2026-03-23 ================================================================= .. _release-1-11-0-added: Added ----- - Add ``PIPX_MAX_LOGS`` environment variable to configure the maximum number of log files to keep. (:issue:`1570`) .. _release-1-11-0-fixed: Fixed ----- - Allow ``--pip-args`` to be passed to ``upgrade-all``. (:issue:`1503`) - Fix ``--pip-args='-c '`` breaking when constraint target is a URL instead of a local path. (:issue:`1582`) - Enable keyring authentication for private package indexes by setting ``PIP_KEYRING_PROVIDER=subprocess`` (:issue:`1603`) - Added error handling for ``pip list`` command failures (:issue:`1698`) .. _release-1-11-0-documentation: Documentation ------------- - Remove outdated "experimental" note from ``--suffix`` help text. (:issue:`1621`) .. _release-1-10-1: `1.10.1 `_ - 2026-03-20 ================================================================= .. _release-1-10-1-fixed: Fixed ----- - Use stored pip_args from install metadata when running ``pipx upgrade`` without explicit ``--pip-args`` (:issue:`1441`) - Fix ``--global`` flag being silently ignored when placed before the subcommand (:issue:`1443`) - Respect ``--quiet`` flag in ``install`` and suppress animations and success messages (:issue:`1508`) - Catch missing Python interpreter in ``upgrade`` and show helpful error instead of crashing (:issue:`1602`) - Prevent data loss by rejecting relative paths in ``uninstall``, ``reinstall``, and other package-name arguments (:issue:`1718`) .. _release-1-10-0: `1.10.0 `_ - 2026-03-18 ================================================================= .. _release-1-10-0-added: Added ----- - Add ``--with`` flag to ``pipx run`` to allow injecting dependencies (:issue:`1607`) - add more specific directions in the logs towards a resolution if you have a space in the PIX_HOME path (:issue:`1634`) .. _release-1-10-0-fixed: Fixed ----- - Fixed upgrade command failing when package name includes extras (e.g., ``pipx upgrade "coverage[toml]"``). (:issue:`925`) - Fix run command with bash substitution (e.g. ``pipx run <(pbpaste)``) (:issue:`1293`) - Allow ``pipx runpip`` to split single string arguments. (:issue:`1520`) - Fix handling of shared libraries when the original Python interpreter is removed on Windows by detecting stale venv references and recreating the shared libraries with the current Python. (:issue:`1723`) .. _release-1-9-0: `1.9.0 `_ - 2026-03-17 =============================================================== .. _release-1-9-0-added: Added ----- - Add completion choices for ``pipx environment --value``. (:issue:`1498`) .. _release-1-9-0-fixed: Fixed ----- - Ignore recursive symlink loops in PIPX_BIN_DIR. (:issue:`1592`) - ``pipx reinstall``: An exception will now be raised if package is pinned. (:issue:`1611`) - Stop ``pipx run`` from leaving bad temporary venvs when first installation was unsuccessful. (:issue:`1709`) .. _release-1-8-0: `1.8.0 `_ - 2025-09-30 =============================================================== .. _release-1-8-0-added: Added ----- - Rename environmental variable ``USE_EMOJI`` to ``PIPX_USE_EMOJI``. (:issue:`1395`) - Add ``--all-shells`` flag to ``pipx ensurepath``. (:issue:`1585`) - Add support for Python 3.13 (:pr:`1647`) by :user:`dukecat0` .. _release-1-8-0-fixed: Fixed ----- - On Windows, no longer overwrite existing files on upgrade if source and destination are the same (:issue:`683`) - Update the logic of finding python interpreter such that ``--fetch-missing-python`` works on Windows (:issue:`1521`) - Fix no message displayed when no packages are upgraded with ``upgrade-all``. (:issue:`1565`) - Fix incorrect order of flags when using ``pipx upgrade``. (:issue:`1610`) - Update the archive name of build of Python for Windows (:issue:`1630`) - Set up standalone python fetching to use checksums directly from the GitHub API. (:issue:`1652`) - Fix running a script with explicitly empty ``dependencies = []``. (:pr:`1658`) by :user:`neutrinoceros` .. _release-1-8-0-documentation: Documentation ------------- - Fix ``/changelog/`` and ``/contributing/`` docs URLs (:issue:`1540`) .. _release-1-7-1: `1.7.1 `_ - 2024-08-23 =============================================================== .. _release-1-7-1-fixed: Fixed ----- - Use minimum supported Python to build zipapp in release action such that ``tomli`` is included in it. (:issue:`1514`) .. _release-1-7-0: `1.7.0 `_ - 2024-08-22 =============================================================== .. _release-1-7-0-added: Added ----- - Add a ``--prepend`` option to the ``pipx ensurepath`` command to allow prepending ``pipx``'s location to ``PATH`` rather than appending to it. This is useful when you want to prioritize ``pipx``'s executables over other executables in your ``PATH``. (:pr:`1451`) by :user:`agriyakhetarpal` and :user:`Gitznik` - List ``PIPX_GLOBAL_[HOME|BIN_DIR|MAN_DIR]`` in ``pipx environment``. (:issue:`1492`) - Introduce ``PIPX_HOME_ALLOW_SPACE`` environment variable, to silence the spaces in pipx home path warning (:issue:`1320`) .. _release-1-7-0-fixed: Fixed ----- - Fix passing constraints file path into ``pipx install`` operation via ``pip`` args (:issue:`1389`) - Stop ``pipx install --global`` from installing files in ``~/.local``. (:issue:`1475`) - Fix installation abortion on multiple packages when one or more are already installed. (:issue:`1509`) .. _release-1-7-0-documentation: Documentation ------------- - Add help messages for ``pipx pin`` and ``pipx unpin`` commands. (:pr:`1438`) by :user:`dukecat0` - Move all documentation files to the ``docs`` directory. (:issue:`1479`) .. _release-1-6-0: `1.6.0 `_ - 2024-06-01 =============================================================== .. _release-1-6-0-added: Added ----- - Add ``install-all`` command to install packages according to spec metadata file. (:issue:`687`) - Introduce ``pipx pin`` and ``pipx unpin`` commands, which can be used to pin or unpin the version of an installed package, so it will not be upgraded by ``pipx upgrade`` or ``pipx upgrade-all``. (:issue:`891`) - Add a new option ``--pinned`` to ``pipx list`` command for listing pinned packages only. (:issue:`891`) - Add ``pipx interpreter upgrade`` command to upgrade local standalone python in micro/patch level (:issue:`1249`) - Add ``--requirement`` option to ``inject`` command to read list of packages from a text file. (:pr:`1252`) by :user:`jamesmyatt`, :user:`chrysle` and :user:`huxuan` - Add ``pipx upgrade-shared`` command, to create/upgrade shared libraries as a standalone command. (:pr:`1316`) by :user:`mayeut` and :user:`chrysle` - Allow ``upgrade`` command to accept multiple packages as arguments. (:issue:`1336`) - Support Python version for ``--python`` arg when py launcher is not available (:issue:`1342`) - Make ``install-all`` gather errors in batch (:pr:`1348`) by :user:`huxuan`, :user:`dukecat0` and :user:`chrysle` - Support python3.8 for standalone python builds (:issue:`1375`) .. _release-1-6-0-fixed: Fixed ----- - Resolve the ``DEFAULT_PYTHON`` to the actual absolute path (:issue:`965`) - Fix error log overwrite for "-all" batch operations. (:issue:`1132`) - Do not reinstall already injected packages without ``--force`` being passed. (:issue:`1300`) - Only show ``--python`` and ``--force`` flag warning if both flags are present (:issue:`1304`) - Don't allow paths to be passed into ``pipx reinstall``, as this might wreak havoc. (:issue:`1324`) - Make the Python ``venv`` module arguments work with ``upgrade --install`` (:issue:`1344`) - Fix version check for standalone python (:issue:`1349`) - Validate package(s) argument should not be path(s). (:issue:`1354`) - Validate whether a package is an URL correctly. (:issue:`1355`) - Install specified version of ``--preinstall`` dependency instead of latest version (:issue:`1377`) - Move ``--global`` option into shared parser, such that it can be passed after the subcommand, for example ``pipx ensurepath --global``. (:pr:`1397`) by :user:`dukecat0` - Fix discovery of a ``pipx run`` entry point if a local path was given as package. (:pr:`1422`) by :user:`chrysle` and :user:`huxuan` .. _release-1-6-0-documentation: Documentation ------------- - Create a dedicated section for manual pages and add an example with ``pdm-backend``. (:pr:`1312`) by :user:`pawamoy` - Add example, test and cli help description how to install multiple packages with the --preinstall flag (:pr:`1321`) by :user:`gempir` - Refine docs generation script and template. (:issue:`1325`) - Add a note about sourcing the shell config file for ``ensure_path`` (:issue:`1346`) .. _release-1-5-0: `1.5.0 `_ - 2024-03-29 =============================================================== .. _release-1-5-0-added: Added ----- - Add ``--global`` option to ``pipx`` commands. - This will run the action in a global scope and affect environment for all system users. (:issue:`754`) - Add a ``--fetch-missing-python`` flag to all commands that accept a ``--python`` flag. - When combined, this will automatically download a standalone copy of the requested python version if it's not already available on the user's system. (:issue:`1242`) - Add commands to list and prune standalone interpreters (:issue:`1248`) - Revert platform-specific directories on MacOS and Windows - They were leading to a lot of issues with Windows sandboxing and spaces in shebangs on MacOS. (:issue:`1257`) - Add ``--install`` option to ``pipx upgrade`` command. - This will install the package given as argument if it is not already installed. (:issue:`1262`) .. _release-1-5-0-fixed: Fixed ----- - Correctly resolve home directory in pipx directory environment variables. (:issue:`94`) - Pass through ``pip`` arguments when upgrading shared libraries. (:issue:`964`) - Fix installation issues when files in the working directory interfere with venv creation process. (:issue:`1091`) - Report correct filename in tracebacks with ``pipx run `` (:pr:`1191`) by :user:`henryiii` and :user:`flying-sheep` - Let self-managed pipx uninstall itself on windows again. (:issue:`1203`) - Fix path resolution for python executables looked up in PATH on windows. (:issue:`1205`) - Display help message when ``pipx install`` is run without arguments. (:pr:`1266`) by :user:`dukecat0` - Fix crashes due to superfluous ``-q`` flags by discarding exceeding values (:issue:`1283`) .. _release-1-5-0-documentation: Documentation ------------- - Update the completion instructions for zipapp users. (:issue:`1072`) - Update the example for running scripts with dependencies. (:issue:`1227`) - Update the docs for package developers on the use of configuration using pyproject.toml (:issue:`1229`) - Add installation instructions for Fedora (:pr:`1239`) by :user:`markmc` - Update the examples for installation from local dir (:issue:`1277`) - Fix inconsistent wording in ``pipx install`` command description. (:pr:`1307`) by :user:`sgryjp` .. _release-1-5-0-deprecated: Deprecated ---------- - Deprecate ``--skip-maintenance`` flag of ``pipx list``; maintenance is now never executed there (:pr:`1256`) by :user:`Arpafaucon` .. _release-1-4-3: `1.4.3 `_ - 2024-01-16 =============================================================== .. _release-1-4-3-fixed: Fixed ----- - Autofix python version for pylauncher, when version is provided prefixed with ``python`` (:issue:`1150`) .. _release-1-4-3-documentation: Documentation ------------- - Provide useful error messages when unresolvable python version is passed (:issue:`1150`) - Introduce towncrier for managing the changelog (:issue:`1161`) - Add workaround for using pipx applications in shebang under macOS (:issue:`1198`) .. _release-1-4-3-packaging: Packaging --------- - Support building pipx wheels with setuptools-scm<7, such as on FreeBSD. (:pr:`1208`) by :user:`henryiii` .. _release-1-4-2: `1.4.2 `_ ================================================== .. _release-1-4-2-added: Added ----- - Allow skipping maintenance tasks during list command - Raise more user friendly error when provided ``--python`` version is not found - Update ``pipx run`` on scripts using ``/// script`` and no ``run`` table following the updated version of PEP 723 (:pr:`1180`) by :user:`henryiii` and :user:`dukecat0` .. _release-1-4-2-fixed: Fixed ----- - Include ``tomli`` into ``pipx.pyz`` (zipapp) so that it can be executed with Python 3.10 or earlier (:issue:`1142`) - Fix resolving the python executable path on linux - ``pipx run``: Verify whether the script name provided is a file before running it - Avoid repeated exception logging in a few rare cases (:pr:`1192`) by :user:`henryiii` .. _release-1-4-1: `1.4.1 `_ ================================================== .. _release-1-4-1-fixed: Fixed ----- - Set default logging level to WARNING, so debug log messages won't be shown without passing additional flags such as ``--verbose`` .. _release-1-4-0: `1.4.0 `_ ================================================== .. _release-1-4-0-added: Added ----- - Add ``--quiet`` and ``--verbose`` options for the ``pipx`` subcommands - Add ability to install multiple packages at once - Delete directories directly instead of spawning rmdir on Windows .. _release-1-4-0-fixed: Fixed ----- - "Failed to delete" error when using Microsoft Store Python - "No pyvenv.cfg file" error when using Microsoft Store Python (:issue:`1164`) .. _release-1-4-0-documentation: Documentation ------------- - Add Scoop installation instructions .. _release-1-3-3: `1.3.3 `_ ================================================== .. _release-1-3-3-documentation: Documentation ------------- - Make the logo more visible in dark mode .. _release-1-3-2: `1.3.2 `_ ================================================== .. _release-1-3-2-added: Added ----- - The project version number is now dynamic and generated from the VCS at build time .. _release-1-3-2-documentation: Documentation ------------- - Add additional example for --pip-args option, to docs/examples.md .. _release-1-3-1: `1.3.1 `_ ================================================== .. _release-1-3-1-fixed: Fixed ----- - Fix combining of --editable and --force flag .. _release-1-3-0: `1.3.0 `_ ================================================== .. _release-1-3-0-added: Added ----- - Allow running ``pip`` with ``pipx run`` - Add ``--with-suffix`` for ``pipx inject`` command - ``pipx install``: emit a warning when ``--force`` and ``--python`` were passed at the same time - Add explicit 3.12 support - Make usage message in ``pipx run`` show ``package_or_url``, so extra will be printed out as well - Use the py launcher, if available, to select Python version with the ``--python`` option - Add ``pipx install --preinstall`` to support preinstalling build requirements - Return an error message when directory can't be added to PATH successfully - Expose manual pages included in an application installed with ``pipx install`` - Check whether pip module exists in shared lib before performing any actions, such as ``reinstall-all``. - Support `inline script metadata `_ in ``pipx run``. - Imply ``--include-apps`` when running ``pipx inject --include-deps`` - Add ``--force-reinstall`` to pip arguments when ``--force`` was passed - Support including requirements in scripts run using ``pipx run`` (:pr:`916`) by :user:`pfmoore` - Pass ``pip_args`` to ``shared_libs.upgrade()`` - Fallback to user's log path if the default log path (``$PIPX_HOME/logs``) is not writable to aid with pipx being used for multi-user (e.g. system-wide) installs of applications - Don't show escaped backslashes for paths in console output - Move ``pipx`` paths to ensure compatibility with the platform-specific user directories - Pass ``--no-input`` to pip when output is not piped to parent stdout - Print all environment variables in ``pipx environment`` - Drop support for Python 3.7 .. _release-1-3-0-fixed: Fixed ----- - Fix wrong interpreter usage when injecting local pip-installable dependencies into venvs - Fix program name in generated manual page .. _release-1-3-0-performance: Performance ----------- - Drop ``setuptools`` and ``wheel`` from the shared libraries. This results in less time consumption when the libraries are automatically upgraded. .. _release-1-3-0-documentation: Documentation ------------- - Add more examples for ``pipx run`` - Add subsection to make README easier to read .. _release-1-2-1: `1.2.1 `_ ================================================== .. _release-1-2-1-fixed: Fixed ----- - Fix compatibility to packaging 23.2+ by removing reliance on packaging's requirement validation logic and detecting a URL-based requirement in pipx. (:issue:`1070`) .. _release-1-2-0: `1.2.0 `_ ================================================== .. _release-1-2-0-added: Added ----- - Add ``pipx uninject`` command (:pr:`820`) by :user:`dukecat0` and :user:`cs01` - Ship a `zipapp `_ of pipx - Match pip's behaviour when package name ends with archive extension (treat it as a path) - Change the program name to ``path/to/python -m pipx`` when running as ``python -m pipx`` .. _release-1-2-0-fixed: Fixed ----- - Improve the detection logic for MSYS2 to avoid entering infinite loop (:pr:`908`) (:pr:`938`) by :user:`dukecat0` and :user:`chdka` - Remove extra trailing quote from exception message - Fix EncodingWarning in ``pipx_metadata_file``. .. _release-1-2-0-documentation: Documentation ------------- - Add an example for installation from source with extras - Fix ``pipx run`` examples and update Python versions used by ``pipx install`` examples .. _release-1-1-0: `1.1.0 `_ ================================================== .. _release-1-1-0-added: Added ----- - Add ``pipx environment`` command (:pr:`793`) by :user:`dukecat0` and :user:`cs01` - Add ``list --short`` option to list only package names (:pr:`804`) by :user:`93578237` - Improve the behaviour of ``shlex.split`` on Windows, so paths on Windows can be handled properly when they are passed in ``--pip-args``. (:pr:`794`) by :user:`dukecat0` - Add additional examples for installation from git repos - Add a CACHEDIR.TAG to the cache directory to prevent it from being included in archives and backups. For more information about cache directory tags, see https://bford.info/cachedir .. _release-1-1-0-fixed: Fixed ----- - Fix encoding issue on Windows when pip fails to install a package .. _release-1-1-0-documentation: Documentation ------------- - Add more examples - Fix the command for `installing development version `_. (:pr:`801`) by :user:`dukecat0` - Fix test status badge in readme file .. _release-1-1-0rc0: `1.1.0rc0 `_ ======================================================== .. _release-1-1-0rc0-added: Added ----- - Add ``pipx environment`` command (:pr:`793`) by :user:`dukecat0` and :user:`cs01` - Add ``list --short`` option to list only package names (:pr:`804`) by :user:`93578237` - Improve the behaviour of ``shlex.split`` on Windows, so paths on Windows can be handled properly when they are passed in ``--pip-args``. (:pr:`794`) by :user:`dukecat0` - Add additional examples for installation from git repos - Add a CACHEDIR.TAG to the cache directory to prevent it from being included in archives and backups. For more information about cache directory tags, see https://bford.info/cachedir .. _release-1-1-0rc0-fixed: Fixed ----- - Fix encoding issue on Windows when pip fails to install a package .. _release-1-1-0rc0-documentation: Documentation ------------- - Add more examples - Fix the command for `installing development version `_. (:pr:`801`) by :user:`dukecat0` - Fix test status badge in readme file .. _release-1-0-0: `1.0.0 `_ ================================================== .. _release-1-0-0-added: Added ----- - Support `argcomplete 2.0.0 `_ (:pr:`790`) by :user:`hswong3i` - Include machinery to build a manpage for pipx with `argparse-manpage `_. - Add better handling for 'app not found' when a single app is present in the project, and an improved error message (:pr:`733`) by :user:`tusharsadhwani` .. _release-1-0-0-fixed: Fixed ----- - Fixed animations sending output to stdout, which can break JSON output. (:issue:`769`) - Fix typo in ``pipx upgrade-all`` output .. _release-0-17-0: `0.17.0 `_ ==================================================== .. _release-0-17-0-added: Added ----- - Support ``pipx run`` with version constraints and extras. (:issue:`697`) .. _release-0-16-5: `0.16.5 `_ ==================================================== .. _release-0-16-5-fixed: Fixed ----- - Fixed ``pipx list`` output phrasing to convey that python version displayed is the one with which package was installed. - Fixed ``pipx install`` to provide return code 0 if venv already exists, similar to pip's behavior. (:pr:`736`) by :user:`wombelix` .. _release-0-16-5-documentation: Documentation ------------- - Update ansible's install command in `Programs to Try document `_ to work with Ansible 2.10+ (:pr:`742`) by :user:`Jackenmen` .. _release-0-16-4: `0.16.4 `_ ==================================================== .. _release-0-16-4-fixed: Fixed ----- - Fix to ``pipx ensurepath`` to fix behavior in user locales other than UTF-8, to fix :issue:`644`. The internal change is to use userpath v1.6.0 or greater. (:pr:`700`) by :user:`itsayellow` - Fix virtual environment inspection for Python releases that uses an int for its release serial number. (:pr:`706`) by :user:`uranusjr` - Fix PermissionError in windows when pipx manages itself. (:pr:`718`) by :user:`mattmess1221` .. _release-0-16-3: `0.16.3 `_ ==================================================== .. _release-0-16-3-changes: Changes ------- - Organization: pipx is extremely pleased to now be a project of the Python Packaging Authority (PyPA)! Note that our github URL has changed to `pypa/pipx `_ - Changed ``pipx ensurepath`` bash behavior so that only one of {``~/.profile``, ``~/.bash_profile``} is modified with the extra pipx paths, not both. Previously, if a ``.bash_profile`` file was created where one didn't exist, it could cause problems, e.g. :issue:`456`. The internal change is to use userpath v1.5.0 or greater. (:pr:`684`) by :user:`itsayellow` .. _release-0-16-3-fixed: Fixed ----- - Fixed ``pipx list --json`` to return valid json with no venvs installed. Previously would return an empty string to stdout. (:pr:`681`) by :user:`itsayellow` .. _release-0-16-3-packaging: Packaging --------- - Colorama is now only installed on Windows. (:pr:`691`) by :user:`hwalinga` .. _release-0-16-2-1: `0.16.2.1 `_ ======================================================== .. _release-0-16-2-1-changes: Changes ------- - Changed non-venv-info warnings and notices from ``pipx list`` to print to stderr. This especially prevents ``pipx list --json`` from printing invalid json to stdout. (:pr:`680`) by :user:`itsayellow` .. _release-0-16-2-1-fixed: Fixed ----- - Fixed bug that could cause uninstall on Windows with injected packages to uninstall too many apps from the local binary directory. (:pr:`679`) by :user:`itsayellow` .. _release-0-16-2-0: `0.16.2.0 `_ ======================================================== .. _release-0-16-2-0-added: Added ----- - Added ``--json`` switch to ``pipx list`` to output rich json-metadata for all venvs. - Changed venv minimum python version to 3.6, removing python 3.5 which is End of Life. (:pr:`666`) by :user:`itsayellow` .. _release-0-16-2-0-changes: Changes ------- - Ensured log files are utf-8 encoded to prevent Unicode encoding errors from occurring with emojis. (:pr:`646`) by :user:`itsayellow` .. _release-0-16-2-0-fixed: Fixed ----- - Fixed bug :issue:`670` where uninstalling a venv could erroneously uninstall other apps from the local binary directory. (:pr:`672`) by :user:`itsayellow` - Fixed issue which made pipx incorrectly list apps as part of a venv when they were not installed by pipx. (:pr:`650`) by :user:`itsayellow` - Fixed old regression that would prevent pipx uninstall from cleaning up linked binaries if the venv was old and did not have pipx metadata. (:pr:`651`) by :user:`itsayellow` - Fixed bugs with suffixed-venvs on Windows. Now properly summarizes install, and actually uninstalls associated binaries for suffixed-venvs. (:pr:`653`) by :user:`itsayellow` .. _release-0-16-1-0: `0.16.1.0 `_ ======================================================== .. _release-0-16-1-0-added: Added ----- - Introduce the ``pipx.run`` entry point group as an alternative way to declare an application for ``pipx run``. - Support text colors on Windows. (:pr:`612`) by :user:`itsayellow` .. _release-0-16-1-0-changes: Changes ------- - Better platform unicode detection to avoid errors and allow showing emojis when possible. (:pr:`614`) by :user:`itsayellow` - pip errors no longer stream to the shell when pip fails during a pipx install. pip's output is now saved to a log file. In the shell, pipx will tell you the location of the log file and attempt to summarize why pip failed. (:pr:`625`) by :user:`itsayellow` - For ``reinstall-all``, fixed bug where missing python executable would cause error. (:pr:`634`) by :user:`itsayellow` .. _release-0-16-1-0-fixed: Fixed ----- - Fix cursor show/hide to work with older versions of Windows. (:pr:`610`) by :user:`itsayellow` - Don't emit show cursor or hide cursor codes if STDERR is not a tty. (:pr:`620`) by :user:`itsayellow` - Fix regression which prevented pipx from working with pythonloc (and ``__pypackages__`` folder). (:pr:`636`) by :user:`itsayellow` .. _release-0-16-1-0-performance: Performance ----------- - Sped up ``pipx list`` (:pr:`624`) by :user:`itsayellow`. .. _release-0-16-0-0: `0.16.0.0 `_ ======================================================== .. _release-0-16-0-0-added: Added ----- - New venv inspection! The code that pipx uses to examine and determine metadata in an installed venv has been made faster, better, and more reliable. It now uses modern python libraries like ``packaging`` and ``importlib.metadata`` to examine installed venvs. It also now properly handles installed package extras. In addition, some problems pipx has had with certain characters (like periods) in package names should be remedied. - Added reinstall command for reinstalling a single venv. - Venv package name arguments now do not have to match exactly as pipx has them stored, but can be specified in any python-package-name-equivalent way. (i.e. case does not matter, and ``.``, ``-``, ``_`` characters are interchangeable.) - Autocomplete for venv names is no longer restricted to an exact match to the literal venv name, but will autocomplete any logically-similar python package name (i.e. case does not matter, and ``.``, ``-``, ``_`` characters are all equivalent.) .. _release-0-16-0-0-changes: Changes ------- - Changed ``pipx run`` on non-Windows systems to actually replace pipx process with the app process instead of running it as a subprocess. (Now using python's ``os.exec*``) - Venvs with a suffix: A suffix can contain any characters, but for purposes of uniqueness, python package name rules apply--upper- and lower-case letters are equivalent, and any number of ``.``, ``-``, or ``_`` characters in a row are equivalent. (e.g. if you have a suffixed venv ``pylint_1.0A`` you could not add another suffixed venv called ``pylint--1-0a``, as it would not be a unique name.) - Pipx shared libraries (providing pip, setuptools, wheel to pipx) are no longer installed using pip arguments taken from the last regular pipx install. If you need to apply pip arguments to pipx's use of pip for its internal shared libraries, use ``PIP_*`` environment variables. - pipx now reinstall its internal shared libraries when the user executes ``reinstall-all``. - Made sure shell exit codes from every pipx command are correct. In the past some (like from ``pipx upgrade``) were wrong. The exit code from ``pipx runpip`` is now the exit code from the ``pip`` command run. The exit code from ``pipx list`` will be 1 if one or more venvs have problems that need to be addressed. - pipx now writes a log file for each pipx command executed to ``$PIPX_HOME/logs``, typically ``~/.local/pipx/logs``. pipx keeps the most recent 10 logs and deletes others. - ``pipx upgrade`` and ``pipx upgrade-all`` now have a ``--upgrade-injected`` option which directs pipx to also upgrade injected packages. - ``pipx list`` now detects, identifies, and suggests a remedy for venvs with old-internal data (internal venv names) that need to be updated. - pipx error, warning, and other messages now word-wrap so words are not split across lines. Their appearance is also now more consistent. .. _release-0-16-0-0-fixed: Fixed ----- - Fixed bug with reinstall-all command when package have been installed using a specifier. Now the initial specifier is used. - Override display of ``PIPX_DEFAULT_PYTHON`` value when generating web documentation for ``pipx install`` :issue:`523` - Wrap help documentation for environment variables. - Fixed uninstall crash that could happen on Windows for certain packages .. _release-0-16-0-0-documentation: Documentation ------------- - Added a "Troubleshooting" page to the pipx web documentation for common problems pipx users may encounter. .. _release-0-15-6-0: `0.15.6.0 `_ ======================================================== .. _release-0-15-6-0-added: Added ----- - Added support for user-defined default python interpreter via new ``PIPX_DEFAULT_PYTHON``. Helpful for use with pyenv among other uses. .. _release-0-15-6-0-changes: Changes ------- - Updated help text to show description for ``ensurepath`` and ``completions`` help .. _release-0-15-6-0-fixed: Fixed ----- - Fixed regression in list, inject, upgrade, reinstall-all commands when suffixed packages are used. - Do not reset package url during upgrade when main package is ``pipx`` - Fixed bug where extras were ignored with a PEP 508 package specification with a URL. .. _release-0-15-6-0-documentation: Documentation ------------- - Update license - Display a more idiomatic command for registering completions on fish. .. _release-0-15-5-1: `0.15.5.1 `_ ======================================================== .. _release-0-15-5-1-fixed: Fixed ----- - Fixed regression of 0.15.5.0 which erroneously made installing from a local path with package extras not possible. .. _release-0-15-5-0: `0.15.5.0 `_ ======================================================== .. _release-0-15-5-0-added: Added ----- - Version of each injected package is now listed after name for ``pipx list --include-injected`` - ``ensurepath`` now also ensures that pip user binary path containing pipx itself is in user's PATH if pipx was installed using ``pip install --user``. - log pipx version (:issue:`423`) - ``--suffix`` option for ``install`` to allow multiple versions of same tool to be installed (:pr:`445`) by :user:`joshbode` - pipx can now be used with the Windows embeddable Python distribution .. _release-0-15-5-0-changes: Changes ------- - pipx now parses package specification before install. It removes (with warning) the ``--editable`` install option for any package specification that is not a local path. It also removes (with warning) any environment markers. - Disabled animation when we cannot determine terminal size or if the number of columns is too small. (Fixes :issue:`444`) - Change metadata recorded from version-specified install to allow upgrades in future. Adds pipx dependency on ``packaging`` package. .. _release-0-15-5-0-fixed: Fixed ----- - Prevent python error in case where package has no pipx metadata and advise user how to fix. - For ``pipx install``, fixed failure to install if user has ``PIP_USER=1`` or ``user=true`` in pip.conf. (:issue:`110`) - Requiring userpath v1.4.1 or later so ensure Windows bug is fixed for ``ensurepath`` (:issue:`437`) .. _release-0-15-4-0: `0.15.4.0 `_ ======================================================== .. _release-0-15-4-0-added: Added ----- - ``list`` now has a new option ``--include-injected`` to show the injected packages in the main apps .. _release-0-15-4-0-fixed: Fixed ----- - Fixed bug that can cause crash when installing an app .. _release-0-15-3-1: `0.15.3.1 `_ ======================================================== .. _release-0-15-3-1-fixed: Fixed ----- - Workaround multiprocessing issues on certain platforms (:issue:`229`) .. _release-0-15-3-0: `0.15.3.0 `_ ======================================================== .. _release-0-15-3-0-added: Added ----- - Use symlinks on Windows when symlinks are available .. _release-0-15-2-0: `0.15.2.0 `_ ======================================================== .. _release-0-15-2-0-added: Added ----- - Add environment variable ``USE_EMOJI`` to allow enabling/disabling emojis (:issue:`376`) .. _release-0-15-2-0-fixed: Fixed ----- - Improved error reporting during venv metadata inspection. - Fixed incompatibility with pypy as venv interpreter (:issue:`372`). - Replaced implicit dependency on setuptools with an explicit dependency on packaging (:issue:`339`). - Continue reinstalling packages after failure - Hide cursor while pipx runs - Ignore system shared libraries when installing shared libraries pip, wheel, and setuptools. This also fixes an incompatibility with Debian/Ubuntu's version of pip (:issue:`386`). .. _release-0-15-1-3: `0.15.1.3 `_ ======================================================== .. _release-0-15-1-3-fixed: Fixed ----- - On Windows, pipx now lists correct Windows apps (:issue:`217`) - Fixed a ``pipx install`` bug causing incorrect python binary to be used when using the optional --python argument in certain situations, such as running pipx from a Framework python on macOS and specifying a non-Framework python. .. _release-0-15-1-2: `0.15.1.2 `_ ======================================================== .. _release-0-15-1-2-changes: Changes ------- - ``upgrade-all`` now skips editable packages, because pip disallows upgrading editable packages. .. _release-0-15-1-2-fixed: Fixed ----- - Fix recursive search of dependencies' apps so no apps are missed. .. _release-0-15-1-1: `0.15.1.1 `_ ======================================================== .. _release-0-15-1-1-fixed: Fixed ----- - fix regression that caused installing with --editable flag to fail package name determination. .. _release-0-15-1-0: `0.15.1.0 `_ ======================================================== .. _release-0-15-1-0-added: Added ----- - Add Python 3.8 to PyPI classifier and travis test matrix - auto-upgrade shared libraries, including pip, if older than one month. Hide all pip warnings that a new version is available. (:issue:`264`) .. _release-0-15-1-0-fixed: Fixed ----- - pass pip arguments to pip when determining package name (:issue:`320`) .. _release-0-15-0-0: `0.15.0.0 `_ ======================================================== .. _release-0-15-0-0-added: Added ----- - Add printed summary after successful call to ``pipx inject`` - Support associating apps with Python 3.5 .. _release-0-15-0-0-changes: Changes ------- - Upgrade instructions: When upgrading to 0.15.0.0 or above from a pre-0.15.0.0 version, you must re-install all packages to take advantage of the new persistent pipx metadata files introduced in this release. These metadata files store pip specification values, injected packages, any custom pip arguments, and more in each main package's venv. You can do this by running ``pipx reinstall-all`` or ``pipx uninstall-all``, then reinstalling manually. - ``inject`` will now accept pip specifications for dependency arguments - Metadata is now stored for each application installed, including install options like ``--spec``, and injected packages. This information allows upgrade, upgrade-all and reinstall-all to work properly even with non-pypi installed packages. (:pr:`222`) by :user:`itsayellow` - Handle missing interpreters more gracefully (:issue:`146`) - Change ``reinstall-all`` to use system python by default for apps. Now use ``--python`` option to specify a different python version. - Remove the PYTHONPATH environment variable when executing any command to prevent conflicts between pipx dependencies and package dependencies when pipx is installed via homebrew. Homebrew can use PYTHONPATH manipulation instead of virtual environments. (:issue:`233`) - Improvements to animation status text - Make ``--python`` argument in ``reinstall-all`` command optional - Stricter parsing when passing ``--`` argument as delimiter .. _release-0-15-0-0-removed: Removed ------- - ``install`` now has no ``--spec`` option. You may specify any valid pip specification for ``install``'s main argument. - ``upgrade`` options ``--spec`` and ``--include-deps`` were removed. Pipx now uses the original options used to install each application instead. (:pr:`222`) by :user:`itsayellow` - ``upgrade-all`` options ``--include-deps``, ``--system-site-packages``, ``--index-url``, ``--editable``, and ``--pip-args`` were removed. Pipx now uses the original options used to install each application instead. (:pr:`222`) by :user:`itsayellow` - ``reinstall-all`` options ``--include-deps``, ``--system-site-packages``, ``--index-url``, ``--editable``, and ``--pip-args`` were removed. Pipx now uses the original options used to install each application instead. (:pr:`222`) by :user:`itsayellow` .. _release-0-14-0-0: `0.14.0.0 `_ ======================================================== .. _release-0-14-0-0-changes: Changes ------- - Change all terminology from ``binary`` to ``app`` or ``application`` - Improve argument parsing for ``pipx run`` and ``pipx runpip`` - If ``--force`` is passed, remove existing files in PIPX_BIN_DIR - Move animation to start of line, hide cursor when animating .. _release-0-14-0-0-performance: Performance ----------- - Speed up operations by using shared venv for ``pip``, ``setuptools``, and ``wheel``. You can see more detail in the 'how pipx works' section of the documentation. (:issue:`164`) by :user:`pfmoore`. .. _release-0-14-0-0-breaking-changes: Breaking changes ---------------- - Breaking change: for the ``inject`` command, change ``--include-binaries`` to ``--include-apps`` .. _release-0-14-0-0rc1: `0.14.0.0rc1 `_ ============================================================== .. _release-0-14-0-0rc1-changes: Changes ------- - Change all terminology from ``binary`` to ``app`` or ``application`` - Improve argument parsing for ``pipx run`` and ``pipx runpip`` - If ``--force`` is passed, remove existing files in PIPX_BIN_DIR - Move animation to start of line, hide cursor when animating .. _release-0-14-0-0rc1-performance: Performance ----------- - Speed up operations by using shared venv for ``pip``, ``setuptools``, and ``wheel``. You can see more detail in the 'how pipx works' section of the documentation. (:issue:`164`) by :user:`pfmoore`. .. _release-0-14-0-0rc1-breaking-changes: Breaking changes ---------------- - Breaking change: for the ``inject`` command, change ``--include-binaries`` to ``--include-apps`` .. _release-0-13-2-3: `0.13.2.3 `_ ======================================================== .. _release-0-13-2-3-fixed: Fixed ----- - Fix regression when installing a package that doesn't have any entry points .. _release-0-13-2-2: `0.13.2.2 `_ ======================================================== .. _release-0-13-2-2-changes: Changes ------- - Make status text/animation reliably disappear before continuing - Update animation symbols .. _release-0-13-2-2-fixed: Fixed ----- - Remove unnecessary and sometimes incorrect check after ``pipx inject`` (:issue:`195`) .. _release-0-13-2-1: `0.13.2.1 `_ ======================================================== .. _release-0-13-2-1-fixed: Fixed ----- - Remove virtual environment if installation did not complete. For example, if it was interrupted by ctrl+c or if an exception occurred for any reason. (:issue:`193`) .. _release-0-13-2-0: `0.13.2.0 `_ ======================================================== .. _release-0-13-2-0-added: Added ----- - Add shell autocompletion. Also add ``pipx completions`` command to print instructions on how to add pipx completions to your shell. - Un-deprecate ``ensurepath``. Use ``userpath`` internally instead of instructing users to run the ``userpath`` cli command. .. _release-0-13-2-0-changes: Changes ------- - Improve error message when an existing symlink exists in PIPX_BIN_DIR and points to the wrong location .. _release-0-13-2-0-fixed: Fixed ----- - Improve detection of PIPX_BIN_DIR not being on PATH - Improve handling of unexpected files in PIPX_HOME by :user:`uranusjr`. - Swap out-of-order logic to recommend ``--include-deps`` correctly by :user:`joshuarli`. .. _release-0-13-1-1: `0.13.1.1 `_ ======================================================== .. _release-0-13-1-1-changes: Changes ------- - Update pipsi migration script .. _release-0-13-1-1-fixed: Fixed ----- - Do not raise bare exception if no binaries found (:pr:`150`) by :user:`cs01` .. _release-0-13-1-0: `0.13.1.0 `_ ======================================================== .. _release-0-13-1-0-added: Added ----- - Support redirects and proxies when downloading python files (i.e. ``pipx run http://url/file.py``) .. _release-0-13-1-0-changes: Changes ------- - Change default cache duration for ``pipx run`` from 2 to 14 days .. _release-0-13-1-0-deprecated: Deprecated ---------- - Deprecate ``ensurepath`` command. Use ``userpath append ~/.local/bin`` .. _release-0-13-0-1: `0.13.0.1 `_ ======================================================== .. _release-0-13-0-1-fixed: Fixed ----- - Fix upgrade-all and reinstall-all regression .. _release-0-13-0-0: `0.13.0.0 `_ ======================================================== .. _release-0-13-0-0-added: Added ----- - Add ``runpip`` command to run arbitrary pip commands in pipx-managed virtual environments - Add ``--skip`` argument to ``upgrade-all`` and ``reinstall-all`` commands, to let the user skip particular packages .. _release-0-13-0-0-fixed: Fixed ----- - Do not raise error when running ``pipx install PACKAGE`` and the package has already been installed by pipx (:issue:`125`). This is the cause of the major version change from 0.12 to 0.13. .. _release-0-12-3-3: `0.12.3.3 `_ ======================================================== .. _release-0-12-3-3-fixed: Fixed ----- - Update logic in determining a package's binaries during installation. This removes spurious binaries from the installation. (:issue:`104`) - Improve compatibility with Debian distributions by using ``shutil.which`` instead of ``distutils.spawn.find_executable`` (:issue:`102`) .. _release-0-12-3-2: `0.12.3.2 `_ ======================================================== .. _release-0-12-3-2-fixed: Fixed ----- - Fix infinite recursion error when installing package such as ``cloudtoken==0.1.84`` (:issue:`103`) - Fix windows type errors (:issue:`96`, :issue:`98`) .. _release-0-12-3-1: `0.12.3.1 `_ ======================================================== .. _release-0-12-3-1-fixed: Fixed ----- - Fix "WindowsPath is not iterable" bug .. _release-0-12-3-0: `0.12.3.0 `_ ======================================================== .. _release-0-12-3-0-added: Added ----- - Add ``--include-deps`` argument to include binaries of dependent packages when installing with pipx. This improves compatibility with packages that depend on other installed packages, such as ``jupyter``. .. _release-0-12-3-0-changes: Changes ------- - More aggressive cache directory removal when ``--no-cache`` is passed to ``pipx run`` .. _release-0-12-3-0-performance: Performance ----------- - Speed up ``pipx list`` output (by running multiple processes in parallel) and by collecting all metadata in a single subprocess call .. _release-0-12-2-0: `0.12.2.0 `_ ======================================================== .. _release-0-12-2-0-added: Added ----- - Add support for PEP 582's ``__pypackages__`` (experimental). ``pipx run BINARY`` will first search in ``__pypackages__`` for binary, then fallback to installing from PyPI. ``pipx run --pypackages BINARY`` will raise an error if the binary is not found in ``__pypackages__``. .. _release-0-12-2-0-fixed: Fixed ----- - Fix regression when installing with ``--editable`` flag (:issue:`93`) .. _release-0-12-1-0: `0.12.1.0 `_ ======================================================== .. _release-0-12-1-0-added: Added ----- - Cache and reuse temporary Virtual Environments created with ``pipx run`` (:issue:`61`) .. _release-0-12-1-0-changes: Changes ------- - When using environment variable PIPX_HOME, Virtual Environments will now be created at ``$PIPX_HOME/venvs`` rather than at ``$PIPX_HOME``. .. _release-0-12-1-0-fixed: Fixed ----- - Update binary discovery logic to find "scripts" like awscli (:issue:`91`) - Forward ``--pip-args`` to the pip upgrade command (previously the args were forwarded to install/upgrade commands for packages) (:issue:`77`) .. _release-0-12-0-4: `0.12.0.4 `_ ======================================================== .. _release-0-12-0-4-fixed: Fixed ----- - Fix parsing bug in pipx run .. _release-0-12-0-3: `0.12.0.3 `_ ======================================================== .. _release-0-12-0-3-added: Added ----- - list python2 as supported language so that pip installs with python2 will no longer install the pipx on PyPI from the original pipx owner. Running pipx with python2 will fail, but at least it will not be as confusing as running the pipx package from the original owner. .. _release-0-12-0-2: `0.12.0.2 `_ ======================================================== .. _release-0-12-0-2-changes: Changes ------- - forward arguments to run command correctly :issue:`90` .. _release-0-12-0-1: `0.12.0.1 `_ ======================================================== .. _release-0-12-0-1-fixed: Fixed ----- - stop using unverified context :issue:`89` .. _release-0-12-0-0: `0.12.0.0 `_ ======================================================== .. _release-0-12-0-0-added: Added ----- - Add ``ensurepath`` command .. _release-0-12-0-0-documentation: Documentation ------------- - Change installation instructions to use ``pipx`` PyPI name .. _release-0-11-0-2: `0.11.0.2 `_ ======================================================== .. _release-0-11-0-2-fixed: Fixed ----- - add version argument parsing back in (fixes regression) .. _release-0-11-0-1: `0.11.0.1 `_ ======================================================== .. _release-0-11-0-1-documentation: Documentation ------------- - Update installation instructions. .. _release-0-11-0-1-added: Added ----- - Add version and command checks. .. _release-0-11-0-1-fixed: Fixed ----- - Fix the printed version. .. _release-0-11-0-0: `0.11.0.0 `_ ======================================================== .. _release-0-11-0-0-added: Added ----- - support --system-site-packages flag (Issue :issue:`64`) .. _release-0-11-0-0-changes: Changes ------- - upgrade pip when upgrading packages (Issue :issue:`72`) .. _release-0-11-0-0-breaking-changes: Breaking changes ---------------- - Replace ``pipx BINARY`` with ``pipx run BINARY`` to run a binary in an ephemeral environment. This is a breaking API change so the major version has been incremented. (Issue :issue:`69`) .. _release-0-10-4-1: `0.10.4.1 `_ ======================================================== .. _release-0-10-4-1-fixed: Fixed ----- - Fix version printed when ``pipx --version`` is run .. _release-0-10-4-0: `0.10.4.0 `_ ======================================================== .. _release-0-10-4-0-added: Added ----- - Add --index-url, --editable, and --pip-args flags .. _release-0-10-4-0-documentation: Documentation ------------- - Updated README with pipsi migration instructions .. _release-0-10-3-0: `0.10.3.0 `_ ======================================================== .. _release-0-10-3-0-added: Added ----- - Display python version in list .. _release-0-10-3-0-changes: Changes ------- - Do not reinstall package if already installed (added ``--force`` flag to override) - When upgrading all packages, print message only when package is updated .. _release-0-10-3-0-fixed: Fixed ----- - Avoid accidental execution of ``pipx.__main__`` pipx-1.18.16/docs/conf.py000066400000000000000000000156611527355705200151230ustar00rootroot00000000000000"""Sphinx configuration for the pipx documentation.""" from __future__ import annotations import importlib import inspect import os import sys from datetime import UTC, datetime from html import escape as html_escape from importlib.metadata import version as distribution_version from pathlib import Path from typing import TYPE_CHECKING, Any, Final from urllib.parse import urlsplit from docutils import nodes if TYPE_CHECKING: from sphinx.application import Sphinx from sphinx.util.typing import ExtensionMetadata _REPO_ROOT: Final[Path] = Path(__file__).parent.parent sys.path.insert(0, str(Path(__file__).parent / "_ext")) project = "pipx" author = "pipx contributors" project_copyright = f"{datetime.now(tz=UTC):%Y}, pipx contributors" release = distribution_version("pipx") version = ".".join(release.split(".")[:2]) # Recipes across the how-to guides share one implicit import, so set it up once for every doctest group. doctest_global_setup = "import pipx" extensions = [ "sphinx_llm.txt", "sphinx.ext.autodoc", "sphinx.ext.autosectionlabel", # reference any section by its title, scoped per document to keep labels unique "sphinx.ext.doctest", # run the testcode/testoutput examples so the docs cannot drift from the code "sphinx.ext.intersphinx", # cross-link names into the CPython docs "sphinx.ext.linkcode", # link each documented Python object to its source on GitHub "notfound.extension", # a versioned 404 page that keeps its links absolute "sphinx_argparse_cli", # generate the CLI reference from pipx.main.build_parser so it cannot drift "sphinx_autodoc_typehints", "sphinx_codeautolink", # turn the names in code and doctest blocks into links to the reference "sphinx_copybutton", "sphinx_design", # the card grid on the landing page "sphinx_issues", # the :issue:/:pr: roles used in the changelog and notes "sphinx_last_updated_by_git", # stamp each page with the date of its last git edit "sphinx_reredirects", # 301 old URLs to their new home after a page moves "sphinx_sitemap", # emit sitemap.xml over the built pages "sphinxcontrib.mermaid", # the .. mermaid:: directive used across the explanation diagrams "sphinxcontrib.towncrier.ext", # render unreleased news fragments as a draft changelog section "sphinxext.opengraph", # OpenGraph tags so shared links preview well "llms_txt", # generate llms.txt and llms-full.txt from the built tree at build-finished (docs/_ext) ] html_theme = "furo" html_title = "pipx" # the logo is the pipx wordmark, so furo need not repeat the project name beside it in the sidebar html_theme_options = {"sidebar_hide_name": True} html_static_path = ["_static"] html_css_files = ["custom.css"] html_logo = "_static/logo.svg" html_favicon = "_static/logo.svg" # Read the Docs sets the versioned canonical URL; the fallback keeps canonical links and the sitemap working on a local # or CI build. Sphinx emits a per page from this, and the sitemap and OpenGraph tags reuse it. html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "https://pipx.pypa.io/latest/") # sphinx-sitemap reuses html_baseurl; the URL already carries the version segment, so the per-page scheme is the bare # page path. sphinxext-opengraph and sphinx-notfound-page derive their absolute URLs from the same base. sitemap_url_scheme = "{link}" ogp_site_url = html_baseurl notfound_urls_prefix = urlsplit(html_baseurl).path # autosectionlabel would collide on identical section titles across pages (several guides open with a "Verify it # worked" heading), so scope each label to its document and only label the top two heading levels. autosectionlabel_prefix_document = True autosectionlabel_maxdepth = 2 # codeautolink threads the names in one page's code blocks together, so a name bound in an early block still resolves in # a later one. codeautolink_concat_default = True # The console blocks keep a literal "$ " prompt so Pygments detects the prompt and highlights the command after it # (custom.css restyles that glyph). Strip the prompt on copy and drop the output lines, so the clipboard holds only the # command. The trailing space keeps the match from catching shell variables like $HOME. copybutton_prompt_text = "$ " copybutton_prompt_is_regexp = False # Read the Docs builds from a shallow clone, so sphinx-last-updated-by-git cannot see far enough back to stamp some # pages and warns "Git clone too shallow"; under -W that would fail the build. The stamp still resolves for pages within # the clone depth and degrades to the build date otherwise. suppress_warnings = ["git.too_shallow"] # sphinx-reredirects maps a moved page's old docname to its new location. The MkDocs-to-Sphinx move kept every docname, # so there is nothing to redirect yet; add an entry here whenever a page's path changes. redirects: dict[str, str] = {} intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} nitpicky = True always_document_param_types = True python_use_unqualified_type_names = True autodoc_member_order = "bysource" issues_github_path = "pypa/pipx" towncrier_draft_autoversion_mode = "draft" towncrier_draft_include_empty = True towncrier_draft_working_directory = _REPO_ROOT def linkcode_resolve(domain: str, info: dict[str, str]) -> str | None: if domain != "py" or not info["module"]: return None try: target = importlib.import_module(info["module"]) except ImportError: return None for part in info["fullname"].split("."): target = getattr(target, part, None) try: unwrapped: Final = inspect.unwrap(target) lines, start = inspect.getsourcelines(unwrapped) relative: Final[Path] = Path(inspect.getsourcefile(unwrapped)).resolve().relative_to(_REPO_ROOT) except (TypeError, OSError, ValueError): return None ref: Final[str] = os.environ.get("READTHEDOCS_GIT_IDENTIFIER", "main") return f"https://github.com/pypa/pipx/blob/{ref}/{relative.as_posix()}#L{start}-L{start + len(lines) - 1}" def setup(app: Sphinx) -> ExtensionMetadata: app.connect("html-page-context", _add_page_description) return {"parallel_read_safe": True, "parallel_write_safe": True} def _add_page_description( _app: Sphinx, _pagename: str, _templatename: str, context: dict[str, Any], doctree: nodes.document | None, ) -> None: # a from the first paragraph gives search results a readable summary if doctree is None: return for paragraph in doctree.findall(nodes.paragraph): if not (text := " ".join(paragraph.astext().split())): continue limit: Final = 160 summary: Final[str] = text if len(text) <= limit else f"{text[: limit - 1].rstrip()}โ€ฆ" tag: Final[str] = f'' context["metatags"] = f"{context.get('metatags', '')}\n {tag}" return markdown_http_base = os.environ.get("READTHEDOCS_CANONICAL_URL", "") pipx-1.18.16/docs/contributing.rst000066400000000000000000000274221527355705200170630ustar00rootroot00000000000000############# Contributing ############# Thanks for your interest in contributing to pipx! We expect everyone who interacts with the pipx project via codebase, issue tracker, chat rooms, or otherwise to follow the `PSF Code of Conduct `_. ******************* Submitting changes ******************* #. Fork `the GitHub repository `_. #. Make a branch off ``main`` and commit your changes to it. #. Add a user-facing changelog entry, or apply ``skip news`` to a maintenance-only pull request. #. Submit a pull request to the ``main`` branch on ``GitHub``, referencing an open issue. Changelog entries ================= `towncrier `_ builds the changelog from news fragments in ``changelog.d/``. Name a fragment ``+topic.type.rst`` before opening a pull request, for example ``+reset-eof.bugfix.rst``. Existing numbered names such as ``1234.bugfix.rst`` remain valid. The release workflow gets the pull request number, the commit author, and its co-authors from the squash commit and links them when it assembles the changelog. Use ``feature`` for new capabilities and Python support additions or removals; either causes a minor version bump. Use ``change`` for user-visible behavior changes, ``bugfix`` for corrections, ``doc`` for documentation, ``security`` for security fixes, and ``performance`` for speed or resource improvements. ``deprecation`` announces a future removal; ``removal`` describes a removed command or option; ``packaging`` covers user-visible distribution or installation changes. Use ``breaking`` only for an incompatible behavior change that is not a Python support change. One pull request may carry several fragments, including two of the same type: ``+reset-eof.bugfix.rst`` and ``+reset-output.bugfix.rst`` are separate entries. Write each one as a concrete sentence about the effect for a pipx user. Internal tests, dependency updates, and CI work need no entry; apply ``skip news`` instead. Bot pull requests without a fragment receive that label automatically. If a bot supplies a fragment, the changelog check validates it. Do not put a pull request link or author credit in the fragment; hand-written credits fail the changelog check. The release workflow appends the squash pull request link and credits the commit's human author and co-authors in the RST changelog. GitHub Release notes use their handles. Keep each fragment wrapped to 120 columns; `prek `_ formats the generated changelog before the release commit. ********** Concepts ********** pipx manages one **environment** per installed application: a virtual environment under ``PIPX_HOME`` named after its **main package**, plus an optional suffix. ``pipx inject`` adds **injected packages** to an existing environment. pipx resolves ``PIPX_HOME`` and every other location from environment variables, platform defaults, and legacy fallback paths (see :doc:`reference/environment-variables`). A **backend** installs packages into environments. The pip backend links every environment to one **shared pip** environment through a ``.pth`` file; the uv backend calls ``uv venv`` and ``uv pip`` and needs no shared environment. Each environment runs on an **interpreter** that pipx resolves from ``--python``. When ``--fetch-python`` is ``missing`` or ``always``, pipx downloads a standalone build from `python-build-standalone `_ and caches it; ``pipx interpreter list``, ``prune``, and ``upgrade`` manage those builds. After an install, pipx **exposes** the package's resources: its console and GUI scripts (**apps**), man pages, and shell completions, linked into ``PIPX_BIN_DIR``, ``PIPX_MAN_DIR``, and ``PIPX_COMPLETION_DIR``. Each environment's **metadata** records the install source, backend, pip arguments, interpreter, and exposed resources, so ``upgrade``, ``reinstall``, and ``install-all`` can rebuild the same state. ``pipx run`` uses the **run cache** instead: temporary environments keyed on the requirements and installer settings, which expire after 14 days. A **script** with :pep:`723` inline metadata works as a package: ``pipx run`` installs its declared dependencies into a cached temporary environment, and ``pipx install`` hands either backend the same wheel built from the script and records its path or URL. **Example: installing black.** ``pipx install black`` resolves the default interpreter, creates the ``black`` environment, and has the backend install ``black`` into it. pipx then exposes the ``black`` and ``blackd`` apps and records the source ``black`` and its pip arguments in the metadata. A later ``pipx upgrade black`` reads that metadata, upgrades the package with the same backend and arguments, exposes any apps the new version adds, and removes the links of any it dropped. ***************************** Running pipx For Development ***************************** To develop ``pipx``, either create a :ref:`developer environment `, or perform an editable install: .. code-block:: console $ python -m pip install -e . $ python -m pipx --version ************** Running Tests ************** Setup ===== pipx uses `tox `_ for development, continuous integration testing, and other tasks. ``tox list`` shows its environments; at the time of writing, the output looks like this: .. code-block:: text default environments: 3.15 -> run tests with 3.15 3.14 -> run tests with 3.14 3.13 -> run tests with 3.13 3.12 -> run tests with 3.12 3.11 -> run tests with 3.11 3.15t -> run tests with 3.15t docs -> build documentation lint -> run pre-commit on the codebase man -> build man page type -> run the type checker additional environments: dev -> generate a DEV environment docs-live -> serve documentation with live reload zipapp -> build zipapp via shiv .. _creating-a-developer-environment: Creating a developer environment ================================ For development (and to attach your IDE), create a Python environment with ``tox run -e dev``, then use the interpreter at ``.tox/dev/bin/python``. Unit Tests ========== Run the unit tests on Python 3.15 with: .. code-block:: console $ tox run -e 3.15 .. tip:: ``tox`` passes the arguments after ``--`` to `pytest `_, the test runner pipx uses, so ``-k EXPRESSION`` selects tests by name: .. code-block:: console $ tox run -e 3.15 -- -k test_uninstall You can usually ignore coverage errors when you run only a subset of tests. Running Unit Tests Offline ========================== The unit tests need a ``.pipx_tests/package_cache`` directory populated from a fixed list of package distribution files (wheels or source files). With network access, ``tox`` populates it as a first step, downloading files when needed, so you can skip the rest of this section. To run the tests without network access, populate ``.pipx_tests/package_cache`` beforehand, while you still have network access. Lint Tests ========== Linting runs through `pre-commit `_, wired into the ``lint`` tox environment: .. code-block:: console $ tox run -e lint The main hooks are ``ruff-check`` and ``ruff-format`` (lint and format Python), ``codespell`` (spelling), ``mdformat`` (Markdown, wrapped at 120), ``yamlfmt`` (YAML), ``pyproject-fmt`` (``pyproject.toml``), and ``flake8-lazy``. ``flake8-lazy`` keeps the ``__lazy_modules__`` list at the top of each pipx module up to date. On Python 3.15 and later (:pep:`810`) the modules it lists load on first use rather than at import, which shortens CLI startup; older Pythons ignore the list. The hook rewrites the list itself, so when it fails, review and stage its change. A module whose import has a side effect that must happen at startup (reading environment variables or terminal state) belongs in ``standalone.lazy-exclude-modules`` under ``[tool.flake8-lazy]`` in ``pyproject.toml``. Type checking ============= Type checking uses `ty `_ and runs in its own tox environment rather than ``pre-commit``, since pre-commit.ci cannot install its heavier dependency set: .. code-block:: console $ tox run -e type A dedicated `GitHub Actions `_ job runs the same check on every pull request. Installing or injecting new packages in tests ============================================= When a test installs or injects a package and version that no other test uses, add it to the package lists in ``testdata/tests_packages``: #. Add the package to ``testdata/tests_packages/primary_packages.txt``. #. On each platform and Python version the tests run on, regenerate that platform's list: .. code-block:: console $ python scripts/list_test_packages.py testdata/tests_packages/primary_packages.txt testdata/tests_packages #. Commit the new or modified list files in ``testdata/tests_packages``. ********************************************** Testing pipx on Continuous Integration builds ********************************************** ``GitHub Actions`` runs the checks when you open a pull request. *********************** Building Documentation *********************** `Sphinx `_ builds the documentation from the ``docs`` directory; edit the pages there. Build the HTML documentation with: .. code-block:: console $ tox run -e docs This runs ``sphinx-build`` with ``-W --keep-going``, so any warning fails the build. `sphinx-argparse-cli `_ generates the CLI reference from pipx's argument parser, so it cannot drift from the commands. For a live-reloading preview while editing, use ``tox run -e docs-live``. ******************************** Releasing New ``pipx`` Versions ******************************** The protected pre-release workflow determines the next version from the fragment types, builds the changelog, runs ``release-creator contributors`` to add a *New Contributors* subsection, formats the changelog with ``prek``, and prepares a patch. The repository-scoped App then pushes the release commit and annotated tag. Initiating a Release ==================== Run **Pre-release** from the Actions tab on ``main``. Choose ``auto`` to calculate the version from the fragments, or choose ``major``, ``minor``, or ``patch`` to override the bump. The input is a bump type, never a typed version number. To preview the same inputs locally without pushing, run ``python scripts/release.py`` or ``python scripts/release.py --bump minor`` from an installed ``release`` dependency group. What Happens During Release =========================== The tag starts the release workflow. It builds the sdist and wheel with locked build dependencies, and builds ``pipx.pyz`` from the wheel and the runtime dependencies locked for the zipapp targets. In parallel it renders Markdown notes from that tag's RST changelog. Once the zipapp passes its smoke test, the distributions go to `PyPI `_ with `trusted publishing `_. After ``PyPI`` has the expected files, a single step creates the GitHub Release with the zipapp attached. Retry a failed run with "Re-run failed jobs", which uploads the same built files; the release job checks their SHA256 digests against ``PyPI`` before it writes the GitHub Release. Version Calculation Examples ============================ Starting from ``1.8.0``, ``auto`` with a feature becomes ``1.9.0`` and ``auto`` with only bug fixes becomes ``1.8.1``. Selecting ``major`` yields ``2.0.0``, ``minor`` yields ``1.9.0``, and ``patch`` yields ``1.8.1``. Removing support for an older Python is a feature here because earlier pipx releases remain installable on that interpreter. pipx-1.18.16/docs/explanation/000077500000000000000000000000001527355705200161355ustar00rootroot00000000000000pipx-1.18.16/docs/explanation/comparisons.rst000066400000000000000000000351451527355705200212340ustar00rootroot00000000000000############# Comparisons ############# ************* pipx vs pip ************* - `pip `_ is a general Python package installer. It can install libraries or CLI applications with entry points. - pipx is a specialized package installer. It can only install packages with CLI entry points. - pipx and ``pip`` both install packages from `PyPI `_ (or locally). - pipx relies on ``pip`` and `venv `_, or on `uv `_ under the uv backend. - pipx replaces a subset of ``pip``'s functionality: it installs CLI applications but not libraries that you import in your code. - You can install pipx with ``pip``. Example: install pipx with ``pip``, ``pip install --user pipx``. ***************** pipx vs uv tool ***************** Both `uv tool `_ and pipx install a Python tool into its own virtual environment and expose the tool's console scripts on ``PATH``. Both have a one-shot run mode (``uvx`` and ``pipx run``); ``uvx`` is ``uv tool run``. They differ in where state lives, which extra commands they ship, and how they handle managed Python. pipx keeps the same CLI across pip and uv backends; ``pipx install pipx[uv]`` opts you into uv-speed venv creation without changing any commands. ``uv tool`` ships a smaller per-tool surface, then reuses the rest of ``uv`` for free: managed Python, content-addressed cache, lockfiles, :pep:`723` script handling. Where state lives ================= .. list-table:: :header-rows: 1 :widths: 24 38 38 - - State - pipx - uv tool - - Per-tool venvs - ``$PIPX_HOME/venvs/`` (``PIPX_HOME``) - ``$UV_TOOL_DIR/`` (``UV_TOOL_DIR``) - - Exposed binaries - ``$PIPX_BIN_DIR`` (default ``~/.local/bin``) - ``$UV_TOOL_BIN_DIR`` (default same) - - Man pages - ``$PIPX_MAN_DIR`` (default ``~/.local/share/man``) - *not exposed* - - Shared pip - ``$PIPX_HOME/shared`` (pip backend only) - *none; uv venvs ship without pip* - - Ephemeral run cache - ``pipx cache dir`` (platform cache dir, or ``$PIPX_HOME/.cache`` when ``PIPX_HOME`` is set; TTL 14 days, pip backend) - ``$UV_CACHE_DIR`` (no TTL; ``uv cache prune``) - - Standalone Python - ``$PIPX_HOME/py`` (``PIPX_FETCH_PYTHON``) - ``$UV_PYTHON_INSTALL_DIR`` - - System-wide install - ``--global``, ``PIPX_GLOBAL_*`` - *not supported* ``PIPX_BIN_DIR`` and ``UV_TOOL_BIN_DIR`` both default to ``~/.local/bin`` on Unix, so installing the same tool with both managers writes the same filename. Each manager refuses to overwrite a binary the other one wrote without ``--force``. Use ``pipx install --suffix=...`` to keep two copies side-by-side; ``uv`` has no equivalent. Subcommand mapping ================== .. list-table:: :header-rows: 1 :widths: 26 37 37 - - Task - pipx - uv tool - - Install from PyPI - ``pipx install ruff`` - ``uv tool install ruff`` (or ``uvx ruff`` for one-off) - - Install from a git URL - ``pipx install 'git+https://โ€ฆ'`` - ``uv tool install 'git+https://โ€ฆ'`` - - Install editable from path - ``pipx install -e ./mypkg`` - ``uv tool install -e ./mypkg`` - - One-off run (no install) - ``pipx run black .`` - ``uvx black .`` - - Refresh one-off run - ``pipx run --refresh black .`` - ``uvx --refresh black .`` - - Show ephemeral cache - ``pipx cache dir`` - ``uv cache dir`` - - Purge ephemeral cache - ``pipx cache purge`` - ``uv cache clean`` - - One-off run with extra dep - ``pipx run --with mkdocs-material mkdocs`` - ``uvx --with mkdocs-material mkdocs`` - - Pinned-version one-off - ``pipx run --spec 'ruff==0.6.0' ruff check`` - ``uvx ruff@0.6.0 check`` - - Add a dep to existing tool - ``pipx inject mkdocs mkdocs-material`` - ``uv tool install mkdocs --with mkdocs-material`` (rebuilds) - - Remove an injected dep - ``pipx uninject mkdocs mkdocs-material`` - *rebuild without* ``--with`` - - Upgrade one - ``pipx upgrade ruff`` - ``uv tool upgrade ruff`` - - Upgrade all - ``pipx upgrade-all`` - ``uv tool upgrade --all`` - - List installed or outdated - ``pipx list`` (``--outdated``, ``--output json``) - ``uv tool list`` (``--show-with``, ``--outdated``, โ€ฆ) - - Diagnose broken environments - ``pipx health`` - *no equivalent* - - Repair broken environments - ``pipx repair ruff`` / ``repair`` - ``uv tool install ruff`` / *no bulk equivalent* - - Reinstall any environment - ``pipx reinstall ruff`` / ``reinstall-all`` - ``uv tool upgrade --reinstall ruff`` / ``--all`` - - Run pip inside a venv - ``pipx runpip install ...`` - *not supported (no pip in uv venvs)* - - PATH setup - ``pipx ensurepath`` - ``uv tool update-shell`` - - Show resolved env - ``pipx environment`` - ``uv tool dir``, ``uv tool dir --bin``, ``uv cache dir``, ``uv python dir`` - - PEP 723 inline script - ``pipx run script.py`` (with uv backend uses ``uv run``) - ``uv run --script script.py`` Only in pipx ============ - ``pipx inject`` / ``uninject`` add or remove a package in place. ``uv tool install --with`` reaches the same end state by rebuilding the venv. - ``pipx runpip install ...`` runs ``pip`` against a tool's venv, or ``uv pip`` under the uv backend. - ``--include-deps`` exposes entry points from every dependency. ``uv`` requires you to enumerate dep packages with ``--with-executables-from``. - ``--suffix`` keeps two copies of the same tool side-by-side. - ``--global`` and the ``PIPX_GLOBAL_*`` variables drive a system-wide install. - Manual pages get symlinked under ``$PIPX_MAN_DIR``. - ``pipx manifest sync `` applies an explicit desired set; ``pipx manifest lock `` writes one :pep:`751` lock per selected tool. - ``pipx install-all `` rebuilds every venv from a ``pipx list --output json`` snapshot for cross-machine migration. - ``[project.entry-points."pipx.run"]`` declares pipx-specific runtime extras in the package metadata. - ``pipx environment`` prints every variable and its resolved value in one place. - ``--cooldown DAYS`` provides the same release-age policy through ``pip`` and ``uv``. Only in uv tool =============== - ``uv tool list`` toggles columns via ``--show-with``, ``--show-paths``, ``--show-version-specifiers``, ``--show-extras``, ``--show-python``. - ``uvx --with-editable PATH`` adds editable extras for a one-off run. - ``uv tool upgrade --all -p 3.13`` re-pins every tool to a different Python in one shot. - ``uv python install/list/find/pin/upgrade/uninstall`` integrates managed Python; ``uv`` auto-fetches when the requested Python isn't installed. - ``--torch-backend`` and ``--isolated`` add controls that pipx does not expose. - The content-addressed cache spans ``uv pip``, ``uv tool``, ``uv run``, and ``uv venv``; ``uv`` downloads a wheel once and reuses it across all four. Gotchas ======= - ``uvx`` reuses cached envs across invocations until you prune the cache (``uv cache clean``), pin a new version (``uvx black@latest``), or pass ``--refresh``. ``pipx run`` caches for 14 days and accepts ``--refresh`` for an early replacement. - ``uvx`` prefers a persistent install when one exists. After ``uv tool install ruff``, plain ``uvx ruff`` reuses that env instead of building an ephemeral one. Pass ``--isolated`` to bypass. - ``uv tool`` ignores project-local ``.python-version`` files. ``uv run`` honors them; tool envs do not. pipx never reads them; pass ``--python`` or set ``PIPX_DEFAULT_PYTHON``. - ``uv python upgrade`` only bumps patch versions. To move a tool from 3.12 to 3.13 run ``uv tool upgrade --all -p 3.13``. pipx's equivalent is ``reinstall-all --python python3.13``. - ``uv run --script`` needs a real on-disk path. When ``pipx run script.py`` content arrives via URL or named pipe, the uv backend falls back to building a venv. Picking one =========== pipx wins when you need its tool-specific extras: ``inject``/``uninject``, ``--global``, ``--suffix``, manual pages, or ``pipx install-all`` for migration. Install ``pipx[uv]`` to keep that surface and pick up uv-speed venv creation. Reach for ``uv tool`` when you already drive ``uv`` for managed Python or ``uv run --script`` and want one binary for everything. Running both is fine; the only collision point is the shared bin dir, and both sides refuse to overwrite without ``--force``. ************************** pipx vs poetry and pipenv ************************** - pipx is for consuming applications: you install CLI apps with it. - `pipenv `_ and `poetry `_ are CLI apps for developing applications and libraries. - All three tools wrap ``pip`` and virtual environments for more convenient workflows. Example: install ``poetry`` with pipx, ``pipx install poetry``; run ``poetry`` without installing it, ``pipx run poetry --help``. ************** pipx vs venv ************** - ``venv`` is part of Python's standard library in Python 3.2 and above. - ``venv`` creates "virtual environments", which are sandboxed Python installations. - Under the pip backend, pipx creates its environments with ``venv``. Example: pipx installs packages into environments created with ``venv``, ``pipx install black --backend pip --verbose``. *************** pipx vs pyenv *************** - `pyenv `_ manages Python versions on your system. It installs versions such as Python 3.11 and 3.12. - pipx installs packages in virtual environments and exposes their entry points on your ``PATH``. Example: install a Python interpreter with ``pyenv``, then install a package with pipx using that interpreter, ``pipx install black --python=python3.11`` where ``pyenv`` installed ``python3.11``. *************** pipx vs pipsi *************** - pipx and `pipsi `_ both install packages in a similar way. - pipx is under active development; ``pipsi``'s author no longer maintains it. - pipx keeps its shared ``pip`` upgraded, refreshing it every 30 days under the pip backend. - pipx can run an app in one line, leaving your system unchanged after it finishes (``pipx run APP``); ``pipsi`` cannot. - pipx can recursively install binaries from dependent packages. - pipx has more CLI options such as ``upgrade-all``, ``reinstall-all``, and ``uninstall-all``. - pipx requires Python 3.11+ and, under the pip backend, uses the standard-library ``venv`` package. - pipx works with a Python installed by `brew `_, while ``pipsi`` does not. - pipx lets you see each command it runs by passing ``--verbose``. Migrating to pipx from pipsi ============================ After you have installed pipx, run `migrate_pipsi_to_pipx.py `_. You can do this with pipx itself: .. code-block:: console $ pipx run https://raw.githubusercontent.com/pypa/pipx/main/scripts/migrate_pipsi_to_pipx.py ************** pipx vs brew ************** - Both ``brew`` and pipx install CLI tools. - They install from different sources: ``brew`` uses its own curated repository, and pipx generally uses ``PyPI``. Example: ``brew`` can install pipx, but otherwise the two do not interact. ************* pipx vs npx ************* - Both can run CLI tools. `npx `_ searches ``node_modules`` and otherwise runs in a temporary environment; ``pipx run`` searches ``__pypackages__`` and otherwise runs in a temporary environment. - ``npx`` works with JavaScript and pipx works with Python. - pipx can also install tools globally; ``npx`` cannot. No example: the two tools serve different languages. ***************** pipx vs pip-run ***************** `pip-run `_ runs **arbitrary Python code in ephemeral environments**, while pipx runs **Python binaries in ephemeral and non-ephemeral environments**. For example, these two commands both install ``poetry`` to an ephemeral environment and invoke it with ``--help``: .. code-block:: bash pipx run poetry --help pip-run poetry -- -m poetry --help *************** pipx vs fades *************** `fades `_ runs **individual** Python scripts inside automatically provisioned virtualenvs with their dependencies installed. - Both `fades's comment syntax `_ and :doc:`pipx run <../reference/examples>` let you specify a script's dependencies in specially formatted comments, but the exact syntax differs. pipx follows a `provisional specification `_; ``fades`` follows no standard. - Both tools automatically set up reusable virtualenvs containing the necessary dependencies. - Both can download Python scripts or packages to execute from remote resources. - ``fades`` can only run individual script files while pipx can also run packages. *********************** pipx vs pae/pactivate *********************** *pae* is a Bash command-line function distributed with `pactivate `_ that uses ``pactivate`` to create non-ephemeral environments for general use, rather than only for running command-line applications. The ``pactivate`` repository has a `detailed comparison `_; in short: Similarities: - Both create isolated environments without having to specify (and remember) a directory in which to store them. - Both let you use any Python interpreter available on your system (subject to the version restrictions below). pae advantages: - Supports all versions of Python from 2.7 upward. pipx requires 3.11 or above. - Fewer dependencies. - Easier to have multiple versions of a single program, or use different Python versions for one program. - Somewhat more convenient for running arbitrary command-line programs in virtual environments, installing multiple packages in a single environment, and activating virtual environments. - Integrates well with source repos using ``pactivate``. pae disadvantages: - Usable with the Bash shell only. - Slightly less quick and convenient for installing or running command-line programs from single Python packages. - Can be slower than pipx at creating virtual environments. pipx-1.18.16/docs/explanation/how-pipx-works.rst000066400000000000000000000270711527355705200216140ustar00rootroot00000000000000################ How pipx works ################ .. note:: This page describes the **pip backend** model, where a shared `pip `_ environment backs every managed venv through a ``.pth`` file. When the **uv backend** is active (the default whenever `uv `_ is available, via the ``pipx[uv]`` extra or on ``PATH``) pipx skips the shared environment and uses ``uv venv`` and ``uv pip`` instead; uv-created venvs ship without ``pip``. See :doc:`../how-to/use-uv-backend`. *************************** Installing an application *************************** ``pipx install PACKAGE`` creates an isolated virtual environment under ``PIPX_HOME`` (for example ``~/.local/share/pipx/venvs/black``) and installs the package into it. Under the pip backend pipx creates the venv without ``pip``, then creates or reuses a shared environment that holds an up-to-date ``pip`` and links it into the venv through a `.pth file `_, so every venv borrows one ``pip`` rather than installing its own. Under the uv backend there is no shared environment: ``uv venv`` builds the venv (without ``pip``) and ``uv pip`` installs into it. pipx refreshes the shared ``pip`` once it is more than 30 days old, or right away when it is older than ``pip`` 26.2.2; ``pipx upgrade-shared`` refreshes it immediately. When ``pip``'s files are missing from the shared environment, pipx rebuilds it before running ``pip``. It also rebuilds shared environments created with Python older than 3.10, which cannot install the required ``pip`` version. Rebuilding uses the default Python interpreter; existing applications' ``.pth`` references are refreshed before their next installer operation. When a ``pip`` command fails because ``pip`` no longer imports, pipx rebuilds it and retries the command once. After installing the package, pipx exposes the application's resources so you can reach them from any directory. It handles three kinds: - console and GUI scripts, into ``PIPX_BIN_DIR`` (default ``~/.local/bin``), for example ``~/.local/bin/black`` -> ``~/.local/share/pipx/venvs/black/bin/black``; - manual pages, into ``PIPX_MAN_DIR`` (default ``~/.local/share/man/man[1-9]``); - shell completion scripts, into ``PIPX_COMPLETION_DIR`` (default ``~/.local/share``). pipx symlinks each resource into place. On Windows it copies the file instead when the target directory does not support symlinks. As long as ``PIPX_BIN_DIR`` is on your ``PATH`` the commands are available globally, and on systems with ``man`` support the pages are too. pipx records what it installed and exposed in a per-venv ``pipx_metadata.json`` file. Later commands (``upgrade``, ``reinstall``, ``expose``) read it to reproduce the same state. When an upgrade or reinstall changes the resources a package ships, pipx exposes the new ones and deletes the links or copies of the ones the package dropped. Adding ``--global`` to any command targets a system-wide location shared by all users instead of your home directory; the concrete paths and overrides live in :doc:`../how-to/configure-paths`. It is not available on Windows. For a ``.py`` script carrying :pep:`723` metadata, pipx builds a temporary wheel from the script and its declared dependencies, installs that wheel like any other package, then records the original path or URL so ``upgrade`` and ``reinstall`` can rebuild it. .. mermaid:: flowchart LR A["pipx install black"] --> BK{"backend?"} BK -- pip --> C["create venv
venvs/black/"] BK -- uv --> U["uv venv
(no pip)"] C --> P["link shared pip
via .pth"] P --> D["install package
pip install / uv pip install"] U --> D D --> E["expose apps
~/.local/bin"] D --> F["expose man pages
~/.local/share/man"] D --> G["expose completions
PIPX_COMPLETION_DIR"] classDef input fill:#3f72af,stroke:#28517f,color:#fff classDef decision fill:#c78c20,stroke:#8a6011,color:#fff classDef proc fill:#2a9d8f,stroke:#1c6b61,color:#fff classDef out fill:#388e3c,stroke:#256128,color:#fff class A input class BK decision class P,U,C,D proc class E,F,G out ************************* Running an application ************************* ``pipx run APP`` executes an application without installing it permanently. Under the pip backend pipx either reuses a cached temporary venv or builds a fresh one, then invokes the app. The cache key is a hash of the requirements (the spec plus any ``--with`` packages, with local paths made absolute), the ``--python`` value, pip and venv arguments, the backend, the cooldown, and the installer's environment variables (``PIP_*``, or ``UV_*`` under the uv backend, in any case and with ``-`` or ``_``, as ``pip`` reads them). pipx leaves out a short list of variables that cannot change what gets installed, such as timeouts, retries, verbosity, progress bars, color and cache directories, so they do not fork the cache. The key also covers the variables outside those prefixes that ``pip`` or ``uv`` read, in any case: - config files: ``XDG_CONFIG_HOME``, ``XDG_CONFIG_DIRS``, ``APPDATA``, ``SYSTEMDRIVE``, ``WIN_PD_OVERRIDE_APPDATA`` and ``WIN_PD_OVERRIDE_COMMON_APPDATA`` - TLS trust: ``SSL_CERT_FILE``, ``SSL_CERT_DIR``, ``SSL_CLIENT_CERT``, ``REQUESTS_CA_BUNDLE`` and ``CURL_CA_BUNDLE`` - proxies: ``HTTP_PROXY``, ``HTTPS_PROXY``, ``ALL_PROXY`` and ``NO_PROXY`` - credentials: ``NETRC``, ``PYTHON_KEYRING_BACKEND``, ``HF_TOKEN``, ``PYX_API_KEY``, ``PYX_AUTH_TOKEN`` and the ``AWS_*`` variables ``uv`` reads to sign S3 requests ``HOME`` and ``USERPROFILE`` stay out: another home means another user, whose default pipx home, and so its cache, already differs. The values only feed the hash, so tokens and proxy passwords never appear in a cache path or a log line. The hash is an HMAC keyed with a random secret that pipx creates on the first run and keeps in ``.run-key`` inside the cache directory. Other accounts can often list that directory, and without the secret they could hash guesses at a token until one matched an entry's name. On POSIX pipx creates the file with mode ``0600`` and replaces one that another account owns or can read. On Windows it relies on the ACLs of the user profile. When pipx can neither read nor write the secret, as in a read-only cache, it falls back to a plain SHA-256 hash and logs this at debug level. Cached environments expire after 14 days, after which the next run rebuilds against the latest release. The key leaves out edits to ``pip.conf`` or ``uv.toml``, so pass ``--refresh`` after changing them. The temporary venv borrows the shared ``pip``. Under the uv backend pipx usually hands the whole run to ``uv tool run``, which uses ``uv``'s own cache. ``pipx run --with PKG`` adds extra dependencies to the temporary environment either way. .. mermaid:: flowchart LR A["pipx run pycowsay"] --> BK{"backend?"} BK -- pip --> C{"cached
venv?"} BK -- uv --> U["uv tool run
(uv's cache)"] C -- yes --> E["reuse cached venv"] C -- no --> P["python -m venv +
shared pip"] P --> D["install pycowsay"] D --> F["invoke app"] E --> F U --> F classDef input fill:#3f72af,stroke:#28517f,color:#fff classDef decision fill:#c78c20,stroke:#8a6011,color:#fff classDef proc fill:#2a9d8f,stroke:#1c6b61,color:#fff classDef out fill:#388e3c,stroke:#256128,color:#fff class A input class C,BK decision class P,U,D,E proc class F out ********************** Exposing apps on PATH ********************** When you type an exposed command, the shell finds the symlink (or copy) in ``PIPX_BIN_DIR`` and follows it to the launcher inside the app's venv. That launcher runs the venv's own Python against the installed package, so the app always uses its isolated dependencies and never your system site-packages. .. mermaid:: flowchart LR U["user types
black"] --> B["~/.local/bin/black
(symlink or copy)"] B --> V["venvs/black/bin/black
(launcher)"] V --> P["venv Python
+ installed black"] classDef input fill:#3f72af,stroke:#28517f,color:#fff classDef proc fill:#2a9d8f,stroke:#1c6b61,color:#fff classDef out fill:#388e3c,stroke:#256128,color:#fff class U input class B,V proc class P out ****************** Directory layout ****************** pipx keeps its venvs and shared libraries under one home directory; the run cache and logs use the platform cache and log directories unless ``PIPX_HOME`` is set. The tree below is the conceptual shape; the concrete locations, defaults, and every override live in :doc:`../how-to/configure-paths`. .. mermaid:: flowchart TD HOME["~"] --> BIN["~/.local/bin/
(on PATH)"] HOME --> DATA["~/.local/share/pipx/"] DATA --> SHARED["shared/
(pip, pip backend only)"] DATA --> VENVS["venvs/"] VENVS --> V1["black/"] VENVS --> V2["poetry/"] VENVS --> V3["ruff/"] V1 --> V1BIN["bin/black"] BIN -->|symlink or copy| V1BIN classDef input fill:#3f72af,stroke:#28517f,color:#fff classDef proc fill:#2a9d8f,stroke:#1c6b61,color:#fff classDef out fill:#388e3c,stroke:#256128,color:#fff classDef venv fill:#7c4dff,stroke:#5a2fd0,color:#fff classDef shared fill:#c78c20,stroke:#8a6011,color:#fff class HOME input class DATA,VENVS proc class BIN out class SHARED shared class V1,V2,V3,V1BIN venv You can do all of this yourself; pipx automates it. Pass ``--verbose`` to see each command it runs and to stream installer output. *********************************** Resolving the Python interpreter *********************************** pipx prefers a real system Python and only downloads a standalone build as a fallback. When you pass ``--python``, it tries the value as a literal path, then as a command on ``PATH``, then as a ``pythonX.Y`` command, then through the ``py`` launcher when one is on ``PATH``. Outside Windows pipx asks the launcher only for an ``X`` or ``X.Y`` version, the one form the Python Launcher for Unix reads as a version request. Only if every step fails, and ``--fetch-python=missing`` is set, does pipx fetch a build from `python-build-standalone `_. ``--fetch-python=always`` skips these steps and downloads a build. The default, ``--fetch-python=never``, keeps pipx offline and errors out instead. For the full step-by-step order, the ``--fetch-python`` values, and when a downloaded build beats a patched system Python, see :doc:`../how-to/standalone-python`. .. mermaid:: flowchart TD S["--python VALUE"] --> AL{"--fetch-python
=always?"} AL -- yes --> DL["download standalone build"] AL -- no --> A{"file exists?"} A -- yes --> USE["use interpreter"] A -- no --> B{"on PATH?
shutil.which"} B -- yes --> USE B -- no --> C{"pythonX.Y?
(non-Windows)"} C -- yes --> USE C -- no --> D{"py launcher?"} D -- yes --> USE D -- no --> E{"--fetch-python
=missing?"} E -- "yes" --> DL E -- "no (never, default)" --> ERR["error"] classDef input fill:#3f72af,stroke:#28517f,color:#fff classDef decision fill:#c78c20,stroke:#8a6011,color:#fff classDef out fill:#388e3c,stroke:#256128,color:#fff classDef err fill:#b3261e,stroke:#7a1a15,color:#fff class S input class AL,A,B,C,D,E decision class USE,DL out class ERR err pipx-1.18.16/docs/explanation/index.rst000066400000000000000000000016741527355705200200060ustar00rootroot00000000000000############# Explanation ############# Design rationale and background. Read these to understand *why* pipx works the way it does. - :doc:`how-pipx-works` covers the virtual-environment architecture, the shared `pip `_ library, and how apps reach your ``PATH``. - :doc:`scope` sets the application model and the boundaries for configuration, discovery, and distribution. - :doc:`comparisons` relates pipx to ``pip``, `uv tool `_, `Poetry `_, `pipenv `_, `pyenv `_, `brew `_, `npx `_, and more. - :doc:`making-packages-compatible` explains the entry points, man pages, and completions that make a package installable with pipx. .. toctree:: :hidden: how-pipx-works scope comparisons making-packages-compatible pipx-1.18.16/docs/explanation/making-packages-compatible.rst000066400000000000000000000177711527355705200240430ustar00rootroot00000000000000############################## Making packages compatible ############################## ********************* Developing for pipx ********************* To let your users run .. code-block:: console $ pipx install MY_PACKAGE include ``scripts`` in your main table [#main-table]_ in ``pyproject.toml`` or its legacy equivalents for ``setup.cfg`` and ``setup.py``. pipx also exposes ``gui-scripts`` entry points, which are useful for GUI applications on Windows (they launch without opening a console window). .. tab-set:: .. tab-item:: pyproject.toml .. code-block:: toml [project.scripts] foo = "my_package.some_module:main_func" bar = "other_module:some_func" [project.gui-scripts] baz = "my_package_gui:start_func" .. tab-item:: setup.cfg .. code-block:: ini [options.entry_points] console_scripts = foo = my_package.some_module:main_func bar = other_module:some_func gui_scripts = baz = my_package_gui:start_func .. tab-item:: setup.py .. code-block:: python setup( # other arguments here... entry_points={ "console_scripts": [ "foo = my_package.some_module:main_func", "bar = other_module:some_func", ], "gui_scripts": [ "baz = my_package_gui:start_func", ], }, ) Here ``foo``, ``bar``, and ``baz`` become applications that pipx exposes after installing the package, invoking their corresponding entry point functions. .. mermaid:: flowchart LR TOML["pyproject.toml
[project.scripts]"] -->|defines| EP["entry points
foo, bar"] EP -->|pipx install| VENV["isolated venv"] VENV -->|symlink or copy| BIN["~/.local/bin/
foo, bar"] BIN -->|invokes| FUNC["my_package:
main_func()"] classDef input fill:#3f72af,stroke:#28517f,color:#fff classDef proc fill:#2a9d8f,stroke:#1c6b61,color:#fff classDef venv fill:#7c4dff,stroke:#5a2fd0,color:#fff classDef out fill:#388e3c,stroke:#256128,color:#fff classDef fn fill:#c78c20,stroke:#8a6011,color:#fff class TOML input class EP proc class VENV venv class BIN out class FUNC fn The ``pipx.run`` entry point group ================================== When a user runs ``pipx run PACKAGE``, pipx looks for a console script matching the package name. If the package name and script name differ, the user has to write ``pipx run --spec PACKAGE SCRIPT``, which is less convenient. Package authors can declare a ``pipx.run`` entry point group to tell pipx which function to invoke for ``pipx run``. This entry point takes priority over console scripts when present. .. tab-set:: .. tab-item:: pyproject.toml .. code-block:: toml [project.entry-points."pipx.run"] my-package = "my_package.cli:main" .. tab-item:: setup.cfg .. code-block:: ini [options.entry_points] pipx.run = my-package = my_package.cli:main With this declaration, ``pipx run my-package`` invokes ``my_package.cli:main`` even if no console script named ``my-package`` exists. The `build `_ package uses this pattern so that ``pipx run build`` works even though ``build`` names its console script ``pyproject-build``. The uv backend hands ``pipx run build`` to ``uv tool run``, which reads console scripts only, so this needs ``--backend pip`` there (see :doc:`../how-to/use-uv-backend`). Detect a pipx installation ========================== pipx records the environment name in ``pipx_metadata.json`` for packages installed with ``pipx install``. Package code can read this name when it needs to give pipx-specific recovery instructions: .. code-block:: python import json import sys from pathlib import Path def pipx_environment() -> str | None: try: metadata = json.loads((Path(sys.prefix) / "pipx_metadata.json").read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return None environment = metadata.get("environment") if isinstance(metadata, dict) else None return environment if isinstance(environment, str) else None Use the returned name as the target for commands such as ``pipx inject ENVIRONMENT 'mytool[feature]'``. pipx records the extra on the main package, so reinstalling the environment retains it. ``pipx run`` may create internal metadata without setting ``environment``, so check the field value. Treat other keys as internal pipx state. If the function returns ``None``, show the package's usual installer-neutral advice. Manual pages ============ To provide documentation via ``man`` pages on UNIX-like systems, ship them as data files. pipx exposes them the same way it exposes apps. .. tab-set:: .. tab-item:: setuptools .. code-block:: toml # pyproject.toml [tool.setuptools.data-files] "share/man/man1" = [ "manpage.1", ] .. code-block:: ini # setup.cfg [options.data_files] share/man/man1 = manpage.1 .. code-block:: python # setup.py setup( # other arguments here... data_files=[("share/man/man1", ["manpage.1"])], ) .. warning:: The `setuptools documentation `_ calls the ``data-files`` keyword "discouraged", but it has no alternative when you need to ship ``man`` pages. .. tab-item:: pdm-backend .. code-block:: toml # pyproject.toml [tool.pdm.build] source-includes = ["share"] [tool.pdm.build.wheel-data] data = [ { path = "share/man/man1/*", relative-to = "." }, ] After installing the package, ``man manpage`` opens ``manpage.1``. For a real-world example, see `pycowsay's setup.py `_. Shell completions ================= A package can ship the completion script for its own command the same way it ships a man page. pipx picks up the three directories a wheel installs completion scripts into and links each one under ``PIPX_COMPLETION_DIR`` (default ``~/.local/share``), so the completions arrive with the package and leave with it. .. list-table:: :header-rows: 1 :widths: 40 40 20 - - Shipped by the package - Exposed to - Loaded by - - ``share/bash-completion/completions/`` - ``~/.local/share/bash-completion/completions/`` - bash - - ``share/fish/vendor_completions.d/`` - ``~/.local/share/fish/vendor_completions.d/`` - fish - - ``share/zsh/site-functions/`` - ``~/.local/share/zsh/site-functions/`` - zsh (with the directory on ``fpath``) Declare them through the data files of your build backend, exactly as with man pages: .. code-block:: python setup( name="your-tool", data_files=[ ("share/bash-completion/completions", ["completions/your-tool"]), ("share/zsh/site-functions", ["completions/_your-tool"]), ("share/fish/vendor_completions.d", ["completions/your-tool.fish"]), ], ) See :doc:`../how-to/shell-completions` for how each shell loads these directories. Read more about entry points in the `setuptools quickstart `_. .. [#main-table] This is often the ``[project]`` table, but it can have a different name. Read more in the `PyPUG `_. pipx-1.18.16/docs/explanation/scope.rst000066400000000000000000000114061527355705200200020ustar00rootroot00000000000000############### Project scope ############### pipx installs and runs Python applications. It manages the isolated environments that support those applications and exposes their entry points on ``PATH``. ******************* What pipx manages ******************* - Persistent application environments for ``pipx install`` and disposable environments for ``pipx run``. - User installations by default and system installations when the caller passes ``--global``. - Application dependencies inside a managed environment. For unlocked environments, ``inject`` and ``--preinstall`` extend an application environment; they do not create standalone library environments. - Entry points from the main package and dependencies named with ``--include-resources-from`` or ``--include-deps``. An install fails when neither the package nor its selected dependencies expose an application. The caller can pass ``--app NAME`` to require an exact entry point. pipx removes a new environment or restores an existing one when the check fails. ************ Boundaries ************ pipx does not manage these use cases: - standalone environments for importing libraries or modules; - tool selection based on the current directory, parent directories, or project metadata; - system installation inferred from elevated privileges; - a ``pip`` executable from each managed environment on ``PATH``; - package search, package ranking, or index-specific application discovery; or - frozen pipx executables, operating-system packages, zipapp installers, repositories, and signing keys. Use ``pipx runpip`` when you need package-manager access inside an environment that pipx created. Distribution projects may package pipx for their users, but each ecosystem owns its build, release, update, and signing process. ************************** Configuration and state ************************** Ordinary pipx commands do not search the current directory or its parents for configuration. ``pipx manifest sync PATH`` applies the desired application state from the manifest at ``PATH``; pipx does not infer that path. The manifest accepts tool state instead of arbitrary command defaults. The `security policy `_ builds on these boundaries: a pipx command that picks up a file the user did not name, from the current directory, its parents, or a location another local account can write, has a security bug. pipx can record an environment's resolved dependencies as a lock file in the PyPA `pylock.toml `_ format, so the environment rebuilds identically; ``pipx manifest lock`` writes these locks and ``pipx install --lock`` reads one back. pipx does not use ``requirements.txt`` as a state or lock format. pipx exposes top-level policy when `pip `_ and `uv `_ honor the same contract. Backend arguments carry controls specific to ``pip`` or ``uv``. ``--cooldown DAYS`` maps one pipx release-age policy to both backends. A release-age policy describes the machine, not one command, so ``PIPX_COOLDOWN`` supplies the default and no configuration file appears to hold it. That keeps pipx settings in one place and keeps the policy out of the backend environment variables (``PIP_UPLOADED_PRIOR_TO``, ``UV_EXCLUDE_NEWER``), which would bind pipx to one backend and leak into unrelated ``pip`` and ``uv`` runs in the same shell. The variable outranks the cooldown an environment recorded at install time, because it states what the machine wants now rather than what an older install happened to use; ``--cooldown`` still overrides both, and a lock file supersedes all of them by pinning every version outright. Application launchers start the installed application without downloading packages or changing its environment. Health checks and repairs belong in commands that the user invokes for those purposes. ************************** Existing directory lookup ************************** ``pipx run`` has experimental support for applications under ``__pypackages__/./lib/bin`` in the current directory. This behavior predates the scope above and conflicts with the directory-discovery boundary. New features must not use it as a model; the project should deprecate and remove it in a separate change. ******************** Choose another tool ******************** Use a project dependency manager such as ``uv`` or `Poetry `_ for project libraries and development tools. Use `venv `_, ``pip``, or ``uv`` for a library environment. Use a version manager such as `mise `_ when a directory must select tool versions, and use the system package manager to find or install operating-system packages. pipx-1.18.16/docs/how-to/000077500000000000000000000000001527355705200150305ustar00rootroot00000000000000pipx-1.18.16/docs/how-to/configure-paths.rst000066400000000000000000000157111527355705200206650ustar00rootroot00000000000000################ Configure paths ################ Choose where pipx stores environments, apps, manual pages, and shell completions, and how it installs for all users. ***************** Directory layout ***************** pipx splits its files across four locations, each with an environment-variable override and a ``--global`` counterpart: .. mermaid:: flowchart TD subgraph local["default (per-user)"] H1["PIPX_HOME
~/.local/share/pipx
(venvs, shared libs)"] B1["PIPX_BIN_DIR
~/.local/bin
(app links)"] M1["PIPX_MAN_DIR
~/.local/share/man
(man page links)"] C1["PIPX_COMPLETION_DIR
~/.local/share
(completion links)"] end subgraph glob["--global (all users)"] H2["PIPX_GLOBAL_HOME
/opt/pipx"] B2["PIPX_GLOBAL_BIN_DIR
/usr/local/bin"] M2["PIPX_GLOBAL_MAN_DIR
/usr/local/share/man"] C2["PIPX_GLOBAL_COMPLETION_DIR
/usr/local/share"] end classDef home fill:#2a9d8f,stroke:#1f7268,color:#fff; classDef bin fill:#3f72af,stroke:#28507d,color:#fff; classDef man fill:#7c4dff,stroke:#5a34c0,color:#fff; classDef completion fill:#e76f51,stroke:#b4513a,color:#fff; class H1,H2 home; class B1,B2 bin; class M1,M2 man; class C1,C2 completion; .. list-table:: :header-rows: 1 :widths: 30 45 25 - - Variable - Holds - Default - - ``PIPX_HOME`` - Virtual environments and shared libraries - ``~/.local/share/pipx`` - - ``PIPX_BIN_DIR`` - App links - ``~/.local/bin`` - - ``PIPX_MAN_DIR`` - Manual page links - ``~/.local/share/man`` - - ``PIPX_COMPLETION_DIR`` - Shell completion links - ``~/.local/share`` The default ``PIPX_HOME`` is typically ``~/.local/share/pipx`` on Linux, ``~/Library/Application Support/pipx`` on macOS, and ``%USERPROFILE%\AppData\Local\pipx\pipx`` on Windows. Set any variable to override its location. .. _platformdirs-migration: *********************** platformdirs migration *********************** After version 1.16.0, pipx moved its default paths from ``~/.local/pipx`` to per-platform user directories from the `platformdirs `_ library: .. list-table:: :header-rows: 1 :widths: 40 60 - - Old path - New path - - ``~/.local/pipx/.trash`` - ``platformdirs.user_data_dir()/pipx/trash`` - - ``~/.local/pipx/shared`` - ``platformdirs.user_data_dir()/pipx/shared`` - - ``~/.local/pipx/venvs`` - ``platformdirs.user_data_dir()/pipx/venvs`` - - ``~/.local/pipx/.cache`` - ``platformdirs.user_cache_dir()/pipx`` - - ``~/.local/pipx/logs`` - ``platformdirs.user_log_dir("pipx")`` For compatibility, if ``~/.local/pipx`` exists (on Windows also ``~\pipx``), pipx keeps using it. See the `platformdirs platform table `_ for the resolved directories. On Linux and macOS, ``platformdirs`` reads ``XDG_DATA_HOME`` and ``XDG_CACHE_HOME``, and on Linux also ``XDG_STATE_HOME`` for the log directory, so exporting any of them moves the matching pipx directory; leave them unset for the platform default. pipx introduced these paths in 1.2.0 and reverted them for macOS and Windows between 1.5.0 and 1.16.0. To move an existing installation to the new default, see :doc:`move-installation`. ********************** Install for all users ********************** Use ``--global`` **after** the subcommand to operate on the system-wide installation: .. code-block:: console $ sudo pipx install --global pycowsay $ pipx list --global Commands that write to the global locations need root. ``pipx environment --global`` only reads them, so any user can run it. ``pipx list --global`` reads each environment's ``pipx_metadata.json``, which pipx keeps readable by its owner only because it can hold index credentials. Other users see each environment reported as unreadable; run it with ``sudo`` to list them. Putting ``--global`` before the subcommand, as in ``sudo pipx --global install pycowsay``, raises ``unrecognized arguments: --global``. For ``manifest``, ``interpreter`` and ``cache`` it goes after their own subcommand, as in ``sudo pipx manifest sync --global ./pipx.toml``. Default global paths are ``/usr/local/bin`` for binaries, ``/usr/local/share/man`` for man pages, ``/usr/local/share`` for shell completions, and ``/opt/pipx`` for environments. Override them with ``PIPX_GLOBAL_BIN_DIR``, ``PIPX_GLOBAL_MAN_DIR``, ``PIPX_GLOBAL_COMPLETION_DIR``, and ``PIPX_GLOBAL_HOME``. Add the global binary directory to the system ``PATH`` with ``sudo pipx ensurepath --global``, which writes ``/etc/profile.d/pipx.sh`` on Linux and ``/etc/paths.d/pipx`` on macOS. pipx refuses to replace a file there that holds anything besides the entry an earlier ``ensurepath --global`` wrote; add the directory yourself, or pass ``--force`` to replace the file. When you install pipx itself with ``--global`` and override ``PIPX_GLOBAL_HOME``, set the override for every user (for example in ``/etc/environment``), so that pipx recognizes its own environment as the global one; see :doc:`../reference/environment-variables`. You can also set the variables yourself instead of using ``--global``: .. code-block:: console $ sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin PIPX_MAN_DIR=/usr/local/share/man pipx install .. note:: pipx does not support ``--global`` on Windows. ********************************* Prepend or preview the PATH edit ********************************* Pass ``--prepend`` to ``pipx ensurepath`` to prepend the pipx bin directory to ``PATH`` instead of appending it, so pipx-installed binaries win over system binaries of the same name. Pass ``--dry-run`` to report which directories ``ensurepath`` would add, without touching ``PATH`` or any shell configuration file: .. code-block:: console $ pipx ensurepath --dry-run ********************** Point pip at an index ********************** Under the pip backend, pipx uses pip for all installs, including its own shared libraries. Set ``PIP_*`` environment variables to route pip at a private index; pipx forwards them to every pip call. The uv backend ignores ``PIP_*``; use uv's own variables such as ``UV_INDEX_URL``. For per-command control use ``--pip-args``. See :doc:`use-private-index` for the full recipe. Set ``PIPX_DISABLE_SHARED_LIBS_AUTO_UPGRADE=1`` to skip automatic shared-library upgrades during commands such as ``pipx install`` and ``pipx upgrade``; ``pipx upgrade-shared`` still upgrades them. Use ``--skip-maintenance`` to apply the same policy to one command: .. code-block:: console $ pipx install --skip-maintenance my-package ********** Verify it ********** .. code-block:: console $ pipx environment --value PIPX_HOME $ pipx environment --value PIPX_BIN_DIR Each prints the path pipx will use, reflecting any override you set. pipx-1.18.16/docs/how-to/dependency-cooldown.rst000066400000000000000000000055651527355705200215350ustar00rootroot00000000000000#################### Dependency cooldown #################### Skip index artifacts uploaded in the last few days, so maintainers have time to catch and yank a compromised or broken release before your machine picks it up. Reach for this when you install or upgrade tools from a public index and can afford to lag the newest version. ******************* Set it per command ******************* ``--cooldown DAYS`` works on ``install``, ``install-all``, ``inject``, ``upgrade``, ``upgrade-all`` and ``run``: .. code-block:: console $ pipx install --cooldown 7 httpie pipx translates one policy for both backends: `pip `__ receives ``--uploaded-prior-to P7D`` and `uv `__ receives ``--exclude-newer P7D``. Under the pip backend the cooldown also covers the shared pip that pipx installs or refreshes during the command. A new shared environment starts from the oldest pip pipx supports, then upgrades only to a release older than the cooldown; when none is, or the index publishes no upload times, pipx keeps that oldest pip. The metadata value in the table below does not apply here: only ``--cooldown`` and ``PIPX_COOLDOWN`` do, and ``pipx upgrade-shared`` ignores both. ********************* Set it for a machine ********************* Export ``PIPX_COOLDOWN`` so every one of those commands picks it up: .. code-block:: console $ export PIPX_COOLDOWN=7 $ pipx install httpie $ pipx upgrade-all Put the export in your shell profile to make it stick across sessions. pipx has no configuration file; see :doc:`Environment variables <../reference/environment-variables>` for the full list. ********************** Which value pipx uses ********************** .. list-table:: :header-rows: 1 :widths: 10 30 60 - - Rank - Source - Notes - - 1 - ``--cooldown DAYS`` - Wins over everything. ``--cooldown 0`` opts one command out of a machine-wide setting. - - 2 - ``PIPX_COOLDOWN`` - Applies to the commands listed above. Outranks whatever an earlier install recorded. - - 3 - ``cooldown_days`` in the environment's metadata - The value the environment's last install recorded. See :doc:`Metadata <../reference/metadata>`. A locked install ignores all three: ``pylock.toml`` already pins every version. Passing ``--cooldown`` together with ``--lock`` errors, while ``PIPX_COOLDOWN`` yields and the install proceeds. ********** Verify it ********** Read back the variable and the value the environment recorded: .. code-block:: console $ pipx environment --value PIPX_COOLDOWN 7 $ pipx list --output json | jq '.venvs.httpie.metadata.main_package.cooldown_days' 7 An invalid value stops the command before it starts: .. code-block:: console $ PIPX_COOLDOWN=lots pipx install httpie PIPX_COOLDOWN must be unset or a non-negative integer, got 'lots'. pipx-1.18.16/docs/how-to/expose-apps.rst000066400000000000000000000041731527355705200200330ustar00rootroot00000000000000############ Expose apps ############ Control which apps, manual pages, and shell completions an environment links onto your ``PATH``, without rebuilding it. ************ Hide an app ************ Use ``unexpose`` when another command with the same name should win, but you want to keep the environment: .. code-block:: console $ pipx unexpose ansible pipx removes the apps, manual pages, and shell completions recorded for that environment from its global directories. Upgrade and reinstall keep it hidden, and injected apps stay hidden too. *************** Restore an app *************** .. code-block:: console $ pipx expose ansible pipx links each recorded app, manual page, and shell completion that is missing from its target directory, without rebuilding the environment. Run it on an exposed environment to restore a link you deleted. When a file pipx does not own already sits at a target path, pipx leaves it in place, links the rest, and exits with ``1``. Move that file away and run ``pipx expose`` again to link the skipped resource. *************************** Expose a dependency's apps *************************** pipx does not expose a dependency's apps by default; select them at install time. ``--include-deps`` exposes apps and manual pages from every dependency: .. code-block:: console $ pipx install nox --include-deps ``--include-resources-from PACKAGE`` exposes resources from one dependency; repeat it to select more: .. code-block:: console $ pipx install "nox[tox_to_nox]" --include-resources-from tox pipx records the selection so upgrade and reinstall preserve it. The install fails and rolls back when a selected package is not a dependency that ships apps, manual pages, or shell completions. Both options also work on ``pipx inject``; see :doc:`inject-packages`. .. note:: pipx renamed ``--include-apps-from`` to ``--include-resources-from``, and still reads the old name from metadata written by earlier versions. ********** Verify it ********** .. code-block:: console $ pipx list The exposed apps appear under their environment. Run one to confirm it resolves on your ``PATH``. pipx-1.18.16/docs/how-to/index.rst000066400000000000000000000011551527355705200166730ustar00rootroot00000000000000############## How-to guides ############## Task recipes for pipx. Each guide states a goal, walks through the steps, and ends with a check that the change took effect. They assume pipx is already installed unless the guide is about installing it. .. toctree:: :maxdepth: 1 install-pipx upgrade-pipx manage-installed-apps run-scripts inject-packages pin-packages expose-apps tool-manifest configure-paths shell-completions move-installation standalone-python dependency-cooldown use-private-index use-uv-backend use-with-pre-commit troubleshoot pipx-1.18.16/docs/how-to/inject-packages.rst000066400000000000000000000055761527355705200206270ustar00rootroot00000000000000################ Inject packages ################ Add extra packages into an existing pipx-managed environment with ``pipx inject``. ***************** Inject a package ***************** Add ``matplotlib`` to the ``ipython`` environment: .. code-block:: console $ pipx inject ipython matplotlib Inject several at once, from a requirements file, or both: .. code-block:: console $ pipx inject ipython matplotlib pandas $ pipx inject ipython -r useful-packages.txt $ pipx inject ipython extra-pkg -r more-packages.txt Scripts can request the versioned result envelope from either command: .. code-block:: console $ pipx inject ipython matplotlib --output json $ pipx uninject ipython matplotlib --output json **************************************** Add an extra to the environment's app **************************************** Name the main package with the extra to add it. pipx installs the extra from the app's recorded source, so an app installed from a wheel, a directory or a URL keeps that source and the extras it already had: .. code-block:: console $ pipx install ./mytool-1.0-py3-none-any.whl $ pipx inject mytool 'mytool[feature]' A spec that changes anything besides the extras, such as a version or a different path or URL, replaces the app and needs ``--force``. ********************* Expose injected apps ********************* Injected packages do not add their entry points to your ``PATH`` by default. Use ``--include-apps`` to expose them: .. code-block:: console $ pipx inject ipython black --include-apps ``--include-deps`` exposes apps and manual pages from every dependency. Use ``--include-resources-from PACKAGE`` to expose one dependency, repeating it to select more. Both options imply ``--include-apps``. As with ``pipx install``, a package with no app to expose fails the inject and leaves the environment as it was. .. code-block:: console $ pipx inject robotframework-keyta robotframework-browser-batteries --include-resources-from robotframework-browser For exposing apps from an already-installed environment, see :doc:`expose-apps`. ************ Other flags ************ - ``--force`` / ``-f`` reinstalls the package even when it is already injected. - ``--editable`` / ``-e`` installs the package in editable (development) mode. - ``--with-suffix`` gives the injected package's apps the environment's suffix (injecting ``black`` into ``ipython_3.11`` exposes ``black_3.11``). - ``--pip-args`` passes extra arguments to `pip `__ (for example ``--pip-args='--no-cache-dir'``). - ``--index-url`` / ``-i`` sets the package index URL for this inject. - ``--system-site-packages`` only prints a warning; the environment keeps the setting ``pipx install`` gave it. ********** Verify it ********** .. code-block:: console $ pipx list --include-injected The injected packages appear under their host environment. pipx-1.18.16/docs/how-to/install-pipx.rst000066400000000000000000000154421527355705200202140ustar00rootroot00000000000000############# Install pipx ############# Get pipx onto your system and add its app directory to your ``PATH``. pipx runs on macOS, Linux, and Windows. .. image:: https://repology.org/badge/vertical-allrepos/pipx.svg?columns=3&exclude_unsupported=1 :target: https://repology.org/metapackage/pipx/versions :alt: Packaging status *********************** Check the requirements *********************** Installing pipx needs Python 3.11 or newer, and so does any interpreter you pass with ``--python``. Without Python 3.11, see the `Python 3 installation guide `_. You also need ``pip`` for ``python3``. The steps vary by system; see `pip's installation instructions `_. On Linux, install through a `Linux package manager `_. *************** Install per OS *************** macOS ===== .. code-block:: console $ brew install pipx $ pipx ensurepath Linux ===== Ubuntu 23.04 or newer: .. code-block:: console $ sudo apt update $ sudo apt install pipx $ pipx ensurepath Fedora: .. code-block:: console $ sudo dnf install pipx $ pipx ensurepath Other distributions, using ``pip``: .. code-block:: console $ python3 -m pip install --user pipx $ python3 -m pipx ensurepath .. warning:: Distributions that adopt `PEP 668 `_ (Ubuntu 23.04+, Debian 12+, Fedora 38+) mark the system Python as externally managed, so ``pip install --user`` fails with ``externally-managed-environment``. Use the distribution package (``apt install pipx``, ``dnf install pipx``). If none exists, install pipx into its own virtual environment: .. code-block:: console $ python3 -m venv ~/.local/share/pipx-venv $ ~/.local/share/pipx-venv/bin/pip install pipx $ ln -s ~/.local/share/pipx-venv/bin/pipx ~/.local/bin/pipx $ pipx ensurepath Windows ======= Install via `Scoop `_: .. code-block:: console $ scoop install pipx $ pipx ensurepath Install via pip (needs pip 19.0 or newer; replace ``py`` with ``python3`` if you installed Python from the Microsoft Store): .. code-block:: console $ py -m pip install --user pipx This often ends with a warning that ``pipx.exe`` is installed in a directory that is not on ``PATH``. Go to that directory and run the executable directly to fix it (run this even without the warning): .. code-block:: console $ .\pipx.exe ensurepath This adds both that directory and ``%USERPROFILE%\.local\bin`` to your search path. Restart the terminal, then confirm ``pipx`` runs. FreeBSD ======= .. code-block:: console $ pkg install -y py311-pipx $ pipx ensurepath Or via pip: .. code-block:: console $ pip install --user pipx $ pipx ensurepath *********************** Optional PATH commands *********************** Add the global app directory to the system ``PATH`` (pipx 1.6.0+): .. code-block:: console $ sudo pipx ensurepath --global Prepend the pipx bin directory instead of appending it, so pipx apps win over system apps of the same name (pipx 1.7.0+): .. code-block:: console $ pipx ensurepath --prepend For where these directories live and how to change them, see :doc:`configure-paths`. .. note:: Some distributions ship older pipx versions (Ubuntu 24.04 ships 1.4.3). If ``--global`` or ``--prepend`` fails with "unrecognized arguments", :doc:`upgrade pipx ` first. .. tip:: ``sudo pipx`` reports "command not found" when pipx lives in your user directory. See :ref:`sudo-pipx-not-found` for the fix. **************************** Run pipx without installing **************************** Download the zipapp from `GitHub releases `_ and invoke it with a Python 3.11+ interpreter: .. code-block:: console $ python pipx.pyz ensurepath ****************** Self-managed pipx ****************** Let pipx manage its own installation so ``pipx upgrade pipx`` keeps it current and you avoid distro packages that ship older versions. Bootstrap it through a throwaway virtual environment in a fresh private directory: .. code-block:: console $ bootstrap=$(mktemp -d) $ python3 -m venv "$bootstrap" $ "$bootstrap/bin/pip" install pipx $ "$bootstrap/bin/pipx" install pipx $ "$bootstrap/bin/pipx" ensurepath $ rm -rf "$bootstrap" ``mktemp -d`` creates a directory with a random name that only you can enter. Avoid a fixed path such as ``/tmp/bootstrap``: on a shared host another user can create it first and plant code that every Python process started from the bootstrap runs, including the ``sudo`` step below. After this, ``pipx upgrade pipx`` upgrades pipx like any other managed app. On Windows, pipx cannot delete its own running executable, so it moves locked files to a trash directory and cleans them up on the next run. To share one self-managed pipx between all users on Unix, run the bootstrap's ``install`` with ``sudo`` and ``--global`` instead, before removing the bootstrap directory. The installed ``pipx`` then works on each user's own environments, and on the global ones only when you pass ``--global``: .. code-block:: console $ sudo "$bootstrap/bin/pipx" install --global pipx $ pipx install pycowsay $ sudo pipx install --global pycowsay .. _install-from-source-control: **************************** Install from source control **************************** pipx accepts any source pip supports, including git repositories. Using ``black`` as an example: .. code-block:: console $ pipx install git+https://github.com/psf/black.git $ pipx install git+ssh://git@github.com/psf/black $ pipx install git+https://github.com/psf/black.git@branch $ pipx install git+https://github.com/psf/black.git@258e7dbf348bc23cb70b8cdf693481c04634ebcc $ pipx install https://github.com/psf/black/archive/18.9b0.zip Use a direct URL requirement to install extras: .. code-block:: console $ pipx install "black[jupyter] @ git+https://github.com/psf/black.git" To test a package from an open pull request, find the fork owner and branch on the PR page, then build the git URL. For PR #794 from user ``contributor`` on branch ``fix-something``: .. code-block:: console $ pipx install git+https://github.com/contributor/pipx.git@fix-something If the branch is already merged, use the merge commit hash instead: .. code-block:: console $ pipx install git+https://github.com/pypa/pipx.git@abc123def ********** Verify it ********** .. code-block:: console $ pipx --version Open a new terminal after ``pipx ensurepath`` so the updated ``PATH`` takes effect, then run ``pipx list`` to confirm the environment is reachable. pipx-1.18.16/docs/how-to/manage-installed-apps.rst000066400000000000000000000107121527355705200217310ustar00rootroot00000000000000###################### Manage installed apps ###################### List, upgrade, and remove the apps pipx manages, then confirm each change. ********** List apps ********** .. code-block:: console $ pipx list The output shows each package version, its apps, and the environment paths. Narrow or reshape it: - ``pipx list --short`` prints package names and versions only. - ``pipx list --include-injected`` also lists packages injected into each environment. - ``pipx list --outdated`` lists environments with an available upgrade. - ``pipx list --pinned`` lists pinned environments only. ************ Upgrade one ************ .. code-block:: console $ pipx upgrade black Add ``--include-injected`` to upgrade injected packages alongside the main app. Pass ``--install`` to install the package spec when it is missing rather than error. pipx exposes any apps, manual pages, and shell completions the new version adds, and removes the ones it no longer ships, so ``pipx list`` and ``PIPX_BIN_DIR`` match the upgraded package. ``upgrade-all`` and ``pipx install --upgrade`` do the same. Verify: .. code-block:: console $ pipx list --short ************ Upgrade all ************ .. code-block:: console $ pipx upgrade-all Use ``--skip PKG ...`` to leave named environments untouched, and ``--include-injected`` to include injected packages. pipx skips pinned environments; see :doc:`pin-packages`. pipx upgrades up to eight environments at once and prints their output in environment order, as a one-at-a-time run would. An app, manual page, or completion that several environments expose ends up with the same environment as in a one-at-a-time run. A failed environment does not stop the others. `pip `__ and `uv `__ cannot prompt for input, such as index credentials, during any pipx command other than ``pipx runpip``. Pass ``--verbose`` to upgrade them one at a time. Ctrl-C, ``SIGTERM`` or ``SIGHUP`` stops pipx from starting more upgrades, and pipx finishes the ones already in progress before it exits, since an upgrade cut short leaves an environment with a mix of old and new files. Press Ctrl-C again to stop those too. pipx then reports each environment it stopped mid-upgrade, ``pipx list`` flags it, and the next ``pipx upgrade-all``, ``pipx upgrade`` or ``pipx reinstall`` of it reinstalls it from its metadata. With ``--verbose`` the first Ctrl-C already stops the running upgrade. Verify: .. code-block:: console $ pipx list --outdated ************** Uninstall one ************** .. code-block:: console $ pipx uninstall black pipx deletes the environment and unlinks its apps, manual pages, and shell completions. Verify: .. code-block:: console $ pipx list --short ************** Uninstall all ************** .. code-block:: console $ pipx uninstall-all Record what you have first with ``pipx list --short`` if you plan to reinstall later. Verify: .. code-block:: console $ pipx list ************** Reinstall one ************** Rebuild an environment from its recorded metadata, keeping the same options as the original install. Reach for it after a Python upgrade or to repair a broken environment. .. code-block:: console $ pipx reinstall black Pass ``--python`` to rebuild under another interpreter. ``pipx install --force`` keeps the environment's interpreter and ignores ``--python``. Verify: .. code-block:: console $ pipx list --short ************** Reinstall all ************** .. code-block:: console $ pipx reinstall-all Run it after installing a new Python version so every app moves to it. Use ``--python`` to target a specific interpreter and ``--skip PKG ...`` to exclude environments. pipx rebuilds up to eight environments at once (fewer on machines with fewer CPUs) and holds back the output of each until the ones before it finish, so it reads the same as a one-at-a-time run. Resources that several environments expose, failed environments and credential prompts work as described for ``upgrade-all`` above. Pass ``--verbose`` to rebuild them one at a time and watch the pip or uv output as it happens. Ctrl-C, ``SIGTERM`` or ``SIGHUP`` stops the rebuilds in progress and restores each of those environments from its backup; the environments already rebuilt keep their new build. Verify: .. code-block:: console $ pipx list .. tip:: To check environments without changing them, or to repair only the broken ones, see :ref:`repair-environments`. pipx-1.18.16/docs/how-to/move-installation.rst000066400000000000000000000053621527355705200212350ustar00rootroot00000000000000################## Move installation ################## Relocate your pipx directory to a new location and rebuild the environments there. The snippets below move from a non-default location to the current default. To move somewhere else, replace the ``NEW_LOCATION`` value. For which paths pipx defaults to and why they moved, see :ref:`platformdirs-migration`. .. warning:: These snippets delete the cache, log, and trash directories (pipx recreates them) and move ``PIPX_HOME``. Run ``pipx reinstall-all`` afterward so every environment rebuilds at the new location. ****** macOS ****** Current default location: ``~/Library/Application Support/pipx``. .. code-block:: bash cache_dir=$(pipx environment --value PIPX_VENV_CACHEDIR) logs_dir=$(pipx environment --value PIPX_LOG_DIR) trash_dir=$(pipx environment --value PIPX_TRASH_DIR) home_dir=$(pipx environment --value PIPX_HOME) NEW_LOCATION="$HOME/Library/Application Support" rm -rf "$cache_dir" "$logs_dir" "$trash_dir" mkdir -p "$NEW_LOCATION" && mv "$home_dir" "$NEW_LOCATION" pipx reinstall-all ****** Linux ****** Current default location: ``~/.local/share/pipx`` (``$XDG_DATA_HOME/pipx`` when set). .. code-block:: bash cache_dir=$(pipx environment --value PIPX_VENV_CACHEDIR) logs_dir=$(pipx environment --value PIPX_LOG_DIR) trash_dir=$(pipx environment --value PIPX_TRASH_DIR) home_dir=$(pipx environment --value PIPX_HOME) NEW_LOCATION="${XDG_DATA_HOME:-$HOME/.local/share}" rm -rf "$cache_dir" "$logs_dir" "$trash_dir" mkdir -p "$NEW_LOCATION" && mv "$home_dir" "$NEW_LOCATION" pipx reinstall-all ******** Windows ******** Current default location: ``%LOCALAPPDATA%\pipx\pipx``. .. code-block:: powershell $NEW_LOCATION = Join-Path "$env:LOCALAPPDATA" 'pipx\pipx' $cache_dir = pipx environment --value PIPX_VENV_CACHEDIR $logs_dir = pipx environment --value PIPX_LOG_DIR $trash_dir = pipx environment --value PIPX_TRASH_DIR $home_dir = pipx environment --value PIPX_HOME Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "$cache_dir", "$logs_dir", "$trash_dir" # Remove the destination first so Move-Item renames rather than nests Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "$NEW_LOCATION" New-Item -ItemType Directory -Force (Split-Path "$NEW_LOCATION") | Out-Null Move-Item -Path $home_dir -Destination "$NEW_LOCATION" pipx reinstall-all To move it from Git Bash or WSL instead, follow the macOS/Linux steps and set ``NEW_LOCATION`` to the Windows path. ********** Verify it ********** .. code-block:: console $ pipx environment --value PIPX_HOME $ pipx list The home path reflects the new location and every environment lists cleanly after the reinstall. pipx-1.18.16/docs/how-to/pin-packages.rst000066400000000000000000000036371527355705200201350ustar00rootroot00000000000000############# Pin packages ############# Hold an installation at its current version so upgrades skip it until you unpin. ******* Pin it ******* .. code-block:: console $ pipx pin black ``pipx upgrade`` and ``pipx upgrade-all`` skip pinned environments, so the app and its dependencies keep their versions until you unpin. ``pipx reinstall`` refuses a pinned environment, and ``pipx reinstall-all`` reports it as an error. ``pipx install --force`` and ``pipx inject --force`` keep a package pinned, even when they install a different version. - ``pipx pin PACKAGE`` pins the main package and any injected packages in that environment. - ``pipx pin PACKAGE --injected-only`` leaves the main package upgradable but pins every injected package. - ``pipx pin PACKAGE --skip PKG_A PKG_B`` pins injected packages except the listed ones (implies ``--injected-only``). - ``pipx unpin PACKAGE`` unpins the main package and every pinned injected package in that environment. ``--skip`` matches injected package names regardless of capitalization or whether they use hyphens, underscores, or periods. For a package injected with ``--with-suffix``, pass either its name or its name with the suffix. Pass ``--output json`` to read the changed and skipped packages from a script, or ``--quiet`` to omit confirmations. .. note:: pipx pins the main package and injected packages, not individual transitive dependencies. Pinning the main package freezes its whole dependency set, because pipx skips ``pip install --upgrade`` for that environment. A package injected after ``pipx pin`` starts unpinned, so ``pipx upgrade --include-injected`` still upgrades it; run ``pipx pin PACKAGE`` again to pin it too. For how pipx records this metadata, see :doc:`../explanation/how-pipx-works`. ********** Verify it ********** .. code-block:: console $ pipx list --pinned Add ``--include-injected`` to also list pinned injected packages. pipx-1.18.16/docs/how-to/run-scripts.rst000066400000000000000000000116431527355705200200600ustar00rootroot00000000000000############ Run scripts ############ Run an app or a script in a temporary environment with ``pipx run``, without installing it first. pipx downloads the package, caches the environment, and runs the app. Under the `uv `__ backend, pipx hands packages to ``uv tool run`` and local scripts with inline metadata to ``uv run --script``. uv's cache applies to packages, while a script's environment stays in pipx's cache. *********************** Run a specific version *********************** The ``PACKAGE`` argument is a `requirement specifier `_, so you can pin versions, ranges, or extras: .. code-block:: console $ pipx run mpremote==1.20.0 $ pipx run --spec esptool==4.6.2 esptool.py $ pipx run --spec 'esptool>=4.5' esptool.py Quote any specifier that contains ``>``, ``<``, or spaces. *********************** Add extra dependencies *********************** ``--with PKG`` adds packages to the temporary environment alongside the app. Repeat it to add more. For a script, ``--with`` requires a ``# /// script`` metadata block; pipx adds the packages to its declared dependencies: .. code-block:: console $ pipx run --with requests --with rich my-script.py pipx resolves the ``--with`` packages together with the app, so ``--with`` can also pin or cap one of the app's own dependencies: .. code-block:: console $ pipx run --with 'click<8.2' black --version ************************* Pass arguments to Python ************************* ``--python-args ARGS`` forwards arguments to the interpreter that runs the app, rather than to the app itself: .. code-block:: console $ pipx run --python-args "-X dev" my-script.py ************************ Run from source control ************************ ``pipx run`` accepts source-control URLs, with the same syntax as ``pipx install`` (see :ref:`install-from-source-control`). Using ``black`` as an example: .. code-block:: console $ pipx run git+https://github.com/psf/black.git $ pipx run git+https://github.com/psf/black.git@branch Use ``--spec`` when the executable name differs from the package name or when installing from an archive URL: .. code-block:: console $ pipx run --spec https://github.com/psf/black/archive/18.9b0.zip black *************** Run from a URL *************** pipx downloads a script with Python's ``urllib``, which cannot send credentials embedded in an HTTP URL. ``pipx run`` and ``pipx install`` therefore refuse an ``http`` or ``https`` script URL with a ``user@`` or ``user:password@`` part. Otherwise you can run ``.py`` files hosted anywhere: .. code-block:: console $ pipx run https://gist.githubusercontent.com/cs01/fa721a17a326e551ede048c5088f9e0f/raw/6bdfbb6e9c1132b1c38fdd2f195d4a24c540c324/pipx-demo.py A script read from a URL or a named pipe has no directory of its own, so pipx starts it with ``python -P``: it cannot import modules from the directory you run ``pipx run`` in. The same holds for apps declared as ``pipx.run`` entry points and for apps run with ``--python-args``. .. _run-pep-723: ***************************************** Run a script with dependencies (PEP 723) ***************************************** A script can declare its own dependencies with `inline script metadata `_. pipx reads the ``# /// script`` block and installs the listed packages before running: .. code-block:: python # test.py # /// script # dependencies = ["requests"] # /// import sys import requests project = sys.argv[1] data = requests.get(f"https://pypi.org/pypi/{project}/json").json() print(data["info"]["version"]) .. code-block:: console $ pipx run test.py pipx 1.9.0 pipx caches an environment keyed to the script's dependency list. Changing the dependencies builds a fresh one. Under the uv backend pipx keys a local script's environment to the script's path instead, and uv syncs the dependencies into it on each run. Under both backends a changed ``--python``, ``--pip-args`` or ``--cooldown`` also builds a fresh one, and ``--refresh`` rebuilds it. On Windows under the uv backend, a ``--refresh`` or ``--no-cache`` run of a script that another run is still executing leaves that run's environment alone: it builds a throwaway one for itself, and after ``--refresh`` the next run to find the cached environment idle rebuilds it. When the block sets ``requires-python``, pipx keeps the default Python if it satisfies the constraint and otherwise looks for an interpreter that does, the same way ``pipx install`` does (see :doc:`standalone-python`). An explicit ``--python`` that fails the constraint is an error. Both backends follow this rule. ********** Verify it ********** .. code-block:: console $ pipx run pycowsay moo A successful run prints the app's own output; nothing is left installed afterward. pipx-1.18.16/docs/how-to/shell-completions.rst000066400000000000000000000045031527355705200212250ustar00rootroot00000000000000################## Shell completions ################## Enable tab completion for pipx and for the apps it installs. *********************** Enable pipx completion *********************** Print the instructions for your shell and follow them: .. code-block:: console $ pipx completions ******************************* Completions for installed apps ******************************* A package can ship the completion script for its own command, the way it ships a man page. pipx links that script out of the environment it installed into, so the completions arrive with the package and leave with it. pipx picks up the three directories a wheel installs completion scripts into and links each under ``PIPX_COMPLETION_DIR`` (default ``~/.local/share``): .. list-table:: :header-rows: 1 :widths: 34 44 22 - - Shipped by the package - Linked to - Loaded by - - ``share/bash-completion/completions/`` - ``~/.local/share/bash-completion/completions/`` - bash, on its own - - ``share/fish/vendor_completions.d/`` - ``~/.local/share/fish/vendor_completions.d/`` - fish, on its own - - ``share/zsh/site-functions/`` - ``~/.local/share/zsh/site-functions/`` - zsh, once you say so bash and fish read their directories without further help. zsh needs the directory on its ``fpath``, so add this to ``~/.zshrc`` ahead of the call to ``compinit``: .. code-block:: bash fpath+=("$(pipx environment --value PIPX_COMPLETION_DIR)/zsh/site-functions") ``pipx uninstall`` takes the links away, and ``pipx unexpose`` removes them while leaving the package in place (see :doc:`expose-apps`). *********************************** Ship completions from your package *********************************** Install completion scripts through your build backend's data files. With `setuptools `__: .. code-block:: python setup( name="your-tool", data_files=[ ("share/bash-completion/completions", ["completions/your-tool"]), ("share/zsh/site-functions", ["completions/_your-tool"]), ("share/fish/vendor_completions.d", ["completions/your-tool.fish"]), ], ) ********** Verify it ********** Open a new shell and press ``Tab`` after typing ``pipx``. The subcommands complete when the setup took effect. pipx-1.18.16/docs/how-to/standalone-python.rst000066400000000000000000000072561527355705200212430ustar00rootroot00000000000000################## Standalone Python ################## Install an app under a Python interpreter downloaded from `python-build-standalone `_ instead of the system one. Reach for this when the system Python lacks the version you need, or when a distro patched its Python in ways that break the app. ***************** How pipx decides ***************** ``--fetch-python`` (or ``PIPX_FETCH_PYTHON``) sets the policy. Without ``--python``, pipx first looks for a local interpreter that satisfies the app's ``requires-python``, and the policy only decides what happens when none does: .. mermaid:: flowchart TD START["pipx install"] --> DEF{"default Python satisfies
requires-python?"} DEF -->|yes| USEDEF["use default Python"] DEF -->|no| SEARCH{"another local
interpreter fits?"} SEARCH -->|yes| USEMATCH["use newest match"] SEARCH -->|no| POLICY{"--fetch-python"} POLICY -->|never| FAIL["error"] POLICY -->|missing or always| FETCH["download standalone build"] classDef decision fill:#2a9d8f,stroke:#1f7268,color:#fff; classDef fetch fill:#c78c20,stroke:#946716,color:#fff; classDef use fill:#388e3c,stroke:#276628,color:#fff; classDef fail fill:#c62828,stroke:#8e1c1c,color:#fff; class POLICY,DEF,SEARCH decision; class FETCH fetch; class USEDEF,USEMATCH use; class FAIL fail; When you name ``--python`` yourself, pipx uses that interpreter and reports it, rather than overriding you when the package rejects it. *************** Set the policy *************** .. list-table:: :header-rows: 1 :widths: 20 80 - - Value - Behavior - - ``never`` - Default. Never download; use interpreters from ``PATH`` or the ``py`` launcher. - - ``missing`` - Look locally first; download when the requested version is not found. - - ``always`` - Skip the local search and use a standalone build for the version named with ``--python``. .. code-block:: console $ pipx install --python 3.13 --fetch-python=missing my-package $ pipx install --python 3.13 --fetch-python=always my-package Set it for the whole shell session with the environment variable: .. code-block:: console $ export PIPX_FETCH_PYTHON=missing $ pipx install --python 3.13 my-package Reach for ``always`` in CI runs that should not depend on the runner's Python, on distros that strip modules like ``tkinter`` or ``lzma``, or on air-gapped hosts with a populated standalone cache. *************************** Manage cached interpreters *************************** pipx unpacks each interpreter into its standalone cache. Manage them with: .. code-block:: console $ pipx interpreter list $ pipx interpreter prune $ pipx interpreter upgrade ``interpreter upgrade`` moves every environment on a standalone build to the newest patch release of its minor version, including environments installed with an exact version such as ``--python 3.14.8``. If the package's newest release has a ``Requires-Python`` that rules out the new build, the command fails and leaves that environment where it was, rather than moving it to another Python. .. note:: ``--fetch-missing-python`` and ``PIPX_FETCH_MISSING_PYTHON`` still work but are deprecated aliases for ``--fetch-python=missing`` / ``PIPX_FETCH_PYTHON=missing``. pipx errors if you set both ``PIPX_FETCH_MISSING_PYTHON`` and ``PIPX_FETCH_PYTHON``. ********** Verify it ********** .. code-block:: console $ pipx list The environment lists the interpreter version pipx settled on. For how pipx resolves interpreters, see :doc:`../explanation/how-pipx-works`. pipx-1.18.16/docs/how-to/tool-manifest.rst000066400000000000000000000076751527355705200203620ustar00rootroot00000000000000############## Tool manifest ############## Declare a set of pipx tools in one file and install, upgrade, or prune them as a unit with ``pipx manifest``. ******************* Write the manifest ******************* The manifest is a ``pipx.toml`` file. Each dependency group holds one `PEP 508 `__ package requirement and names its pipx environment. ``project.dependencies`` must be an empty list so each group resolves on its own; pipx rejects the manifest otherwise: .. code-block:: toml [project] name = "pipx-tools" version = "1" dependencies = [] requires-python = ">=3.10" [dependency-groups] black = ["black>=25,<26"] httpie = ["httpie"] [tool.pipx] version = "1.0" [tool.pipx.tools.black] apps = ["black"] lock = "pylock.black.toml" [tool.pipx.tools.httpie] apps = ["http", "https"] Add a ``tool.pipx.tools`` table only when a tool needs policy beyond its package requirement. A tool table may set ``suffix``, ``apps``, ``include-dependencies``, ``include-resources-from``, ``expose``, or ``lock``. Use ``include-resources-from = ["PACKAGE"]`` to select dependency resources; do not combine it with ``include-dependencies``. Relative ``lock`` paths start from the manifest directory. Put `nab `__ settings in ``tool.nab``. Use the normalized package name plus its suffix as the environment name when a tool sets a suffix: .. code-block:: toml [dependency-groups] black-24 = ["black==24.10.0"] [tool.pipx.tools.black-24] suffix = "-24" apps = ["black"] Package requirements use PEP 508 syntax without environment markers. ****************** Lock the manifest ****************** ``nab`` resolves each locked dependency group into a `PEP 751 `__ lock file. Install it, then generate the declared locks: .. code-block:: console $ pipx install nab $ pipx manifest lock ./pipx.toml ``pipx manifest lock`` passes the manifest to ``nab`` once per locked dependency group, and records that group as the lock's default so installers select it. Existing locks seed their replacements, and pipx overwrites the declared files only after every resolution succeeds. Entries without ``lock`` stay unlocked. ``pipx manifest lock`` refuses to run when a ``lock`` path resolves outside the manifest directory, through ``..``, ``~``, an absolute path, or a symlink; ``pipx manifest sync`` still reads locks kept elsewhere. ******************* Apply the manifest ******************* .. code-block:: console $ pipx manifest sync ./pipx.toml ``sync`` installs, upgrades, or downgrades each declared tool. The backend resolves unlocked specs on each run; locked entries install from the artifacts in their named PEP 751 files. Under the `pip `__ backend, a locked entry needs pip 26.1 or newer; run ``pipx upgrade-shared`` when pipx reports an older one. pipx restores an existing environment and its exposed resources when a tool fails to install, lacks a required app, or the sync is interrupted. For an environment that already exists, ``sync`` keeps the installer and venv options recorded at install time, such as ``--index-url`` or ``--system-site-packages``, the same way ``pipx reinstall`` does, and keeps the apps, manual pages, and completions of its injected packages exposed. Pass ``--prune`` to uninstall environments absent from the manifest. pipx skips pruning when any declared tool fails to sync: .. code-block:: console $ pipx manifest sync ./pipx.toml --prune Without ``--prune``, sync leaves your other pipx environments alone. Pass ``--global`` (not available on Windows) or ``--backend`` after ``sync`` to select the pipx store or the installation backend. pipx reads only the manifest path you supply on the command line. ********** Verify it ********** .. code-block:: console $ pipx list --short Each declared tool appears at the version its group resolved to. pipx-1.18.16/docs/how-to/troubleshoot.rst000066400000000000000000000313221527355705200203140ustar00rootroot00000000000000############# Troubleshoot ############# Diagnose and fix common pipx problems, from wrong package versions to broken environments. ******************************** Wrong package version installed ******************************** pipx builds venvs with your default Python, and `pip `__ installs the newest release compatible with it. When a package drops support for your Python version, pip installs an older release without warning. Check which Python pipx uses: .. code-block:: console $ pipx environment --value PIPX_DEFAULT_PYTHON Install with a different Python: .. code-block:: console $ pipx install my-package --python python3.12 If the package is already installed, reinstall it instead; ``pipx install --force`` ignores ``--python``: .. code-block:: console $ pipx reinstall my-package --python python3.12 If you do not have that version, let pipx download a standalone build: .. code-block:: console $ pipx install my-package --python 3.13 --fetch-python=missing Pass ``--fetch-python=always`` to download even when the system has the requested version, for example to avoid a patched distro interpreter. See :doc:`standalone-python`. .. _repair-environments: ********************************* Diagnose and repair environments ********************************* Check each managed environment without changing it: .. code-block:: console $ pipx health The command exits with status 1 when an environment cannot run its Python interpreter. Pass package names to check a subset, or ``--output json`` to read the result from a script; each unhealthy environment then appears in ``errors`` with the code ``environment_unhealthy``. Repair failed environments with the default Python: .. code-block:: console $ pipx repair Pass package names to limit the repair, and ``--python`` when the rebuilt environments need another interpreter: .. code-block:: console $ pipx repair --python python3.13 ``pipx repair`` reuses the recorded metadata, like ``pipx reinstall``, and leaves healthy environments unchanged. It refuses a pinned package because its recorded source may resolve to another release; run ``pipx unpin PACKAGE`` first. To rebuild healthy environments too (for example after an old pipx release), use ``pipx reinstall-all`` (see :doc:`manage-installed-apps`). .. note:: A package installed with a pipx before 0.15.0.0 has no recorded options. To specify options, uninstall and install it manually: .. code-block:: console $ pipx uninstall $ pipx install Do the same for an environment whose record is missing because its install was killed, or unreadable because a newer pipx wrote it: ``pipx reinstall``, ``pipx reinstall-all`` and ``pipx repair`` refuse it rather than guess its source. ******************************** Start over from a fresh install ******************************** An interrupted install or shared-library upgrade can leave state that ``pipx repair`` cannot mend. Reset pipx to the state it had at install time: .. code-block:: console $ pipx reset It uninstalls every managed package (unlinking their apps and man pages) and removes the shared libraries, caches, standalone interpreters, logs, and trash. Because it asks first, ``--yes`` answers for a script, and ``--dry-run`` lists what it would remove without touching anything: .. code-block:: console $ pipx reset --dry-run Record what you have with ``pipx list --short`` before the reset, then reinstall afterward. ************************************** Specify options that take an argument ************************************** Use an ``=`` sign for options that require an argument: .. code-block:: console $ pipx install pycowsay --pip-args="--no-cache-dir" To ignore SSL/TLS errors: .. code-block:: console $ pipx install termpair --pip-args '--trusted-host files.pythonhosted.org --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host github.com' ************************************** Check for PIP_* environment variables ************************************** Under the pip backend, ``PIP_*`` environment variables change how pipx installs and upgrades packages. If an install or upgrade does not do what you expect, check for them. Unix or macOS: .. code-block:: console $ env | grep '^PIP_' Windows PowerShell: .. code-block:: console $ ls env:PIP_* Windows ``cmd``: .. code-block:: console $ set PIP_ See `pip's environment variables `_. The `uv `__ backend ignores ``PIP_*`` and reads uv's own ``UV_*`` variables (for example ``UV_INDEX_URL``) instead; check for those the same way. ****************************************** Recover shared pip after a Python upgrade ****************************************** The shared environment can retain an older Python after you upgrade the interpreter running pipx. If upgrading shared pip reports ``No matching distribution found for pip>=26.2.2`` and rejects releases requiring Python 3.10, rebuild it with a compatible default interpreter: .. code-block:: console $ pipx upgrade-shared pipx detects shared environments created with Python older than 3.10 and rebuilds them. If ``PIPX_DEFAULT_PYTHON`` still selects an older interpreter, update it to Python 3.10 or newer first. Existing applications keep their own interpreters; their references to shared pip are refreshed before the next installer operation. **************************** Clear runpip cache warnings **************************** Under the pip backend, ``pipx runpip`` runs pip from pipx's shared libraries with a pipx-managed venv's interpreter. Warnings such as ``WARNING: Cache entry deserialization failed, entry ignored`` come from that pip and its own HTTP cache, not from pipx's venv cache. Inspect and clear the cache for a managed package: .. code-block:: console $ pipx runpip cache dir $ pipx runpip cache purge Clearing pipx's venv cache (``pipx environment --value PIPX_VENV_CACHEDIR``) or the cache for a different interpreter does not clear entries used by ``pipx runpip ``. Under the uv backend, ``pipx runpip`` runs ``uv pip``, which has no ``cache`` subcommand. Use uv's cache commands with the binary pipx uses (``pipx environment --value PIPX_UV_BINARY``): .. code-block:: console $ uv cache dir $ uv cache clean ********** Log files ********** pipx writes a verbose log for every command to ``pipx environment --value PIPX_LOG_DIR``. By default that is ``~/.local/state/pipx/log`` on Linux (``$XDG_STATE_HOME/pipx/log``), ``~/Library/Logs/pipx`` on macOS, and ``%LOCALAPPDATA%\pipx\pipx\Logs`` on Windows, or ``$PIPX_HOME/logs`` when ``PIPX_HOME`` is set. When that directory is not writable, pipx falls back to the platform user log directory. A command that fails while pip or uv runs also writes the backend output beside its log, as ``cmd__pip_errors.log`` or ``cmd__uv_errors.log``. On POSIX systems the log directory is readable by its owner only (``0700``) and every log file is ``0600``, because the logs record the full command line, including the arguments ``pipx run`` and ``pipx exec`` pass to the application. pipx tightens a log directory that an older release left with a wider mode; one owned by another account keeps its mode. pipx keeps the logs of the last 10 commands, counting the one that is running, and separately the last 10 error logs. Set ``PIPX_MAX_LOGS`` to change both limits. ``0`` acts like ``1``: the latest command's log stays, because a failing command points you at its error log. pipx never deletes the logs of a command that is still running in another pipx process, so concurrent commands can briefly leave more logs than the limit. **************************** Lazy imports on Python 3.15 **************************** On Python 3.15 pipx makes every import in its process lazy (:pep:`810`) to start faster. A missing or broken dependency then fails where pipx first uses it, with ``lazy import of 'X' raised an exception during resolution``, instead of at startup. To load modules eagerly, set ``PYTHON_LAZY_IMPORTS=normal`` or run ``python -X lazy_imports=normal -m pipx``; pipx keeps a mode you set either way. .. _sudo-pipx-not-found: ************************ ``sudo pipx`` not found ************************ If you installed pipx with ``pip install --user``, its binary lives in your user directory (for example ``~/.local/bin/pipx``). Root's ``PATH`` does not include that directory, so ``sudo pipx`` fails with "command not found". Use the full path: .. code-block:: console $ sudo ~/.local/bin/pipx ensurepath --global To avoid this, install pipx through your distribution's package manager (``apt install pipx``, ``dnf install pipx``) or system-wide with ``sudo pip install pipx`` (without ``--user``). ************************************* A suffixed app runs under a new name ************************************* pipx 1.17.14 and earlier put ``--suffix`` before the last dot of an app name, so ``pipx install pip --suffix=_old`` exposed ``pip3_old.12``. Later releases put it at the end, ``pip3.12_old``; on Windows it still goes before a file extension such as ``.exe`` or ``.py``, so the file keeps opening. After you upgrade pipx, an app keeps its old name and ``pipx list`` shows it under that name. The next ``pipx upgrade``, ``reinstall``, ``inject``, or ``expose`` of the environment renames it and logs ``Renamed pip3_old.12 to pip3.12_old``. Update scripts and aliases that call the old name. ****************************************** ``Cannot create ...: permission denied`` ****************************************** pipx creates its home, binary, and manual page directories before any command that may write to them. When the current user cannot create one, pipx exits with this error and names the directory. With ``--global`` the defaults sit under ``/opt/pipx`` and ``/usr/local``, so run the command with ``sudo``. Otherwise, or to keep the installation out of system directories, point ``PIPX_HOME``, ``PIPX_BIN_DIR``, and ``PIPX_MAN_DIR`` (or their ``PIPX_GLOBAL_*`` counterparts) at directories you can write; see :doc:`configure-paths`. ************************* Debian and Ubuntu issues ************************* On Debian, Ubuntu, and derivatives, install these packages, which Debian systems do not add by default: .. code-block:: console $ sudo apt install python3-venv python3-pip See the `Python Packaging User Guide `_ on installing pip, setuptools, and wheel with Linux package managers. ************************************** Naming a pipx app in your own shebang ************************************** The default pipx home on macOS is ``~/Library/Application Support/pipx``, which contains a space. Apps installed there run because pip and uv write a ``/bin/sh`` wrapper as the shebang whenever the interpreter path holds a space. A shebang you write yourself has no such wrapper. ``#!/Users/you/.local/bin/aws`` works, while a shebang naming a path with a space does not, because the kernel reads the first space as the end of the interpreter path. Point your shebang at ``PIPX_BIN_DIR`` (``~/.local/bin`` by default, no space), or set ``PIPX_HOME`` to a path without spaces. ******************************** Does it install with plain pip? ******************************** To tell whether pipx or the package is at fault, try installing the package with pip. Unix or macOS: .. code-block:: console $ python3 -m venv test_venv $ test_venv/bin/python3 -m pip install Windows: .. code-block:: console $ python -m venv test_venv $ test_venv/Scripts/python -m pip install If pip also fails, the problem is likely the package or your host. Clean up with ``rm -rf test_venv``. ************************************** Files not in the documented locations ************************************** pipx after 1.16.0 places ``PIPX_HOME`` and the data, cache, and log directories in the platform locations `platformdirs `_ reports. Earlier versions defaulted ``PIPX_HOME`` to ``~/.local/pipx`` (or ``~/pipx`` on Windows), and pipx keeps using such a directory when it exists. The venv cache and logs still move to the platform cache and log directories even when pipx falls back to an older home, because both are disposable. Setting ``PIPX_HOME`` yourself keeps them inside it. For the full old-to-new path map and how to migrate, see :ref:`platformdirs-migration` and :doc:`move-installation`. ********** Verify it ********** .. code-block:: console $ pipx health A clean ``pipx health`` (exit status 0) confirms every managed environment can run its interpreter. pipx-1.18.16/docs/how-to/upgrade-pipx.rst000066400000000000000000000061211527355705200201670ustar00rootroot00000000000000############# Upgrade pipx ############# Move pipx itself to the latest release. Pick the method that matches how you installed it. *************** Upgrade per OS *************** macOS: .. code-block:: console $ brew update && brew upgrade pipx Ubuntu Linux: .. code-block:: console $ sudo apt update && sudo apt upgrade pipx Fedora Linux: .. code-block:: console $ sudo dnf upgrade pipx Windows (`Scoop `__): .. code-block:: console $ scoop update pipx ********************** Self-managed installs ********************** If pipx manages its own installation (see :ref:`install-from-source-control` and the self-managed bootstrap in :doc:`install-pipx`), upgrade it like any other pipx app: .. code-block:: console $ pipx upgrade pipx A pipx installed with ``--global`` belongs to the global installation, so upgrade it there: .. code-block:: console $ sudo pipx upgrade --global pipx **************** Upgrade via pip **************** When you installed pipx with ``pip install --user`` and cannot upgrade it as a pipx app: .. code-block:: console $ python3 -m pip install --user --upgrade pipx .. warning:: On systems that adopt `PEP 668 `_ (Ubuntu 23.04+, Debian 12+, Fedora 38+), this fails with ``externally-managed-environment``. Use the distribution package or the self-managed path above instead. See :doc:`install-pipx` for the PEP 668 install options. *********************************************** Respond to a distrusted certificate authority *********************************************** After the `eMudhra/emSign CA incident `_, `pip `__ 26.2.2 and `certifi `__ 2026.10.19 stopped trusting the compromised root. Upgrade pipx first. Under the pip backend every app borrows the shared pip, and pipx replaces a shared pip older than 26.2.2 the next time it installs, upgrades, reinstalls, injects or runs an app. To replace it now: .. code-block:: console $ pipx upgrade-shared Apps that depend on ``certifi`` (for example through ``requests``) carry their own copy. Upgrade it in each app that has one: .. code-block:: console $ pipx runpip APP install --upgrade "certifi>=2026.10.19" Under the `uv `__ backend pipx runs the ``uv`` from its ``uv`` extra, or the one on your ``PATH``; upgrade that ``uv`` to 0.13.4 or later. ********** Verify it ********** .. code-block:: console $ pipx --version .. note:: Upgrading from a pre-0.15.0.0 pipx to 0.15.0.0 or later requires reinstalling your packages so they gain the persistent metadata files that release introduced. These files store the pip spec, injected packages, and custom pip arguments in each venv. With no ``--spec`` installs and no injected packages, run ``pipx reinstall-all``. Otherwise reinstall manually: ``pipx uninstall-all`` followed by ``pipx install`` and, where needed, ``pipx inject``. pipx-1.18.16/docs/how-to/use-private-index.rst000066400000000000000000000116321527355705200211360ustar00rootroot00000000000000#################### Use a private index #################### Install from a private index, behind a proxy, or offline by forwarding index options to the backend. ****************************** Set the index for one command ****************************** Pass ``--index-url`` to pipx, or forward `pip `__ options with ``--pip-args``: .. code-block:: console $ pipx install my-package --index-url https://my-index.example.com/simple/ $ pipx install my-package --pip-args="--extra-index-url https://my-index.example.com/simple/" Quote ``--pip-args`` and use an ``=`` sign so pipx passes the whole string through. Add trusted hosts and other pip flags the same way: .. code-block:: console $ pipx install my-package --pip-args="--index-url https://my-index.example.com/simple/ --trusted-host my-index.example.com" pipx records these options and passes them again on ``pipx upgrade`` and ``pipx reinstall``. A ``pipx runpip install`` that reinstalls the main package with index options (``--index-url``, ``--extra-index-url``, ``--find-links``, ``--trusted-host``, ``--no-index``, or uv's ``--default-index``, ``--index``, ``--index-strategy``, ``--allow-insecure-host``, ``--keyring-provider``) replaces the recorded ones with them; without index options the record stays. pipx resolves a relative ``--find-links`` (``-f``) path against the directory you ran ``runpip`` from and records the absolute path, so after ``pipx runpip my-package install --no-index -f ./wheels my-package`` a later upgrade or reinstall reads the same ``wheels`` directory from wherever you start it. Under the `uv `__ backend, pipx passes ``--index-url`` (``-i``) to uv as ``--default-index``, so it replaces a default index set through ``UV_DEFAULT_INDEX`` or a ``default = true`` ``[[index]]`` entry. uv still searches indexes added through ``UV_INDEX`` or other ``[[index]]`` entries before it, as it does for its own ``--default-index``. pipx refuses an empty or whitespace-only index URL before it creates an environment, whether it comes from ``--index-url`` (``-i``) or from ``--index-url``, ``-i``, ``--extra-index-url``, ``--default-index`` or ``--index`` inside ``--pip-args``. An empty value usually means an unset variable, as in ``--index-url "$INDEX_URL"``, and uv reads it as the default index, so the package would come from `PyPI `__ instead of your index. Under the uv backend pipx also refuses an empty index recorded by an earlier install when ``upgrade``, ``reinstall`` or ``install-all`` replays it: .. code-block:: console $ pipx install my-package --pip-args="--index-url=$UNSET_VARIABLE" --index-url is empty; pass the index URL or leave the option out ************************* Set it for every command ************************* Under the pip backend, pipx forwards ``PIP_*`` environment variables to every pip invocation, including shared-library upgrades. Set them in your shell profile or pip's own config file for a permanent default: .. code-block:: console $ export PIP_INDEX_URL=https://my-index.example.com/simple/ $ export PIP_TRUSTED_HOST=my-index.example.com $ pipx install my-private-package See the `pip configuration docs `_ for the config-file form. pipx always sets ``PIP_USER=0`` and drops ``PIP_TARGET`` so packages land in the venv. For the same reason it removes pip's site-level file, ``pip.conf`` (``pip.ini`` on Windows) in the root of the venv, before it starts pip there: installed packages can place files in the venv root, so keep your settings in the user or global file. The uv backend ignores ``PIP_*``; use uv's own variables (for example ``UV_INDEX_URL``) or ``--backend pip``. **************** Install offline **************** Point pip at a local directory of wheels and stop it reaching the network: .. code-block:: console $ pipx install my-package --pip-args="--no-index --find-links /path/to/wheels" Pass ``--skip-maintenance`` to keep the bundled pip instead of downloading a shared-library upgrade during the install. .. warning:: Under the uv backend, ``pipx run`` translates only a subset of ``--pip-args``: ``--index-url`` (``-i``), ``--extra-index-url``, ``--find-links`` (``-f``), ``--trusted-host``, ``--no-binary``, ``--only-binary``, ``--pre``, ``--upgrade`` (``-U``), and ``--no-cache-dir``. pipx raises an error for any other flag instead of dropping it. Other commands pass ``--pip-args`` to ``uv pip install`` unchanged apart from ``--index-url`` (``-i``), so uv decides which flags it accepts. Use ``--backend pip`` when you need a pip-only flag. See :doc:`use-uv-backend`. ********** Verify it ********** .. code-block:: console $ pipx list --short The package installs at the version your index serves. For a private index that pipx must also use for its own shared libraries, set the ``PIP_*`` variables before the first install. pipx-1.18.16/docs/how-to/use-uv-backend.rst000066400000000000000000000151101527355705200203710ustar00rootroot00000000000000################### Use the uv backend ################### Make pipx use `uv `_ to create virtual environments and install packages in place of `pip `__ and venv. The CLI surface stays the same. For why uv is faster and how pipx compares to Astral's standalone ``uv tool``, see :doc:`../explanation/comparisons`. ****************** Make uv available ****************** Install pipx with the ``uv`` extra so it ships the bundled binary: .. code-block:: console $ pipx install pipx[uv] Or install uv 0.9.17 or newer however you like (``brew install uv``, ``cargo install uv``) and put it on ``PATH``; pipx picks it up. New venvs then default to uv. Existing venvs keep their recorded backend. When the uv pipx finds is older than that, pipx warns and falls back to pip. Asking for uv explicitly (``--backend uv``, ``PIPX_DEFAULT_BACKEND=uv``, or a venv that recorded uv) with an older uv fails and asks you to upgrade it. ************************* How pipx picks a backend ************************* .. mermaid:: flowchart TD START["new venv"] --> CLI{"--backend given?"} CLI -->|yes| USECLI["use it"] CLI -->|no| ENV{"PIPX_DEFAULT_BACKEND set?"} ENV -->|yes| USEENV["use it"] ENV -->|no| UV{"uv 0.9.17+ available?
bundled or on PATH"} UV -->|yes| USEUV["uv"] UV -->|no| PIP["pip + venv"] classDef decision fill:#2a9d8f,stroke:#1f7268,color:#fff; classDef uv fill:#7c4dff,stroke:#5a34c0,color:#fff; classDef pip fill:#3f72af,stroke:#28507d,color:#fff; class CLI,ENV,UV decision; class USECLI,USEENV,USEUV uv; class PIP pip; **************************** Choose a backend explicitly **************************** .. code-block:: console $ pipx install black --backend pip $ pipx install ruff --backend uv $ PIPX_DEFAULT_BACKEND=uv pipx install ruff $ pipx environment --value PIPX_RESOLVED_BACKEND ``install``, ``install-all``, ``inject``, ``upgrade``, ``upgrade-all``, ``reinstall``, ``reinstall-all``, and ``run`` all accept ``--backend``. Switch an installed venv with ``pipx reinstall NAME --backend uv``. ********************** What changes under uv ********************** - pipx creates venvs with ``uv venv``. The venv contains no ``pip`` and no ``pipx_shared.pth`` file. - ``pipx runpip`` runs ``uv pip --python /bin/python``. uv rejects flags it does not understand rather than dropping them silently. - ``pipx run`` execs ``uv tool run``; its cache lives in uv's cache directory. Pass ``--no-cache`` to skip it or ``--refresh`` to rebuild the cached environment. - ``pipx run script.py`` execs ``uv run --active --script script.py`` for `PEP 723 `__ inline scripts, with ``VIRTUAL_ENV`` pointing at an environment in pipx's cache. - ``--cooldown DAYS`` (or ``PIPX_COOLDOWN``) maps to uv's ``--exclude-newer P{DAYS}D``. See :doc:`Dependency cooldown `. - uv reads your user config (``~/.config/uv/uv.toml``, ``%APPDATA%\uv\uv.toml`` on Windows), the system config (``/etc/uv/uv.toml``), and a ``UV_CONFIG_FILE``, but no project config from your working directory or the script's directory. pipx sets ``UV_PROJECT`` (``--project`` for ``uv run --script``) to ``PIPX_HOME``, replacing any ``UV_PROJECT`` you set, so uv looks for a ``uv.toml`` or a ``pyproject.toml`` with a ``[tool.uv]`` table in ``PIPX_HOME`` and each of its parents up to the filesystem root. - With the default ``PIPX_HOME`` under your home directory, the parents uv searches for project config belong to you or to root. If ``PIPX_HOME`` (or ``PIPX_GLOBAL_HOME``) sits below a directory other accounts can write, such as ``/tmp`` or a group-writable share, any of them can place a ``uv.toml`` there that steers pipx's uv calls; keep it in a directory only you can write. - A ``--project``, ``--directory`` or ``--config-file`` in ``--pip-args`` points uv at other configuration, and pipx records it with the environment. On POSIX, a later ``upgrade`` or ``reinstall`` refuses to replay it when another account can change the path. See :doc:`Metadata <../reference/metadata>`. ************ Limitations ************ - ``pipx install pip --backend uv`` errors: a uv venv has no pip. Use ``--backend pip``. - ``pipx run --backend uv`` honors ``[pipx.run]`` entry points only where it builds a venv, which with ``--spec`` means an ```` that differs from the spec's normalized distribution name, or a spec carrying no name at all. An ```` matching that name goes to ``uv tool run``, which reads console scripts only, so reach for ``--backend pip`` to override a console script of the same name. - Some ``--pip-args`` values have no ``uv tool run`` equivalent (``--editable``, ``--no-build-isolation``); pipx errors instead of dropping them. See :doc:`use-private-index` for which flags uv translates. - ``pipx run --backend uv`` rejects ``--system-site-packages``, since uv builds the environment. Use ``--backend pip`` when you need it. - ``pipx run --backend uv`` against URL or named-pipe scripts falls back to a pipx-managed venv, because ``uv run --script`` reads PEP 723 metadata off disk and runtime-fetched content has no on-disk path. pipx logs a warning. - ``pipx run`` falls back to a pipx-managed venv when it has to infer the script name: from a VCS URL, a local project path, or a name it normalizes (``pipx run pip_search`` installs ``pip-search``). ``uv tool run`` takes that name up front and would guess it from the package, so pipx installs the package first and runs the script it declares. Pass ``pipx run --spec `` to keep uv's cache when the package declares a matching console script. pipx installs a spec with no distribution name (a path, a URL, a direct reference) twice, once to learn its name and once into the run venv. ************* Cache layout ************* Under the uv backend, ``pipx run APP`` caches in ``UV_CACHE_DIR`` instead of ``PIPX_VENV_CACHEDIR``, while ``pipx run script.py`` keeps the script's environment in ``PIPX_VENV_CACHEDIR``. Switching the default backend on a host that already used ``pipx run`` leaves the old pipx cache behind. Find it with ``pipx cache dir`` and remove its environments with ``pipx cache purge``. The 14-day expiry sweep pipx applies to its own cache does not extend to uv's cache, which uv manages on its own schedule (``uv cache clean``). ********** Verify it ********** .. code-block:: console $ pipx environment --value PIPX_RESOLVED_BACKEND It prints ``uv`` when pipx will use the uv backend for new venvs. pipx-1.18.16/docs/how-to/use-with-pre-commit.rst000066400000000000000000000022451527355705200214040ustar00rootroot00000000000000#################### Use with pre-commit #################### Run a pipx app as a `pre-commit `_ hook. Reach for this with apps that work with ``pipx run`` but have no native ``pre-commit`` support, when you want the prebuilt wheel from `PyPI `__ rather than a source build, or when you need pipx's ``--spec`` and ``--index-url`` flags. ************* Add the hook ************* Add this to your ``.pre-commit-config.yaml``, using the code linter `yapf `_ as the example. Set ``rev`` to the latest pipx release tag from the `releases page `_ (``1.15.0`` shown here): .. code-block:: yaml - repo: https://github.com/pypa/pipx rev: "1.15.0" hooks: - id: pipx alias: yapf name: yapf args: [yapf, -i] types: [python] ``pre-commit`` passes the ``args`` list to ``pipx run``: the first item is the app to run, the rest are its arguments. ********** Verify it ********** .. code-block:: console $ pre-commit run yapf --all-files The hook resolves the app through ``pipx run`` and reports its result. pipx-1.18.16/docs/index.rst000066400000000000000000000117421527355705200154610ustar00rootroot00000000000000###### pipx ###### ``pipx`` installs and runs end-user Python applications in isolated environments. It fills the same role as macOS's `brew `_, JavaScript's `npx `_, and Linux's `apt `_. Under the hood it uses `uv `_ when available and `pip `_ otherwise, but unlike either it creates a separate virtual environment for each application, so their dependencies never collide and an uninstall leaves nothing behind. .. mermaid:: flowchart LR USER["you"] -->|"pipx install"| PIPX["pipx"] USER -->|"pipx run"| PIPX PIPX -->|"fetches from"| PYPI["PyPI"] PIPX -->|"creates"| VENV["isolated venvs"] VENV -->|"exposes on PATH"| APPS["black, ruff,
poetry, ..."] classDef you fill:#3f72af,stroke:#28507d,color:#fff; classDef pipx fill:#2a9d8f,stroke:#1f7268,color:#fff; classDef src fill:#c78c20,stroke:#946716,color:#fff; classDef venv fill:#7c4dff,stroke:#5a34c0,color:#fff; classDef apps fill:#388e3c,stroke:#276628,color:#fff; class USER you; class PIPX pipx; class PYPI src; class VENV venv; class APPS apps; ************ Start here ************ .. grid:: 1 2 2 2 :gutter: 3 .. grid-item-card:: Tutorials :link: tutorial/index :link-type: doc Install your first application and run one in a throwaway environment, one step at a time. .. grid-item-card:: How-to guides :link: how-to/index :link-type: doc Task recipes for installing pipx, injecting packages, pinning versions, configuring paths, and more. .. grid-item-card:: Reference :link: reference/index :link-type: doc The full CLI, environment variables, exit codes, the JSON envelope, and worked examples. .. grid-item-card:: Explanation :link: explanation/index :link-type: doc How pipx works, what it manages on disk, and how it compares to other tools. ************* pip vs pipx ************* ``pip`` installs both libraries and applications into whatever environment is active, with no isolation. pipx installs only applications, each in its own virtual environment, exposes their commands on your ``PATH``, and runs with regular user permissions, so you never need ``sudo pip install``. ********************** Where apps come from ********************** pipx pulls packages from `PyPI `_ by default, but accepts any source ``pip`` supports: local directories, wheels, and git URLs. Any package that declares `console script entry points `_ works with pipx. `Poetry `_ and `Hatch `_ users can add entry points the same way. *********** Highlights *********** - Install CLI apps into isolated environments with ``pipx install``. - List, upgrade, and uninstall managed apps in one command. - Run the latest version of any app in a temporary environment with ``pipx run``, without installing it first. ************** Testimonials ************** "Thanks for improving the workflow that pipsi has covered in the past. Nicely done!" โ€” *Jannis Leidel, PSF fellow, former pip and Django core developer, and founder of the Python Packaging Authority (PyPA)* "My setup pieces together pyenv, poetry, and pipx. [...] For the things I need, it's perfect." โ€” *Jacob Kaplan-Moss, co-creator of Django, in* `My Python Development Environment, 2020 Edition `_ "I'm a big fan of pipx. I think pipx is super cool." โ€” *Michael Kennedy, co-host of the PythonBytes podcast, in* `episode 139 `_ ********* Credits ********* pipx was inspired by `pipsi `_ and ``npx``. `Chad Smith `_ created it, with lots of help from `contributors `_. `Ase Morales `_ designed the logo. A team of volunteers maintains pipx (in alphabetical order): - `Bernรกt Gรกbor `_ - `Henry Schreiner `_ - `Jason Lam `_ - `Tzu-ping Chung `_ - `Xuan Hu `_ Emeritus maintainers, who shaped earlier releases: - `Chad Smith `_ - `Chrysle `_ - `Matthew Clapp `_ - `Robert Offner `_ The documentation follows the `Diรกtaxis `_ framework. .. toctree:: :hidden: :maxdepth: 2 tutorial/index how-to/index reference/index explanation/index contributing changelog pipx-1.18.16/docs/man/000077500000000000000000000000001527355705200143665ustar00rootroot00000000000000pipx-1.18.16/docs/man/pipx.1.rst000066400000000000000000000065451527355705200162510ustar00rootroot00000000000000:orphan: ==== pipx ==== ------------------------------------------------------------ install and run Python applications in isolated environments ------------------------------------------------------------ :Manual section: 1 :Manual group: User Commands SYNOPSIS -------- **pipx** [*global-options*] [**install** | **install-all** | **uninject** | **inject** | **expose** | **unexpose** | **pin** | **unpin** | **upgrade** | **upgrade-all** | **upgrade-shared** | **uninstall** | **uninstall-all** | **reset** | **reinstall** | **reinstall-all** | **health** | **repair** | **list** | **interpreter** | **cache** | **manifest** | **run** | **exec** | **runpip** | **ensurepath** | **environment** | **completions** | **help**] [*command-options*] DESCRIPTION ----------- pipx installs and runs Python applications in isolated environments while exposing their commands on your ``PATH``. COMMANDS -------- **install** Install a package **install-all** Install all packages **uninject** Uninstall injected packages from an existing Virtual Environment **inject** Install packages into an existing Virtual Environment **expose** Relink hidden or missing resources of an environment **unexpose** Hide resources without removing an environment **pin** Pin the specified package to prevent it from being upgraded **unpin** Unpin the specified package **upgrade** Upgrade a package **upgrade-all** Upgrade all packages. **upgrade-shared** Upgrade shared libraries. **uninstall** Uninstall a package **uninstall-all** Uninstall all packages **reset** Return pipx to a fresh install **reinstall** Reinstall a package **reinstall-all** Reinstall all packages **health** Check installed package environments **repair** Repair broken package environments **list** List installed packages **interpreter** Interact with interpreters managed by pipx **cache** Manage cached run environments **manifest** Manage tools declared in an explicit manifest **run** Download the latest version of a package to a temporary virtual environment, then run an app from it. Also compatible with local ``__pypackages__`` directory (experimental). **exec** Run an application from an existing pipx environment **runpip** Run pip, or ``uv pip`` under the uv backend, in an existing pipx-managed Virtual Environment **ensurepath** Ensure directories necessary for pipx operation are in your PATH environment variable. **environment** Print a list of environment variables and paths used by pipx. **completions** Print instructions on enabling shell completions for pipx **help** Show help for pipx or a command Run **pipx** *command* **--help** for command-specific options. GLOBAL OPTIONS -------------- **-h**, **--help** show this help message and exit **--version** Print version and exit ENVIRONMENT VARIABLES --------------------- **PIPX_HOME** Directory for pipx-managed environments and state. **PIPX_BIN_DIR** Directory where pipx exposes application commands. **PIPX_DEFAULT_PYTHON** Default interpreter used to create environments. **PIPX_USE_EMOJI** Set to ``0`` to disable emoji output. SEE ALSO -------- Full documentation: https://pipx.pypa.io/ **pip**\(1), **virtualenv**\(1) AUTHORS ------- Chad Smith and contributors https://github.com/pypa/pipx pipx-1.18.16/docs/reference/000077500000000000000000000000001527355705200155515ustar00rootroot00000000000000pipx-1.18.16/docs/reference/cli.rst000066400000000000000000000002241527355705200170500ustar00rootroot00000000000000############### CLI reference ############### .. sphinx_argparse_cli:: :module: pipx.main :func: build_parser :prog: pipx :title: pipx-1.18.16/docs/reference/environment-variables.rst000066400000000000000000000145731527355705200226270ustar00rootroot00000000000000####################### Environment variables ####################### pipx has no user configuration file. Every setting is an environment variable, all of them optional. Run ``pipx environment`` to print each variable as set in your environment, followed by the values pipx derives from them. ******************* Settable variables ******************* .. list-table:: :header-rows: 1 :widths: 34 66 - - Variable - Meaning and default - - ``PIPX_HOME`` - Root directory for pipx virtual environments. Default: ``platformdirs.user_data_path("pipx")`` โ€” Linux ``~/.local/share/pipx``, macOS ``~/Library/Application Support/pipx``, Windows ``%LOCALAPPDATA%\pipx\pipx``. - - ``PIPX_BIN_DIR`` - Directory for application entry-point symlinks. Default: ``~/.local/bin``. - - ``PIPX_MAN_DIR`` - Directory for man page symlinks. Default: ``~/.local/share/man``. - - ``PIPX_COMPLETION_DIR`` - Directory for shell completion symlinks. Default: ``~/.local/share``. - - ``PIPX_GLOBAL_HOME`` - Root directory for global (``--global``) virtual environments. Default: ``/opt/pipx``. - - ``PIPX_GLOBAL_BIN_DIR`` - Entry-point directory for global installs. Default: ``/usr/local/bin``. - - ``PIPX_GLOBAL_MAN_DIR`` - Man page directory for global installs. Default: ``/usr/local/share/man``. - - ``PIPX_GLOBAL_COMPLETION_DIR`` - Shell completion directory for global installs. Default: ``/usr/local/share``. - - ``PIPX_SHARED_LIBS`` - Override the shared libraries directory. pipx ignores it with ``--global``. Default: ``$PIPX_HOME/shared``. - - ``PIPX_DEFAULT_PYTHON`` - Python interpreter used when ``--python`` is not passed: a path, or a command name (``python3.12``) or version (``3.12``) looked up on ``PATH``. Default: the interpreter running pipx (``sys.executable``); on Windows, unless pipx runs inside a venv, the ``py`` launcher's default Python or ``python`` on ``PATH``. - - ``PIPX_DEFAULT_BACKEND`` - Backend for new venvs, ``pip`` or ``uv``. Default: ``uv`` when `uv `_ 0.9.17 or newer is available, else ``pip``. - - ``PIPX_COOLDOWN`` - Default for ``--cooldown``: ignore index artifacts uploaded fewer than this many days ago, on ``install``, ``install-all``, ``inject``, ``upgrade``, ``upgrade-all`` and ``run``. An explicit ``--cooldown`` wins, and ``--cooldown 0`` opts a single command out. A locked install ignores it. Default: unset. - - ``PIPX_FETCH_PYTHON`` - When to fetch a standalone Python build: ``always``, ``missing``, or ``never``. Default: ``never``. - - ``PIPX_FETCH_MISSING_PYTHON`` - Deprecated alias for ``PIPX_FETCH_PYTHON=missing``. Default: unset. - - ``PIPX_DISABLE_SHARED_LIBS_AUTO_UPGRADE`` - Set to a truthy value to skip automatic shared library upgrades. Default: unset. - - ``PIPX_USE_EMOJI`` - Set to ``1``, ``y``, ``yes``, ``t``, ``true``, or ``on`` (case-insensitive) to force emoji output; any other value disables it. Default: enabled when the stderr encoding supports the emoji glyphs. - - ``PIPX_MAX_LOGS`` - Number of command logs to keep in the log directory, counting the current command's; `pip `_ and ``uv`` error logs share a separate limit of the same size. ``0`` acts like ``1``. pipx keeps the logs of commands still running. Default: ``10``. .. note:: pipx falls back to ``USE_EMOJI`` (unprefixed) when ``PIPX_USE_EMOJI`` is unset. .. note:: pipx forwards the standard ``PIP_*`` environment variables (for example ``PIP_INDEX_URL``) to ``pip``, except that it forces ``PIP_USER=0`` and ``PIP_DISABLE_PIP_VERSION_CHECK=1``, drops ``PIP_TARGET``, and defaults ``PIP_KEYRING_PROVIDER`` to ``subprocess``. See :doc:`Troubleshoot <../how-to/troubleshoot>` if ``pip`` behaves unexpectedly. Legacy fallback home ==================== If a legacy home directory already exists on disk, pipx keeps its virtual environments there instead of the ``PIPX_HOME`` default: ``~/.local/pipx`` on any platform, and ``~\pipx`` on Windows. pipx uses them only when present and when ``PIPX_HOME`` is unset. See :doc:`Configure paths <../how-to/configure-paths>` to migrate off them. On Unix, a pipx installation that manages itself recovers ``PIPX_HOME`` and ``PIPX_DEFAULT_PYTHON`` from its own virtual environment when those variables are unset, and ``PIPX_BIN_DIR`` when pipx was launched through its exposed link, so later shells stay attached to the same installation. A pipx installed with ``--global`` lives in the global home (``PIPX_GLOBAL_HOME``, ``/opt/pipx`` by default) and recovers only ``PIPX_DEFAULT_PYTHON``: without ``--global`` it uses the per-user defaults, with ``--global`` the global ones. With a custom ``PIPX_GLOBAL_HOME``, every user who runs that pipx must set it; otherwise pipx treats the global home as their own ``PIPX_HOME``. pipx recovers ``PIPX_DEFAULT_PYTHON`` only while the recorded interpreter still resolves to the one its environment runs on; it ignores a record left pointing into a removed bootstrap environment and falls back to its own interpreter. ******************************* Derived (read-only) values ******************************* ``pipx environment`` also reports the following values. pipx computes them from the settable variables and the platform; they are reported, not set. .. list-table:: :header-rows: 1 :widths: 40 60 - - Value - Meaning - - ``PIPX_LOCAL_VENVS`` - Directory holding the per-package virtual environments (``$PIPX_HOME/venvs``). - - ``PIPX_LOG_DIR`` - Directory holding command logs. - - ``PIPX_TRASH_DIR`` - Staging directory for deletions on the same filesystem as the venvs. - - ``PIPX_VENV_CACHEDIR`` - Cache directory for ``pipx run`` environments. - - ``PIPX_STANDALONE_PYTHON_CACHEDIR`` - Cache directory for downloaded standalone Python builds. - - ``PIPX_RESOLVED_BACKEND`` - Backend pipx resolved for new venvs (``pip`` or ``uv``). - - ``PIPX_BACKEND_SOURCE`` - Where the resolved backend came from: ``env``, ``auto-bundled``, ``auto-path``, or ``auto-pip``. - - ``PIPX_UV_BINARY`` - Path to the ``uv`` binary pipx would use, or empty when it finds none. - - ``UV_CACHE_DIR`` - Value of ``UV_CACHE_DIR`` in the environment, passed through to ``uv``. pipx-1.18.16/docs/reference/examples.rst000066400000000000000000000205311527355705200201220ustar00rootroot00000000000000########## Examples ########## Copy-paste command examples. For every flag see the :doc:`CLI reference `. For step-by-step narratives, follow the how-to guides linked in each section. ********* install ********* Install from PyPI, optionally pinning the Python version: .. code-block:: bash pipx install pycowsay pipx install --python 3.12 pycowsay pipx install --fetch-python=missing --python 3.12 pycowsay Constrain or eagerly upgrade a version spec: .. code-block:: bash pipx install --upgrade 'black>=22,<23' pipx install --upgrade --upgrade-strategy eager 'black>=22,<23' Install extras, include dependency apps, or preinstall build-time packages: .. code-block:: bash pipx install 'black[d]' pipx install --include-deps jupyter pipx install --preinstall ansible-lint --preinstall mitogen ansible-core Install from a VCS URL, an archive, or a local project: .. code-block:: bash pipx install git+https://github.com/psf/black pipx install git+https://github.com/psf/black.git@branch-name pipx install https://github.com/psf/black/archive/18.9b0.zip pipx install ./path/to/some-project Install from a :pep:`723` script or a lock file: .. code-block:: bash pipx install ./script.py pipx install --lock pylock.toml . For a local script, the filename without its extension supplies the command name. A script without the ``.py`` extension needs a path such as ``./script``; a bare ``script`` names the package on the index. Pass ``--app`` to choose a different name: .. code-block:: bash pipx install --app greet ./script.py Pass arguments through to `pip `_, or install globally for all users: .. code-block:: bash pipx install --pip-args='--pre' poetry pipx install --global pycowsay ***** run ***** Run an app in a temporary environment, optionally pinning package or Python version: .. code-block:: bash pipx run pycowsay moo pipx run --spec 'pycowsay==0.0.0.2' pycowsay --version pipx run --python 3.12 pycowsay moo Run from a VCS URL or an archive: .. code-block:: bash pipx run git+https://github.com/psf/black.git pipx run --spec https://github.com/psf/black/archive/18.9b0.zip black --help Run a local or remote script, with arguments: .. code-block:: bash pipx run test.py 1 2 3 pipx run https://example.com/test.py 1 2 3 A bare filename is ambiguous: pipx runs it as a local file when the file exists, otherwise as a `PyPI `_ package. Force the interpretation with ``--path`` or ``--spec``: .. code-block:: bash pipx run --path test.py # always a local file pipx run --spec test-py test.py # always a PyPI package A requirement with a version, extras, an environment marker or a direct URL always selects the package, even when the working directory contains a file with the same name. Use ``--path`` or an explicit relative path to run such a file. Scripts can declare dependencies with :pep:`723` inline metadata; pipx installs them into the temporary environment. See :doc:`Run scripts <../how-to/run-scripts>`. ******** inject ******** Add packages to an existing environment, from the command line or a requirements file: .. code-block:: bash pipx inject ptpython requests pendulum pipx inject ptpython --requirement useful-packages.txt You can combine and repeat the two forms: .. code-block:: bash pipx inject ptpython package-1 package-2 -r extra-1.txt -r extra-2.txt See :doc:`Inject packages <../how-to/inject-packages>` for the requirements-file format and the ``--include-apps`` option. ****** list ****** List installed packages and their apps: .. code-block:: console $ pipx list venvs are in /home/user/.local/share/pipx/venvs apps are exposed on your $PATH at /home/user/.local/bin manual pages are exposed at /home/user/.local/share/man shell completions are exposed at /home/user/.local/share package black 24.3.0, installed using Python 3.12.0 - black - blackd package pipx 1.4.3, installed using Python 3.12.0 - pipx Narrow the output or change the format; the package filter works with every format: .. code-block:: bash pipx list --short pipx list black pipx --short pipx list black --outdated pipx list --output json > pipx.json ************* install-all ************* Reinstall from a snapshot produced by ``pipx list --output json`` (see :doc:`json-output`). Each environment gets the interpreter, venv and pip arguments the snapshot recorded unless you pass your own. A ``--backend`` other than the recorded one skips the recorded pip arguments with a warning, since they can hold options only the old backend accepts. pipx translates options with an exact equivalent instead, so the environment still installs from the recorded index: =============================== ====================================================== pip uv =============================== ====================================================== ``--index-url``, ``-i`` ``--default-index`` ``--extra-index-url`` ``--index`` (pipx drops a ``name=`` prefix for pip) ``--find-links``, ``-f`` ``--find-links`` (split on commas for pip) ``--no-index`` ``--no-index`` ``--pre`` ``--prerelease allow`` ``--trusted-host HOST`` ``--allow-insecure-host HOST`` ``--constraint``, ``-c`` ``--constraints`` ``--keyring-provider`` ``--keyring-provider`` (``disabled`` or ``subprocess``) (always picks the best match) ``--index-strategy unsafe-best-match`` =============================== ====================================================== pipx translates a ``pip`` option recorded as an abbreviation ``pip`` accepts, such as ``--index-u``, like its full spelling. When a recorded index option has no equivalent, such as a `uv `_ ``--index-strategy`` other than ``unsafe-best-match``, an ``--allow-insecure-host`` URL, or ``pip``'s ``--keyring-provider import``, pipx refuses to install that environment rather than fall back to ``PyPI``. ``pip`` searches every index for the best version, while ``uv`` stops at the first index that has the package, so a ``uv`` ``--index`` moved to ``pip`` can pick a newer release from ``PyPI``. Pass ``--pip-args`` to replace the recorded options. pipx skips the recorded interpreter, says why, and uses the default Python when that interpreter is gone, sits inside a virtual environment, or, on Unix, can be changed by another account through the file or a directory above it. Pass ``--python`` to pick one yourself. Up to eight environments install at once, with their output printed in snapshot order: .. code-block:: console $ pipx install-all pipx.json 'black' (24.3.0) is already installed. Not modifying existing installation in '/home/user/.local/share/pipx/venvs/black'. Pass '--force' to force installation, or use 'pipx upgrade black' to upgrade. 'pipx' (1.4.3) is already installed. Not modifying existing installation in '/home/user/.local/share/pipx/venvs/pipx'. Pass '--force' to force installation, or use 'pipx upgrade pipx' to upgrade. $ pipx install-all pipx.json --force Installing to existing venv 'black' installed package black 24.3.0, installed using Python 3.12.0 These apps are now available - black - blackd Installing to existing venv 'pipx' installed package pipx 1.4.3, installed using Python 3.12.0 These apps are now available - pipx *************** upgrade-shared *************** Upgrade the shared libraries now, instead of waiting for the automatic refresh once they are more than 30 days old: .. code-block:: bash pipx upgrade-shared ********* pin ********* Pin an environment so pipx holds its packages at their current versions until you unpin. See :doc:`Pin packages <../how-to/pin-packages>`. Pin only injected packages, leaving the main package upgradable: .. code-block:: console $ pipx pin poetry --injected-only pipx pinned 2 packages in venv poetry - poetry-plugin-export - poetry-plugin-app Unpin the environment so upgrades resume. The output lists only the packages that were pinned: .. code-block:: console $ pipx unpin poetry pipx unpinned 2 packages in venv poetry - poetry-plugin-export - poetry-plugin-app pipx-1.18.16/docs/reference/exit-codes.rst000066400000000000000000000034771527355705200203620ustar00rootroot00000000000000############ Exit codes ############ pipx returns ``0`` on success and ``1`` on failure. ``run``, ``runpip``, and ``exec`` exit with the code of the app or pip they ran, and argument-parsing errors exit ``2``. A command that raises an unexpected error or is interrupted also exits non-zero. The named codes below come from ``pipx.constants``; each maps to ``0`` or ``1``. ``reinstall-all`` exits with ``1`` if it fails to reinstall any environment, including when its selected Python interpreter belongs to that environment. With ``--output json``, failed environments appear in ``errors`` and only successfully reinstalled environments appear in ``data.environments``. .. list-table:: :header-rows: 1 :widths: 12 48 40 - - Code - Name - Meaning - - ``0`` - ``EXIT_CODE_OK`` - Command completed successfully. - - ``1`` - ``EXIT_CODE_INJECT_ERROR`` - ``inject`` failed to install one or more packages. - - ``1`` - ``EXIT_CODE_UNINJECT_ERROR`` - ``uninject`` failed to remove one or more packages. - - ``1`` - ``EXIT_CODE_LIST_PROBLEM`` - ``list`` found an environment it could not read. - - ``1`` - ``EXIT_CODE_UNINSTALL_VENV_NONEXISTENT`` - ``uninstall`` targeted an environment that does not exist. - - ``1`` - ``EXIT_CODE_UNINSTALL_ERROR`` - ``uninstall`` failed to remove the environment. - - ``1`` - ``EXIT_CODE_REINSTALL_VENV_NONEXISTENT`` - ``reinstall`` targeted an environment that does not exist. - - ``1`` - ``EXIT_CODE_REINSTALL_INVALID_PYTHON`` - ``reinstall`` selected a Python interpreter that lives inside the environment being reinstalled. - - ``1`` - ``EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND`` - pipx could not find the interpreter passed to ``--python``. pipx-1.18.16/docs/reference/index.rst000066400000000000000000000010251527355705200174100ustar00rootroot00000000000000########### Reference ########### Technical reference for pipx: the command-line interface, environment variables, exit codes, machine-readable output, and on-disk metadata. `Sphinx `_ generates the :doc:`CLI reference ` from the argument parser (``pipx.main.build_parser``), so it always matches the installed version and covers every command and flag. .. toctree:: :maxdepth: 1 cli environment-variables exit-codes json-output metadata examples programs-to-try pipx-1.18.16/docs/reference/json-output.rst000066400000000000000000000075321527355705200206210ustar00rootroot00000000000000############# JSON output ############# Most pipx commands accept ``--output json`` to print a single machine-readable result envelope on stdout instead of human-readable text. Selecting JSON suppresses pipx's own progress messages and sends whatever `pip `_ or `uv `_ print while installing to stderr, so the envelope is the only thing on stdout. When ``install`` moves to another Python because the package's ``requires-python`` rules out the default one, pipx says so on stderr as well. ********** Envelope ********** The envelope is a JSON object with these keys (serialized with sorted keys): .. list-table:: :header-rows: 1 :widths: 26 74 - - Key - Value - - ``pipx_result_version`` - Envelope schema version, currently ``"1"``. - - ``command`` - The command tokens as a list, for example ``["install"]`` or ``["cache", "dir"]``. - - ``status`` - ``"success"`` when there are no errors, ``"partial"`` when the run both succeeded somewhere and recorded errors, ``"error"`` otherwise. - - ``exit_code`` - The integer process exit code (see :doc:`exit-codes`). - - ``data`` - Command-specific payload object. Its fields depend on the command. - - ``errors`` - List of error objects. Each has ``code``, ``message``, and the optional identity fields ``environment`` and ``package``. When ``pip`` or ``uv`` fails an ``install``, its ``message`` ends with the installer's error output. .. code-block:: json { "pipx_result_version": "1", "command": ["install"], "status": "success", "exit_code": 0, "data": {}, "errors": [] } Once the arguments parse, a failure on a JSON-enabled command still produces the envelope. That covers an invalid environment variable such as ``PIPX_COOLDOWN``, a ``--python`` that cannot be resolved, a path or URL where a package name belongs, and ``--backend uv`` without a ``uv`` executable. The envelope has a ``status`` of ``"error"`` and a single error whose ``code`` is ``pipx_error``, and pipx exits ``1``. Only argument-parsing errors, such as an unknown option or a missing argument, print plain text instead: the usage message goes to stderr and pipx exits ``2``. ******************** Supported commands ******************** ``--output json`` is available on: ``install``, ``inject``, ``uninject``, ``expose``, ``unexpose``, ``pin``, ``unpin``, ``upgrade``, ``upgrade-all``, ``upgrade-shared``, ``uninstall``, ``uninstall-all``, ``reinstall``, ``reinstall-all``, ``reset``, ``health``, ``repair``, ``manifest lock``, ``manifest sync``, ``interpreter list``, ``interpreter prune``, ``interpreter upgrade``, ``cache dir``, ``cache purge``, and ``list --outdated``. It is not available on ``install-all``, ``run``, ``exec``, ``runpip``, ``ensurepath``, ``environment``, ``completions``, or ``help``. *********************** ``list`` is different *********************** ``pipx list --output json`` without ``--outdated`` does not emit the result envelope; errors still use it. It prints the installed-package snapshot that ``install-all`` reads back: an object with ``pipx_spec_version`` (currently ``"0.1"``) and a ``venvs`` map of environment name to an object whose ``metadata`` key holds that environment's metadata (see :doc:`metadata`). ``pipx list --json`` is a legacy alias for the same snapshot. For a self-managed pipx on Unix, the snapshot gives ``source_interpreter`` as ``null`` once the recorded interpreter no longer leads to the one its environment runs on, for example a ``python3`` from a bootstrap environment you removed, so ``install-all`` uses the default Python there. .. code-block:: console $ pipx list --output json > pipx.json $ pipx install-all pipx.json ``--json`` is a legacy alias only on ``list``; every other command uses ``--output json``. pipx-1.18.16/docs/reference/metadata.rst000066400000000000000000000161451527355705200200720ustar00rootroot00000000000000################## On-disk metadata ################## pipx has no user configuration file. You configure it only through :doc:`environment variables `. The only per-installation state pipx keeps is a metadata file inside each managed virtual environment. Every environment under ``$PIPX_HOME/venvs//`` holds a ``pipx_metadata.json`` file. pipx writes it after each operation and reads it to drive ``list``, ``upgrade``, ``reinstall``, ``uninstall``, and the rest. Treat it as pipx-owned; pipx rewrites it atomically and may change its shape between versions. pipx puts the file back after each installer run, including ``pipx runpip``, so a wheel's data file of the same name cannot replace it. pipx masks passwords and tokens in URLs it prints or logs, but keeps ``package_or_url`` and ``pip_args`` here as given, since ``reinstall`` and ``upgrade`` need them. Those credentials stay in plain text in this file and in the ``pipx list --json`` snapshot, so prefer a keyring or a ``.netrc`` file over credentials embedded in a URL. pipx writes the file readable by its owner only, whatever the umask; for a ``--global`` environment that owner is root. The current schema version is ``0.12`` (``pipx_metadata_version``). pipx migrates older files forward on read. ****************** Top-level fields ****************** .. list-table:: :header-rows: 1 :widths: 30 70 - - Field - Meaning - - ``pipx_metadata_version`` - Schema version string, currently ``"0.12"``. - - ``environment`` - The environment (venv) name, or ``null``. - - ``main_package`` - Package record for the installed application (see below). - - ``injected_packages`` - Map of injected package name to its package record. - - ``python_version`` - Python version the environment was created with, or ``null``. - - ``source_interpreter`` - Path to the interpreter used to create the environment, or ``null``. For an interpreter inside another virtual environment, such as an activated bootstrap's ``python3``, pipx records the base interpreter that environment's ``pyvenv.cfg`` names, or ``null`` when it names none that exists. - - ``venv_args`` - Extra arguments passed when the venv was created. pipx records only ``--system-site-packages``, and drops any other value with a warning when it reads the file. - - ``backend`` - Backend that manages the environment, ``pip`` or ``uv``. pipx reads an unknown value back as ``pip``, with a warning. - - ``exposure_enabled`` - Whether pipx currently exposes the environment's apps and man pages on ``PATH``. ***************** Package records ***************** ``main_package`` and each entry in ``injected_packages`` share this shape: .. list-table:: :header-rows: 1 :widths: 34 66 - - Field - Meaning - - ``package`` - Canonical package name, or ``null``. - - ``package_or_url`` - The install source as given: a package name, path, or URL. - - ``package_version`` - Resolved installed version. - - ``pip_args`` - Arguments forwarded to the backend at install time, replayed by later ``upgrade`` and ``reinstall`` runs. pipx reads them as `pip `_ does and stores each option under its full name, so it stores a clustered ``-qf wheels`` as ``-q -f wheels`` and an abbreviated ``--find-l wheels`` as ``--find-links wheels``. It stores a relative path given to a pip option, such as ``--cert ca.pem`` or ``--find-links wheels``, as an absolute path, and resolves a relative one already on file against the environment's directory. Options naming a one-shot input or an output location (``-r``/``--requirement``, ``--requirements-from-script``, ``--group``, ``--report``, ``--log``, ``--log-file``, ``--local-log``, ``--cache-dir``, ``-t``/``--target``, ``--prefix``, ``--root`` and ``--src``) apply only to the command they were given on, so pipx neither stores nor replays them. An abbreviation the current pip finds ambiguous but an older pip reads as one of these, such as ``--requirem`` before pip added ``--requirements-from-script``, counts as that option, and one that could name a source option goes through the replay check below. An abbreviation that could also name a flag, such as ``--b`` for ``--break-system-packages``, takes no value, so the option after it is read on its own. On POSIX, a replay stops with an error before the installer runs when a stored ``-f``/``--find-links``, ``-c``/``--constraint``, ``--build-constraint``, ``--cert``, ``--client-cert``, `uv `_'s ``--config-file``, ``--project``, ``--directory``, ``--overrides``/``--override`` or ``--excludes``/``--exclude``, or ``file://`` or local-path index URL names a path another account could change, because it or a directory above it is writable by others or owned by another user. The record stays as it is; passing the options again with ``--pip-args`` uses them for that run. ``pipx list --outdated`` reports such an environment as failed rather than checking it against that path. - - ``include_dependencies`` - Whether pipx exposes apps and man pages from all dependencies. - - ``include_resources_from`` - Dependencies whose apps and man pages pipx exposes individually. - - ``include_apps`` - Whether pipx exposes this package's own apps. Always true for ``main_package``. - - ``apps`` / ``app_paths`` - App names and their paths inside the environment. - - ``apps_of_dependencies`` / ``app_paths_of_dependencies`` - Apps contributed by dependencies, and their paths keyed by dependency. - - ``man_pages`` / ``man_paths`` - Man page names and their paths inside the environment. - - ``man_pages_of_dependencies`` / ``man_paths_of_dependencies`` - Man pages contributed by dependencies, and their paths keyed by dependency. - - ``completions`` / ``completion_paths`` - Shell completion names and their paths inside the environment. - - ``completions_of_dependencies`` / ``completion_paths_of_dependencies`` - Completions contributed by dependencies, and their paths keyed by dependency. - - ``expected_apps`` - Apps required after install via ``--app``. - - ``lock_file`` - Path to the ``pylock.toml`` used to install, or ``null``. - - ``cooldown_days`` - Index-artifact cooldown recorded for the install, from ``--cooldown`` or ``PIPX_COOLDOWN``, or ``null``. - - ``suffix`` - Suffix appended to the environment and executable names, or empty. - - ``pinned`` - Whether the package is pinned against upgrades. .. note:: pipx encodes path values as ``{"__type__": "Path", "__Path__": ""}`` so it can round-trip them across platforms. The same snapshot, wrapped under ``pipx_spec_version`` and a ``venvs`` map whose entries nest it under a ``metadata`` key, is what ``pipx list --output json`` emits and ``pipx install-all`` consumes (see :doc:`json-output`). pipx-1.18.16/docs/reference/programs-to-try.rst000066400000000000000000000047071527355705200214010ustar00rootroot00000000000000################# Programs to try ################# Applications worth trying with ``pipx run``, which fetches the package into a temporary environment and runs it without installing. Add ``--help`` on first use so an unfamiliar tool does not act unexpectedly. Swap ``run`` for ``install`` to keep any of them on your ``PATH``. .. list-table:: :header-rows: 1 :widths: 22 48 30 - - Program - Description - Command - - `ansible `_ - IT automation - ``pipx run ansible`` - - `asciinema `_ - Record and share terminal sessions - ``pipx run asciinema`` - - `black `_ - Uncompromising Python code formatter - ``pipx run black`` - - `pybabel `_ - Internationalize and localize Python applications - ``pipx run --spec babel pybabel`` - - `chardetect `_ - Detect file encoding - ``pipx run --spec chardet chardetect`` - - `cookiecutter `_ - Create projects from templates - ``pipx run cookiecutter`` - - `flake8 `_ - Style guide enforcement - ``pipx run flake8`` - - `gdbgui `_ - Browser-based gdb debugger - ``pipx run gdbgui`` - - `hatch `_ - Build and publish packages, manage environments - ``pipx run hatch`` - - `ipython `_ - Interactive Python shell - ``pipx run ipython`` - - `jupyter `_ - Web-based interactive computing notebooks - ``pipx run jupyter`` - - `pipenv `_ - Python dependency and environment management - ``pipx run pipenv`` - - `poetry `_ - Python dependency, environment, and packaging management - ``pipx run poetry`` - - `pylint `_ - Source code analyzer - ``pipx run pylint`` - - `pyinstaller `_ - Bundle a Python application into a single package - ``pipx run pyinstaller`` - - `ruff `_ - Fast Python linter and formatter - ``pipx run ruff`` pipx-1.18.16/docs/tutorial/000077500000000000000000000000001527355705200154565ustar00rootroot00000000000000pipx-1.18.16/docs/tutorial/getting-started.rst000066400000000000000000000105611527355705200213200ustar00rootroot00000000000000##################### Getting started ##################### This tutorial covers the core pipx workflow: install an application, run it, upgrade it, and remove it. Work through it top to bottom with one small package, and you will know the commands you need day to day. .. note:: Install pipx first. See :doc:`Install pipx <../how-to/install-pipx>`. ******************* Packages and apps ******************* pipx installs **packages** and exposes their **apps**. A package is what you download from `PyPI `_, such as ``pycowsay`` or ``httpie``. An app is a command that package puts on your ``PATH`` through a `console script entry point `_. One package can expose several apps: ``httpie`` gives you ``http``, ``httpie``, and ``https``. For the full picture, see :doc:`How pipx works <../explanation/how-pipx-works>`. *************** The lifecycle *************** .. mermaid:: flowchart LR INSTALL["pipx install"] --> LIST["pipx list"] LIST --> RUN["run the app"] RUN --> UPGRADE["pipx upgrade"] UPGRADE --> UNINSTALL["pipx uninstall"] classDef step fill:#2a9d8f,stroke:#1f7268,color:#fff; class INSTALL,LIST,RUN,UPGRADE,UNINSTALL step; **************************** Install your first app **************************** Pick a small package to try. ``pycowsay`` works well: .. code-block:: console $ pipx install pycowsay installed package pycowsay 0.0.0.2, installed using Python 3.12.3 These apps are now available - pycowsay These manual pages are now available - man6/pycowsay.6 done! โœจ ๐ŸŒŸ โœจ pipx creates an isolated virtual environment for ``pycowsay``, installs it there, and links the ``pycowsay`` command into a directory on your ``PATH``, without ``sudo``. Confirm it worked ================= Run the app from any directory: .. code-block:: console $ pycowsay moo --- < moo > --- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || || If you see the cow, pipx is working. .. tip:: ``pycowsay: command not found``? pipx installed the app but its directory is not on your ``PATH`` yet. Run ``pipx ensurepath``, then open a new terminal. See :doc:`Install pipx <../how-to/install-pipx>` and :doc:`Troubleshoot <../how-to/troubleshoot>` if it persists. **************************** List installed apps **************************** .. code-block:: console $ pipx list venvs are in /home/user/.local/share/pipx/venvs apps are exposed on your $PATH at /home/user/.local/bin manual pages are exposed at /home/user/.local/share/man shell completions are exposed at /home/user/.local/share package pycowsay 0.0.0.2, installed using Python 3.12.3 - pycowsay - man6/pycowsay.6 The output shows each virtual environment's location, the apps it exposes, and the Python version it uses. ********************************** Run an app without installing ********************************** ``pipx run`` executes an app from a cached temporary environment without installing it: .. code-block:: console $ pipx run pycowsay moo Reach for this when you want to try a tool once. The :doc:`run-applications` tutorial covers it in depth. **************** Upgrade an app **************** Upgrade a single app to its latest release: .. code-block:: console $ pipx upgrade pycowsay Or upgrade everything at once: .. code-block:: console $ pipx upgrade-all .. tip:: See what has an update before upgrading with ``pipx list --outdated``. The output lists each package's installed and available versions. ****************** Uninstall an app ****************** .. code-block:: console $ pipx uninstall pycowsay pipx deletes the isolated environment and removes the command from your ``PATH``, leaving nothing behind. ************ Next steps ************ - :doc:`install-applications`: install a real tool with ``pipx install``. - :doc:`run-applications`: run apps in throwaway environments with ``pipx run``. - :doc:`How-to guides <../how-to/index>`: inject packages, pin versions, configure paths, and more. - :doc:`../reference/cli`: every command and flag. .. tip:: Scripting pipx? Most commands accept ``--output json``. See :doc:`JSON output <../reference/json-output>`. pipx-1.18.16/docs/tutorial/index.rst000066400000000000000000000005021527355705200173140ustar00rootroot00000000000000########### Tutorials ########### Step-by-step lessons that walk you through pipx's everyday commands. Start at the top if you are new. .. note:: Install pipx first. See :doc:`Install pipx <../how-to/install-pipx>`. .. toctree:: :maxdepth: 1 getting-started install-applications run-applications pipx-1.18.16/docs/tutorial/install-applications.rst000066400000000000000000000066601527355705200223520ustar00rootroot00000000000000########################## Installing applications ########################## :doc:`getting-started` walked through the ``install`` โ†’ ``list`` โ†’ ``run`` โ†’ ``uninstall`` cycle with the toy ``pycowsay`` package. This tutorial repeats it with a real tool so you can see how a package's name and its apps differ. **************** Install a tool **************** Install `HTTPie `_, a command-line HTTP client: .. code-block:: console $ pipx install httpie installed package httpie 3.2.4, installed using Python 3.12.3 These apps are now available - http - httpie - https These manual pages are now available - man1/http.1 - man1/httpie.1 - man1/https.1 done! โœจ ๐ŸŒŸ โœจ pipx creates a virtual environment, installs the ``httpie`` package into it, and exposes the apps it declares on your ``PATH``. The package is ``httpie``, but it exposes three apps: ``http``, ``httpie``, and ``https``. .. tip:: To install a tool for every user on the system, pass ``--global``. See :doc:`Configure paths <../how-to/configure-paths>`. ******************* See what you got ******************* .. code-block:: console $ pipx list venvs are in /home/user/.local/share/pipx/venvs apps are exposed on your $PATH at /home/user/.local/bin manual pages are exposed at /home/user/.local/share/man shell completions are exposed at /home/user/.local/share package httpie 3.2.4, installed using Python 3.12.3 - http - httpie - https - man1/http.1 - man1/httpie.1 - man1/https.1 ************************ Run it and verify ************************ Call one of the exposed apps to confirm the install: .. code-block:: console $ http --version 3.2.4 If the version prints, the app is on your ``PATH`` and ready to use. (Missing? Run ``pipx ensurepath`` and open a new terminal, then see :doc:`Troubleshoot <../how-to/troubleshoot>`.) ************************* Hold back fresh releases ************************* The newest release is the one most likely to ship a bug or a compromise. Install the next tool a week behind the index: .. code-block:: console $ pipx install --cooldown 7 black installed package black 24.8.0, installed using Python 3.12.3 These apps are now available - black - blackd done! โœจ ๐ŸŒŸ โœจ pipx remembers the number, so later upgrades of ``black`` keep the same seven-day lag without you repeating the flag. Set ``PIPX_COOLDOWN=7`` in your shell profile to get the same lag for every tool. ************ Learn more ************ More on ``pipx install``: - :doc:`Expose apps <../how-to/expose-apps>`: hide or reveal commands with ``expose`` / ``unexpose``, and pull apps out of dependencies with ``--include-deps``. - :doc:`Tool manifests <../how-to/tool-manifest>`: manage a set of tools with a ``pipx.toml`` manifest and :pep:`751` lock files. - :doc:`Run scripts <../how-to/run-scripts>`: install a local :pep:`723` script as a managed app. - :doc:`Use the uv backend <../how-to/use-uv-backend>`: swap `pip `_ for `uv `_ to resolve and install faster. - :doc:`Standalone Python <../how-to/standalone-python>`: install against a specific Python version, downloading one if needed. - :doc:`Dependency cooldown <../how-to/dependency-cooldown>`: set the release-age lag once with ``PIPX_COOLDOWN``. pipx-1.18.16/docs/tutorial/run-applications.rst000066400000000000000000000102311527355705200214750ustar00rootroot00000000000000##################### Running applications ##################### ``pipx run`` downloads and runs a Python application in a one-time, temporary environment, then leaves your system untouched. Reach for it to scaffold a new project, check an app's help text, or try a tool without committing to an install. ************* Basic usage ************* .. code-block:: console $ pipx run pycowsay moo pipx installs the package in an isolated, temporary directory and invokes the app: .. code-block:: console $ pipx run pycowsay moo --- < moo > --- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || || Arguments after the app name pass straight through to it: .. code-block:: console $ pipx run pycowsay these arguments all go to pycowsay ********************* Ambiguous arguments ********************* On Python 3.11, pipx can swallow an argument meant for the app when it looks like one of pipx's own options: .. code-block:: console $ pipx run pycowsay --py pipx run: error: ambiguous option: --py could match --python-args, --pypackages, --python Put ``--`` before the app name to forward everything after it verbatim: .. code-block:: console $ pipx run -- pycowsay --py ********************************** When the app name differs ********************************** Some packages expose an app under a different name, or expose several. Use ``--spec`` to name the package and the app separately: .. code-block:: console $ pipx run --spec PACKAGE APP Under the pip backend, pipx lists the executables of a package when you guess wrong. `esptool `_ 4.6.2 ships no ``esptool`` script: .. code-block:: console $ pipx run --spec esptool==4.6.2 esptool 'esptool' executable script not found in package 'esptool'. Available executable scripts: esp_rfc2217_server.py - usage: 'pipx run --spec esptool==4.6.2 esp_rfc2217_server.py [arguments?]' espefuse.py - usage: 'pipx run --spec esptool==4.6.2 espefuse.py [arguments?]' espsecure.py - usage: 'pipx run --spec esptool==4.6.2 espsecure.py [arguments?]' esptool.py - usage: 'pipx run --spec esptool==4.6.2 esptool.py [arguments?]' Run the one you want with ``--spec``: .. code-block:: console $ pipx run --spec esptool==4.6.2 esptool.py Package authors can remove this requirement by declaring a :doc:`pipx.run entry point <../explanation/making-packages-compatible>` in their metadata. .. _tutorial-run-cache: *********** Run cache *********** pipx caches each ``pipx run`` environment for 14 days so repeated runs skip the install. After the cache expires, the next run fetches the latest version. Under the uv backend pipx hands the run to ``uv tool run``, so `uv `_'s own cache applies instead. .. mermaid:: flowchart LR RUN["pipx run APP"] --> CHECK{"cached?"} CHECK -->|"no"| FETCH["fetch + build venv"] FETCH --> CACHE["cache venv"] CHECK -->|"yes"| REUSE["reuse cached venv"] CACHE --> REUSE REUSE --> EXPIRE["expires after 14 days"] EXPIRE --> RUN classDef step fill:#2a9d8f,stroke:#1f7268,color:#fff; classDef decide fill:#c78c20,stroke:#946716,color:#fff; class RUN,FETCH,CACHE,REUSE,EXPIRE step; class CHECK decide; Force a rebuild before the cache expires with ``--refresh``, which replaces that app's cached environment and keeps the replacement: .. code-block:: console $ pipx run --refresh APP ``--no-cache`` also rebuilds, but under the pip backend marks the new environment for cleanup by a later run. The two options are mutually exclusive. Show the cache directory or remove pipx's cached run environments: .. code-block:: console $ pipx cache dir $ pipx cache purge For how pipx decides what to cache and when, see :doc:`How pipx works <../explanation/how-pipx-works>`. ************ Learn more ************ - :doc:`Use the uv backend <../how-to/use-uv-backend>`: run with ``uv``, which keeps its own ephemeral cache. - :doc:`../reference/cli`: every ``pipx run`` flag, including ``--python-args`` and ``--with``. pipx-1.18.16/get-pipx.py000077500000000000000000000004211527355705200147720ustar00rootroot00000000000000#!/usr/bin/env python3 from __future__ import annotations import sys def main() -> None: sys.exit( "pipx dropped this installation method. See https://github.com/pypa/pipx for current installation instructions." ) if __name__ == "__main__": main() pipx-1.18.16/hatch_build.py000066400000000000000000000016741527355705200155130ustar00rootroot00000000000000from __future__ import annotations from pathlib import Path from typing import Final from docutils.core import publish_string from docutils.writers.manpage import Writer from hatchling.builders.hooks.plugin.interface import BuildHookInterface class CustomBuildHook(BuildHookInterface): def initialize(self, version: str, build_data: dict[str, object]) -> None: del version, build_data if self.target_name != "wheel": return root: Final[Path] = Path(self.root) (output := root / "build" / "man").mkdir(parents=True, exist_ok=True) source: Final[str] = "\n".join( line for line in (root / "docs" / "man" / "pipx.1.rst").read_text(encoding="utf-8").splitlines() if line.strip() != ":orphan:" ) (output / "pipx.1").write_bytes(publish_string(source, writer=Writer(), settings_overrides={"report_level": 5})) __all__ = [ "CustomBuildHook", ] pipx-1.18.16/logo.svg000066400000000000000000002003701527355705200143460ustar00rootroot00000000000000 pipx-1.18.16/pipx_demo.gif000066400000000000000000010075171527355705200153510ustar00rootroot00000000000000GIF89a$Œ๗1� H IN EEP=#E 6(7 7 1*T $"0_# $8$ ('.'%/)#) ) * +(+#,, &,B,:g- - - 1.1#H33>3O44 4,U5)8 8C`99:E;,_;Ar<3D=Pu>>5h>;R??F}A;BC&QFG.GVdH0EH1;JJ+#K(NWsOOH~OR‰P3P0ZQT*"T.7T4JT7VTBnV+WBYY*[fŽ]1^Y_52`Gs`V_actd7CdQvep hDUi3&mSEn:nJ`nขpW`s:1sR to‰uK.uL2uM6w~ตx‡‘zJUzTf{^u{`V|SA}SX}…~bgq}qŒน€†ACpŠODŒQŽ| ^e’es’j]“Ž“ขร”\U—i@—‹ฒ—žจ˜s„™s]™~™คถšxtš€iš…@šกšฝ›ฆูœ‹qžจฤฃtฃฏไฆฒ็งณๆจi^ซ€„ญ นฎwgฎŒdฏฆอฐฝ๏ณ“ณฟูดtด›ดนรต ถsถŒŽถ–ทฅฅธญฯธฐซนš@นดญผnพนดพฮ๘ฟำ�ร•oฦ`<ฦฯ�ศพลศู๙ฬ–„อศํฮทฅฯยูะชะ฿๛ัฐรัผซาีืำฅŸิ›ิฌฑิฑงิฟีฝซึฅfึบทึใ�ุิ๓ฺศถ�ยล�ื��ํ๙�ํ��นž�Ÿ8แฒแิยไธฆๆฌ–ๆป8ๆฺ๐ๆโ๗ๆ๐�ๆ๛�็ฏก็า�็ๅไ้ฌ.๊ฮะ๋ฦ ๋สTฺ๋�๋๓ุ์์ฤฤํฮiํ�ฦ๎ฟฌ๕ยณ๖ห9๖์�๗ฺ,๘ไ ๙ห/๙๘�๚ฺ9๚็๑๛ัท๛�0๛๘��าย�ุั�๏๒��ล�แศ�๖๎�เ฿�ๅฺ�ๆฮ�๎ำ�๏��๐��๚�!� NETSCAPE2.0!๙ ,$Œ�-Hฐ มƒ*\ศฐกร‡#JœHฑขล‹3j�ศฑฃว CŠIฒคษ“(Sช\ษฒฅห—0cสœIณฆอ›8s๊�ษณงฯŸ@ƒ JดจัฃH“*]สดฉำงPฃJJตชีซXณj�สตซืฏ`รŠKถฌูณhำฅกถญ�ทpใสE9ศถs๓๊�หทฏ[u…๘LธฐแรIiุEฬธฑใวGบoไห˜3k†L‡–7‹MบดZ8Lซ^อบต๋ืฐcหžMปถํ�ธs๋�อปท๏฿ภƒ Nผธ๑ใศ“+_ฮผน๓็ะฃKŸNฝบ๕ุ๋ณk฿ฮฝป๗๏เร‹�Oพผ๙๓่ำซ_ฯพฝ๛๗๐ใหŸOฟพ�๛๘๓๋฿ฯฟฟ��(เ€hเ&จเ‚ 6่เƒF(แ„Vhแ…fจแ†v่แ‡ †(โˆ$–hโ‰(ฆจโŠ,ถ่โ‹0ฦ(ใŒ4ึhใ8ๆจใŽ<๖่ใ@)ไDiไ‘H&ฉไ’L6้ไ“PF)ๅ”TViๅ•Xfฉๅ–\v้ๅ—`†)ๆ˜d–iๆ™hฆฉๆšlถ้ๆ›pฦ)็œtึi็xๆฉ็ž|๖้็Ÿ€*่ „j่กˆ&ช่ขŒ6๊่ฃF*้ค”Vj้ฅ˜fช้ฆœv๊้ง †*๊จค–j๊ฉจฆช๊ชฌถ๊๊ซฐฦ�*๋ฌดึj๋ญธๆช๋ฎผ๖๊๋ฏภ+์ฐฤk์ฑศ&ซ์ฒฬ6๋์ณะF+ํดิVkํตุfซํถ�v๋ํทเ†+๎ธไ–k๎น่ฆซ๎บ์ถ๋๎ป๐ฦ+๏ผ๔ึk๏ฝ๘ๆซ๏พ�๖๋๏ฟ,๐ภl๐ม'ฌ๐ย 7์๐รG,๑ฤWl๑ลgฌ๑ฦw์๑ว ‡,๒ศ$—l๒ษ(งฌ๒ส,ท์๒ห0ว,๓ฬ4ืl๓อ8็ฌ๓ฮ<๗์๓ฯ@็์ษ่Zะ’๑J2จt„Xย!พไRด;<�` h€K.พ$ƒ‹ึ"€ร(ฐDcL“- d/ˆ@ ‡ภ"LMำq7�š@D"v฿๎6แ: ท*N#� แบ8๎`เั0~ช˜ ถชโƒ7N*ๆDhฎชแc_ž9ซำwฉB„.zชŠW๎๙็ญฟŽ*ไจfn๛ฉฑ[njํป›Jบณ‹šy๑ผ๋๙ๅฎฃ.8+ศ‡ช;๊‹็^{ซQ�`=ๆ’7่z๐�:๘แ8}๙ ~๚งฏเ๘๎ฟ฿~�ชO�ื฿o��๚h��๔ฟ๖็|<`๓xภ�1p?|`~ (ม๛ฏ‚๚ก ้ม rpฌ�B(B�‘0><ก{.จB๙คฐ…๋้ ู๓ยขg„6l sxC๒P=,�!�}(ฤ๓์ฐˆใฉ!มƒร%–็ˆN�Žฃศ RQšฐ‘|t#$�ธฦIฺQ’–,ไ#3‰HLrr‘�dr )ส�$ฒ”น$*้ษU๚†”ฎฤอ)cIFา๒•›ผฅ)[ฉKYVฒ—ฟ™%0]หaยF˜ฦ\-“i›b2“5ศ|&iT)อฺDณš›q&6งษหmB3”�œ6รฉ™k’2ิ*ำ„fดฆ ])N฿‚าๆ%ฆ>ํŠKƒ U+4=ช[†ชTก๊ดฉe้)TีbิฉR…ฉVลJUณ•คr•,X�๊UŸ*Vก๚ณฌ0%+Z‘zำตUญnฝŠWใส•ฐา•)[ฝซQคชืญไตฏCฑ+`๒ืมeฎ†ฝj[k•ย2–'|}์T+ูœถฒ?ก,fm‚ุอโuฑžuŠfC+“ศ’v)ฃ=ํK.ซฺ›คถต,้,l‰ยฺูxฮไตถE‰isXธ๒ฒ �ญPp+�‘ศถธภต(r•B�ๅ~dทฮ�Isฃห‘ฺR๗$ำฝnFŽซ]šXทปฦ๕-xW{ึ๑๚$ปๆศwำ๋๔ฒ"�}olƒ+_˜ธทพ .~c+��–dฝ�ตศ}|๘˜$>ฐD€!๙ ,#‹!๙ ,$Œ�5Hฐเภ&„`ฐกร‡#JœHฑขล‹3j�ศฑฃว CŠIฒคษ“(Sช\ษฒๅH…0บœIณฆอ›8s๊�ษณงฯŸ@ƒž„ฉPฆะฃH“*]สดฉำงPฃ%บPชีซXณj�สตซWซT�Jถฌูณhำช]๛3์ฟฑlใสKทฎ�ป:)P0A‚ข[ธx Lธฐaตเ<1aโ฿ร#KžLน๒ฬ �ั*#"EŠฦ[Mบด้ำ“4 ๑(ธLE@7บถํ�ธscี{อพrELp˜ฐ๎ใศ“+_^’B<อส5baใqชฦ™k฿ฮฝ{๒WŒี�3๖‡‰i{_ฯพฝ{ศnข"ลyˆ๊฿๋฿ฯฟฟืศ&Q~�hเ๖ฤมp˜เƒF(aMNhแ…fxQ…v่แ‡b โˆ‚hโ‰(ฆจโŠ,ถ่โ‹0ฦ(ใŒ4ึhใ8ๆจใŽ<๖่ใ@)ไDiไ‘H&ฉไ’L6้ไ“PF)ๅ”TViๅ•Xfฉๅ–\v้ๅ—`†)ๆ˜d–iๆ™hฆฉๆšlถ้ๆ›pฦ)็œtึi็xๆฉ็ž|๖้็Ÿ€*่ „j่กˆ&ช่ขŒ6๊่ฃF*้ค”Vj้ฅ˜fช้ฆœv๊้ง †*๊จค–j๊ฉจฆช๊ชฌถ๊๊ซฐฦ�*๋ฌดึj๋ญธๆช๋ฎผ๖๊๋ฏภ+์ฐฤk์ฑศ&ซ์ฒฬ6๋์ณะF+ํดิVkํตุfซํถ�v๋ํทเ†+๎ธไ–k๎น่ฆซ๎บ์ถ๋๎ป๐ฦ+๏ผ๔ึk๏ฝ๘ๆซ๏พ�๖๋๏ฟ,๐ภl๐ม'ฌ๐ย 7์๐รG,๑ฤWl๑ลgฌ๑ฦw์๑ว ‡,๒ศ$—l๒ษ(งฌ๒ส,ท์๒ห0ว,๓ฬ4ืl๓อ8็ฌ๓ฮ<๗์๓ฯ@-๔ะDm๔ัH'ญ๔าL7ํ๔ำPG-๕ิTWm๕ีXgญ๕ึ\wํ๕ื`‡-๖ุd—m๖ูhงญ๖ฺlทํ๖�pว-๗�tืm๗�x็ญ๗�|๗�ํ๗฿€.๘เ„n๘แˆ'ฎ๘โŒ7๎๘ใG.๙ไ”Wn๙ๅ˜gฎ๙ๆœw๎๙็ ‡.๚่ค—n๚้จงฎ๚๊ฌท๎๚๋ฐว.๛์ดืn;‹ฒไ.หํpb ๛๎ผปyม๏มฟI|๑m�šส/Ÿf๓ฮŸ }๔eNO�˜ึ_f๖ฺษ}๗]~�–โŸe๙ๆ_‰~๚Uฎฯ�”๎ฟe�๒?I�M��’๚๏Ÿd��;XคpH<`จภ1ฐ=z w$ม ๆจ‚ผ3Xฃ rpF�`ŒB(ย‘ฐ„-:! Wคยฆจ….< cขาะC6ผก†rจC ๑ฐ‡๚!�%$ฤ!BจˆFL“x %2ฑ@N|bข(ล�PฑŠ๏น"�ฃล-ฎง‹^์รธ1’‘9f<ฃrาจFไฐฑบy#q#ว9ฺฆŽvD ๓hš=๒‘4~�ฃe)Hสฒ’9$"!ฃศEฆ‘Ž$ $#)˜IR/–ผค]2ฉIบpฒ“r๙$(ู"สQชฅ”ฆD *Si–Uฒ’,ฎ|ฅWb)Kฎะฒ–Zน%.ฑขห]Zฅ—พŒ 0ƒ๙”aณ)ฦ<ๆR’ฉฬค0ณ™Gy&4ƒ"อi�คšึ์ 6ณน“mr3'��ๆMย)ฮšณœ39':[ขฮuฎค๎L <ใy’yาณ$๖ผ็H๒ฉ�ฯ๐ณŸ๙'@;"ะnค อBz‘…2ด"}่D"*ัˆPดขน(FขัคฃHCช‘†ิคE้FUŠQ–Vิฅ…้CeสPš&ิฆล้@u Pž๖ำง๚๊=…JOขฦำจ๎D๊:•ŠNฆ–ำฉโ„๊7ฅสMชfำชึฤ๊4ต Mฎ6ำซส๋1ลJLฒำฌพD๋.ีŠKถึาญฒ„๋+ๅสJบฆาฎฆฤ๋(๕ Jพvาฏš์%KIยFาฐŽD์"‹Hฦาฑ‚„์%หGสๆัฒvฤ์5 GฮถัณjํEKFา†ัด^DํU‹EึVัตR„ํeหDฺ&ัถFฤํu D�๖๐w!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,ก5(ยฟƒb Hะ`ย„ |QโDŠ#JtˆQ#CŽ14hแ„ )L€|(าฯ2๕”นQ‰1ฃK็ิd้ิ/ฬJˆ.M{มเC/b?�œฦใย…6ฬ’ฺฺ๔)3 ๒jbšํEธzI�ท๕’ฝD] ๕‹3ถ,ฝy๕ภJั6จ0$ค [“lPฆ๋R �ืใ@ˆ1(^Œq@!๙ ,ถhHฐเฟƒBXฐaย‡ :|จกฤ+$ ร'!0f�Q+�t™qa… `iกกKŠ.cX€O˜›F’ยC'ฎ\…tไB!6ŒPฅ”ฉะ…ElHฅบ๔&DYทV๕š1$8x˜ † $ฎ ‚:ืนyืฉE %:ฝ{w*…�‚ @€ ยฐ฿‚&ถดxQ.ส`ุฬรe!๙ ,#‹!๙ ,#‹!๙ ,"ตะ@ฐ A�*TˆA Aƒ.l8๐แA‰Zิใ?Š9zLrcGŒ4€๐@B†&NJL้g™ฟzส�ฤ๙Q K็ิd้ิ/ŒฬŒ\š๖‚ม‡^ฤŽ2๔ii ฺ0“Jา็าซY™I7’ขาl/0ภี‹kฯค—์%๊ฉ_ทLมPJo^=p€RเMกBฉ0$คฬำ์W ๑zต ๙bYภภน3ล€!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,+บhHฐเฟƒBXฐaย‡ :|จกฤ+^$˜๑`ฤ:�[Xก‚ $B$H B…H’1บกbขฤ-[)^่BG3F)DE�aBง‡ศไ;ฅK:‹ุ˜0aถR>0„์ธP๊„ตฐ;‚‘+1š@รฒl‘:C„ศsŒJ -ื�ุะู`Iพ:!4,่4oˆฮ Dˆเะ!Aƒ–:^ จตlฦuฐX†ฯ 1l!๙ ,6\5(ยฟƒb Hะ`ย„ |QโDŠ#JtˆQ#CŽ1tฐ๒กว†3ZิPฒ"ษ”�Nlฉp%อŒ!^ฆฤ “%L tvด๙“่NฃC%Xสณ)ฯฅ!๙ ,>�5HP€ƒ@๘วฐaC #&DธะกCˆ NSฐlะนP๐ @ม?†82๚?!ซ฿ผ~0ใศ„ย‹ท !๙ ,#‹!๙ ,#‹!๙ ,Pญ5H๐Ÿมƒ!\จกC… :D1bร‰+Fฤ˜ัข@Ž� 6h‚&L€„๘มP5�”)Qษย… y˜�แ๒ +/x8T‹J H„XฉแยŠ[�4…iA‚&Fˆ hัิฯุฬ•8Svๆญะเมƒš1:LLั๋ฦŽHฦ@น2C!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,m%ัHฐ มƒ*\ศฐแA #Jิเฐขล‹1N�˜ฑฃว7NIฒคI‘Mช\yeD–0c"t QฆM›h��ษ๒BN”<ƒ–์ SจัŽ4)]ฒ(ำง “Bš๐'PชX Z™ต+€ญฝb•*–*ูฒP“�[ห–-ดAนhK๗-\จt�ฺฝห4ฏ[พO�ฎ� ุจเ„ =œX๑NฦŽ nY&ไสฮPขDŠฅ4Xd0Hย†  0gJๅIตชีซXqv`‘ฆ5r๎ะ}CG,”:ุ่ Pกƒิฉ=bภเ๓&…)R` y7oึฟ๛ฆH)x/เ‚>™bฯ]cwƒ๐ sดŠ›;H&lภ๐–f\p์„qณDzIV€ศ„แรฐ Žุ3ว5Jำจ_ว�JŠ\ใxฉฑน”l9rข ˜๐ฑณL…ŒPษ!ฐยVฎF๔XcW)#X�šžค๘i>rะaาฃOgXยšผไ๗Nˆaํ>\ภืณIyq็‚0๐ACผvƒญื�{#๑Pcภ=5ิt่Ž;ค กย^ฮลค ึ่aร^ึaง�@U 0้‰$ภ%ไ•ดc~8pE?บ`X‹.๊vะ2เ zM0 8เ‚ 6TเีxcŽ1ฐD>a๔#… Iค‘!1QL‡pบs\œ�่!ยG 4มHWLyB *ะA เpอ"ˆ ย#Œ2สƒzhเ0D{yิ€‚j่(jฅ†"ดฃ0"๔้ˆ �๊่hP`jจ…&ดjซ~@<๐@ฃฃyช€ซ)‹kชqd*-0(Ÿฝ (ˆ`‚ $ จชเSฌฬย\gพๆมuูมg,ฒึ๊ มฬุมAˆŠ Gˆะmฆ›ยธA.่ฎปย›ะดd‚ ฮ*ญดz๒้0ฤ๔‰eท„�{P} ใุˆ${๘˜cŽ๘ภมฏ~h“N=�ผ‘ w€ใฯ8n๘ด2ํ๔ŒO=ฟX7ส5๏�๘�#ฯ/*„=๖ๆ‘ิ€หpญŒSŽๅ~=ใ—฿ *,„Pย-ถ๘z”|:คธ๓ )๊h Iควรฯ9ไ€„T่  ๊ˆ1 โ|เ‚`„k AุคXแ‹7ŒaุC6O๔z$ร@ม 3ไ xุว&ฒp…}4‚Yู<�๘€„N์ร VAพ<`#ƒ8ด!ž๐8ภ@!;ฒG"บ‰~ฤม:vx"ธ@†f`‚`ƒ—เ}4ขp…/dHCvF่ม\q;(8f� Ud 6h„f|0„$€™่ฤ ’&v/ฌฮl7ฃ4ฎฑ˜@&XA'(KIใ9|ฐL ๗H‚ะยฎl= D‚ „‘ˆFDโข@5dก�C๒ธว>๓˜ษ šมˆˆ";0AB:ะฟ�แเ 9พ๙อoฐ/ศ^@€�#“�† :@‚€"*ป€ šก�‡„€Bs…เf`ยiภdyฌใŠQx=cŽQŠLเ๗๘แ0ีi‰lฐำ‰๐  ์ข7ฤ<คเเnฤผ„G0\ใ1ุ†2`‚ ย>pภ1มƒ 4@ฦม๚ะˆข w ภ&ษฃพำp‡จ๔‚—r@Ÿ�ฬ |า‘–๔ค9ธ—B )NยGจD5*ROะƒmะ!$@€xฐั\aLุB:–…y •]yโ๐Š<ะาเAo‰,ฦ"Yฎ๎Œ0‹฿JGyส-wน๕‹ๅิ็2k)เ0ฬ๖Vว9ำ้ว2[X๑„Kํ) œ๋อฺอ@ู…็T7ๆxK๏:G ›ฐ่สัˆห�ู฿ภ5๓2มS:7€.�e€ ์! ค�7ด 1FำCจ@/ุึD?๑‚นธt„svxB 6 Xภภ.๔ฟG#ฌน€เ)ฦย%N‘ �มB@%คAuCnด`าI0ัP๔ผซอฉ `gณ#8,@Œ&ิถH+๑ฎ‡`70ุCIฅ‹•1)lชศฏ}์;R<ใC`Oc`a‹ฐม๑ฒbฐใ๓์™m1[uฐร๔์Hง๚ญไW`‚ธF/˜A<ุrร”*ฃŠg ๛H„๎)NgD‚[4„€xu๗฿qgล2�ภ…๎ธw€๘ย ๎|?›ษ็XC�†L€๘8ลฦ`'๔” ๚ฐEฦ!ศY‘tVศ8@ูปแ $`xฐvฐ_๐^`Fๅq{z {f&}ิg}ๅC๛s๐ฃBHุo0$F�` 2T0tะ™๐U€qฯVฏ‡B`=F|ย๗ฦgษ C(€ใ๖qZฤE^„%ภ�ืƒ—„]ฃ‘วฐUZp 1S6vฯภ�€…ิ@ /N?…`(b๐ฑฉ -<�  ฿c3ใ0_ำ]>๙` ๋โ ผเ€ ™ คA1  �p‡,Gผภ|}๘‡os้�นะ€%a†hจ†™๐%ะ E๓ฺ๐)ะw  ๒เเ 0ฐQ˜๓9์C>†8‚3\`นณa7รภมภ tฑˆ€จฐ‰๏๐ฆ–˜†'ฐ†ณ[ฃ6™?๗B‡vˆ‡Iร‰/๓‰พ"ˆฉwุ66Vฉ07ฆƒ86\{ภŠ�0๖S_ตŽEะ@ณˆƒXˆฆภเFฐ5ฤุวˆ‰—6y3|ใ7ŒœHŒ0ฐ#๔€}ŠcRคฑ0`/an �ภ โ@ }๐"N ’� 0๓(#>q-* .ท/ � ,ฒ@ฒฆฯC`*;น}ฒ2A™Dฉ'Kq0V๑’“3)/ศbT”ข0ม0<ๅ+ฏท'ศโทR)<‰-ฑ”ู•๒’*@•1นL €)ๅb“.ง•รb—ืข0Œf”v/ก’_U–๚,ยย•๑ �๒)&`ั” @,!ฐ ฎ๒– @(Uู'‡7ฌ‚˜ืโ– ำ+ะย‡า๚‚„B โ›ฯ๐ Žภ ต›"๙ G0#1แPVœก)ฑ–.ye0Z0 ๖ะอGœ 1! 9t SฯI6F…(Aœ ษž�‘เ™.๗๓q@ฒีู!เ0›ฑ™’๑Y ฒ0—•"ยฉ `๏ะ้0 f mฑ1 zๆษj ”b@ ๑๙ โะ>เRัUYชX ฺกช]ต-T`|ภ…`J?๘ก,ฺข.๚ข1„๒</€t0šฃ:บฃ<บ=๚ฃ@คB:คDZคFzคHšคJ๚Œ†ฃK๚คPฅO`RzฅX*ค �@ e y!žY:ฆdzˆBงh7eฺฆnJ ŒvhฐๅP&ฐ2ošงzJะxะ ว6เ{Zจ†:pฦPฦ.๐Šดก‡ฉ’Jp ง 7'•“บฉœJ!ภbฺฉข:ช๐ˆงคzชจz!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,$ 1� 8มƒTธแฟ„ !*”ˆะแC‹ flฃG‡†|ˆกคI R!๙ ,$�5H€มƒ�)\ธม‡"dHั!ฤRdh๑bฤŒ66๔(€{œ8„H(๒_ว‹ฎ๔ำฅ’ฅH @x !C *(P€ฅ€67โ๔ณฬ_=en€ฦฐ๖Ÿ0HCถ„ ภา95Y:๕ ยC'ฎ\…Xฉ๕ฆ€Kำ^0๘ะ‹‹FจRหถฅหฎำx\ธะ†Yy๗ฎMZ๑mเม…'ๆห˜ใ�l/0ภe—“`ม0A‚„_ฎ—์%๊ฉ_!8p s ‘›7ฆทพฅ7ฏ8@)ภPขำปwฆ`œ~+L„ )‚ฬ B หแ๒ภ๐ะ ‰ูs/๒๔E1จ_��`@!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,$$จ5(ยฟƒ Hะ`ย„t๘๐`D‰(Vผ(QใCŽ =Blะย R˜‰C?ห�ีSๆfeล–,S“ฅSฟ0nZpiฺ zร ๔†œำx\ธะ†ูBŸ•J•Y‘vY‰f{ั€ฎฅMตฺKิRฟ8Aร^ข7ฏ8@)Xฝ$L„ )๒ฆ%•ฃ^ง„yN‹€c #cp!๙ ,,$ท5H๐Ÿมƒ!\จกC… :D1bร‰+Fฤ˜ัข@Ž� Tจ A‰ จPRdŒnจX„(qหVŠ–B่hฦ(…จh;Lด๔"™|ง๔a้@ขe&LยVส†‹!€ฺฃถsG>r„ ๓(cX–-ฒัขeˆyŽQIกๅฺ-,ษW'„†ๆ i9ก:$hฐฒฅภ ฏŠอ๊๋Dญ"ภภน3 !๙ ,#‹!๙ ,6$ส1\ภ€กC 5Xศpแฟ‡!BHH1aร†3NฌH๑"รŒ7rD่ั!ศ‡"Gะa‡&OฆไHเJ?]/c‚„pc… `i ƒN#5Tˆaํ>aŒ8‰2้=žธrๆTช3^เ@a„*ฎ^ฉ� KqƒYดGC&E่๖lืธCฬฝ:  $Hจ…€a/:ืนy#์…น0”่๔๎)ƒ็*ะ )Td†๙โ่ค%ฟส™š!มื!๙ ,#‹!๙ ,H$�5H€มƒ�)\ธม‡"dHั!ฤRdh๑bฤŒ66๔(dH‘�:^4‰2eƒH"…ภ— HaยDK �hKWฏ�›>ชๆฏŸ2%>QB`้œ$ฉ๔มภ!ณ?\พภaAโง€Kำ^Xq/‡jQI‰f-Mใ1แC4E8ฌธๅOS˜$ขŠœŠ–๏‰ฟ!B$X ES?cPอ6๎ 9ฤƒuAkลC๓^ฮŠ"�ู๓ๅ‰—f˜JK]*X‹ 'บe๒เa2�่ebWj‚*Tlจ03„ rŠ๙งภื๋–ฌ+ั:I–RWš4Xท/H˜pb™ ีjฎSaฃj @A"าาด(๗_D‰u๏ภทฅ฿–|!๙ ,#‹!๙ ,t$Ÿ5H๐Ÿมƒ!\จกC… :D1bร‰!Lˆa >\Bฤ˜qย8pแ"!๒"FˆFฐrข%ษ7xPEำ&IˆF๐ฌ9๒งภ B{}y4)Q—s:๕ษด“hฑ0A‚ฤM…6pศƒ ะ›9^z‰กD*๏Lฅ๘๚ดATฬ5j๊รพ~)†รˆ1 !๙ ,#‹!๙ ,#‹!๙ ,~$ร5H€มƒ�)\ธม‡"dHั!ฤRdh๑bฤŒ66๔(dH‘�:^4‰2%I 

!๙ ,$�hHฐ €ƒ�[ศ!#&Lุฐโรˆ'*ฌ่"ฦ7r�w๑#ศ#–4@J’8lP@‚„)$Pกโ`… Dผ|่มรj๕”อษiศX‹*œ Wf(€x๔mสขฉ_˜lฮ๙X@cB{iฌF8V๊E‚ฝˆธ2๏ศ–tKขฬ#2TCfzxpเะ†YŒ(๙ˆ ยถg ผ#Vk\ |แBแ=่ลyuˆขl>ิฒ}ม`.b#N�ŒJ-PถxXลช•ซื�Qฑ!ู)n0jญ^77&๏ูV$D”t\lX�pฆMœ4@P`ย„๎&ฌฤ€!cย.ƒ{�ุR<ฦ–ษห'0 !๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,ข$ฃ5(ยฟƒb Hะ`ย„ |QโDŠ#JtˆQ#CŽ1Pุ @*T€|ˆมร‰Gึ�ฝWd%ฤx์z๒ ›  š'ๅย$€fฌะใV:TZZไP๚O$FPอBC Hศฐ'Iฦฅ{}นqg_ขฎ ๖T๓็\ฆŸgซVhpR‚„)บZิ ุba†cXฬCโ€!๙ ,ช$ง5H๐Ÿมƒ!\จกC… :D1bร‰+Fฤ˜ัข@Ž� &h‚!B€„€C‰\้�ั๛ใA%Gˆ#บ|‚+[bx มฎV9Vnุ€เถwศฐตzำƒ‡Nว๊bm๊ส '– "Ab@-ฏ7'Œpๅซ—Gฉb„XขS5๕ด�IฑR`’$HHม๗ฆว‹sฏ "ก!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,ณ$มhHp „&ฤ ฐ มƒ 2t๘0ขฤ†!Z�7‘ข;f�ˆa‚*TpธBy$–ฐfJ%‡GฺXไ @คs>&|H& dD†ช์3Aฦพ8F/NˆN+๛คD]`BAั Dšๆckษ†5š•ั†)‡Yž€ฺŒ� แง๋B‡ทyeุKƒแ#^nhก๕KEร1DaVOำ…3ผ ณ@ฯB‹f่น3้าจK!๙ ,#‹!๙ ,ผ$ #5Hฐ มƒ*\ธ๐‚B !"์ภฐขล‹3ppc@!๙ ,#‹!๙ ,6ญ�/\xะ E *\ศฐกร‡#JœHฑขล‹) œ๒(S„CŠIฒคษŒฝ`�gฅรษ—0cสœ ๅส–4s๊�ษ3!†ŸึุUŠIย  qB k�๐แ๊"a k๒�Mใม”€{œ8TํIVฆภ*ุ€๙8บ$_˜‰&ะภ ‰ฑ dเ5mญBW๚้[ถ๐CกƒJ1)€D‘จ"6&Hว6P‚ :l  €ณg*pก/L็ฯฉKK`ก‡ฌ\…๛Sภฅญc+ภา"tย <˜m๐x๒ๅšCคะเๆ)8lP๛ไฯ Sg�Aaปi๐$‡ Bลn๕Ÿ9\่ย„@^ฺ้Cภ พ~XDฦะU��#Lิไทิใฯ8sx ฺ Xใฯ;ใฑมฏดƒ>�ศ๓ j1ไ’N=ฺ’BBx Šnc%$@%}…๗TTยHxœฺœุอ+ะภฺ\จฬA>TมฉคใO>ˆคเwTSฯ‘)$`ˆ1-TUม ๔”แ…b๘%˜่=xยUฆจภhS5€F:๙8ัภ๕๔iž yžlnพŒ1(ฬ˜‘CxุCศ_ภƒศpล>„ขyPฦQ@ยฯtPม/๎�ถะŒŠ*ุรฎภ(ฃ%็ศD*๛ฝ*ศ5™|QE3›ๅPjF`cฬSภ๑E ”๊ณIš๔Flœใร4Lะร=iPj)ฆš&4B_@6|๐@$�N๐A2ขpZำ (๐Y�I`‚ Dซฒฏบ2Š8ภ &๒2‘@BM�ฐฯ?QuมHภ ๅ4ฒ�†ฑฦภม ]ภฐ๊บ๒o/ฐย=‰Tร6‚ Œภ1ษ*9‚/ ภ ยh)ม1ฅผภฝcภ๓ฑE:KD1HL๑�„ม !ฐK+“VฑศฐO %ุ๏g 4ภมฑ ภซน…�@ยตดยC=�’*Zด€ ฬฐ๑B  ๐8้Tร$'ว,ซBzณจ๙XXฒ•ฝั(B#4ะส6ฤ€�‰PตP อ่มรœm0C๙16{Œฯ„Žธโl€ภ!ฺผƒ 6‚;Ž้ŸJแRž{ๆƒhผฃ&จฬ9ใ#เ‚W@šจ`8„„?ฎPฉ€SZฬ ! ,ณŒไ[ˆ�6็›ั tง�‡ฒบ Œn ฎ—\รvษ] z@8ผโด@D6|@๕ตo๏๓@'ŽQ‡ มัซ€ ขa€HิจeP‰>ฌ@€;�‡ jศผ�ฬ๑‹๑ํ|ใA(`ฑืูs!˜ภ Zร0ค`Dy้$€pภ“  L6 า(โล:p0ยd$Ay@:EˆkŠฐุฯศ ม-HAล”ฦด0`ธ ฦNp Pƒ ต…-”ศD8Q8ม2a1๔)‹veะ&r ี`Ÿ`ย้D!*ุ€) ๖‘ˆ1ผแˆป๑$๖ˆ/�เด&@ฒd8๊ ปฤ<๘ฐ„,”CTู๒€ @ผa3#@5‡7„ฦetธ"�€ฬ9ึ1.จุ"ฦ€‡k๐‘!ุภœ๕‡/t›ีบVถย0�‚ �๎*X618^๚˜‰˜ภ\แ‹:pa(‚“(ถ0r!bู@:FA†P šx’)XฐW๐ข‹@F&T0=Tร�G&` L2E tฆ ีฃ่€Kะ”Šœ้ฃMส‡)�4uโ๏0 Š`์๔�ศร>‰wิใ๎ไ๘R,๐ขLฆHA‹๎`ŸบaPุร6Š‚(คˆ p)LeJS” /Eั‚t:~่ขU ๑ Xภ)ลฒภ8ก€J#$o6xzš r€œฮ`งesPCštฆ*ชs^sTา( ฒ›๋,R ‚๏ศญณ้dปftฌ กŽ–C›ฃ]€;ุI0ฐา ฌ๋๙ฌขKยสภi€ฅMX�„ร 6่จD$๑†– นžn1ผ-la Zภ=ภGจˆค!@‰ˆy)r€h#๊่vญP�(ผ†Iฐ+`ˆfศใฎภ ๐ฆเˆD๕ะฤeด €)๘E ภb๛Tธย๙m*zOŒ_ \@l่ศ(‚ จจyYฬใOdล ูฏ‡LdŠ!๙ ,6 8Pศ!„ ฆ 1!D0T�๑ย…ƒJ1)pPT‘ƒ8xiง /๚!ผMบ|N์iศมc*T4HะPFŽ !๙ ,#‹!๙ ,6Ÿ[�จก ม*\ศฐกร‡#JœHฑขล‹3j�ศฑฃว <ฒคษ“(Sช\ษฒฅห”#IพtนaC† :คผpA†:g J4#O ?ƒ]ณเR•F * ฤNˆ…ฐ๚ดซืง<ภฑณ๕+L ะšฝุTแ„ๅ$PjฒยVฎสฎ๕สณATจ˜๋ะ/„"TeุW`"๔R4 A‚„) 0B%‡@ปx%ไ™เo`!่"ฌฐAcภง&H๛4ืฝ�&œ€"๎ฯ” ๓�ฦ]”็N๏�)sณธแ?ี�๛Cxaุ-ท�IŸใaธฤ†���ซื  ี ]Xำc€เข=^ิ;้zๆ3ดปL�ฟ�ั3„{ -‹<�ษˆgM้F�C5PมˆEย('”ำP j`ุd‚ ๎%`‰mี—iEZ $ Xdf5 ‰&ฆะุŽ)ศ„ะ‹,Z8bbpเ˜ &่4›`#–xb•—ฅ กePNฉ Uร6ํT๓N;ุ˜มƒwŸM6๏Œƒ 6|๐ภPTค“K5h๊agETbS�ั"5e ย\ฅ Vื|!“#Œ2สฑuะค†D6๖ุ`่ ฤ“~Œั(ฬtฦมBx๐S@�a„5ภดืะฏ”ฦOฐ@BZ!๙ Cข‘‹?๔0า]TXใO.�|ุAˆ ๘ฑŒ?๕,‡"%ไ’ฒtวมQคr_>ŒŒp—+UฉgŠ bz[6€)โฺ �Bz€ฦ-๕€ƒH{j)ฤSน็žโ๋ะI. ฃคฃD”lแ#6|`HtสqaBY,“ฮผ3ฐ>�Lผ‚5๙4ก_˜กลŒาN)k.4A ฬ0ร…Uด“lภ“ Pะ,ํ`’CจPษ4Hง ุ%q c@•žvน ( บˆFฤ็๎ƒ$8!ฐ‹?ภ 1Uๅ,็mร$ AAม{ ๘กiฅ๎ำxA {AŒ๎`เะvัŠ`า@๓œ*Xƒ/p0 ๔ฆbฺ†  €?๒ภว*ะQรW น$>แE‰`Qภ3ฐ‚-`cL ƒ�N๑Ž@x์6@ ˜€–x€๒ภ[Œ ๕ณEpืŠ?9„h‡/ว\กกh6บ‹}Hกh!@�ำ�dPม‘กhQl [ธlf‚ใrA3๔ƒศฅอ€s3œๆวRผ  `คบ\1 `@ฃN;ก˜ฌั}„!)€ |P†D`ๆฃว~3น`ัฑ!@‚๙ดฃฐี`pž้$ y‘จŽjสQ„๘dอธ‡ E/๔รพ‚ๅ>ฬฐ?z๒Ctภlเ…v�cŠ f „hX๖�ะE. lŒcฃ€f N˜€B๓ Rฺy~�|‚Bภ•=๑9 @Aš?“Dค†|`๋CX7มƒ„*ม๓‘จŸtŸ  ๅฌมD!สฉ*ชศ ฎ[N‡„%Y˜D3ิd� €€�[D(ฐ�ี)-`mะOิ~ิB Aš ‚ฃ๊A JeชZœŠHDuช6่ฤ1๊ XƒซŸkฺ`แ }๐ซ H8๊€ƒ)ุ#pUCA่Š=ํึม &B`6a•‚R ๛A„€�C ฐ!ยใึ ๊fpˆj ฬ )ˆ€5๊!ˆ-"ดzรU=ฑunp!่„๔uC1h฿๛บ&ๆงข(A-๚๑‰ข฿ขห E3ฺQNsใ#ธมC@0ฃ�Hƒ์๖๓ภc�ยผ8E�ƒใ~๐Uไx��rง“๎฿ŽีุS�๎ €,ฌ` ^ฆ@Qํฬ ฅภp `ๆ c๐๊2#Šw›ะUร“h”v'`p0L”;q8e /3=ฐ€ร� ภ7pฆาD์wj#av ั `,ุา ำ*{€ ฺั6จƒw—@๓0๕ฒ*%€�๐ฺ@“ เ๙` ภุg ๔Pš` @ฐแฐฬี*�’ƒ\pBGƒ0Eh J@๐ัั ะ ๐:aฐ†€„P,เศp� ,@#Sเ-3˜ iฤธCAผๅ@ึ�๐๒เ๒D ะ ๚ ฟฯ๓ะ…\q‰™๘ะšภ1ฦเ6Aมw€2N`Q‡มะช1.C๖C๒ี~]p-M่#€}@๑ศ *7w0eส๐6๐„มภ s!ŒฤX4Qแ‡ฺ‘Šzม†qpQa@€ฺ่"qƒ xี6รั‚Mๅ‚ช6‡a5ก0†ฤƒย Dา#+a/ `“’(ภเ%~ฃ˜- tฑ๐ล]ฎ๑)ํE๚%”![แ%ฅม$๒ฑดtr•’เ@า๔P*ๅEฏA‘P#“าKb๐’ “�˜’‚Qญล ^& ตศ~ไ'ซั26!VR*pœ$5Q•9Ž7yู# `’b™gฦk&ษ ฐฉ”#,.่ƒ ๖–€k๗G,s)•#่กg65ฑ[0Z  ๛01! ะ‚้™กrfœๅ หะาPo™“9™A˜“้+ gิก„Z@ บะsF1ฃ›“i—{ษŽA9xษตYP†ะ ๒๐ธเ1&€j{Qdํ  RภdD. \๐qT 0B้ C€uบ้d�"แษำ—D!›ฉœ{‘Hม•ฤกtฒi:‘ฟYž›๘นŸ‘›�๙Ÿz�G‘z *ก4<‚ ก 0ฤ W1~กš€ฐ-ชšก๗นก1ตก!i[๑f ๚Ÿ%` �‘—๑T4+ฺขพฉy1๐ˆ๘€ ำ๗ ๖ภ ภ3๚.ฺ0zv7๚9บฃ)สFเ� (ฃ๋‚KMบก 8 ำฃ/ฺ` Yบฅ]*#G8€ ธ@ผๅ๐Nบค1ฆ\๊ฅF ฆ2Griบฆm ขภOlpk๐APt–า–C’%=�b Pฒจ บ,ู)T™)Ub"�BสJ Q,‚!@,9ช@!!Ki!Œq ’ ฒ)˜š%^๒ (ตนฉ‡ฝ & *ธ–๛ข -Hัซแ‹3’•Cฉใ4Ÿ8โDยCน(ฤ๚ฌีชDาญXะี‘ฌx๊ฌฃบมชŒ ฬ*ขโช�บฎJเNปu!•ฒ6�‘a๑/30๎ก1ััส01� 0๓eช๒B/)ะ*y€ƒ๕0n‚ีr-ูโ�"ฐ�@ฐใ�๒-:ี๒นp.Œฐ1x,ษ›ึแw“Wmฃ7ฦ87ส�ภ/ `ฦะVแ@๔PนŠ/Exณ0dvฺwาม€k๔0@C! ..kด7แฅ+j ะส  E‹aยฐ#ใฐ ะ๏ ๓‘wะ2๑Rถ๕ฒRJ* zƒV;S;ฒ!เดP ๕า•ต\qฐ�ชฐW`Oซ’DKิDO4z�ำ=฿s!ฬ๐\๐pเ+ห=�>ำy$#ฐ<อรkพ€E“08< :ฌใ:ฐณ–‹นškฅ A;ถc_<ภ7 ขฐ&ž@5OHบฮ=๐ฎเฎqฟ1ฟบ�พ๏ฟv‘ฟpง=ž;นก กˆซธ‡6 ฎฐkดQ9ป KดdK,ภ‰C)0n?KณTKท4ย๏JJฆไ!ร3• าเxฉHภH�ฑต`ร8 5H๑1I•ิ|๛ฐIฉd`{ฤˆ a$eR(ลARŒdW0Gฐ้ฐQ0D@iG‚ะเmใ Œว@E<4ธฆk4ไkƒ˜MU�LI\dม]9L>๛ Y\›GQJงdขเ }…c5•@|JGCLมไ;5ๆ4ล‹\I…ผk€ศ๘๊มฐรงh.c4 c4ฦ  Uฤ†ห� /c36h$b$fbœเsฃ‡L8ออ ๅิ�ี(afaๅ„ลํL �๕_B4PfิŠh?๗หฌ๛`1ะ๔ฏpด€C3™)ๆำ ƒ�ะ�`อฐ g}a-ำ‚yสz=6d‘Pฆ b#Vb'6>4พ€ภ&เูYฺšbูรaมpcึ&ู_ญำฌ-GMี4ึง�9ิซRิ”nโถล$เ@!HวภUวnใๆร๑ึ๖ั€(•2 & ศ—@Aฏฬkฒl��€�ๅฅ3' lyคhjsฉt*T|�“้�สํQLMt'p   TP  ` ศ+sฺฆyฤว"5า๐œ4&‚GE|ฤIฬิฤ๐kใฝJ$€ฦ6เ•Pเ0ีญ2 ด ส์ฺUฺเ4�/pmrัำ[ v``เิ�ั !:$hyไnเ$0^�”ึเ ๐เƒ7๎แ$ kมMn)ส�ผ,Hzฆ‡zBp4vฐ_๐^pผฃWzง—zผ…k”gy˜'#ฬพDิบ— w|ทWžๅ[: แใ€'x„—ฮ๋ผึf:iทvm็โฌjt}€R”€ 8�๐? ‹ภ?<*#wแเy ืวฯs๐#'=hฮพซณ]`‡T็ƒWxq้™>M๒&€๐s > �O๔ฅc~yŠPp+ฐ ืHไ{ ทŽyฒร7tp เ๛X‡๊š�wW๊7�้กปยฌ๎๊zฤ#ˆE`�ค €ถMฐธ€}Uะ๊/ู!\ฐo7จ๔ ˆ!@†็>gx9X๘-่๓Q1 �  ้@7ทท=๘ƒ~๛ฐฐ๎ศโ๎R<„๏Pถฟ�+/)ƒไแ1(๎!@๎ฦˆŒสx4{€า!้ฐP๐‰% ๗†ฬฅW˜ ้ะ‹0ฺ่�ฐฉฺ0k9ก๐Ex„0ิ๒/o ฐ‰% Eh 0Tก…š๛Q๏Eุ  8.‘็W˜…3ุ…:!๔ศ๑ฆpp.๓ ฯ๐Zb๓ฺB BO๔\Ÿ๛V†่Žข�ูx=‰ั`ซข•jIงั*’$[1. aช#ี)pิด— ฌY}oฌrBžš%44)เˆ9๏(ฏ QvOฌ •SY•˜1 PโP50h๙PV‘ 0)*[ฉฑฉ&C“ฟ ฐ/๛\‘‘G/๖๗I๘ฐZjฉ˜ม๒งสaู~ฆ‘ƒgษ๛นฏ%$ทพ๘ล�ฅ1ŸŸ>gทๅŠเOŸ ฆ‘�”ูƒY˜‡I€.ถแ�q7 ด๐ กข‚ 0๐  ;…U๊TชUj\Z๕#ร ฏjV์Xฒeอž%›ํZถm�พ…W๎\บuํ�ล›W๏^พ}��X๐`ย… †{แBƒ จขcว*Tpเเx%ม Tค  A‚&*TP @‚„)Ng‘0Aหˆ�m฿ฦ�Vq”T๏�ๅcดฒมp�ฦน้ภแ‘2,|ฐ6วฦCี�๕SฆฤtŒ\้๊iไ:8Q:$€4oHื�ํ�ฟฉ˜เรŒ’๐#็ห›/&ช‘1น†่€ๅ„šPมf๔ฐ!f�เโ 8X aส˜" H๘Yc4exเcฑE b…X๐ "Š ˜EšHเ‘„ bุF%ถจ&8๒มย…cจฐadšH!Bธ€ $`…rq‹kธฐ!‚fแมฦืds0ล๐hง|�ศaฃŠฬฤdล‚@b5 € {Žpก™ 4ั'�-–ัn…[าั$Œ€J „GภIง~B!ศ]6แAL%lฐณMSOๅ๋‚VbŠ0†HAMƒ๐ิ“ >�TP&8Ž\>‘†ZX0!ดะคcด+ p๊ภa {$YI€Aฮ9bZ`0ม9Tฟ—ฎ‰0(ี"…yก2ภเว ‡EPtfšf1็“8xภ+Ži…v)ๅ… >ˆ†Z&ˆย8ข dณp'ฆุฏฎ่G�สxc‚�ฎ@ˆ„ŸB˜DB8มŽ=พxย‹f๒ti>–ศขE:Fฑฦ)Tญธhฃํฺ-•z~3ๅก�ะฦธ9Jเ�z†` Š|ธภƒ=๑วz2a! bะ$�้ๆž9ธ lTไเhป๏vkฃƒ@„ศB 2ษfณฌ ใภpฬ๏•(@MสT`_"8FZ๑ๆผsภพ*( Rศgด F—ๆน6ุ ƒ ฬs‹บV๑ผ‰ˆ qจŸE^Šg!”๘ฦ7คเ?R@C ๘; ๘"ฐ�z๚aY60D”กrdA1~เd@จh�๒‚4<ƒ๐4พAJdภ �  H๐๔ฉ….„ก Cธฤฑฌ’HHSG9‘ผเ ๋ DW†X”ใรจ ิB:pแXP‡;� 8แ‚’กGฌˆE-’€‹^ฃ™ธว&zIMฐ&•k~?ˆE<ผ!ัeเรฦDS‘4‡-hม กFwP -€ <@ป@J‘ŠdไบษI๒ั•RูG|ษi|ซอd๓€ีด†3xภ็œxaV๘ˆ฿~pลu4ำ”๘ม $;H@‰�9C1  oPรiTฃ:ผแ nl๓œp E”ษLgBSšิดๆ_9ฯ™Œ๐ึ๐G.๘ังว8ศ™ ิผำ7ค ! @C.ฤฦˆี ˜ว๔~ˆU|ฃ™อTว:bแ"!  Z8เGPƒ“œŒ‡ัฑRtPixB$JQ‹^4ฃํ่GCJOžลZ๔ุD>ิ�(dโ,q9 !๚รI์P@p 3ำำCญเ-‹ภ )j +ˆ&”Bธ&ู4 'u;โมvด๔CซˆWม*ึf’ีฌ0@ซ<{:X`n/๐ู=1 B]D2’�Kฆ๑ฌเ‰จ@ถa‡ ˜ ƒ0‡XR/�C \ๅˆ�~@Šuxใ"€€ธ ํYไ!/4�ชvฤ•ฎ์ %Ž มช–ตฎ…ญli‹Aย67$5ธ†r0?�j ิ ” “ƒEC|๊>ย6๕ุ* ๖`B�@๒U!|C’e!†'ฯิทพœ่m~๋๊ >ุ7"0ไBภ๊^วฤwพฮE๐sฃห๊~)}๚“Cuษ2๘�oh A!ภ ~แƒ‘dิ๘Z†P0#GE,V!‚Bxฟญ+4คภA>ˆ‰Qฌโ ฐุล F2G0�Š้~เ‰๎ ˆ$ฏ wW๎:ฤgpา$ภย j @€/ฉ๑ hŠ`w2}.1„เทฎtฝ1;œื]`น)๓™ำ|†5ท๙อIFดA€}”" Wุวb+  ฉUF˜"F0:ดใsƒœ ๘ˆj็็!ฯ๘†;พัˆ4Œ™"/๐AšPŒ฿r“ฎ�83ฎ�WJ‰ฐฺีฐ–5ญmhf‹่k�จว8ฆ€งEMฺY@*‚ั‚ œ ™PUเp hwƒ คf€!๒แมV„ ๓}ม'"™๖Xไ*}๘5จฑ &€มิ@G1˜V�Šผ›!๑žwฝ™�์ลH๎1”.#sฯจ'ซq0yษƒ€ด‰อH00„๙Šภ Žx-ึg๔แ`xม(มbœaง[!9CLŽr•/�็QH>”2ซ•!,@ ฑ B‚:ย $`ž^7ฃฮHัฝ‚t(�็_หCย�n0ฝ>เศ1‡ภF 6Ÿ‰ุc vD�ร๎wฟปŠ๋ยฒ{#CH„bƒCฯ„๏tO4�ฏwบฤQ$@ณ~ฬว#>มŠว;ใo3ž[�๓ม|�??๚‰…�๎š'}ู๊d๚ ^๕ฏg๋]{ฺ็F๖ตวฝ{nŸ{��;f๗ฝ>a~|โ�e๘ลGพ^ŽŸ|ๆืe๙อ‡>\ž}๊ฏe๚ีว>YฎŸ}๎ke๛�Tผ2�๐—,!๙ ,#‹!๙ ,~ฒ5H€มƒ�)\ธม‡"dHั!ฤRdh๑bฤŒ66๔(dH‘�:^4‰2% &H@d‰‚8OL`xHS$„ �ั*#"EŠ=7BHะ€ฦฃ|เ2A ฒฅล—ะ์+Wฤ‡ช5R่งYนF,l\๋๓ภc๕Œ�aAขรวŒV�:%DEŠบ%ู* มa็Lม9p๐˜ด"ษปญ>nฬqฅIƒ2kฦp0 !๙ ,#‹!๙ ,~ 5Hฐ มƒ*\ศฐกร‡#J„ b@!๙ ,#‹!๙ ,~ฃ(ยฟƒb Hะ`ย„ |QโDŠ#JtˆQ#CŽ14hแ„ )L€|(าฯ2๕”นQ‰1ฃK็ิd้ิ/Œ„š�0ธ4ํƒฝˆa*ิา4.ดa6ฤ_MกDกJeVค�ีกู^4`€K)ำก๖uิ/ฮฯฏ—่อซPŠ•‡ !ABŠปgณjฤซp่ำมL-ฤภธqฤ€!๙ ,~�5HP€ƒ@๘วฐaC #&DธะกCˆ N๔"F๗cx๕ฆศv ฺ0+�ฤy๗2๖๛823 ๒ยb`A‚ƒ‡Iฐ`˜ Aขoถ เ๊E‰8ะน†ศอ›ึ—์%๊ฉ_œฺ1”่๔๎)}้อซP ไB€BE_a"$HHq=le‚ 7;ถฬั|๚ˆ}ืSt0ใศ„๒๋g!๙ ,#‹!๙ ,+~บhHฐเฟƒBXฐaย‡ :|จกฤ+^$˜๑`ฤ:�[Xก‚ $B$H B…H’1บกbขฤ-[)^่BG3F)DE�aBง‡ศไ;ฅK:‹ุ˜0aถR>0„์ธP๊„ตฐ;‚‘+1š@รฒl‘:C„ศsŒJ -ื�ุะู`Iพ:!4,่4oˆฮ Dˆเะ!Aƒ–:^ จตlฦuฐX†ฯ 1l!๙ ,#‹!๙ ,#‹!๙ ,6~\5(ยฟƒb Hะ`ย„ |QโDŠ#JtˆQ#CŽ1tฐ๒กว†3ZิPฒ"ษ”�Nlฉp%อŒ!^ฆฤ “%L tvด๙“่NฃC%Xสณ)ฯฅ!๙ ,>~ 5ศƒภ&„0p`‡*dุฐขฤ‰-^DaB k๐แ๊"โฦŽhเภ…‹DI 9 ๔0‚•ซ/c�ฃธมƒช›9cRฌ0โ'N“yŠTขRฃAO �ฐ๔(Lก9x˜D‹… t๒ไ 7oยbˆกD*๏Lฅ�ฐARฬ]›Qบ}›N / ˆc !๙ ,#‹!๙ ,F~บะ@ฐ A�*T(ฐแ@ƒ.\่P ฤˆbภ0ะโE 3�ใ่๐ย…ปJ๑8(2!ษ†&ซ`ๆƒeห—$€€cH [ŽฌXยš@a9.ฝYัCžฆญ\*'“*IตYต"มฌ[ฉfด๚U๋ิc’ ’น$H ๘r ‚ุ์K4ๆอ\^5Pˆกษ_>SพT๑‚ DˆH์าแGฎi-ค™๒ญั–•'cF  3ฤฯ;!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,b~ '5Hฐ มƒ*\ˆAแ…†;,œH๑ D„-V�X€ƒ!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,b~ :!�H  Lจ!ร Btศ0โC‹%HœธcB ;R�82คDKƒห—X!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,~m%ัHฐ มƒ*\ศฐแA #Jิเฐขล‹1N�˜ฑฃว7NIฒคI‘Mช\yeD–0c"t QฆM›h��ษ๒BN”<ƒ–์ SจัŽ4)]ฒ(ำง “Bš๐'PชX Z™ต+€ญฝb•*–*ูฒP“�[ห–-ดAนhK๗-\จt�ฺฝห4ฏ[พO�ฎ� ุจเ„ =œX๑NฦŽ nY&ไสJ˜ W?aุศb„ ชะมƒ Pจ`†‹ตธ`ƒ‹p34sUQะภ5Ž˜ย‡!Žจ„ J€ˆ& ฌแ# 0@๘ˆ"ไ’MVxใAF"i‘uะ2„ณ™ ฑู€“˜‰ฆjjiฃฃJ๚žA_†9ๆด‰evโฉ'Pจช�สeŽ$6ภ€˜@‚A‰&i'‰คชŒZ2่ ง†rhlดŠซB๒๗U้ธก์aM’# ‘‹?๔0โมƒ~h“N=�ผ‘]ไใPึๅำฤ'Œ8ธ@นเf‚š—ฐฑ[oT0E*้เ{ ิ;s=ใ`ั@฿ๆผอ;ˆจะ…<ํ�ฃฯ@ฑ�N;๘เcฑผrญ�๒ห•|ฒ ]@@ค“V:‚ึ �๛‡ @)อดำ0Tล23ฒจ�xJดัผ}wH5Mwˆƒ[€-6ูHNน?–;E;๔41A๘tฃฤฎศญ ส†ด๓‹ ŒเK;sุฐ๗ฬ๔� !ๆ๚ 04`H=ูuฐ@รs\ ต?Tcมด]9ฐK,9L๐.ธŒP\sAF3˜๐ €%็ศD*๚€แ/8‚ภ‚žภ๖๒ฤpภเะ#(รข ~ฌแA3lก‡)Œม $์‘ˆ%๘Aธ๋โ € ~ฑฉ+|1†1€‚fศมศเ๓Šไ\KฺG-ฺaภ 0`ํxG7ฐัŽMไภ-พHG?�’qm(‡ภF>xq l ฦ ๘,๐AˆœH7�ศG.ด๑‹`๘ุ/ด‘'Hฐใ`6f)D! ภฦ=˜ภ˜ผฑ�ุ‡+xqŠ"ฤp†tPcZXˆ6คhฅ €HเCศb#Bฐ,j‘‹)ฐc.”q‹k๐ฃ!Hคจ E)R1{'€6บ1Žv`cF*วFฤ|ฃํภžT6@ชHภ‡/†`ƒ0อƒ _ ใหศ๊%%‘8วฉ8T �ฐCL€A˜รธฤ4^@�€�#ตCE4๘ข@แิsR( Q๊๖B 0ๅ˜d›ม‡Œi8F xPˆย,ุ`Bส"€JP&i#pล(<€0ฃ|ัA3์ม j“เG,l0 )ุ6พ้-<"kHโžfp5ธฦ<ŽPžb#Eฐโ jLด@B0ข_?$Aด g Cง ‰{€แ ˆภ5h™ฉ$€ ๘ะ. ›ๆ4ใ2ˆLi*1„0n”‚๕$P~ุc,เชWมšษ @ภศ?l7E"ฤชXEฅ*� 4 ๕ZA3๖AGุrดฅ=->ภŠv�0ฃญ@š`gZSGnขมฎแTจJ• „Jภ t๚๘zฐั#Vฒš๕Pๆด `@ ยƒ �ํ>�A^ฬc๙„‹๖ขกˆ` ุะC์ฐ"˜เทH*ดะ‚Œ5$%x8าQ ~Hb1h†~ีzิƒผ๘(Gษ’žT„ฎ P‹Vˆg!'ยรnxภวจA‚�ะ…ผภg„4ฃ๛ƒ[บเ‡}ค!ุˆ๊ก6‹‰ญ บ฿fEF€ –1บรู–กˆ&ไลrpKac t มs<Pฌ hลฤ\vPˆGผยF‰rRิ๑6ฒแƒ‡†�P€ย‘ตS€$t#l�HฑHpๆQ]@ศถูkใหƒ๏ร ภ–™`A๋กะซCY"ฐ |Dƒ›30H €๛๕#ำค ผ6y`ๆl#—7เ›พ‡lภ%ทƒษN.lv๒ะd|bšุ o„@b7A้ๅ-ูปbcพˆ฿}AdTไk(R*ภQLมกะ˜มุฅอจƒˆ˜˜ œฑpGภŠ ‡€ร6ั-ห]bUณ!ล๐B?œ!?๋๑Cฦุ่"๖ฑ๋๘ั(ƒ&ฎ‡0&ิrpT‘{>แเภG>ไ ม}D™Z�จฑ0 h1ตึ \ฦภ—�โ)U`฿œฎ��FY5ณy(๐6พฐŽ$วภ‡ ถpˆ“6oมธฃ]Qคว˜ๅ‹ฎๅพs`u0`ภ,อ@ysU๒ษฌย5๎9j|,Cโow ]„€ ภ†6ๆ€dภภถ๚xศG>ฝ];ๅKd8F๚1๖ ฤ`‡A พn dฏทฝ(ชa{xฝฎ$ศค์qเ2-/Xฏ$ด“เr�้Ÿxห}tšใ>มฐƒ๐�แ๑่€๗HRcˆpฝL งฬpBtCTlT๔…8�š8(h/|ฟ้จœ`]}�ฐdเ†Hqษ๖กฦ9 ปBz?ฐ@!”ฏฝภ@` wโ \ŽR�u๖E d 4= F\ - 2ลMp๕X; Yโ๗$&๖0 ะXบกิ€ุ๐€- eฑ}ญ5hคeCีZ๒Gเ}ฐƒ฿ิOัะ“ะๆpt๖{มG73qT๘€ ดิg}€}�มeเวั�๐I"C~๚‡~5Wf†W .…ช`)P@๘p s0vเ&`yภl!ะO�p ๛ฤ"{_pwฐ‰ v ๓ภKๅ Ž2nยa�คถPcQr‰0o {๗ึaฝ—0� (3|tมe 08คCฎKn๑he!b_ภ‹€ +ƒ8Œ`Q"๖DISบ๑H[4Cp ำ žเIœPrr๐๗ณ+‡๔F�ะ ^๘ฺ@D…�€™Šขh ิF„ฺG๙ะ ๔€^Žิศ@C€*ฝจฟP –ึ ภ€ก๔่จŽC๐ŠNƒ–ใภCฑภำ๓Œ๛;pK‰!}kม‚ื๐ "%pY*b"ƒ) ม”"ต p2!s�rฉRศ“‡(3p$I"#Z ซ œr)ย"`+w" a` 2’#m€ ฟ€+"ะชaPม\B*`าวษr ”eQ,R@"ว–ผทฐ*ห.7‘ฌ(„ขฤ}๒ษก�nษชข-œ4�0รศ=ใ๐.ไ’’๕  nภต`3ำส | QC5sม๕’ จษ3วb”แX Q”฿๒ฮ!PศะE�ข งเ3อT๓.*์wP ู<)@��อแ Ž ำ0s•�ผ �ะI8ฯwใYZ.็’.ุตc ืย  Eภ pัํ(  ณss<#2s0๏�0ฉ1ด0 (ƒS;?&•" e%ร 0kp–ฑdาEฦpn ว ]ฮ็œ�p6๘€<‚ศPใ๙แโ ฿ม7~s Xƒ/ผฐ5ˆะ฿ฑ3ำ•ล{ฎcน๐F๋| !•ะ๘ข Iy3๕3;ร์b1๘ภ“ [เฎ”6[ญ)ึ=ฃe๑ีัrดl133ใpฮeืuฉื!6 .€ภŒูŒ–u2๘0} CPุ�pุIย?i.C<Œ@D@$B$4“p v>็ Yะ Lโ yภ ภ�ฃฎ 1]๐P bๅฦ7 ข� @c๐'b๕€โC>ธ‹†ไ ลใS>วOฐ็ภ#@oร]�ตŒ›šะ�!๐�ั=�4AtA0 ๅ="Kๅ„† Žc0ืPnศญ>์ใu‹N็ึ ม5>แn>žฐi@W’`e ฤ@Aฉแ“ะ q y1าi&ๅภ‚P,` `ชpsWเิำณ]๚"’y@ี a�ุญ�‡Fp „AฐหQ4EUTwๆ|ๆใH$IpiดF้ะF๕T๔ ึะŸ ส€YL[VโฅC�๐ ต#b๚ ๊œ”@$DD�ด N๐ฉ ฃิฐ4\Eฟเ จฐŸช”ŽฎTnฅ$ๆPฺ‹Eจ˜C;ิC<|Ž~� :ท นภC ๊,ไBพuุ๙e็ฐ4Žž70M๘PM4$LŠQ๑HoฎเA. ๘คO/Eฝ@ ใฒต@“‡^Vf,๗ปะ 9p7๛ฺž{'O๔dO>PNŽO>QO๗ค�๘ฐึBฦ์ ภfHW /ะคำฮึŽํณตQ๕Q'Ot$€ทปB๏.@Aฮf้ลO�ˆ๐†๊1–Hๅ>G๑ฟ๏>p(Qซ7QฯwQ1๘8ฤ�+2`R<๐]ขเ .p `pฐ ?ŽไJ�:7PjY-“ˆ๊ฏ<�ํแ.šเอ _ึี`�Sกๅ‚๐ญ< ื ‹upj… lๅVaBXฐ@ภๅ ฮƒUS์Š`~ฌj„ุภโ6`EuTIu‚&ภ+๐่3าdะบ 6่ช[?ŒZYeญดnU?T1H๗v/€า เ;Pส›ฦดŽqoSx `Gฒ@๘˜ณภ$ส…ฏฌ`V`’P฿๕ๆ ๗3U็eัฬP6ฐำฉ aะ|š€‡  ๏€ ุเap&Wฎ��ฝๅe^Cภิ๘�็ีซิy_ส�x0U่๖hŸo�ศฏ�้ภ�ฮo ๖`�a(€ฑ/ ŽmโัภB† ูท€l์`i ข)๊ขaร 1ภ)"peŸ”%Rดˆั0Ž(ส5บะ!B3Bํผ่วBp`~ฤ(ฯ5†*ˆl)ใ๗x˜Hโัฅ๓@5จ ‡ „ธ@�๔Kฃ2๚Y0„v)ๅ…Ž>ยr”QL แ€Kฬ๑มรl^h€\ˆa!�„ภ Ž (ย–„จGyœท9D…้ดGชถKลเLHเƒฉoุล–Š>:้ฅ}ˆเ˜ X`๊ช)ภz]ฎyH�“„{mA`Hม „ฌ๛8†mยNiš�#๑s/4หะ“เD'�ำ}Xสก?ธ้เ(ตtฏถ0€`ก<1‡‡ฅ ํั!์‘ใ‰ n„iํb ๋ž—*า๕ฯภ›์`ืm†2 แเตBฌr๎0ฦขCsŸ&จ‚Ÿrฐl …�ฺฯj๚`0H_K๘q"˜IX"H  ˆ`*4ฐ(pิ(ฬ�|ภ€o€ำ‘qAฅภCŒH@,hึฟข ด๏} aภม Lp%>ว่\8‚(`ƒNศภ�ว์Šภ@(8(lcEAgเ1 Q{S xณ~„ก๙+GIˆ –๊@iHPฯ<4>,! ๅP–Xม DŒaื่‰‡์‘ˆ.@b€ฒรพ๐/4ฃ‡�cล2�ภ…์ข.ธมŽš7 ะง˜ร์เ=žฅ(II?ะƒHย>๘0 ๑ั€T๔ฑ‰.จ( ˆT$#ฉ<6่รe „0ะก™๘Bš๑9ฏ๊}��#ฅ๔คบ>(ล6ภ‘๔โšทคิv๔QŠ,tA3zโM^yp€#ฏTยP !€์ม ๗ฐ‚H`ตYer ฺษีฎ~B˜HNฒ’]๛™9ไ0†7f}8-ธ?่่‚ˆ>๚Œv๐ฃธฺQ๚�"”ชฺ่#DR€A๔C0ใv๚wtฃสุ r‘ŽeะขํจKbฬวส0ฃภF>บA!ฐ�ฐG4ถัƒ"$,1& B๊C0เืh‡ฎภb OเƒFมัŽขถ*Qช  ภํ่ฦ/<บฺตเh`�z๚ำ1ๆ๓จE;๚ัˆฝuซZๅ๊^ม5€;บ ศ4‘๕E�†6าA@dศC๔˜G=ภˆฤfศP=2Q5ะ1%่ฤ;าQm�!่มAh� !O้J ”T๗บ๕ศn �๊�ุhbIล6–แz�€ัฌQeผมT๘mp‡‚ุ แชา€=TปsHม^.ะ?hรใ˜รฒŠ`ด '่F&<บ—‡(('!ฐ!’ฉไมภŽpVŽ9ุ`n/าซ5ฺัz”ขj ่D…ƒ‡cซญ๙Dึะ…w4,TฬGภ�’€Vแล 9nr—�� ]Xo>Lแzโะฐ๘pCภra_ศ_wว/lเEolธmBบ €ุ>‚ซบh ส{ษ๎ขน ฦฝจIx3•ษk€†+ผ…`€ธ7@วส ‘žน )<๔pกฝY‹๐จ‡|8†vˆX ฟ่‡~ธ…–’ƒƒKภHฎn˜ณฤU (๘2h†บุพx๓8˜Šp}ƒสำ7~›ษธKป๎ชพdŠ+๋ฃ+๐{p—ฝ๐€'˜(ˆ‚$ฐ‡๊๐žd„ฒp‚(ศ•TM@๔€<ฎz7`H8kพ)HึสZ�ฆ ˜‚|ะฐ�(ฅ๒3.‹ฏืŒอ๐.๖b.ร8�dญw8๐€๐ƒ๙๊†?0 ห‡&@’!ซFุ€630ุฤ`7๖„๏pt่Nฺ่|x@ (#๘ฐ~(…ฌ หcะ,ภŸ๎ . X€ˆข€…~(ภห P…Q`ฌs8‚ฐSPB{mHชบ�Œ4Sั}่วฐ;‡sƒea0ู*Qœฃฑi‰ณDJ>,H€‡เซ;จฬ!ิ\อ!ตC๑๓…1ƒI๘ธ€(ƒ)ˆHเ‡5( #ภc๘ƒ)€ƒ/H}{)ุV\ˆ่‚/€‚ำH'<ุ‡�Mศ•h๔ฐƒz.๐หG2ฅ ่Œ~ศ‡\ะ†_่า”าdH‡อโ)}ฐ‚จ‚€•,|ุ^ะ†|p‚`ภ[ศ…\`•ž@–ห€%Iใ"˜ ๐~H„7<ฤ๖ก…: ‡0๐…p(ซ+ดธ„2ห†#`5๗บ๙ใฒ =‚ˆ„s๐ŽHQ๐•ˆ้Q‚๐xเ$่„}ฐ@„zภฆkะ&K89@‚Tะ0เฯฝุพ˜„s`‚[ๅpk๘� jฑ˜I"ฅWJ<ยฑำก* €ศธ‰๑„Zเœ  € }#kฐƒp1ก€@Kส๐š�Žธ‡F(‰mฐฑ‘,ฆฑบ KรLhŠsธ~ภศ{pCุ304@}PจซF€œh%Sุ6่ ษา@ตไธ|ตย -CพธผSุƒwpX'ฐซ ‘ฮ sž ˜?`$8‘ €*แุ‡8ŒY‰กƒต„ง!€ธ‡Dะœ(วะ“˜�บ�ษKฑะvธเ€Xฐ#yWhUฤาN!ญืศย'ิ€”โ–r)UฦT(_0A8€[(*O8YN่รJฌลขกฤi|0ƒJู หІ`p…`@๋p+ธ’+Šภป"๋ทlŽทย<[ �ำน4Kมkจ0tmˆ\Eยˆ งm 82›H$}ƒขถ่(งsสฆ“‚่nZ ุƒ7๊Nเ ๎€ y็��� ;]ถุํ�ฆI8ุา.่„~เ‘šfnn๔Zฏ๖z/ ˆMฐ์๚ƒeฉ€,เ…๋~Vœฐ`P9phไR.ๆr. 8CH‡tศ…อbQ๋ย.ํฺ  ศQH40†"ธ0„7‹ณฉะ33C๘‡?P๋\/ะ†L N0ถth‡dซ2~00ใ…ฐPy  ชนWSถ๘ปNๆชYkตQH‡_Npฐz0๛"h iๅZQ^pฮ‘่ฐžIกร3เ๚๎o ร0 ณ2?Hั๋€$ฟฐ �ฐX4ณว7 Fุ๐€G`ืV€(05eˆุ๐r�็R;?Xฏ,oๆv๓ษ}๎Qจท๏๛Eง;Q์ย~S๐9รƒ= ˆป†#XXฦธ‡8xฏŒณพg –ษŸ�=B9=xธšK9 Pพน3€๔-เ^ใG‚ณน+a7�3H™=�k7ท9…ซ7p�๒€J๐bแ๙sŒฮ{0:พเ€H<›Sย:กƒLฅ=c: ˜d+‹Laฌ๕JoEN๗90FูฃพซG0๓oGภ ™?Œ^–IFD„ฟธ‚'ี‚Qฐ‡&hไ%ฬม‡ูฟlˆI‚WX|0‡ˆwดฟOfA๕c€x}@๓ @�ล…�ฝ๘ฟˆ๗ฟz_@[ดxˆ\p็xส�ร0„wh‡t…ฝ�๘?DฟฤV0’™~DX‚Sภ†R*ดB,™๛ฝ็{ษ ‘`๓#๎:Uัเ€GP†�khฑ0๋ehฺœ2�pˆ:Hจ�้๊๛ฯ�R<๗ก๏TbุRฺ.'kE' X8xะ ศf๘.๘8๘�{า(RัX๘;ัข‰ะ7�ใวDฐMช* d‘†›ฬก‰…วุ‚jˆ8ศ,pc0ŽE@†&จล†.> เWmู‚k™่ —G�๘—�( <˜10ƒx ๘mถ:d<ˆ#.šัค/‘–eJLฌธ•NS˜$L$(๑\บj�Bเ`ื&[ฎ)ฑqก#ส”*Wฒl้๒%ฬ˜2gาฌi๓&ฮœ:w๒์้๓'ะ BY^่ฐศ”0CRtpก&) ูงฆc6๖˜88'ื'ithฑ ธ@‹ฆ~ฦHฎŽ)๖$…!`ะน#‹hม0b(เภ‚.l๘0โ�ฤŠkb่ˆแ$Lณ„ฝ่@"† 1ถ "Aโ(i>ผr6-ย,sŸBpx€แ5ˆcญxุU๊ล„ั่˜ลœh๊..n�8๒ไส—3GเJ?ipพ”y3a‚ kˆฦ บฦ‡G€H� ˜ค‡v๖|yโ*—ๆ๑Y’ฅœข!|eอžE57 x ‚ ๖tมQคR?๙˜าA ๘ก?ใฬAawะ3Qไร์Œ?�ะ“ !Pƒ&้๘๓N7่วม5` (๘#A 9$‘@ด@@ ‚u5$*4ูA จ B Hษ $ $“ๅuQP (y%ฌqม1zHU$œqส9'uฒ๔ฺKMภZฃ‹ู9(ก…z่€!Z“Ž(C 'ข‘J:)ฅ�s)ฆ˜BPฉJM…ะAcœŠ:*ฉBfz๊ฆฅชบ*ซญ*wjฆฉบ:+ญตฺบฌš�บ+ฏฝ๖š๋ฅฒ๚:,ฑลR ์?ยป,ณอ‰ฌฒฮJ;-ตหA[-ถูj{ุต�z๛-ธ:u.นๅš›าธ็ชปnถ้ฒ๛.ผฦบ/ฝ๕ึ:ฏฝ๙๊;*พ๛๚๛ฏก�<0มp \0ย #xฐย ;œ�kใ๙0ล3!๙ ,#‹!๙ ,ุ t5Hมƒ "จpaB‡.@‚ x€๓ฤ†ƒ>€๒GซŒˆ)>Hะ€ฦฃ|เ2Q)ฐโ4๛ส1มกf<อส5baใย+ฦ๊๛ร‚D‡„nข"…ำˆ!8|ฬศก็Dˆ!Jd l@!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,ุ 1� 8มƒTธแฟ„ !*”ˆะแC‹ flฃG‡†|ˆกคI R!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,ุ 5Hฐ มƒ*\ศฐกร‡#J„ b@!๙ ,#‹!๙ ,ุ 1� 8มƒTธแฟ„ !*”ˆะแC‹ flฃG‡†|ˆกคI R!๙ ,ุ•5(ยฟƒb Hะ`ย„ |QโDŠ#JtˆQ#CŽ1\ธะ *D€|(๒B”T๏�ๅcดb$�ˆม๙๒ๆKM…fI{Aข(‰Ÿ#4รฤฃ!ฦคK›D๚CPa/:ศ€*†W๚I�Yๆื‘/๋ษ4uVร“Dˆศ๐ดชE ^๏ๆ• /†ฟ€1๔ !๙ ,ุ�5HP€ƒ@๘วฐaC #&DธะกCˆ N<‚Bˆpฌิ‹ z;c› $€’–Cmfzxpเะ†Š,๛ึ$ ภๆžฟื_ธP๘๐>5‹ู์{์rm� เ"6ขฎ y๗๖ํŒUk—ฎ_'ˆ%k–ฯj•D๏XC๚&EˆฆFฃฮม€Aํ† 2%dฐ‰S'O!€vH\z>% ร'Š�ึฝ+Q/_‚ƒ N ทณ็ฯ C‹]ัˆฟจๅแ๋งd+W,HดMษํZถq“ฆน@P—+_ Lฑ๗๏เ&ะภ  ลN \้ง ฒ@ใภ๛ '.0๙๒ๆฯw‹�Oพผy๑o'v`๑›{kžมbิ(ญ*T่ฐยY 4 •"˜`B H มIิ&P(ะเƒ)$ค@ๆงAB๐€ค`‚@!B€ ‚%` W*๐DA… h%‚ 5Pมˆ O1ˆŸ~lภม†~„U~Vxโˆ%ฆฐรQ†โ‹Lv€ฅ0]a(‹6ฦย•Yโๅkb)ๆb~\ U Dก…Iฆภ@*ˆ0&Ÿ*�yแG‚bัย~กษdยศำ—aBธๆk@ภ`ž็eช้ฆœvช‘nQpAW์#OŒ0ส5๏๘3ฮ!๐�็ท"6` ม†TใO?ส(ฑเฌทิskฎ๊VA นคS6€คภy ใO=ใธA ๘ฑฬตสธaิฎฝ�์‰•ะฌ?๔�แAœ๐H.ญๆรO  ‘ ปŒผค~๋๊้มีิฃฬ)$`ˆ1-๔Tม ๔”eA �ก?๏, TŠw€ใช aIฯ8ฦF+บ้ฐ๋ฎb! ภ„Uฉ\ฆจภhS-�์ณั6ะล;r\ภ่๙4aดณะฆฐฌfยLPอฺฝผnป๏ชฬ28.฿œs›แDซฑธ^์้�pว-๗gตู๖ะY”*ค�สŠ/oŒ1 6`ไ๖$ฒ„๚4โtฬ�มลpฤvxโ‹7~lบ]0BSD ?kTเš/c~<่uŽYtาOะ bผkfDใsr…<@ ุE+r@์#i$Acฐ ;dภ„9|‚˜ฮฃ๐ฎฝฃ/Hภ i€+ฬใ[Hวข0"ภmƒู† :c~™!‚9L๐1ผD6^ะ€ เ‚,8ำ#ษI:D™Iี€*์ ฐ$&7จ:ฺฑฆ…/Š4ƒ<ุ@;๋x�วŸQเ1Lฆ�ไˆmv3Xšf5ฏ™Mqฦpmฃ‹จฤŠZ๔ขu ๒DSE‘Mฑ@-Zมƒ`ฃ๕๐‡?๐Q„`ท๐‡&ยะ˜ ื@ฉJYJฯ่&%x8าQ ~Hb A๐pKห๐ !m`FlS™าิฆ1กw $ภ ๖!‡…„HXJU:!LR$)Ikะ ึ�,ช1ˆB>ˆ0l์a ๐8ยCะบ5eวคŸ2 kXr"๎€|๊ข:U,ขซ_%ฉ@ํB‡V „АŽฉy  •จFํ‰I๕;lฃ& @P‡ZTI�   •ฺ,�ดสUฏ‚‡vชTmPณ๙ 8ี้JหัำŒ:๗นะ="หโD ไอฃ0ฅB ื’6ฃN ˆ@:1`Z๐ž1„EW๐Š—ผt๒iz  pิSpŒv�tธy0!ฒยช,ำ�๕ขห 8๋ฤ1๊ Xƒค•$ซY€ึ~ิB šงญ/ฉ�ล)๋„ƒะ#ฏ8-MทQ ๛Pƒ@‹ฬ๚q๖ล1โc๕‡๋‡-Rgs;ฃ�๛เ�K์ฆQ,ญ |ร™+xก|™๐ฑT:.xภ{ฐป2‘อiัŠ�\ภHuณ,š3๏ะ ฐi�  ้ฐ@—@๓ะ2า‚๚G�+%ะ ๏@5ๆ3@๊“(๙ฒ/๕ะ \wๅ๋็R๏8sึ 0o`?ณฏQ\`V Qะ*ฐ๓ใฉ๙` ำ-๓*9โพข0โยะญ๒ฺ‚ฑ„M๘„�ยฉภ1ฆ๐1hลบpƒท>1P€ƒ€‰'_€,Kจ ึp-t่่๏` 0 „L่„Pธ…‚)ะ-W�.๒fh%p†ƒ˜๗๗ฎ€โท‰œุ‰ŸB]นก ภา7เ |#K*ะaฐ!C2-ฎศ?qb!CBต1#ถ๙!EฃฐM*๐!’'8‹>2‘"yRM>„2"‚)ฑง(DฃŠ9 OHR%N   ข o8 ๒"+ภ ๘ิ@rŒ�ย%?RŽั๘IฃŒbุ๋Žณธ%๘ก†2`iเbก‹5ย‹๚‘pญจก„4ข-€#Œ”") ๘ศŽ เŽ๖๘SB"&๐1�ศ‹า’ท่‰$Y’& ‚‹‘’bV ˜6มHrท5ibใ�(G+q“‘“แด[ฐS ช@oG4ม“)q7 ด๐ 8rใจ”๛ัSฉT$+I~'ู•^้•,y7้A3)C5้\P†ะ ๒๐ฝ€XUQภ ๕  C)]\๙•|ู—~๙—2q“%X ก ‘—ๆ1]Š ˜Œู˜Ž๙˜qƒ˜็ก˜‹ ™–y™JG5–(1–'้™/ม™)G™•‰™ฆyšžข™Kšดมšž(š*›RAš|†šถy›ค‘ชฉšญูDfฑ—ซ้š!œธ(!*งQภ9ป9–ฤ9œฒนดyœธ9ิ9šถฑœอ ษู™ู9~ฯ)ฯGา�ฉœ�ุ๙’�Iž้‰ัY7ี๙ž๐ู›บaœํYuŸ�ั๑#"pยs"_ ทฆŸฑ"&@!Š!๒$๛๑&ม่žษRŸฦ)–oA]*Gก๕9โ)กตน•mA™ฺžๆI›rjก๚’๑ูข.Šœ๓นข&ช˜ ฉP/๗ย-%s24ส๐.g๑ึ06ภ+ีภ",Sƒ4Vเ Z˜ะV ัก2*œ๊กWช›\iฅ๔)ก ช"บข+(Jข*ชก/บฆlšฆ[J™ q ก˜�@ p๐o๐๘ร<๛Cyt '0M Wค6`;“S9$�€B*D:8ฃ ฐ06€eภภojฆีๅฅo:ขธแ™œชฉaJškแฉ&สQhชฆnŠฆmฺชmšฉ๎Y,ภ;ภฃ˜0 าš„mœคmฯไ[P 1๙€.p T`‹€ M H GsฤOxไ[p ?๗ฤdซ:ชvใญ\z]ฃ)บฉฑ:ฎ็ŠฎคjชํŠช2๊ช๒๚ขํชฎ๖9] ํะ๘เ6]ั“„%cŽeGเอš ‰ ห ,0•3US&`ZดลกPฐ ๓sญJ„โ:žเzฆšjฏๆŠž){ŸœY™ Zฎ$kฒ"๊ฎฅ ฏd�:ฏ8๛ž๕ฎŠฏฐ@0a0)ะญuฃญ‹ซc๖ภkน๐ า@๏vฑ่ฅ^ย๒eTf[$4็p‹@ '_#+›กบณ!Z›/‹ฒœjณฺ้Dk ฆเ๊ฒฌบชขšณx{›e๋žบขpค™ซยฐ$mšฤG~„eฮ0 0 ๆ๐ ด๘‡ูhAp๙ k1Q` p Hถ{[ž4ซฆ็๚ฅฐสฎoKฎ๋:ท{kทw›ทฒ{™ชบกฅ[7yำางe๐xํ#y<ฤ…“ „Xวsะ#uฝ7{‰1Hฃ` ๖ n8บf›บทช��บชถฺ�ซบฃ ปง:ปๆ ™|–ฅ$บ˜lQฃฉROX…S8 ‘w@Cภzล`�˜M{ุ€๘.'&uฉDรฒ�Zข™ฺฝl{ฏfณ� มๅ๋บ฿kบXzณ็�มŽYป์�ณ้‹€ป`#ณ่Ÿ๑ุŸ(BŠ"‹ ๎HทH‹เJฌ#ณi ยคkมcšชZŠถข)ม+{ฅv[œ[๊ภ�๋มL�—๊›0มข: ล+‰ข…‹p|V)ƒ„Z@ บp\ลฉ’w›eลทRœฦนaฦชฤkฬข, Šk์ถ�ฤxœวฬ”$ ,0x๚k ้ Cp|ัวz�์ฉ0Zวฑฤ‹ษ็GX%Mq,๊ัšt/ฐ ri’ แR ™Pษ†ษš,ษ›yวฎหMŒ%rดOะสฑNะสR0 ฑ`ห0 YนตŒ€หบ,ห2Qณฬ�ฬเGZ(œผ #'รXถ/ ฯp A ฿๐ R0อค JฐฬปLุ ว/ ก๑gฐ”Y\ฑP๗�0 u.f•\๐X ๎เะ NฐฮฑRสเสา. ำ$ ำ4mำ8-าR=ี9v$ ‘ช™{pฌฮ8๐ฑ� Yœ?0 รp*๒!๐.,ะ-๐ิ๎@ †0เ<Žlึc]ึgึk�ึT}ุˆm’/w”#\B$ ข:ย#๎ˆ7^ฐV`ข>๒-ฝขํ ”๐/ q`ฐqtล ะเ ิเ @ิ๊เ �ภ ฐ� œ ฮu๓ูก=ฺฅ}ฺฉฝฺฏ�;ลœุฬ��5‘*ะ‡นภ#;š-Hอ€ะ o0 �ข/�คึตู-ขaซ๐ ข-ฺ๊ฐฑเไ๐Kย>เิ`ืvA�ิ iiธq�้ฝ�๋ะ�๏฿๓}�๑:พฮแ.0๔ภ=ฃOํใง�<!H ๚ฆณ<€เ<>งูœ-บ๎'B@ ๊ํ�V "z-ฌ]7 ัi0 ่ ฿๎ภ๑ภ์เŸ`ฝLไโ0.ใข 5.7ž•ุม฿:แV~ๅ#แJ› HpภLœฌa{๗เlžJขDJP^pง2ณ1อ?@ ๋เ ? เ\ ™ฌ˜ ๑R ?ฎ์ไD�ฮิ@ G OLฎtn็xฎ็|๎็์ผวSฮณ๏Šๅœ�้!b9`ี%อXGq9‚5˜ZHะ๘ฐV�ะVgAIะด|Sๅ†ใ฿ภึะ0P–ฌ˜/์มฮ ‡^์E๎ | ์) €;ฎ/.น๎ฝnษnŠฤ™พ�žžํฺ.bd‡@ck๊˜q™Va&^APจdA3๐ >เฬ‘0@ ?a r9ฮDG ฑฐ "P�€‰^ไะ |๐๏_ อฎ้๗>๏๕~๏๙žอโkํื�ฺž๑Wrก�%ธ�ๆGaฉNkถ†k  g‘€/pซฮŒ�ภ / ,M ฿Pฺ"p|:�บC๐ฤ^ไDN๐์ส}  X^*๓4o๓g€๓:ศ๑�ซ๑XŸ๑uW YP*_พdภ�แ๔C๒๚ฆ#0tะkืvow ๘บr.GŽ Dฯ๐ ๎๐ 0_/เ6�ล่ฑMไ�@๓?อFปรgu๗yฟ๗}ฏ5ฎk๕�รYฟ๙ฬ3๙w-TุŸ~0ฟ ฑฉ -01� nุpS)ธ‚gม†ฺ‚บkฬถ�ŸฐึPฅำลฺ}๐ ิ@ ›ภ`` ิ€ล ฮ๛Žฒบ๏ษฝฟŸ—™>ล2ห๙ฺๅ๚�ไ5ฌยฑฒ$*–’#;2"”=๛|"›.GC`ษ"`Ž๐ O™ ใฯะ//๐A‰[ฑ3$0h@˜! 5`"Vฝธ แFŽ ~t่$H†#K*์˜RๅJ–-]พ„SๆLš5m�ฤ™S็Nž=}�T่PขEสDุ!้DŽ›ขlบั้J 0eGDฉ'งj มข˜#'$^ฌ2๓"…›b…2ฬฌzubึญ1vผหr๊L’G��X๐`ย… FœX1เ/๋์ะห 0ร๖๑A‚rs^d!ฅ ฮศ“+/~๚๕jึญ]ฟ†[๖lฺต�9ฒ8 “)ร!‰�ˆธJ‚ซN�ฎ๛ฺFž\๙rๆอ?‡ฮ2ฒM /DˆPqม็๔ุ{ฃ^�x๒ๅอวไšQuะ็�๛l�^�|๚๕ํF้h�๛�๕ฎ๗/@$๐?จ"@{8แ วฌ้‚ jุฅ”†`:ภ*rXHฟŸ๘+ะซ<”๊8ผF"ฑ%“\‚ ฤžP$FW ฑFoT Dฎ่G—ฬห& ซภ™\ฐF ’= ”ัภ„.ฬลšฌœqJyาศฤฑฌrKว$< (0„˜h€$ศ *จ@ฐhAฎ4`‚7ใ4! $ !„`H�)/9:†ศกฃ €@L0แPฎ๙ ป,�ƒ1TQuJ)KUTรค1ตEE“ลPERตCUฉLiึ\ฃdย2–9เxย„�Zjภe�ฉG7ๆŒมš๐ฦE“]ถูg+ˆกTXก„[lIกี”J๖arะŽก ฉ€~”Qย„i'฿z๐๙ฅ�^๓ซ๕VZG=5เ6V1u�2aY†˜เ^�ืื†-6ca๛h)C„R8ถ#,9G,:้'Œ<่แ W\ !„NNyๅ–;A‡fIA”hv0ืŽ<O8’0f�เ 8X �i#š!Œ v(tฅˆฟŽุkฐว~ีUS/Ž๕เ&a"ปmŒ^{ืทฦucป๏F-hx$p2)ขdŽธdš๘ bภ�`UhถYย G\qB€Œ|Nั H0ši7บภƒCjก‚d 4ใเšั#แ†ซTทฟ๛๖ฑ<ํ'แNฑ`ฒีธv�๋–๛wผ—gฐ3@cŸrŠ0•Nž† �`ฆผๆ›-ษ~๛๎m˜`‚Iฐ)ล‡สBg๑‚n@“0Z กh ^ืƒ‡sG˜ูLEฐU%Tบ3฿ฦ0ฦํSฉ" ‡'ผเ• mอร`�Bเก�ๅh l0บLN{เž๗ภน›๎„)D_jsx[ M๔ร๕B`็ฟ.5ฐxฤปแKeภฏ ๐ˆพฃ`๏�uฅพXI‚ป‹ข^heๅiะ‹_ฌษฎ`ŒzใTฃ]J—4€ธX 8เIภi"ม�๘ฦลเ 0–ฑด r#rI$ฬฑpเ˜Žั �a@ˆ|xfRไD'>“ต2"ภVJ ‚ศ$Ÿtเฌโcส‚ั•ฏœ nq !จ ยi แ์‘ˆ.@ขq(T#้p Dธแ yผฤ.{๙หšๅแฉำย5�ฐ$0ญ„ �HฤะH;์แ O๐B30Aฤ์ยzร<ETBŒํฌฆฏ(*โญจ”พฒ็'Cy—วฐRK๑„e@ส‘p@p,!=ๆQpข\ม@ :๑Žw˜ ](ฺ€%ไฃ6[@'ๆ1๕Xp#š๐CXฐ= #ฅ๔ศธ”า€.,๙0ล:๗YOx:,‰=M›*็ฉฤ%์P�))ŸฅUโณจ…*T9ย Cƒ™B๚จ ชภูศpt@o*เฉKtสl@ˆ‚ภujฆ” ธ้`u๑ศำ.้ฮ“@h‰NูT�nงบ `[ิฅb‰ฐMZb�๐ข:Yส2ฤ„%ำHษ.�y4yˆg‘27ถๅhŸ�-i%๋!๏๘TฑฆอUIbห—.สVž’}Nพ"Vึทฟ�์K‚›œฺ~(ฐฦ1ib๒Cลด�ึนฯ…nx๒"ล๎5ึU‘Hขป]๎vืป฿oxลห\๊ึ็Lšญx(€�1awผ๏…/~Š{ LBศซ{๊ �ถืฝ๑p€qาสึHˆBฅเมN6P2`#{˜?า฿กdฎGเ๊ษ8เfุข# `ˆ‚แ@Xย�ศ‚œ=’เ ๒W8จ`ƒ4๊ฤล๒๘G?dLใŽXXเŠ†9์แใ)VลNฐ€•�ผไ‡1ฐภ 0| เx„เ‰'l”xข…๘O6pH`G๐W๎ภ/”8ย[NIใฌŒๅ๘`ฃ E?๘1ใŸุฯzๆsGบ�ๅ™ฬfFs“›ˆ9_9ษL†tw฿:ฒ\@ ภT*๐Hน† Oo’ภศะˆ!gUม(`'Jฉญฤฃ“R๒้Vง umบmC€Gๅเ €ฌ]ญงู™๛จ„[†ี‡`R QW€A=ฒิ$vถ›:ญ” p`อธ‡lF<@€€-U}้Vซ€M”@Uฌ€(XCคภ$  ๘Eป•†�]ู&PJ"ร„cว Yw˜r›�ถ�4G�๐+ค�฿๗6ภV‹ f๕พwพ!๎Vaƒ๕PฎvใUำ„ƒืซ�ฎฎห1>ฉปš�ำšฐ<ย�xว8Šฏ๖อำƒ!ˆhณG.Ÿ๙@m‘! ว�8?X์0–x๏{เรYฐ)Cp๔ซ฿ฟฝ�kœCจ†฿๋@จ™-ˆพ้ซ>ภx€t(hh‚ ศ|่% ?]ธ#Ch‡_P _h‡9ฐฝฌ‹)O3ฏ๓‡†‚0„z๘…6[ศ›ƒ >ใร๋aบ!์ุ…Xศ ๘\ภ…จ;h>. qโœ‘$H}nA€Oศภ „'€‘ �2˜‚(€~Xƒ6"[ะƒ)ƒฒ6ุฅ%๐}h„ ่ภ@ฅa1๙V๐…1P`3ศ{Šx…/ป Pถ}จ…vร `€Eh‡w่lh‡Mศ๓…t่‡dธ†mP8lศ^ธlฐ"BXภBศ:!’~ศ‡\ะ†_H|ุ^ะ†|p‚=์‡q`lฐ;{‚(HPภ†{`iณฦZ่‡}p^8…"ศฤM”CˆJดฤƒ0ฟ6›�k|ะdPลF ๘ล`ฦh€(ศeธ…k€Bธ€xG‡›1…ศล]Lย€l่†qhlุ�ฐFlิFฎ˜ˆ”HŠ€MุkW~P‘PGƒฤ@4c+‡Xฬch†v@B˜|˜sT@|๐…!ฐo›&0FdTFfD1"tJ๗€Hจกwิ‡8จ€ุ;ศ€A0˜œ €ธ‡D`Tˆp",๋สy A/‘ @"ฑKbA$8†@เ hQ0ช €J8Dt…Q๐ pfงAŸฑ&8ˆ[€Iเ‡X`€(ภfภ‚ุl8สำ‚พYƒX�ฆt`. ‚ธ†y8›๙Ll‡"่ล˜กLh?ฑฅ�0์I €hpฦ0ˆล)ะ„{ƒh€ธ†rK2ภ]€€ อไฬXXงสl†หd’v์?…ด†ะkเ,P€*เ{ุ่อ฿|Œซ๐ศ~ฐ„H?๔dHะฬอ‡Œศ฿@ำ<6Yfุ‡m€Žyภฑเพ7�ี ๐€?ต(_�7ฐTUUV๕ตๅุ๒ฐหd๘Dย"ญ! “mฏrกŽtRฃD`_๐ย9Y5#@…ธ@‘Hpจ˜{=fเศฏh†:p“ฑ0C๚ˆ:%"dศัS>ญ’ ๐ศฃT‡eC-/g๘B#ไ‡Gล†.@„}Xƒ?ฝิˆ†2ะ‚บีƒPŠ‚•ƒศUM~‚C|ศ9ุC€UfะI5ƒ‰pื=ั€]ล€t[CๅฎHฏ= ผ8ฯdฝ€emึ @_ภ†/Xj�ˆ8|„-8„ฬ๋�pEฝŒxๅƒP]Gu\pƒP�ล��cะ� i=ใ‚�ำˆฎX˜ฌ‚k๘สƒล‡e [ปm-+2่‘‚lะ†9ภ‡d€pฃภ�ยยŽ5฿๏่628†:่‡โ€ุ†C ธLaาhYkeƒjฐ{ƒƒ 7ศ†ณ‡3M8‡(' iRIhณ4=ฺ�R�O M •จ4‡L"ซลฺZ(6เ‡{"  ^/่‘ Mfp‚ ’ำฤ†�ฝฤJ�.จล9G‚h)=J๎ุlศ„hทXศ€0Cฝผ}Pฅ ื4Pีํ,”;"์์ ศ˜�7�‰Œา{ žŒ`ˆˆ„]ย�ํ˜€ˆF.Ÿแ‡&8๘”ฯ่M พO๘6{‚€O]@ว5nใ7ฮŽ 1อmิ€x� #Hdฎ€b)–€€ uไฃหh่‡Ihs8=)ahl,|ภ„‹‚ฎแธa-�ี๐€.k‡mเ‡Bฐ!ฦb#ถ\I<฿_NํˆL|PH ˜:ภ‡S˜ƒ1ฐ'0๛mูหmธรAˆุ@๘‚ธƒ}H=กำK˜>X‚,(EˆŒขลฒ�ฺC}ฐ…2ƒ๛’ZุCpN' ฎ™ฌ�„~…;๒ีสี…PลŠผศ๋ ๙ำ/`Eภ�ฮ[†t`„ฝ�ำ[�ฝ จ‚yิa‡!8ศ๋O`ONธg90ˆO�‡ฦล€0Dฦn่†" f$0Rpะz X[]๘โh cข~˜ž@YไŽนฉn ‡!€Hภ‡~ภ~h‘#zip€ZศืNธC…ืŽ‘Eฆ&ฉ ศh"Šื|RŒฐ˜�™�J–ลตฦI(€Nภ‡FPเN`hึUK‚‰ึื$�ฤ‰ฆ‡eน่๎ๅธ‚v‡3Cว@Q4้ฟฦ6`๖์ๅภ€0„|p๋ม€ธ:f้.ฐ—T†เฺnศkiGH‡/ &mึ†”๚�5Dnั„ปEำ[tPม�(ม.8` €@)œZฉpิ€๘dผฆ, =Eษ„ร์wุj!1พ‹—n„หูƒvp[ร€<‡โ=ภ:ศปฑ]|pƒเˆj€,…w@Rc(ฌh�ฮ@ี~8; `Ch฿สจ€ ศ†P…ziœ)ทJ 5’A จซ”0่ช�6฿้+  ธด๋นA๙oRva‰ร>ณ„8URSCนŽศ`H:›a๔ 6L๓วr๛“‘!€.JขdW8“K˜cx‹“xนญะs5 ว5†ศ S€I‘W{นWซบP๕Ož’ำN€XW ฉทPฐดs‹๔6+๖sรœ+7A“ธJบ_v[dtะ095aˆJx๘ื3t6L๙๖˜6W›Ž�ำๆu8Tƒu{_7p5yท^'™?y็ญแ`ืcูD™ุ’D_n—อํ๕P˜@f‰พลจ๐• กส “€ง‡X š์:-�๒ฌป˜y[\S†Hฏ))�บ �zŠม‰„ฮ๔Vx=ื$๓ๅสˆื�A๙ง˜ˆฃฏyผ` ž ฒ๏ˆฅ๙ด(๊-—x฿โzภุj ร%›€Ÿ›x{yอ‰ไช‰นO๎๐]_ๆˆภw๛€๛คW๛ฤW�Ÿ˜ˆฦ `ใŽk"ฦo|ด�+ ะฯ2๛ฑ฿|•จ|ห_�ะ}๘r๚ั7�ำr™Ž•GยะoŽึ€ื7“๕2ิว}็จ๏�90Ž-)xๆ๒;฿�๐จ/zฮ�ไOL<†‹<6fˆˆL›H_}ไF6zKJก”ซZฐRcˆ@ท‰ ู™๐ฑธ๚ข8ฒ1๔‰๐‚}๘„;ฒs/”ๅศ๕W ›rัˆ*ุ���วค€„ 2lุ๐ย "\ธกlำx ˜ล/M†ผ์๛ร‡*Wฒl้๒%ฬ˜2gาฌi๓&ฮœ:w๒์้๓gฯว6๑j๔ๆ?ฬฺ๕3ๆฤC๏�ี๋†ย :ฅหคก�ษรื/”|aR&๐Co†š.ฌ้ฑๆO� M8 ›žขGlแ !�ม< D๊Wฎˆ‰ ๆi๗+G�พ๙ไจศ0๏^!BœqiT;Š=KŸN}aแร‰;~ญฑcศ’๓๎ํ๛7๐เยUVจ @ )R@T@*thะย R˜ˆk "L˜`™เ:*B„ `\AB๕+J ”aœ๘…pขv�Y]}*ˆะ เ IไตXcุ�"‚x๘%จzข&tŸ~Ja[C`‚ŸL,tbกUx{ $ด!‹์=ส2ค)งžvิO>ฆภ@ซฏฦZ%คฆ8ปํบ๛.ผ?UC.้ิฃ )pเAศ๘Sฯ8ntู€ห�ซŒ�๐!ี๘ำ2žฒTBฝ�ะ๓‡ํ๐H.กั1=Œd์_๛5๐1!g\2�ฟค0ฏ2งxเ�,ฐƒ�QM=สฬม�ย ?qd qK=เ bWะ�๘sห5!ฎิภเ๘pี๙กฝ�ผ1แJ%Xc ‹<ขMPm5ึ,ห/*่šศ|ภq:�ผใ•J…ั2สI๕�BKฑ 0>šฯ;!ณW˜ึT๕GŒ]ไข7/�ผƒH …ํแ08™`ฅะใ�่rR‹Qค๒Ž?๙œย ;�Ž2\˜ะา๏๐b � tA>�xšา�ดƒ >๕ืฃฤฎ�ธ๋>š์ด๛ะ"ฌ…$ลaใภX b=์ioIQ@๖่ก$„p€ ‰�๔Lะ|Œฃ๏8ลิว>๗™! 0ื’ˆ็ลซ….|! ฿u”a Q€?ึPฐยcร$ฎXโjศB'ภลž<0ใ\๘ธWฒ)`]๘๐€ >๐€7ะ‡(j8†' vHศะ Lภ?�ฉภ์P@@˜เมขฐ ;`็`ยx่C ^ƒ3ภร>6‘M(1Lt"นว&{$b ~ะG#&PAิcะ"Qˆฅ8rา“ƒ($ˆx9 !๚๔�  ็ฐ�’!ŠL ”c8%เ `เๅD,‘e =La N A฿<0‰fฤAหII ็ƒ 0@ๅh�@๑จโไYSๆค Aแ‚@๒0บ•X‹Zไ ‚`„k AุAึXย€ภ„ศมํบ1ฟ`F๚@F:๚ัˆ�ƒฬภFฝ์!… ข๔ศ…5๚๑ Y`ฏุหhY…~เใ�่ฦ/vจE}H#๏_F|ัd\cN๘B*rŽpPมเภRQ lะใฎ@E ๒งะ- f =่nV"ภl%„XD;\Šv%+m้KมL�แนศลX“ญjk!&E)6ศ[D๔HชP‰j๊`๘˜G-ฺม5„@–1|,d#+ู—@`€1QVธbง4| €KLใํ12ถˆZPแR Ac�่ฦY .!ุE+rpฅ}ฤS ฑCLภQsศ๚™@ ๖H w›qฤว"ฌa†์ณž g6|c”โ Xj—ดฺึ ถ8F xPˆย'่ม6่ `(<%rท!ˆปข%ํ ฐ‚{$B@*1๋>ภ0๐vษ�oA‘�ผsฦ”uลˆ,7š จฏโds#�่B8Dม 9€ร2‡Nอญ๓๕“‡๊ไ1O‡๋ถลm๖ๅ‚f่!!€ฆcปl„ฃ�•2ฐlุ‚:ธ†>ฐ €*๐รจh)hโ`่=ฐฐ ๐ฃ€ša&ฤฅฐ๘P„r$‚‚Œxxl`‚Œhแ๙Xƒ˜ลว€ ตจฺก d #€ฒ”ฉ|_0`ษM๎LK่ชdl0 )ุ6ๆม;ใYฯP›<ฐƒZ!ขfHœ็—ิ\ 0งvฐE7ฺจ%ธ0~ฤ"c“}6ดฃ-ู”เเHG5๘!‰@r–=‡œ�วˆธˆดEฐม nแM„ก$0q๕๓ B๏@6ZGฯษมX 1‹?๎y !�`'K>๐‚c@แŒช๋ oใ�5 2^]๎sง;์vท jpzิ#เ๘( ฐ4ฬR(Rk;?—I†�™ภขกˆˆR!ศ‡"ฮจœrกcƒ=ย=ฬb–ฃ่€ๅ๊cฑ  ฉ๘ว2\œอ๚� „Cj(< 0#03V'ุy@<แ 3ธ๛ฦz�฿๚Nศ“d…\เ�ฐลˆ˜Œฮ ุศ.fร1ๆ;๐Cษย#V.๚ฑJฐAq�ดXk๔)[žูาuEม)ศH�ฐ๒<`cบ .6BžžbvI๐< |Q๚އฟุ%ท ?œมฝ๓&ธ๓ฏIโvจn#sต ๒+ ๙Mำ& V<lภ;!ีg?tมsi“ฟ�ๆNRŽ:เ` ๖เ„ฤฝอUv�ุธ พซFรSืJ'CXƒพํVฟ๙V?ิ Aะ@ไภฤย1 ะ@4มWh@ิ?œร˜ภfM\ฐ‰ช‰\`›นๅ฿์_Sxสลี\ Aฬฉ๗ู—,�เš ]ธ‘€ฮฑ‡Jh’ DƒDย4๘�]าQำ!€3pQฯ9D,@ม5Cd“1`d@ ค@a๔YภU)FคS้ๅ`ภใ์ll…  ฒ่�KVใeํอž^vYุ60ร์Aใ‘3h๊™@€Ch„Aฐๆํ uภฑ‹g‹ฆย6ิC?ธม้aC ย็hQ‘]I๗Mฦ"uข�$„สA@…šD?8 „€์?ˆ๑yฦผ\” ”ม!ดรFฤลญ!Ÿ&RŸGจ!†ุภ๋ –๗yDHฯ๘Ÿ8Ž#9ๆฤ์Bwแœฮฦ(Œ‚ ๔ื%˜รeร ฐ� .ร’ิG2ร�๑‡l%D_,ƒ ,ฏ ƒ% ~(ฤ!�ศhภ;b@ผU lร!”กเG|ภ=�ภ.ุย ฐฃ;ยฃ๐ท๑็/๙็!้ร&t#…Ah‚.(›่ƒ-”ม‚LภะA;dยTA3ผ�\ƒnา5๔g0ก: ฅ{&„€ย6€ƒ‹‘R“พ(„>”Bt&%&ฦ”&„V @A‚FM*iำB€ฬYJ@�ƒ„DาXฺy่ˆŽ[6„ƒBจ„Rจ๗ํ‚9ศมผA๏aแhQ•คm�6่B*5T? ƒb5ยใX(4าส9œY€ƒ6๔#„D B?ˆยIH/ขU7(รฐ@ฝ,-4C;่›iูฃฦ“6ไC7ผ…n๖ƒ=Dร6๔@�ลเU*„€@*ƒzั5ดƒ›ค„๗yB๛ผT ŒXนิ�”•ฐ”Kล*  €/ดLFvๆลถ‚dpิชถ�๊ฑVbIyเฐ ๋[˜ิb€„_ฃส'มฌ8ฮ‹&ไK�;nืะC ฐวhัร<จพxภ๘‹ลdย’8”@'ผƒฝhรคภ๔@cฆิ~ฤสT…๘œ‰ดl7pA �ภ5”์พhย2ฬ๊(lƒม\ ไฬ๘Ž2|Vdlภัว"ˆัจ�ีpB4ภ๒�K7�ฬJdศึ\อdอคB0ด€ฎtC& ฌCฐS๘=�,Iญmญกl@8Œึfอ คร0 KFtญ=”ย’0@'|ญm๘-เ–Qไ ฤ’๚ผCคDฉ[ธ\จฤว†์ศ–,…tมไƒ)8›C`�–/เƒY€/ศƒ)�<�‚ xภฦ๘พดี”b\DE>pมฬ์ำ^ u๐ZใASBผฺ้1ฬjอœุrฯดƒ)rjถร5ฆิห;รdกTร?ผC�ภ2คร้ฆฎ๖ก+่Š.šฒ€&\C&ค€mTภไS ฏ �รtC ,I๕\๐ADAฝศรฝค/๒ตo;ไC/„ x ๘.,$\[UB @๓Fo:L๏ษT”0ไขม~0O–{‡ศอดcจuŒ–0เŠ„\€8ตˆJ’Uˆฅ4ภ�วพัŠ,‡ม้Gq8Š„8วด�xl  pzเ‡ƒ\€‚lsฤ๐ด41{ด Gวtค €X Kˆืˆt\Drxpู†e`Jโ๘ˆ?ˆ†€G ฬH(@ ฌGURŠAuพ@ด0€txซ�1hา}Pน”ซ0ˆ๑ซ„ งศzีGต`!t>๘ย\„@ภr0ฒg)ˆ�H๘ฑ „‹€ J'kqB$6ะกณ„ {<@+ซJcวr$„.—ะิHx,‡wฤIฐภ@ง#W๒%g2'ข)+/‡16€-฿yœ.'จPKwš€ ‡$SณXsภ2ชห.+0KศŠิด์]ว�ณ<ฟห|๐ .ฤO๚VB|ๅM$™>O]B.„qก้จ ดB๐sB�๓dH.CำDEpโCcๅ h-ฤ <ปDF/Lม5ผYBศ’ˆหDgีp8ณy€“4m๙K�MYค K'™IฯRM„„ฟีดร…C,HCฤR๐๓Ds4I๕>ณ๔ฤดฆ-ฤFฯ3TGu๙ลฑไsp๘ใNฬ(ูs~`ตCx5ดE3ิƒ& f ‡ Nyศgf~„๓r-•ูMV[งAะJV\ƒJ5_๗ต_๓†V๓Bฏฤl๖~ศX๕aทั?ฦฑคิK�ตOHRลQW6ผ@u<�ตป\ถd6h‡6Mh5b'vC6ษถKถj?f‹6lวถlฯ6mล@ท‹usi๔lฑDวL�voืvNP@่6q'ทr/7s๗Fjป ม˜ุ๗mซฤoWwKP๗p7wL4 ยฅpทx7y—๗iถi฿Dk;ฤO–ฬuถBผ7ฬfท|ง๗NŒฐTw”4{๑ิ สDg:หDsL‹x๘สJ TD.•ฺ�^๗D$็๗vฌตJ3xคG7๗ —๐k›ท‡8ˆ+๖zใฤvณ7๎๖o[w}—๘yห7oT$,๘ฬ…cแผmUผ 9„wืKฯ� t๘ฏx€!ค�ƒ<๘C70ย’ิจ>ŸธQ4@ะ‚ฝ L7ฤ๘ A”ผ*0lŒ€๛D=ยM‡ธ™Ÿ9ˆฏธ`\/ˆ 4Hq‡ท-[สO&NŽh@œตฌ‡ก$ศ-ฯHโHฒจ�6#Gt˜!ข( ;�ศ›k4€zxภ†k1D๐น๏นฃรQrœ“CœW=ไร1คF„cCR?๔ร-(–ฐ‡žธMศvร,:M†*ะ|้�Š 1 ษธ€;•0 ปp:หศŽ`‡;r*Žมx‚F%™<ม@A$=žc๕E2B�9ม9š;นรถš�6 ๗หฟL\ฬ‹ใ� ุT@RๅM>€๗ONภไรุ†ลไ้Šฮุ ึŒสวXŒศ„ภoห{*(์)ภ€Œ@๏�Ž tm>4มชO=0ยlŒร TYŒ’€ คCลŸ์6X�•฿†�I8๑C)ฤ’JœW–f๐uฌC,ภ@็ภย^xภL ‰*Œ pGรˆ ูผฺูฬ๊ฌS^๊ชภh้ƒ>ไ8คKี\d มคื€#Sภr�ส%™ๅK@<�€haฮีAyyนฯ=�'๗นqB๔ะ‘L ู้P6รL|A ฐ๐ Lม6Hท~ิSQจ*Rๆ4ษ�ั‘ุ€hD?ไC.hƒหˆ๊CEิD ‚>ภ TA ภม^p>์/hC>8ตดี[ล�bŠ�ผ—'๔ฏ\ภh๑C"�๕C่%-ิ=„/4™ \แซีภ%”n6ถฦฤEtฦ�ิา-แœ.yศ@ๆฤA™(AŒ<๐tย>Xv—ฆRฑ’+มา�G L@œcย@Aƒlเเโฺœ€b›Rคˆธ‘cGA†9’dI“'QฆTน’eK—/aฦ”ฉRCM›7qๆฤ `+W.% ‡ $0@Qฎั†ฐhมHqB . #dอ�N‹‡0<จ‰`WซŽ•z1แรฝFbT$A"ภ s>B่ฌpขถL- ยสV๎แƒH:9ฆŽ €ฐ†I'ก”’J๚ฉƒ!hข :ะ้ห0วจ3๓›hfฆศ"FšูeTเ9‚€8|๒‘.fฐคŒศ eเZ๐Aรšž!hƒ"มFšB`•# jh&bKg›"lจ! ธ‚ŸdjฮƒI` BˆD˜k‚ ‰ฦ€H๒+จผd v‚Œญr5 ZถYรWไ5(ุ€€’e�Ž`– 2Aบ€ 0Gn้M]๕ีYo�๕ื[สW๖{Ge๎ปไ.vQซXI๖‚”ฆบ`ฎ�|€ค>ึ:aAๆ� &#hf“ต>` ฎm!>,L0qพพ2yTA‡f๘iโลส‰ก‡œ้ฉ›]ฒ‰q…‹0„]pllc)PŽกจB†โร9P�A–ีhฐ€ศ๎ุฒf`‚ธ@Žก,ผฮ๐@2ธม`+ฐเ๐ภaฐ: H€ Tเ‘ซQ‡ [บ `7ะG<€คภp0ึ|ถฝ๛ E?—ˆ–%'ฎ‚P€๖qZ2tT`.ศŸ2Oร"ฃั.’_ลx`—G=๎‘}๔cLf7ป}1cฤ5มฌฬƒKศ�B9ฑ lฦ๘รบ0‡ซ\blภรcวŠeผ 5(‹"#ADtม๚hD&@|œbcฐƒยง— (๚ศ…6ฦƒD�@F;๘๑สง`E๐6ดs€[ผฃž0 '&โ �ยุ@เั|˜Z,ศfhรมhfxv๔Œ ซ)ๆ1๙!‡ื๑ุ '”\()Ÿ$€ฌ ฯศf:-—@ฑmP‘R2ฦ0†7l๒ŠOœ€ ฎQศC๒A‘V$'ท ์แ ˜์?ิ5ฦภB D]๕8xัิฆr„\ฺ90์E5๊Q�‘šิyฅEิภŠFฑe€ A(MXร๕จPn*^p๕ฆHมR x€ฃ‡LP‚Nผ#๕ะฦR@"้ศE?‘› !]ํrฉ4 ฃxG–ŒQ„ ฐภึฐ+ Z IMศร &0ฤ?� ‚๘aYาF&`เD!้h‡]3ก ‚ๆ`ืd y0ว๘x\7ภM\#ถนš,ซ:คเ8มE:Š €tB]5ฦd+บ3ัษœ ๑€*xqยˆT้ิrู.`d๘ฃฌ-@ซB๗าLx`ฑ~‡?ฦ1‡�ผท๑๏ า�CT๗สxร…ŠโG4Dถ‰B:�H•jมx 0iูkเ(•ร๖๐‡A|5ีฉP ภ†P€ฮื”ษฑ x€0ข‚�P’< Lะ€œวกŠ@�#W๕ำnŒ‘ุ‹‹/ฦ1%ee)ๅล้ฒ 0(y*P@ˆ,ๅ‚U…เ@ฆพfร๓O1tบ,a—y@ห1.^<2 ๙ฐ`  ”T‚jฒูภ๙Rz„ู# ]Pdุหภฑ`‡Œพw€ำ=๖ด6!F@tน.gƒ˜๙† vYฌเ็ล�6ส‡ถuˆla{ุภ—พF@H$7ฉ๊ Rตš๘&Hภส0-ŒยMSE‘iŸ(6`ูˆน ”$ผb๘0G บ[ข‚…aๆVดBิ0`ะ‡…Or€hฟ"Htำt{$แ ๗ˆร?๑่›ุท๘ล1๎ว}9tC;Y€!�ัŽtŒ‚œ 7JvB’‘ก๘`Ÿ,/,แ8๐ ลCfิCCศฮ3tก่E๏๐ดเAฐ8� Hง6‰Yโ๔’`ˆ้ชฃทยQ–ฃH�่]๗๚ืม๖I}$'r ูลžvตฏํmw๛‡Œm์ทฯ�๎uท๛�๑žwฝ๏๏}๗๛๋ฬ~vลRํฉ๖฿Ÿxล/�๏�เ๏‘”3ž๒•ท�ๅ๛xxEวvช€ฒjQรF๔8ไ  `Zะ๑SŸSเง ˆ€L h1Ÿ{�๏ž๗3™ผไO�๙š% ฉx์อ^ำ€;�wn(ำ#”qยš|ภshYDๆำฺหฦ>เH .ฅ๗ๅ7�๙ัฆ>๘ ฟ@Mrcฏ$ฤjKa)ˆzˆ๔$ฅ,N`Nฦ˜Aฎ!‚+�ข๘a fC`al`^ม4า๏10o�6โ๓Žอ:v€ฦร^ `คแๆb.� HŠ๐รถ b๒ \เฤdก "จ)”‚)fc ฎ l z)ๆ40 •p ีŽี๏ไ€๐�ภAF งx@าE jยqŽภš!4AA –ฎ "BFhฤF๘! ยvAH„P l฿˜0๕pƒฮ › ;`€` ย €Rฦ์ฐP ง =-เ >A่€n‹#f` ฦ$แ5`ncขยโIฑM๑�๐ …jยƒdง…แ์#0`็B(คม(ะฆ!fม>:(Žw|ว‰6‘ˆขภเ aูN๑ก1]gฤ�:B๘ฌQฺฆคจสเ &เฃBส๚๏4" !&A@8b‘้‘ฆž>`ฌมคเP๕q๙ัC๎E$จ‘๐ภญฺ. ๘๊ม๒มหช+ฟr#๎€†€ขธ@่Kฅด*ุ๋ซBภ(j 4*ชIฒ$Mา๗ไ ฯ๓ฎq๘X(ิT,๙B-}`ร๖pจ ‡ #ญลล` }r่#9„๔ O๒(‘2)5ไ๗š!˜2%SRDtรip@ ˆAฤK)ตr+นฒ$˜’��qu(าาA†เชบ2-ีr-‡-6`ุ่2.ๅr.้ฒ.ํ๒.“๐+CL/๑ฒ/��2๗๘๒ร๓/ ณ0+.๒Dฬ^81Q.๐ฬN/S ร2๊’% O2W"2 s3๙P3หN1;n0Ib%ซM๎ “/+“232Wฒ153a“ธw)๔frิJON/๕j‚๕R@๔$@ภ‡#dO4€ิT@|xํฦ2B!ฮŒฬฆ-9ฆSอvํ|p่8ฉฑ%+<ม3_$“<�๐4๒ฦ3*ืณOยเเ Lvˆฯ๘’’o๙๐ห๙8๚คOจฯ๚>ภชม๚Aะะ๕บ๏๛ยo�X'` KNกภaR`6๎ ๊A<E”� ย๙ผ+DGิCไD4�F,>‘.=i๔Dฎ๑<'ำ)mG�SGsด%แ๓FKsv่๓HEโ@มhก D #*T'ๆฏ�๎๏ ˆH�๘เ�p ะ๒€�€ พ((ธฌฦ P)ะ4Lค@ไ ฐฤ๏VC6! 4!]FฐF๊4๐@O๙ิOGKล‘คˆt�ีH…T ซฑG•=ๅ3G‡ดR9I9ต#l!ภ!Š Jsข‰"3…>n๒ใcpk๐s0ภย ไ(M…pHคYคšกฒภAH-`zN€>์ฃ๕�’uY›5ŠžีU}€4u'ถ•ฉ"u0ี3>แS…ิษต[ดSี5"j‚‹ ๖กŠภด5'pŠฺ‡ ฑ‘Yพ0 วฐ ฯะVเาAย €l#Bก&ไp ๏P' xEX‰ีฌ lŠ๒๔ฺ€P ฮƒ›Ec+Š:๖cCv_›]=0๎ฤuH35ฯ๕^8ฯeOฮ=7ี\1“eAs]}ถ]{š)X bํต0_ทPd™ภ!Q)ั’U ๚ิ!61:๑CQ๕p‚’เ˜ภb‹U 8†€โdAV_ฝ0$7๖ิvU๖xึfFิ4=pR�มsf{g!U๘`ถnSำg9๕ฎภ๊ม’(่ueฑ‚j‘Uง(wฑ๑Šโ่ ށI `#๒b’q›ัkฟXคJ่A ข•ˆa(}เucwvk—ŠP—pล•[ท5oypื[�3\}๗5 IเNAใช์%ท๑ บ๑A*KษััQ9 ์`พเ ผiRt†ํMdbyEp(V ๒tบ Oร`>j�UY uOํuP“ทFธoว•x 7fวMS๘w�vy๔#Mีnqb?2!cฃฟ๔‹ิ"r"ฃ ธ`'๗`ฝ��2†(€#>๒Bา4ยDภfˆ€ (2šD-หภ˜k๕B”ƒฉE‡ฝชณํ‡าq“ทษณx ๘)Sฎ€7Šีo•X>!X]O๏5Mไ�&W,Joิ@9ม,'E TฮŒฮพุ#N(R†oO'q2}$i:]mสV๏6aฝฒSืย๘8๗๐พต\‹๔xwy฿]qถŠ—G ู[๓Š!8*า๓žP�.‚*ญ+{„’'N฿@๙ ฮ37ฏ’กาR•๎6๏+GY๒ผ๒3๙‰;PR;]#๙#.“�`Y’}9uฦฒ,ฯ2;ฤ”I4�ล˜�…Rwfo•�9š‡ฮ-ฑฎ^ y)]vฏY^โŽ—U๒•qyGฅyœษนœอ๙œ3n›ฅ1—S™๕™ัyœแู�๙จ๊9ๆ9ž}9ŸQฑ�˜๘;ล๙›มนe๖%๎น•�yฏy5 7%�ะ<…WŸฃY“*}t‰),›ู‘ูู ๙ก™f“ก;:1#ขYข•ชeฅXo๔™๛ึQ�Y3/ฺ3YZ<:‰ŸŽoo– Xxก4Kฺ๑‚—ฃƒดคUšuˆดษ™œษ!�ษYฒDZธ�แ-^โ ฝ'žแห‡�ยžฯํฺบฝ�โ}Y��="lว ฌ;;`ีฎำฺT ล€๒8ฅ3ำิ,Ef“Xป™c๛Qพฦ�ผไทo%ตผ+>่=^฿M^’E็ เ ๊ nมฎ4ก่aH@ฃญ@ด€D]fฑด๙&†EIt*Rฤ>๑s‚žใZ่Oผฦฝrxวœฑ� �พ‰พ้ีตส hก~ม๐!„••๖aBP�ดt~๕~U•๒ํ๗5LDI™ิIกด yผฯฏ�ด ้…~ฏ/;ส){๏๙พS•์ฌก„ำœศถu€”@ h฿> " Zง•YuŠ๒ƒ๘••hˆฏ1เSCuTKีฝํ�ศแ:๕�ผใMŠ๓;ฝAzุQ�๕}šย@*ช™#ศึ…ี‘"˜H–c=6 ` Pคm›acM–ขˆ  ค@แš}ๅŠ˜เ`ฐ €‰'Fิ€`ฦ‹;z�hฐขศŽ"+‚ฤXา"ว”,7žŒ�่ฅส3AถŒ)๑ๆล–W�� 4จะกD‹=Š4ฉาฅL›:} 5ชิฉF เัW.S @š๕ศBOŠ ฏY๎ญัภ�‘ุ๔๐เpก 3Y๖ฉม€Zถnแส๘‘B<อ4baใษ’;qพ| ™งฯ”'%7 9gHŠ•/f๙™rO “ฉš>:ต๊ีฌ[ป~ �h'ะไj๗+LRvฅbฃ€ฎJ”TDb"(i"›ศ`.b#blCNโภrฮKฝˆ>EˆŽฎซg์ <‚ฮ ?~Nั๒{ึ<Ÿ~�š=๋Gึ฿œมtZฑˆ`‚ .ศ`ƒช†� (เ‚.ยฌวาoม ื ,ROE|ภU›dกI?aŒ0ˆˆŒ1ศ5|๐Pข>'ฆจ�x!pห)Bจ{4่_‘ฅ”Y$ I ‘ŒํdไK๏�“IP†๖`–Znษe—^~yT\€มMSคฃ„ Ma60pG5Œ3G!lเมิำ&`ิ@œ�ะi'žwXรงŸtd'‹ฺ7e”๑๑'‘L.IเbGZนง�%9ฉฅ]†ำ€›a fชชฎสjซฎชฆ€&จ‰ ฬZA E๔–€ค @ "„ะมฏ ;lฑq•ขJj“c–Eฆ�)L4mj๊~ฅRŠmงHฎtY“ฺ�๗jป๎พ oผฎbP&Eevะžธะ[้€#ั”{S๕‹ไFขyช.eœ๚�/มœ E*Qเ26i“ๆ๐{Fe,oว rศงEŒšภ^b4๑‚)๋Dฒศ.ฟ sฬ2ฯฌ`ห'ซtฒน'Lsฯ>� tะG้,tัFtาJ/ญฯL? uิROM5ซNWuึZoอuื;่uุbMvูAmvฺjฏอv�ข`‘ไย7ฎ�v฿w�ย �J๚ญwเ‚N๘ฦโ•@ศ"+‚Tฐ๘โ*จxไ?9ฒ“Wพxใณ–KwแขNzแ|ทไ�B:��#ฯ?๘€ณรชฏ�:=CคNป๋ฐหพ:๋�ุŽป๏ฏA่ก—Ž|๒ส“}zJฐ๐D๔าแ๔าG„ #\}๕�?‘�๖�‚Žs๛m๒๒๊ฏฯ�ำอง„๒{€ฏฬ?? ณQ€�๗ใ๏�y€๓รณะbผตo lเฬ�W’ฬnu๘(G xืป฿ A‚ฌเ'ธ:�qฐw,B๙T9p…,lแซ X ภA฿ฐ „œ€8ฐaฌะ‚� †=ฤNฐรึ๐‡AโŠ(<… tกฏˆล,มฐ$๔ยบ€t(ำ3เล2�b@Œ],#ฮ่ล|JRYฌฃ๏›-ŠdŒณืํ๕ฦ@ฦ๐Yฟจ่-›แq‘Œl$R๔ธว>F’(’Œ ุ(ฦ.Gjr“œ� >ษƒคฉฐ“ค,%๙€‰T* e—,สL หXโx{ฅ,o‰ห\ถห–บ์ฅ/้ ^s˜ฤ$f qิห’้+ฆ3Ÿ‰ห่`i Š0ก‰อlŠ †่3R�ฮล‘ œภ‚0ม)U tfK›์lgวฎฮPQ๑Iyœ |aย|�ํJ‰ฅ;� ะ/5aˆLาตา0ร-Ud@ Q 4\ 5h<92€Wุ‚…dD? า’ฉะ�\ง2�๙JเGx~ ฉสtฆฎ™(I_ฺQ‡„30‹๘Ÿ˜าtจDefสrŠฎ‰๕ณ -PA‚?uดจTญ๊R�นOคฎJŽi*–AผœZuฌdลL+ท%7lกี3หŒXi๙”uฎt-ฤฒjา••๔"x0B‰W Zดฎ„-lkฮ๚ฐL&E`ร<˜ฐLษlข†ญฌe_ƒUกเ๗TฎyูะŠvd.‰Jd™ฒฒัชvตฌmญk_ �ุสvถดญํว.ภCภb6ฐญo๛ฅ1uE68-p‹�`ฅlrŸ �ื”ˆฅดŒฎuฏkDAa�Hvฟ �ฅ\€Hฦ'‚๐ชwฝBนภฏ๒ฐ � ‡ฝ๔ญoEฦไ‚kืพ�ต/2บั� ˜ฝy™วŒ`๐2เ�ะอ| แไNเฤ แ ๛vLd`r แ�ถ7.`!rะซ8ถK๑Š_ ใหxฦ4ฎฑoŒใŸ)VฤยpQะe๙8วDึๅ�ัŽ97) 0D=๊d�"K–<}‡6เ๑๚eภม5–ฑŒ!€xสdŽe.ม !ุฃ์ู๒F €#c.ณ7๙8-\ƒ )๐3ภP็กเ ๗ร๎@"ุ้ฮŒfdbเ‹Cุ ๐�0-”ๅฦ" ฐ.<�F‹บŽ8 ˆะ Lแ 0๒ONฐ%$็P€จG๋+ส$(*@ฌ(ท ภฌzใล๒ๅzูu#ณŸ ํhK{ฺิฎถตฏด T V™๛bท“%|a{�๋�ภrัปึษรง@ทบYWSˆ›�๔F�˜j๘ฝเ›{;cฝ�=บ1้๗* ผ ๐„็mเ• ร็ญ๐ˆ›ํ~€€‚ฐ‚[8|ฎ–ธวณ6ฆํƒk�ฒัŒ• ทบๅํว_.ต�Tแ„xณถฬ b`˜ˆp›A�‹ร|่@#ภ ์ ๔Ndx–ฐˆ�5‘่*YฝN„ฦ9ŠIOdนอ%บื‘F€;�C ˜ฏกชQeฬ!ฝ 8ๅ†yW€zๆBoIดา#T๗๋|�™,ํ\ผ�E*ม\#9๗ฟปุ`ตห]„๗}๒2@ใy๐เ๎ผ  ่1N`At ; ศ†๖.€K�ULไ5ฏา)๛—9`˜ศมƒk๔ทp@.tูว&’ตT$‘˜F(9`?๘ส7๖ฬ™ๅ1yŠิ@ฟ<๏๋ขเ๋ๅภ€%€แƒ๔โWฟwoพ๙ใ๕w๐WD๓เกN่ฎi„cB@-Ja๛‰�Wฃ?ฟ�]d`Rj‚ท ]€�#,‚x12#>wธg0โDNๆไc0ฝ`a “ๅƒyga7veงsWww7]zื. ๖„O=9ภ`qเ”๙ไ“Œ–~+I�’–็xะฑ]�ี.pๅ B™P ต’yL@i้Yษh^}L‘zซ7^ๅu^ศ*6ษ P5_ถ’‚Y„)—v6{ตwAA|ฦศwสท!ญ‚'จเ ,จR,ตLใ็9ฐw™ูR‡YfฯW—Kแ}เ'~_๛ๅ*xย ชภ‚<ๅSั’๙`ฒน‹)š1–~0)^`๘7_๛`ฎาiๅะ0ุTOUะQ^ข`NฑœPฅ&ปYd8€|๙Œุ 8ŽUซา €๚€w่U`ๅ]ไนaqM9_E<ึYdธบ™)ธ‚|้‚ถ*\†ฅ็›๖โVกT�Žyทwšr\IŸ3Fƒ6ˆƒดุCX„|‰„†Ÿ ‚'„h%ัW•’ถQ๖ก ฺ 2ๆ„†…ฐhˆ,j(9"P…Wุaถ*วค| e"ัX%\ไe? Y'šcsX‡pn้ฦ:’๘n๒oฐzศ‡>๖‡'V^RU  6d›ีY[ตภ[CสYžeค9ถ‰๗vŠฑศŠยแ‰ืbซR& :lปจ?!กi๚bธ˜Œ4dวงƒJจ…jจ‡zER๗เ–^ขจˆ๊‘ |๚จ]Bฉ’ฺ|@€ ภฑจ–บ@691Y œศฉžชฉๆ—ฉŒ๊จกj�ฦะ้%N๔Pจฺ ชบชฬ—ฉJ๙ชด™b๏9&1oรFfฌ๘’ษด40=pi@&7‘ฉปJyฉ ฎzฉ๔ว�ฺญหิญ;ภ=z�๚ญฎใฺpต้ฐQ0Dะkิฺฉ5jญ}ืช J `ญำr@Kc‚ํะ๘ะ™ภ๒ฏ�ฐ{{ฐIH@NฐุฐcภR\‘ซ๘šฏ_ืซ�: ฐS0k0+ื+SCะ‡$kฒ(ซฒ,๋ฒ0ห2@q๐ y@ ˆ <€ซบ๚ฑ๚zฏŸ๚ซ>FหT/Pฤ(H‚ได)Nฏ ย@ต� "ŠŽุZดืฺฑHปญง๑8ƒ€ ะK&Jี๚ตDฒI‹ฝฑ�`B@ฉ%แถo s^Kฺบฝ๑ฒbถb{+€๋ท^ทฏr›ฦu}�ธทcซ%”[นืpฌาน› บก+บฃKบฅƒ‹นฑ!ธ‹ถฆซp—ห พŠนš๋บืฦธฐบ ’ปm{ดต pจห ป;ด๋ปิป "ป|�บลKoท+ผ๒ผทหผุผ ฝห;ฝิ‹ฝษ‹ผ"�ถุ›ฝึๆผ�‹ ั�ปแkปุ๛ผชKพBด่kผ��พฑมฝAผ๐›kใ นะ�พา‹ฟัVฝบ�ฟเ๛�ฟะvผ Rฟ฿๛พ�lŸซ*ฬภ,มLมlมŒมฌมฬม์ม ย!,ย#Lย%lย'Œย)ฌย+ฬย-์ย/ ร1,ร3Lร5lร7Œร9ฌร;ฬร=์ร? ฤA,ฤCLฤElฤGŒฤIฌฤKฬฤM์ฤO3$ลปลิ&ลDลU,mW�;Yฌละฦลซใล_ฬla�cLฦนfฦhœฦ36’wk�ฦ8ๆp๐r<ว6๖ เดP"l-กว{Lc@FsVWศ†Lcก แv: ษ2&„œˆ‘ฅ—ฦlœษ"V็‘๋‡˜<ส*ฦ#>$$mFศกผส1&Š{ ช<หF-DหนLdฝ์ห8ฬม<`!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,z 5Hฐ มƒ*\ศฐกร‡#JL!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,z *� 8มƒTธแฟ„ !*”ˆะแC‹ flฃG‡†|!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,z�5H€มƒ�)\ธม‡"dHั!ฤRdh๑bฤŒ66๔(€{œ8„H(๒_ว‹ฎ๔ำฅ’ฅH @x !C *(P€ฅ€67โ๔ณฬ_=en€ฦฐ๖Ÿ0HCถ„ ภา95Y:๕ ยC'ฎ\…Xฉ๕ฆ€Kำ^0๘ะ‹‹FจRหถฅหฎำx\ธะ†Yy๗ฎMZ๑mเม…'ๆห˜ใ�l/0ภe—“`ม0A‚„_ฎ—์%๊ฉ_!8p s ‘›7ฆทพฅ7ฏ8@)ภPขำปwงRœ~+L„ )‚ฬ ‚ ๅบแ๒ภ๐ะ ‰หตs/๒t@!๙ ,#‹!๙ ,#‹!๙ ,"zฌะ@ฐ A�*TˆA Aƒ.l8๐แA‰Zิใ?Š9zLrcGŒ4€๐@B†&NJL้g™ฟzส�ฤ๙Q K็ิd้ิ/ŒฬŒ\š๖‚ม‡^ฤŽ2๔ii ฺ0“Jา็าซY™I7’ขาl/0ภี‹kฯค—์%๊ฉ_ทLมPJo^=p€RเMกBฉ0$คฬำ์W ๑zต ๙bู€!๙ ,#‹!๙ ,+zฏhHฐเฟƒBXฐaย‡ :|จกฤ+^$˜๑`ฤ:�[Xก‚ $B$H B…H’1บกbขฤ-[)^่BG3F)DE�aBง‡ศไ;ฅK:‹ุ˜0aถR>0„์ธP๊„ตฐ;‚‘+1š@รฒl‘:C„ศsŒJ -ื�ุะู`Iพ:!4,่4oˆฮ Dˆเะ!Aƒ–:^ จตlฦ_!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,6zT5(ยฟƒb Hะ`ย„ |QโDŠ#JtˆQ#CŽ1tฐ๒กว†3ZิPฒ"ษ”�Nlฉp%อŒ!^ฆฤ “%L tvด๙“่Nฃ!๙ ,>z5H๐Ÿมƒ!\จกC… :D1bร‰+Fฤ˜ัข@Ž� lุ AB‡ จPRไcจSvๆญะเมƒš1:LLั๋F !๙ ,tzฃ5H๐Ÿมƒ!\จกC… :D1bร‰+Fฤ˜ัข@Ž� t่ "T„ย…%rฅ๓G๏–!ะ0ขห(xฐ๑แัr$†์j•ฃๅi{‡ [+ข97x่tฌN ึฎถœpb™ $ิ›“,ซeoธิXŠ#0;ฝKWO�Ÿ-W60 A‚„€szผhwqห€!๙ ,~z‹5(ยฟƒb Hะ`ย„ |QโDŠ1”ฐ&ภ4 bฤฐ@Pำ|\‰ƒ€KCbฬ(P@ฅ”+)bกมๆวœ1t่y“ะŠDjฉณfัฃ จ$ํ LƒJลณ/ะœ7Y‘Rˆ‘*]>Saฃj @A"าาด(๗_D‰u1!๙ ,#‹!๙ ,†zผ5HP€ƒ@๘วฐaC #&DธะกCˆ Nิฒ}ม`.b#N�ŒJ-PถxXลช•ซื�Qฑ!ู)n0jญ^77&๏ูV$D”t\lX�pฆMœ4@P`ย„๎&ฌฤ€!cย.ƒ{�ุrh@!๙ ,#‹!๙ ,ดzš5(ยฟƒb Hะ`ย„ |QโDŠ#JtˆQ#CŽ1Pุ @*T€|ˆมร‰Gึ�ฝWd%ฤx์z๒ ›  š'ๅย$€fฌะใV:TZZไP๚O$FPอBC Hศฐ'Iฦฅ{}นqg_ขฎ ๖T๓็\ฆŸgซVhpR‚„)บZิ ุbื€!๙ ,#‹!๙ ,#‹!๙ ,ผzž5H๐Ÿมƒ!\จกC… :D1bร‰+Fฤ˜ัข@Ž� &h‚!B€„€C‰\้�ั๛ใA%Gˆ#บ|‚+[bx มฎV9Vnุ€เถwศฐตzำƒ‡Nว๊bm๊ส '– "Ab@-ฏ7'Œpๅซ—Gฉb„XขS5๕ด�IฑR`’$HHม๗ฆว‹sฏ !๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,ฦzค5(ยฟƒb Hะ`ย„ |QโDŠ#JtˆQ#CŽ1$h B‡ RbฬXยš)“i๙C€H็|L๘LMˆช์3Aฦพ8?NˆN+๛ค`X๙ร„ ‚ขˆ4อGาŒk4+ฃ SŽฏU ต ˜ณT#@ยOื…hๅสฐ—fa�7ดะ๚ฅขƒ†ผQ˜ีำ4$‡฿ธ!๙ ,#‹!๙ ,$Œ�Hฐ มƒ*<ˆE !ฒศฐ@‡$H@|มIฮ ˆ)2tจXฐแรˆ&’\ษฒฅห—0cสœIณฆอ›8s๊�ษณงฯŸ@ƒ JT(†@bุ๑„โย,œ8•B.ิG0”œ\y4iDฆN‹ŠKถฌูณhำช]หถญ[6dศภa$K x๓ๆฤธ๊#D"ฐฤ๑š๐EจgGR๚๖ํŒ ค ) ๛”D฿ญ�}๋3ฏฮj=ƒMบด้ำจS�0Q!Zzฦป—Eฑ!f…ชลV„/า<ฃDมดoไ(Ipย +ก�ฮ `w๏฿ชeฮฮ>t;๗๏เร‹��}ย‡r’,"œอ^4ม Tจx๙โฬ:J2(ซ�ฝauภฐA]/`กŽ;๎@#‚“…P`E๕�—฿~��เ€ูตท{yุ�ghฑ'‡ ภˆไต่โ‹0ฦ(ำ( ‡]hbA ์‘ด„มฑฤใ๚e๐ร0รœ…เA,ดะย 0่5Xด ฐpัBCyd’K6๙คx;ฆจbB;ฒXขwmย ขœ2ๆฉ็ž|ขถ $@ม ๅ4rQ @|! มHB x5Pมˆ B!เ€๛XA�Š:j๚ƒ}๋ค๊ %?ผ AvH�๐ะˆg 4�Pใอ‚ ชใ7��๚ '‰yg*ชชฒ๊*ฌฒb #Š9Š๘์จ�4ํ�้xโตฃ’๊�ดึ‚๋-ทด‘-นิา mŸ์ถ๋๎ป7ฑ๖ˆ5�ไยก4p8�Œใฦก~h“N=�ผAiพhไ=ŒL้)จข–;โฌ๒MชฉชณN,ŽADhิ>8B–Zฦร :,ฃC 4i|ดลcผŽฦ{ ฒศ่ฎ‡ฎดz๙gะ�’[ดบ�Ž›.žqซtฯฉ๘๓ัKื ๏ีXgตƒะณI�[ม‚ิƒศƒ\รXrŽHคข`G=€pAF3˜๐�๐pจNหมd )olล“_ 1k{7vฦ0่ ์;๑ฐรŽหŸHq‡ƒ~xโฑ,.AใCํฯืฎŽโทฌƒ+uถฺ ปำQ�t์TWซ๕๏ภ�bวl๒�ำศ1l#F€’\2อ ฌpO"๔ฐ˜ภ ๆ๘ะi^�#Eเ�ฮ๕)๋x๓ƒxภ…={ั R@Sน:์ธœๆุA JAฮ‚–๛เ'?๚ูธ]ธnW;‚<๋hฝ›šาฦeAคYญƒl]๎บ4 fPwยKก WHš\C9 z€„}ฌ"`ƒ=Žะถi๐ภ<ัP �๛ั๓^{0?�7œ/ย! ั ถธEN๐‹›๓ธ˜|๎MRคขฑ84ฆ0„oกฤชFต�Amvฐ[WำXวคๅฑ'D! IศBล…z๘a€ชA 9�a๕|8qˆu h„&จ  ภ ~แƒุeิ๘ชซคฎ=GE,V!‚Bx�4คภ‡Y~Agไ)S  d •~iฃiBืaƒะ„ใ3{wB7™ˆuป{#35(.Cz๓›เฤ ๑Bร๓HขอDFข' ๊Y๏‡"šwคเ0 ๐’�€`แ%บ0Nผ@๕กฦ7X%t6x.1Ž —๔Ht…๚."=hB:?‡�Q/‚ดc3]งฬ>พŽv|�่ฏ 4˜ึิฅF งNwสS–๚(EฎฐๅU@ื8[ฺึ6Ixq C;N1‡1ุม ๛ิภR@pŠup„ˆ‡g|ร฿hDชE ๔ล ฎpษ ƒพภsyhา”"Vฒšญjek +8Sœ’จฐ-5์`ESปพQ„Tฌ{Jูส๖tุ2�Qqฬa$ Xฟ>�ค"-จภ บ‘‰ ไ ทเl7ธU�"nศโฯธ€ล|ขI �ณfี‡oPƒ›`lA t#1ฏŒ#x๛฿WธุาฃUชบวาTฒ'Eibญiด ฦ�uฌeืห^nเ P€ขG-jSH@ไณ D๊P—‚”ค(ฅฤGทรภฐ(+8โAโ๗Œ>ผ /๘%ธQŒ3๐Lป fฐƒ!lตeึัi„๕.cั+ง{๐ฐถ[๑w_^�ษ๑‚ํอฑŽืk—`ว%{=A €�ฐ`08ŠฺัH`G8/X…^7ฃN ฒAˆld$ฏN9ัฒํ”bูภ๘&ฺMQดœู4œˆ9�G;Žณœ็ ”‹�“Codr|&c/ศ€คเa™ุ๙*จมq[าL็F;๚ั,dž๒›!$ย "(ฒCk"้๖อga4คGM๊RณkL/‰จฆ€‰TC๚ฬฆŽตฌg-ผ‘‰ึธฮตฎwอ๋^๛๚ืภn4t`Np 68až5�\tูธ@ vQ ฌไF B‚อํn{›ืธB?tq์œL`4ภ.pAh_  ุ†Vโk่ม“�ถพ๗อoฌQ€&€Iฉ คภจ€ะ%๕€เ08ย�ฒ่W>zFภ W|U๚…†ƒ,"Dฟ8�ธฆE๐Inฐ฿0นฬรใ8<ม๙.Hh—มYeธแ1X6>„ะF๙็๕๐9ะป „ ทฐG„YUxฤKดRy#4ƒ`ยฺmbถ›�๔จ็ |h๐ˆ|€#Eจป๕Ž๗^ร้ฌ™<ฒ'I{�O)dศว)๔Œ<+๗Wื�P‰ฬkเ8D-จ@ ฮอ€กC/Rๆิ{�๛เwษ�ฟ€†}”ฃ&เภAzศƒhทEฐ๒9%xK๘ะ�๐ทมน'R๘"ณ3์ฃฬ็|ฯ'�ฐGุ—HU#Sแ๗€@=€อP(,`ทVM…๑'€‚๗Nจ=P ุpG0Mค"€žQ€~“Rคู๗‚�$6xƒฆwW` ๕` ภ›†ี“ /ะ € ทg}0 ฐGCX„Gx{!=ฦ€หฐ6ะ8ฐ„ดภO่, ๆ๐ก~๐9‚28Fกƒ8X‡v๘mp ง ๖„Bx ๖] �ํฦ@Iๅo…€�(ˆ„œ’ว@) ื๐๘ฆ#ภy€ €๐s@žย๛c๐ †ั2ป`zŠ‚ฅ^wX‹ถศk!ภ9gีC๓Pเ) %ะ ๏๐ฆฝ๘‹ม˜ ฐ๙PฑฑD�…% �€ŒF1  h ,ฐJ ะ<—ฆ0‹าu‹๎๘Žฒ†ˆ-Q=ย ‘2)a_) ๔h6  )u๒๑ฯb_๖„ลฑp™ไx T €)šด™‘ฝึT“ฦ!Yธ‹:‘X ฆ‘&y’ฆ๖‘ก’0kฺก;ฅ'd(9“4‰z฿B*!�"csd%M8Y“@”ม–].YX'F‡lMBน”Lฉ'Y-๕!�E^U๓o!’.b•ั”\Y‹Œต7U”�ไ€ค“0€ ศึ'g™–kู•p)_้fHchwcymำVmO๙4�EุเC0 �oษภ7nN/เฬ` <ฐ ภ#!1˜…y˜๑—imQW �ะแ@6d4šขIšฆ‰š‰ฉ ‹y่˜‰• ม™>—บ้}ifu‰Xw‰—สo๒๖”ํมž€ „ภ˜i˜@1žะ…Pn6๑^‘€ ๐p˜wPvืœš้#�œห‰0ฃ@ �ภฆ™็™žู๋žาIaุฉ4œส้™ป๙Ÿd(“าฐp๚V‘แ ฆr๑กฒp๓5 ‘p wFp๓•ƒ_zB!7r%๗|๐%_‹2v๑,็ษ๛ &0˜{ข—(™๒I๕•‘‚)*!ง ถฦ3ะ ๊0‘ ‹’Šข๒(˜"+Š2)  8๐kPr ส ะ๘ *ฃ 3`ัข/jจ)ม2ฅi› ( @ฃG๚Iบค7`kฺฆoJ คD*qAก๗ล)UzฅY ƒ๊ค›�bq๛ฅFh'f ฃU*ฐ gO?ฺจyJฅ‰_'‹Zกบ›]n Pดึฃ#0 ำ0 ส]€[ฮย๙ะp๓ฒใ{น00ฺ Œx๑Pฌ�˜ ฌ๊hp ภˆ›8"Y๗[ืuฯ็~ ญฃUtำBl0nN8˜๘@ำš#๑~P �๒ีา๚ผ` ๙ภœฒขE™›ัฦ๑6o:ฉ�x `โซ๎ฺYXpŽนฐผฐ ๏€*ะ๒ะ� Cถ*v๘€ุบซๆŠฎเ€๊:โJฎ&@|*Ÿฌแึ@0€s�[ฐ ห`ฌŒ€ฆ-๛ฒp:�ฐฌqี`ฐ�œฒ›ฑ+žHซดLQะ๔ะ๘ะ J p)ซ Nhํ๐ *yพะs`#ะฎ�ภฏNื† 0�Œ0 ท๕๐ ฑา: ฐœ5XภŠงส•ฐ ฑPN๋6`๏0]{ƒ เ 0€y๐S๖@O๐p q#PSaณฎ%ƒถ S0OI‰ฐ~ pคุ•wyฮyฌเ c0 ภ fs8๐ า$2บตะ�ฐภ‹ะ๏ะ ุะ›€้ ้ะษ�p � ,pุผp ุ™<ฐ€„ o�น  ฟ\ƒ๛ภ ฺNC�0ฬ€ ลjRฉ ุpLZ \ �ฐฎภ งPอ๛ผัKผห›s0B+x‘€๚€ ฿�!ฐ๔kฟ๘๋ŒQ สp ืภ…p! ยn*Ÿํ๛๏ฟ• ุะ ใะุฐ < ภ ์ภ#ฤC\ฤGฬ๚ฺฎu� k้ม7Œ:Ÿ-Z;0ๆ›ฦะ ํธ0 ๘0ฌ €๘เ C`C:Lฐฟ�๛ฟNฐ … ”eˆ‚"ฌqP=`7แ3>ๆ #�€ ัเt2ธœใ3žƒป[‰ั†ภ‹$!oุu”์ว< ‘ข` @8"ัๆ‚xyฎ0 €ภ }ฃb€xNไ,bบ“ภฑภQ€ ฬ€)ฐุ`ว€ฉญทๆ๛OฬภDเB๓pฑแหุE ฟ'`‚™ะbO™�’@ะ) ภa`พS  ๗ะp L< d€บ0)นผหฑ0%aหLฐ‹AปรCซ5Œ P�`;ภ�์อภฉP?�` แฒMึŒอ1tBŒihPฬG€(+ะ ๛๐ภเฤ$mา๐ฌะฬ�ะญ ฐ� ทŒอ™ฒข0Kp ฯอื`วbšƒภห๐พ€๗ฐฮใ\ฮรุว]ICั›C๐ภADG„DC—`!€5€ 0d�€~„| ้€ Zะ]— P้P � ŸGƒเB๕PF„็๗qํกสเปGP ญเŸT“pxo<J˜ฉ9ฤบ`^ภฮ„ ผ`˜]เ๛Ÿp@vุŠญ๚ะ๚SPp=ภw vห M›-€™ฺ7โืฌ›ญ ํv™„ฉูlภูœBู–ฆขรo$^€ ำ�ฃษแ€ Pนm�€ฑ�ภู@ฺอร@�ถPNœH~*>ฆ�\�า"ึ�เ —{ธ�Pฺุะˆ`C•อฺ� e xฎ-0.q�= ภ@pเ€๙� 9ดฤอ Z€ฺf๐๚#อศึœอ�lbŽ d>บฝ�ะ�ำC#พ€ _ฐ.ฤƒ‚ฐ‡@ฎ�งั็แฤ|ฤCฺ’Nค,m๋ธ~ บ.mอฐž\0ด›�้_\ืP>Žหp็y�อYd0nนุ  s€ษ6`„…~่‰Nธ'”JวP�P์ ‡0wO+{RlP v``เ,GJภฆ๖ภืุ�มิ๖@$ r่ืPžฤ๓ ~‘้U†g˜]_.ูGS:ภ๗ฃx1ํ^0n๐หฬ ๐fฬœ�u๐‰_ภ๊;"€)�58gmว1'ฐ ุ /Pก ฐ`œ-ฎ๛ #A�iฤำX)NH๗� …าžแhฎ QHภ๑่ท จOฎ5=Pภผี"€x�ะภะฝ��๓ญCjC ญ ฐa๖h/-LาVฬฝ�฿˜f„?M�๔€๓#-ˆoวู �0 ํ`)ุฯ๑0บ™#ภภๅ€˜ โQ๐๒1�…้ำญ๕ํฐ 6P?_8CฯูฎV๎@๙9ฑŒช`)06t€TeUX%!=�p ืS){_pwpŠว‹—0|ฐYPŠpN�พœ์“C๚` e0nษ]‹ŽŠoP๑?A0� N๘๑fพ๚ 0ี{ฝFŒฤ€™HัVู๑…ล"l๘่-Kว(ว… xฐูโ@… U ๊หฅm�ท๊u๓tงl๖ษ!!ž}f0`เ ฿พn�Š„ €ค>pฺ่0‹_š’�tu่P`P?Q0L\Dณ~ๅŠ˜8แ(l๙บั" พ~ศฺ๑kดaƒE|5ยpP _ดNทŒvก•ซWฐ BฬzVืqฺุลโม@ฬ™5w&lqN๘ 52DฅLJ�@!IมhCrL`@ะเ2 ƒ-เๅn‡W�ฺษƒw@ˆศ�ษ)D‡จลGž\๙rๆอ?‡]๚t๊ีญ_วž]๛v๎�kภฐภPพ0!8€€&€\ฐ"๗€ร…G้พุภ กมm��ห†ๅ*ˆA“t�๙ํคVHg�4€๐ะธลฟqธ 5„ฐC ๔'SXุภ^v๛™LT9จ…5:่ฐทwไ``|„๎ƒGช๑ง›@<av�H ๒‡ Fุ#�ด$P๐7rP ศb”w�3ฆˆ�@ศmdเŽ~�ภ ้‡‹8+"—t๒QE 6!kงˆ<ย๑�Ž%‚‘”Œ๛ }2ม ร 0ภกยw‚)‚‚ฑฆ*SL1�ฉงฬฆะีn2!กƒK3Eำ+ณ�Rฑ ๋&Ÿ|Daa8(X‚–t๚ &^}6„.ไูจ%�yว  ˆqฦโ0่Fฬ“J)ีฌr฿�‘ƒ*!—Iฉ3YS3@๏๎ล7_}๗ๅท_�8เโ L TุPƒ &เ $H!…ๅR@ˆil  x8โ8“ฃ ‚Š!€เ` BHภb7คX’6ฎC™)๐ bDP๋aลX@……Ž๋pด�:์€ƒUH ›‘ส๘eจ8b‰*6!�…Œp8ƒˆ ่_†ฐกƒ)  โ‡n€ฌ%ะ:…คUธ8๘ฮl„?9P Ž~้–@h�ถm@้ธ-ฝ eง=ธภlˆ%๎€lณ18ธ]–๎แrผdธ๖lโvฬ n‰BฦปศTg=„@ป‡M0ม๐ใ?)/ฏฮ:โ‚W|ธะ*>ธEโู๖X`้งงพz๋ฏว>๛๋>–๎ฅโ2ฌŽธ„5 ฑรจ JNf๕ื�ๆž€ ˆฬŸฮ}‰โ฿๛จฤ '\—๗”ณ0๙Eฤ8tŽ�œƒภๅˆ…ฑ๐Q‡ =|�ภ+(‘ Fๅ�%|ˆŸฃฟ2ƒฺCa UธBถะ…-T฿wึ7C Hg1จy@?~ลe(ฑjแ sธA๚0 ยห d๘ƒg‚Klขq„จรVัŠWฤbตธE้m่9^ไะ๘jุฝ—xŒ๛bุย2’…P1ก—โˆœ2–ฐŽ\ฤc๕ธG>๖…g„๛ถH?า‡Dd"นHF๚ฑ†cค ตFVฒ8 ม๏ๆXHL>@“†$ RbIR–า”งฤ”ๆ`‚Mขrš‚ฌeIXส๒=…คภ!„นา—ฟฤฃ 9C‰“92›pŒMˆP‘3† �5c“� ฿T&ทŒd"ภ*7‰Oี9€จƒ่€ �ะล Cฤ|‰“ ุ˜†3ณ/์ใNQ ่Jะœnž๚ŒŠ;แฌใ0ฤ"�๘ว>˜ล๊8•hํ‰Oผ& ฝLh?�i‚.ำค'ญž1ณCรbฦP†< ุ2›ูศ๘ํ่‡1ฌ5งw�ใ#ˆ€q6เNคcEฤ1‚? ๚y%P ฯฮ�–V&วึะ~ 3O๔ฃDhwๅ l"ฌึ)@$ชrจ0$ํ๘Eโ๔D&"‡ซ^ๅHฯw\bํศ(wาืฟถ8c-kG…๒QใคuญOAi�d%; ‘) c) X$ :5&'?ึ”5M@C‚๙ "`Q`็ฒำด จึjGฬจฤ๖"ุ๓2W‡™Gšๅl6ภ7อb -C๎vY‰X๓ฬh&EkZ˜ฝึo3nrฬ9’…ภึฌnเ(ฒ:56mc%‹rิB‡k‚ บ V*a0LมMŠ0Nd  A˜ใ ž6�c Oยf@5Hค$นYNมพ™ฺ‚แเ_ธ™k'ฦ4ใR˜`R€Zผ,Ow“{Hฑ l o—ๅ^fภ}๘i>ึฌyก๗]�AkpK›p ‰0๐n>Ik6~�Nศn0a@ญ6#‘ pุรRะ๋10•{0ก8ๆ%q”kฺw๓ดRมKธ์ๅฦฮธฦ&J?ย๐2+1@ศDๆ๓ฎห�ษ๖๙—า“จชฤไจœฒ์A |ภ@๊‡2�DZ ” U๔๘‘<๕ˆ\Œ)฿ Aิใz0‚Hด=ฎzPj"•ฟH”q xภ‡ฎGข+5ิ;Tฃส˜fเhHK๚)๒8กUGoรทธFt•ำ€;€ร‰fšด‘ŽผAbส)5Lกpเฺ(จmmlw`น๘… d๗itภGญzวŠ’(ZŒย)๐dP‰�iไถขˆ L@6๐ย๓XCœ(p\aX๎nฐเม‡KŽฅ„i"๊ํh>๊๑‹ฮ*‡!lภ0|0ม|ผฃิ›”Bี?|ท Rz/ถ๑DดP{€‚†ร ˆB*<}Š�AสpO<tk|š]€(>๘aclมํ(>Tฎศิ�ๆฝ|yฬ}ภ Dภรl–HM4ŽL‹๊RีC฿, N�๏~œฆ๋ย)Aมว8๊๑ŽS แ๋aป ˆ#žmื€XบŸEoส Œ  Sˆ$๘ฑvฑยcร$ฎAฯใ„ ฬ๘พ‡�Q�z]๘ย0เ๚ล้ว๐Šุกˆเš‰บtaฮคO� Dav8ภ"ฮม„Œภ๕ฐ—�WฒMdAr�ฬํsฟ๛�•ฤ‰X‚๔ัˆ T@ฯ‡hฆE›ˆ ๘?DƒAธโK89@‚Tะ0จ: €H8‡™๛€d…0@T@๔`เ @r„J™ [ะƒ)' ใชIh†8ˆQŒ 8จ„rPธ…?ฐU8�q0‹“ลศ }˜3H€<€0ๅ๘ฝเ>v #ธBƒ ุ5B๘XภB8ถ๋ดcภฑะ�d8–F(Efภ=ฑ)€Aจzศk่‡O02€W๐ชK €*ุ‰฿่†_`‡ะi่4๗ธ'_8–dธ†mp‚/H…\‡p ‚ฐแ€)H…ข‡_pTP–ม๐ 54 1$C3T ใฐป.“ˆŸv๘ รhฆCLฤš๘u™‚[ศ…\0 ณ’ˆYฤปจฤBTฆYป[8–O๐QไDOด52gœ‡Z8 5–ัวFb €+)‡ด0?Wุ€ Bi˜;๘€Eจ*H— แ ก—xA€]h… ุ‡8ภQ"๙พ 0<4—บก๏%†ไม ฐ�|Xk0ƒh=udGPศˆ€c(…เ™^ †`‘Gzดวp€cะQ0†่m ƒ `&ˆขˆ„ ! Iธ„ixX{H„�HŽC�0˜ŒƒˆmสกIเ*4ATฃฉX"†ฅ}๓‹qชzำม|๚€.Qุ 80ศ8X…Kย‹cยP;y89�นใะG‰่ว$4h=ศึiนไ`€(ภ†p(‚3L ( ธ}ภจ~ฐP”08l˜Mธ0่0zภ‚~(?‘h{`‚8ษF|PX&ฎ`„� 0{า‚Gศ‡5(M๗`€่ฤO”•Œ!ƒvะศ€ ูมฬ"Žศœฬส”ฎc\<9‘Lfภ‚ุl˜&ศอ�์อrปุ$#Aโ@ฦใ M�Œ‘30� hฮ็ Aœ9K€Ax " วUค(G‡tจ~„๒cWุŒ•˜‡ม<ŽจMƒะ.8mxdภ†V(ม“�ษ ฒ๐�˜‡!h)/ส‰€ฝQ/‹~่ศ ลƒญลL hf(ัtQ5ธ†zจ�ภ‡r@$ุ‡4๐™๒Iไศ Ž‹๛;‚ฬ'�ญˆEภ‹ไธ!pPPย๐าˆ๓86ฐ‡4ม<มFธ€ฐ;clŽ—`.H…XปิAตC•สS‹6`"ช พ€œธ‡@x‚'˜A่ะ}ใUQuQ‰Pฬ O*[ ~ฐ…zซ{*8/ภ†ผ’p0อ;p”{ส‚G๘Rถภ‡ฐร%ฐŠpˆ๚€๋2๐!42HEU"จš{๊?๘า<ภ†œJ%Ž@ฝ'ฐYๅจ๖yีXmUธO“ g๘Ÿ—ŒJfจฺš#‹v8น|OZฌญ Hึ1๕(ิU0ฐe”ล*Šฃ @uุ<*€T‡:ภ�)ฐN(า(‰๕Œฐะ‚๗3†I›กก๊„cจƒ kpั€< ี‰: !h‚ŸAฆM}ู˜ฅ (๛Œ‘sุ?Iว eƒ๕ดา bˆ&}Rž๙ุœ๒“$ญ' "(S5`”žŒขซu0=Mำฃ,มhำ)CŽ�„h0€H˜เฺ<�ำฎ๔SŒ`โkXใ`ˆh ธ`ศ4,‚ hฎ๐ร(สิ%�ิใ‹8 `ธ)”Aข2Y”UYš VuŽltค W™ณี4มm`†)ุƒ^e-ภV3ฐp-ะ‚08V%๒ษ-ป2๐ฉ2;„�mจ‡7IkEŽsฦ H„mŸ ืน๋€ะ}Gฯ•0LŽuๅง~ppษ]เ1W๐!:„28„vศง8YW‰p2l >โx 5(�pp’nตฦ„…wbุ‡ี_>€](I6U@…Q0กผ„†TŽภ<ฅก]YA%YYŒัAศCPุว[ZŸ @m8„ย8�I€ภbธ]ฐ…`6เ†ษ’d€Hแ`ก€Pณๆ‘�„�*ศƒผJA€ำ(BH ึJw4ส5�€ฑฝ[Hา2ะL8!ฅv๔๔/ญ8G5ฒ;ปeŽแR1‰8O0�hKQฐ9@ฐ๖8%dB$0ฐ3eส•ขˆเ ฎ…Tี�%สืก(†จ๕œW ส[อง$�‡&8€ฯ,ั\=โxี4่ฐ~ˆภ€ืฑS~ธถ€ธะM`œูฐ  €า$ม{ห%ูึำ@:ไ(�๖ฑUbh‡ayjdŽBdE1\f พโษ~ภ‚‡๒t‚ Hฯ๕4€]ะM ศถ้พmุ†"‹ะWu=h�ๅR†ฌ8 ƒ ไซ0A]ๆe#VX6ธ‹(�฿}v!ฃœ>X‚,(‡6?f@@$ิฺ;ุƒ/x/˜พ๊[Ÿ�]a…ex.จ|ั–�˜ ˜:ภ‡S˜ƒ1ฐฅํ…i‘&ik!€ฉ"‚H‚}เ 5่�B5ฦ}ุ„.xฟ๒†vhˆฆพ’ะ[(ƒ1ุฅ :h‡L๘‚*his"�ป†ƒ^@hโ|bE �ไภŒmป\Aจj›&>~า‡Rศ‚.ุ?‰ [ณา็กB8€‚‡Ž6ฌ�มโ /ฐMCL‚{ฐ‚i�พไธฎๅใโ h‹ฦhf”]09ƒ7จทไ๐‹ Œมจ {าฌ‹ฒ8‹F`ธ“p๕Kฑ ‹ž่‡o€ฮh ศบ^|PDeุะ“e …fhฅe.i� :รQ่Šฏ‚c๎{ˆ†m่"ภlะl๘ g2พkh‡�()ฎปšˆX\LClุE/่ลnPภ’vXDฒ*g์ๆ‡n‡ซศCฯhgmไฦ~h�ั เๆ‹!D‰ ฃ˜l~p,"Ažเmุ6z„ฎ6B�ƒC#ตL–๒Y (Nxnำ†?H ่Nพ5)i๗˜กืRต๖Hธ'4MX†ู…m` L‹^ e๐ถกŠp�  7dณ 9.คs•`sนะถk›คX€T†W ๐ไศ ฃิแA๒qx๒P‹<'_ัช�ๅ”ŒD9z(…`a€N˜rz๓8Ÿˆศ@จ”ฏ{‡&ะ•(ˆ*H€ฉ‚8ยp ็poฎ.` aะ0ฤ-แ’‰8_$นy๘๐€O *ij{“8™“|เ‚Z�ƒ !ต?PฃFy‰(ทจ“w0†ูพ5�x•�1$Q’ทr’Yั“M๎ X?จ†uถCKว๔‚}๔\‰Š `Mธ†L •ฺZ‚\ภฉY7! –๙€u)“มำyjW฿ko‡|่… ลG‰ม€„ �@h†P๘๕`เˆsi“ก�๘Z-งa—�ิzLผญ3ซ _�ะBฑ ธZ๋˜จ !ŠX›ษ๙ญ ๆ.€v„?3ๅ‰อj ๙ญoBณ๙ฮ:.�โณไ/)๋€ภง‡ xฝvใ4เุz›ฎป!™8ง›W™ธv1‚p~อH›เ‰Šจ/จ ช€›Q# ›sŠy…t�แzš7->ำูB3ไธา๙šฐมqrใ๛u�ญณqบ‰›ฏ‡(ึ‚™ยม>3–฿Œเ{›šo‹อkœnšœ8มฑ ฐท{ต7Yžษ฿อน™jบ.ฯ๑;“�Y๙kขคฒW€ฮ฿™๚=หฎฏ็ใ9šคat๘�วž ษX�่` —า๘฿็ ๅจช"$๕UะPtฃ๛1กXd~ํศฑูคธ- …xณ�ๅศ�๛™‚kอจˆข บ#ดง‚ ฌฃ๑—ฃแO ยฌ#้R ๖7Ž๑ž๘็�‰Aข^ ,HB„H(๐B… ‹ฐูโQฐรŒ7r์่๑#ศ"G’,i๒$ส”*MVมสUˆ'5ะคน๒fม–/cโ์I2 ณzš†ไ่sฃ‚&LmสัA-~i:`tZpBŒZุ๔XดŠชTUฝ’ฤช•+ูดjืฒm๋๖-�ธr็ฤpม!�ผ'/4t๋ะจ�…๘R ฬ๐ฏแ�ฤŠ3n์๘1ไ€#Sฎl๙2ๆฬš7sู AŠ“+ืดููci G)€}๚5์ุฒgำ–�ภฉšSื^ศ�g8ˆ\๗.n�8๒ไสI ธ4ญซฯิชQJ��“B 2dHaรtมดKzc I3˜ื @๚@๋ƒ$€ A„ž i2๘fxช `ษs3UงZ <ฐ]w&˜ถP๕A0ก,•ัใ)0ก *฿r!Š8"‰œ5WเQ x`x8U…&้ผฃฬถXะ#ไQM=�ผรX —K:๕(๓ •๖^|Kt†ค#?�0ย‚L๛iภภ๙„aฺ‰�ะ‘tเ@ dAK‘สธacF ใฯ?�ศƒ8E€แนดg=๐ีค“% :(กสฑxhu๓% jะA T�yฐถ ฅ)„ู@ˆ@ *ฤด ภก tภ้เSu๏ipฉŒชย๊5i„Žnู@ฉhศ๊`ขฎ @~ˆฺaDๅ-u วิ“ฯ1ํ์3U€G?��า?rฤิt,ะษ;�ฬขmˆVทมชะลd4ำ  4ะ!L˜แ.่ซอ ๓ฉA ฌฺจ fD& tศWxbซ,z๐ฤPD‘„=ถx€nuœŒ �O<แฤเ*/อ5S\(ฮ9๋ผo6งeoึ3Ž„PA Dึฃ ) 8E*้๘“ฯpaN0E>™xB D๚Cฯ[oภB�A  ‘หืŒl]+M@-๕;งภเม~Xำใ&,J>M\ฐe๕0ฒม TใฯะX2๏@ ,เB:Oะร6๘(aฃt๑…€ยO) ,]ต๚`q@�8ƒๅ๊ฅ-ฐK?…xเณช2 0ศ9G”`)*pภม#ฺ์ภยป๘เ#็/*4ง>ฝ‘F �Ž?gฟ๊‡6๙่tญS@  ฑmํฉQp�@ฯt›๒ขQ„ภOfถ๐ˆจ1ฐไษไ_Œa “ธ†ฃ2L! เว"elใS€ร4u aผ` �ศ MFะ…/@ุเƒE๐ฐMdแ ๛@D์P@pA_˜เŠ*Pl๔#นะฦ/4 |่้่G# 0}XaU(zw<|€ฺศ‡B€)�"น!x`€Y”ซIๅชc”ม4‡‰Kขj€ Zิa๐E8Š`ƒ�ฅๆy5ธ„/ไ‘#pxจ€*\ฑตใ%/‘8‡&๐dˆยใ๐„ (#—€ะ‰�}XaD=06ˆkเp@็Rก0จNEฮj@ &q&T๒Pจrม5�`ƒฅ �ฐ ฺžrS^�&8ุอ฿ผd0ฆ“†† 0ๅhโ`A คเH FkุกำDศh‚€]ด")ล DyF`ๅš๐;ž9|ภศ@+™hฐ+\ƒXP@์์แC์ร ‡hะ 5ภ๘hะ‚T2 ๐ภฺ`๐เx�ˆลท๚ม‰s‘iฐa๒จ’จ$`ใ{x‡`',2QXbœฬ!…‰า์’™d ’pŒŠ  ๛รdฐ8œ� 7ุว‚|Z"/ ภ ๎‘คmC ้ฒแƒ�5ฏ‰•1—ืŽk˜A2๊(`aƒ ๎@iEd;บัทณณ 'hCK"ฯฦอ%0 มทๆq„”เเHG5๘!‰ด๎ใฐYHs๊ัŽM๐€*จBภ!ด๑d`ƒ_ัC&เNI �ภ‡?ข๋y af!ภ ย*ุเQ•๔6�ฮp` h€*,  Dะ’whG;–กˆ&ไ@„ตภวžฺ๑ พ((๔1๕ ƒ่<œ@๘จKด…z0ZLตชโ€fเ ~ุข ซษฬJCฏIดร�๒่ว)์„p(‚WธวV5@€ภฤV `‹็ทš€ ๖8ย€ž#สh(m*บUบ ฬ4T–Eุ!Œฦ"พ€็+€D?ย T“6‚ข�2˜‘ำeœถว๓`BRŽ:เ` ๖d ืั4วฌธ|๋NฃA‚5Œ‹มไ.—:่Gh ก *ภˆ“ฐซ]๎าd0เฌrทมŒ)d!Œhฦ.– x ย!ภ|ศ+ฬภ† สpา�~€๘@ƒ5H]? ุฦB๐แa อศ&P‚plC‘ปฎษ`p~$รN zฌ&‹ ก"€�Ÿ- DBฐLz๋Žk&œ€ศอ�‡x์c ;'ูํVญฌcฉ `”ขุ๒เใ๗<๛g`@ฬก พภญห 3ฤ#›1`ฃ0`/Q์กCโ `Šิ๓๐;‘ ั >$๔ห�P ใ6cๅ;z’c@)Lqฌjฒ‚Lผ€C"ะA3๘ัุ๑ฃ1่จ้Ql์b’ XA6`~€7ภ6†เž็ุ“ ุ่œ-rPp ๔ธัฐw0.สE0ใˆv‰ภ1\€*ภ[›{�yžฌ€;ภ?†%š+bฎ—+Xฐ:‚ป �ภ„A�Usƒƒy$PกqฬW˜่ผ1‘er Pเฏ�I†.TW ธ ๔๑< ฌn๖ตฟ�ญ�‡ื—่!vxอ$ฎ�ๅ_F5žี 3ty86g|XBสกˆ m„๘Bๆp' [:4ƒสXฑŒ7pกๅ"ADtม๚hDD่€Sฬa vpยฯม‡a=ั8ภภL? นเ_ศa*/Pภ-ด‹'\p‚r๙tร/xLƒ]�ต>˜pUi ภ,เC4ƒ+C†€ื]C;Z%} >,`;€น„ภ�ภ5`ƒjภ๒8q•�หะXC0ะaาinย6l๐ „;%Cฝมิ�)BDย5H_/‘!Žฉ^uPภผ๘u?hคŒ,ศCฺUD@3QiดD๎แญภ uA'๔ƒP““ —ฑ๓Eข$6FรูLKŒย6,รื IP@ h‚5฿PEd/Xƒ?ผCn�M0จ€ฐภ(€ร็”ป‰6�A Pภ B:คC.`‘ซจ �๔ t`€™Œย;D—1มฐ€!์ ำด@†h‚<ฌ‰!�รจ@�hข?hC&�D‘ดราd๛ภ ๐ƒ9ฐภฐฅฦ๒ศCœ๘>ผ�ร็l hย5 #Mะ‹�ิิA t >ŽB:๐ฃฌ@'hใร๗T€-ฑ`M•*๐ย�Ds๑ƒ. mภ2 „)ด+ถ@œ@7hj๘๙ŒรPลIฆไ@xะัDCฮศ|‡`ุ#D๛จFคƒ€OMฅPโคh"Oถ@๛฿t8ขพMขU^ๅ\|–ฯ\โ(<ฉไG'fว†@@ <šฅ4@RPŠ ,@tH†L ˜ภgLHฆXŠ๛๔ภ=ฤXDJฌิๅMyHจ@ฏfhฬ‡„†vค€ 4ภ๔e{�}LŠ6i€lX!pภ‚Dศ„P�ˆy˜ภ% Dh  ”hฆญฆ์สฃ|†Wr‡wTู @%่mภ„่Aศ˜ ุCp@,Šy8K[f”G๘4€ั…@4�%1็_ฮๅH@ƒะๆbฒOCะ‡kB�l'N*ศศๆPZaAษ$ม+,>˜C จ^oถ#&D]ธŠA๐'„‚ฌภ#่.n„1ภ h-�eyDUt@„jฤŒึจoะBLๅo„วƒ:_.่_&จ‚NL‘2 |&�ฉ’6™�'Y,€!ผC;คร(จเhDGU‚_่ >ฐ}‰–|Ypษ)เ€?]้†~PQ )[\›1…ฉ˜ๆ่œ.ฉ�)žJ�่'YdฉHŸj„M<ศZ$Bะg!�ธ้›"iŽ‚z"jคๆ)ฅVชฅ^*ฆfชฆn*งvDv๊HDeoˆjg๘)จž*ช„ฉฆชงฎ๊ฤน*fภ*ซฮ*ฆส*g๘ŒTฒง~jซjฏ’jOฤ)uจ'ˆ ๋๑ัฉฑ–$ฆ็กจjฒา*ดย'ฏพFUbณฺ*ฒฉณ�LŠ`๋GXkฑซฐ.ซJซTŠ)ฐFkฆโ๊ตฆkš�ฬ|ฦ@จ�@ณT ซ<ฯnfIq๎Šฝฺ,Eคf DŠง<Šฟ œ&{ jฯpkถ.$6๋รRe#N๊ถฺŒŠR์ซฦrSฎฉVR์ฤฒซปชk’:�2ฌt FคCŒœ ๔(รึ ฤXรุภB5@?(ร็ า0อภฦ8ˆD'$Pื]€ซ7™๋ลz“สv,ศz–ฎšฦดฒkฦv์อpญำJํณ†ณZm–ฎ'ิšฌ’zํt ฦ@่็ุ? ษแอ ย@Œม.๑ะ,œภ8š0ƒุ@0รภภๅเ#y•&ภยุภผOฉญ|z์ศ:bี6�,ฎ:–ฎnญ็†-ๆ’I้ŽำวN-วz™m.อ\-ฺ�)ๆXMO[ํjMฬ‘4ผภ๘้lA5ฤไธภ1pื" Cˆ†็…€=ญS;ฝ“Qmม5p ๘aญสบฎ้vn7-ฌœZ-่Jl˜ฎ๎ Š๎้๒hธฎ๏๋–ฌu]ํรอ้่พ๏ูฦฎ{ชํ`เ|แƒP‹๘! ถผษ�น@3&่C"hม2|ฮ �B:hBด@\ฒ–kม?„M8ภ.๐–๕* 4Y—_ถสฏฏพkฤ&ะฏฎg๙�o่‚ฎคบฏ้ สฮoุฦ/ฦฎn�ๆ๊็š/�Jซื^@ฌL�ม ๅ0pผฑ1AตภA.|‚4ะ-ธc,€hB?ร็คูšต™=HBLจc๒,ย<™Eขฎ _,๚Zlว1สฑ๗๎p�šชฤฺ* ๏0 ฟฎ๑mlท๎oSป๊ื.+q�zm_4ฤŸสฑะ_e`�ฎ้๙@ๅ:ร4Dภ,˜ร'„<@B€ภ1๐ วy\™ฑL@XDC ไ›#฿ฑรFญ—ๅpลRฬ/ษ่‚ฏ/30r C,ฑŠ,๙š0‡o!ณิ2๒{ช-0ื„ด๕ƒ4 Pผม@ ก๖T$๐C!ภ$่!๘์ม<�!�]‚๕aŸ๖ก�MŒ‚5ุƒ๑์ฺ2�’/ฌ*,๚พ0ึฺq/'kยzฌ�ๆqol๊rํ2ื1iAณื๒ๆฒl*ิริ˜Ba Oฬฦdt�= DA>pxภผษไd–xbŒคขfกMภO5S ฿ฒ ๛๓๘:ดๆNญB#t0ทo s์นณูณ '๔็�ไM#/53C4V’์รM๓เ,€s~eLศkฃ€ๅม6&ะ+†ั…ไG`‡vฐŠˆ2เW0uสN๓"GญSณฯ๕‘�ดN๋p]งฌMkฌ�๖ฐ3๗่ำบpT2๔_;๕ุ2TŸ์ฑช๐ฃฮ„ Š„เจATEเ€ƒ.ิ�,ณ>ถชiนŠ๚u‚iคf๎ุF๖[�uงถฒ๚ฉ/ซ๖|†ชญRoซณฮ๖ kk๋Š*o/6ช“ทBFH[C:ˆยˆlภoท’vฌ๗Š์5] r|B7p_wg`t2๊s[79Ÿ[x๗Z ๊oว…xc7zงjyง7{ท๗ฉžw{รท{ฯ7}Sขtื๗๘โท~๏wfL๋ ภ๐[็ดs๓ดซZ‡›„M�5\๓7ƒC๕—H‚ ๕‚›๖>Ÿ๖}ฟ6้ย0€78‡ใ ๋a�G ฦ\n5tฮ%Aเ+ไ�พฆ€รLณฌXf มŠ€ม�‹ใIG‚๔ๅ:A@h  }๘e‹CŒฤtกฒ4�ษˆ; ๗Nต๋ยu o8“—p Gธ๘ ธ@Ky‡_y‰x<มwxE/ฃงe๕๘๘C�ฮ์�hภอๆ์ฮ.ฮฯmาุbำ อั&m,ํ4mj4ภฐ6œB5€ร๐Š�ะH=Uฬ˜›Q:๚เˆฯKบภ=4•“ถB'6tฯnRW:ช“c9จ/ว€‚?ะBˆ@h0*�สํะํเ"ภ €�๒ญ฿ฎเฎแrมะ. 0n}ะ@ฎไRฎๅ�ฅ�่ƒไ6ภมามท่yqŒภภ:�๒ROแดg&@ต_{.e;น๕gธ]๕งใ2Rธ.?�ซ•‡บผ#‡ฟะภ#ไ8dB0ช๎๒.��I ๔� D6๏๐๏๑&ฏ ,o4ฤ๓F/;นH4Cd=ใBmq/ร ผ_•ž`‰� ผษฃ�_V:ัตlฅห๑บczป๋hทฐs็�ผ=mฐ� ม>”C˜@.ฤป=1 +0;0K0[ฐ `๐kลk€ƒ๐5ˆ0 S3ภXวผ=Xฑqล๓ด3 @ิ�X�s!ภฝ�ำ}�X๗Ryงฏjผ๋�Aทุ*ถ€๏v‘๕… ฝใร๔4ร;ฑภ3Dำ�ฤำ›วLq_qo={1›€ณ™›™q ๑จ1 ฐqญ$ม=0Aฺƒผิ๑ภ`่��?q฿A €�_€Nกp�6€G๘3�<คฒpศ๔ฅ;ี:jง?>๖ว\1ิC Y˜ฤ$ฟ@% สŸ้ภ&w๒'‡๒(—๒)หQว%ิ*w‹+รฒ,ำrjผ�=ห@ ก'%ยฑR/,ภEl€m‚H8Jš‚.ใaฤ‰Ch9’dI“%คTฉ๒dK—/5x™’ไJ›2Qฺ<‰fO˜7i๚น2'ฯ‘A[Uบ”iSงOกF•:•jUซWฑfีบ•�kWฏ_มrEp๋”)Ht`เJ?ipพ”y3a‚ k+๙‰_ก“๔โยฮž/Oผ4รฤ#ๅ<>Kฒ”Sิ!ƒฃฌู“rกCหH๎IIด”x๔m๊ขฉ_˜u๋!3่งSgaํถlฺy…๚\|(๑˜3ฦTj๒ๆNใฯ•7'ฮ๒(u”.รfืพ{w๏฿ม‡?^{ข^ธ%U=๙Lu่ะภ6ใๆจMp‡�Q๒แB๖@ฦ่ษ„…(ˆA“t�yง›?(ใเ‚ฐ†\b ‹|ˆเ๐ฟ|šุภƒ;ฌฉG™7R €;ช๑?สLผ#ฅn��0Aƒ๙`”1ฟฮŒCชบๅ‚๒บœ’Jn("kา้ศ้žc’9ๆฌ[RI#‡$Oห-น์าห/ม “K8๊hD!„PTJ Th€DเภXณอ7)จ@ึฤS๓8(H^bเNDา“ฯ 6T‚ RH!ฅ8uSญ3 JP!เ3 งปRJก”\rJ์vz5บ*ฃƒ.H h-ช(W“Sุa‰-ึุc‘“JSญL  š•)%ตฐ -ˆัลƒฐฬRฉgฅz๖V้pโ๕V์˜:ืสโt.ชeงชต)ๅผๅฉงขMw\|“ํื฿Xเdแ�ๅ๊?kาeˆดบอ—ุใ V6b|'ุโ‹1ฮXใ9&VญUัรu_‚ƒB๖Wฉ*๎Xๅ•Ynูๅ—aŽYๆ™iฎูๆ›qฮY็y๎ู็ŸZ่ก‰.ฺ่ฃ‘NZ้ฅ™nฺ้งกŽZ๊ฉฉฎฺ๊ซทาUVญ‹k๋ฏม[์ฑc-ฒlฎ&[ํตูnปใญ๕E[๎“�ฎ�๎ป๑6ๅณ็ึ:๏ฟ\๐ฐะ5ทู&๛F<ุมo�๑วEฎทJf้M\8ศ1ฯ\sท+wส๒ ฿\๔ัIo๛๓นKO]๕ีญ๎›๏VY]๖ู‹†๘๔™ไฅ]๗�y�rC]Sx „/>-~Mโ•‡เ๘ไ•�ว“Z”Gfฉแ�ฑฯ^๛๎6!—ภ—็y�9ๅ{๑ัฏว|๐วฟ�๓�—G�๏รw฿”ฮฬ.๔ํ๙๏฿�๔.„' €Oุ 8ภ&PLเจ„iวv�ณเ1x๕จ็YเBฯ๔T"vผ‰/ะมv€tแ aCฮ†5ดแ q˜C๎‡=๔แธ;’Œ \‰$ <`N#�ั8 ) tชYŠ๊Pชษ•„0A #๗“+กkJA4ใ฿6’ pเ pRpp‹zhCี วŠ”+žจ*šC .pช;€ใ>nOชeเ1-๑�ž`‚ B็uณฺ1™ษbuQจ-๚๑ €๑&@hF ฑ>b;`�q�lB7ญม›ูิๆ6จกๅn�ิ’€ยด(ƒ:ญ�]N“อt&ฑ(ะƒGXฃ‚ ‚Lภ- ˜ฒY˜ก%$าˆyAะ bœ€#‚`แDฒaƒœ3ฤXะI0`'<"เศD”นฬž<“ ํšA0ภ ่6ะMอ˜2*™ j€G‘ฉ ฬ@A๖ฑ‘€ ๖8B šฑQtด6ภN€†}”ฃ&เP”*ฉธด”ง= ‹PSŽา @ˆจ7�ฅเ‚ft๓kฐษIฑฑั t๔ฃ๛PรHK UฉฒิฅI‰&šQŽFฐภ�UซผๆSตฎต+!๐ภ ะ‹v�" !0A�lbJ*ุ@&ฆg"obฮŒ0คกˆะƒ/|‡I9ށฏ#-'!‡แท๘ล1žqo|s€ฤ9| ^Žœงะม>า@r•ซญฒถฺ๊“จdษฝฅ•)8/‘Mtฏœ็PซXโส+9#=ฅศ‚๐Eซw�sฅโO๘ฯA<ญ0ใQKทzำšNนงำผaภ+lม˜ซiQฏdะ,F—๏ดx€วDNvน๛ฬ�ท Oฐยื^=`MM๚�o3ตkํrห{S*0Z ‚ึ �ใwv^ยoฝzfOIโ้ฐ 5Cž๓‘บบ�œBœ*Pชm:Ÿ๚ฺ™WWกŸน�›"€WhH๕ตฺแสฅ/๊q‡l˜โn{แฯŽ7ธ‡†Ÿ�์_๙อw�๓ก}้OŸ๚_ใ!`1พVŸ๛‚S)๕`ๆwŸ�l3ภ,J!๖๒ฏ฿nงy{™ุ�/Q�ทZ๙>ยฐf๙๏<๔'ชW.ภ2’แ@›๘๏ฟร��ฏ+.ภพ๒`ด๏๏pณF5l+ิรฎ!�(ฐ)ศ/P+0เ๋ยฮMะ+@๐๓�ผ‚คๆแN๐ปย9dN8,๏+เ๘กฎ$yp8~ลWคe\€0ฟb>€Dvฐ™pโฬe๗ภD=ศ€จ šนฮgฮฤ˜๐็ 0 }\๐ ๗oอ0 aF ีฐ [† �09] ŸB=ฅS–(์„O๖Pี฿Pกักๆ@�ฎb ก๒c�1เ‘eL*๘๎ด^ ะจSpเ–a†ภ #ฑฉว™ข.„ภaฆข{FhภŸHัe•ขศดเธ ผ€ภเฃ‚-๎ €?ˆ€2p๑เสPg&(�* |แl ภ€มกโ�b!เตลŸ๑!๖–โNˆเL(` ��N`” …. เ`[ะ๑N•bะ0€ะฃP’)\ภ‹๎ช„ ภี ๋ฯ)�-"-ฒง๑"523r#=r;:๒#E๒+xั)~zЇyขG�h$-2$—ข{่'~�'~ฦงโฃ]า ฒˆ ศ hj'ฒ*6(…Hศ„l""Ž๒)๏P)u(Fจ%ก๒*ั ฆRƒ8.8@'ฑ'R=Nc�:ักl=ฎ/๛ถo,อฐฉชเแVฅ�9เaภ %พ/ฅ‘.ำp `์! 0% ฟ<PO๒จณnnฒ1%ฮ/�S y‘๎ 482๙ศ‰2IF ‰Z*€~‘ ถฯ�Ž�>๓ yQธQ�h+7vใ–bk—PฯY`6A์๎/�n7[ฎ)Š๓8งวœะIุ้!� โษ6เ4ค๐๐1—ณ-ฯvr`zPJฅจ คœJ/ฒj%๖bรO.เ#p<ษ‹s1ิsy@=ฺำชR‚คLj%ภ€มธ%7p.๕“,Q7t%K!ซ#$โฐ๔ J!=‰์x3B_P�€ 4รgi5lฉ7„S์ิฅฬ€Xิ๗\ะDypoชqฐฑกถ๋DRdEZD5cdFจล2ˆ ฒZpPu๔Dัฐ&` ฺัพ4…S&ณ2E%%4DฉeIิbฦQฌ*6๑ฏ๑ ž~ภ^�aGJ"žš'ร;g‹ฌง\ุ›ุ3>ฑ'$%OไG>จKž‚ฮๆำS~ธQ�;|�ษ�ๅ~ๅAฒ9 ฝๆm�ฤ�รใ฿{žมx>LŠ~่‘>้•~้™>��่Iฒ^„พ้3๋็ม„๋ฆž๊/็ษ๋้]๋ี‹๋ฝ=_ฒ์ม\์ลร๋ก�์ซํƒ^๊ฟžํห๊็๎ื^๎�ืŠ๎ฝDํ๏ท�้๛ํ>๐��์�๊ฟ๐OL๏ป„๏฿็�<�๑๗๛๐๋ž์ใž๒i<๒9๓๏^๓Ÿ้๓ทD๔Aฟ๔M�๔Q฿๔ @์รKVฟ๕_�*ภชะ)Jภฤ‡>ัcbภ�pLSŸ† -ta๖นไ๘๛!๙;*\ภ8ถ)@pฆก‚้bืpญ๘Yง‹พ*@ๅ6ฅ`‹์Pฐ เด�ฯ฿Š,‰ ศคงพ6 ‰ไƒ–�ŠR �B–:ะ „$คHq0!D˜0‘ ŽknDˆPักC…>†ƒ€Kำx`8�ภรVฎNยผ‰“Bƒ >ไฐAAŸ)2ชPqฐB… "(U0๑่ม‘M†y”ร…‰!L\ภ 6ฌุฑdหš=‹6ญฺตl�บ} 7ฎ�นtฺ๋ฝ‹7ฏ�ฝ|ษz€๓ฤฤKณ �,๓WO™bX๛‡O˜ƒ #Vฬ8F7T,B”ธe bุ #Œกš�มZ&…ตฅซืํMŠฦ#OŽ่ว5lู?ชๆฏŸ2%& k‡ผพ_:”ศ•ฮฝ?BPiš›<จชiฝl…)ฉข็C”ยƒ‡;ีฯ)jศX ๋Nะ+ใแฤ#k��+’าsัMW] ึ˜ข<ขM๔�a„NHa…^ˆa†nศaZ€โ-eˆ๐ะ`a `ษ9jdัI?a„เAOธ�u)ฎุb„ C3Œค J4;˜ V#ะ‚  ฐห&< xŽHคขฤ8c@)%•Vr3p๑,เAF4CA์@#t๑x`รึaืาMF~gำXJ1Lว)€‡>›dก‰‹ฐqŽ ะ0A๗คมจ=„ง่ƒEq†e�d/I.yŒ“ฯฐย=‰\ภ#4{K/k.R )คD ZนะŒ9Rๅaยƒ`กุาหm0S„ ฬjI5VcmรL‚M)>�Œ“‘H*9@ธOฒฤรชั(ฒใึv-7�ฌp‹?š„ั Dภฏ ฐขอ;ศ`ำสแ 7ฬ๎X ๐œศŽ7ีะ/ ^C๙16{Œ�ฯ๔pK:จhัBlฐxใG>A เ(Bภ๛HAายO|๑ฦ�Z๔€G3ๅ4ย‚ _M ๗ืY�ํl๕\uฆต`sฮd้Š*ท-.�œ0๗Iุท�า๚ํ_ต€‘C�A t~PwS‡ ม‘ปNvื?‡*, ฯกคอ]รp_‹A่‡W‚ˆศ†”ฆ# ย?CPฯ�8@�U@ั0@$ด“ผสp†4ฌก %t€+ฃ†:S๐จ—4€ธP–พ<0 Xภภ๙ZI‡Xฤฮฦภย2aƒ๙Œ‰JL�Fฌธ}`~เโ›จพ1�*โ$x@J@pŒb`|xAพึท Aไkต( P iุ1Nื$hม&’mูภ `Šคเe๙าวฐ€"ŽเทE0จP Pุ‚  @พ.gIเQ$เฃ9Wm`"7ฌฅ-o‰ห\สท8…T€ฑRฯ‰่$๚„@Atธ"�๐ฌ„˜ฦDๆI๒p {iแฐ#ว-`ร_ธ3า?5ž]อ|f4ร˜ท“ุa_x‚š‰ร1ษzรึวŠeผ 58ุแต@ฬแ 6๑†9l ,ุ€8 ๕….H”QŽ‚”‹Fฐา�A�‹ภ!xP=โ 7ธร>1ธยu จAฏŠm€ ดิฅO ิ  ีBห�ฆXVByิ€ 0P‚Nผใฆ€R•สิ4` ๙จƒuะ‰y azaY€€๓Žุค`ฉF ๊ำL�ชRฅ* ุ๊Vธสี{@†?ค“‰ฮค]8L>L1™จพใ5ฺ๘ƒh(ƒ๒ถ:O-A*๚c งr+ ผเUSะ;X6‹QะถQ„D!\ฐม๖KTˆ*p๊ัXั �ะEs† �เ wธฤ ‹‚ิฒaˆ€(7‘R ‚ไ.ื!)˜@<ย‘4@*b�้@)'" <“Gู@’ˆบFฉุy%#กTE'1Hv๒œฤนฬ ฿๐8˜dั‰โŠ]@(D๙ p@˜1$้/ฉ{ฺ# F-ฎ‡? โ็2Œ฿D‰qBึ3๐ ยเิภw|0‡ƒˆิ i€g8ฦ†n‡๋ ‡th‡xจ‡H‰•bเ ภ7]' Fย พ0c ฬ`9& เ }žŠขHŠ6ƒž�*px พAJ0zPSภkะBƒ“ณŠ`๕\@๘๔$ƒ@"ŒB๘e�@ ‰๒_P ๕€c0ื't '0M ภ z`a2&er&ภฟŒรุš C`๐ šx}ูgี6Z ค๐†shxB ~Oi0 ่๐‡๎ภ๑ภ์PˆŸเ2b ษเ )9‘–ศ’-ฉK˜จ‰\Gqไ3 €๎8KZHชH“6‰“<ฐ2-CIqา@7ฐk;r€ภ]ื๎ฃ@‰Œนณ v/+c> �1›-๗ะ0 ‚”/๙า�ภJSไA[P 1c็ูd‹€ Mp/ L@M๙”ฯำ[p ญlยQ^pR`eกj?@ ๋เ ? เ\ €ฆc๐R ฉ์ ‘ษิ@ G Z๖˜‘9™•y™™Il.)›ณiK‰›๘<žXญิG’s@I1ป้J(ล:ฎ;0Gy €้P � RY9คŠปี๘๐W%V50A9 “๗e…™O7ฑ[๛ ืว๖p�š ‰ ห@|“8Qะ)� แMย‚ฉ�า“œI`L 5h‘B๐ r-… f*๖šกœ š๊‘�PGš"Pv8ฆ:jก+I›-๊ขฤ“ธู‰มyร‰‡ฃ›5ฺ›@!4BใPB@Fน7Qฉe€Z0ก€=8•7ŒฺY4 4์ž†3žb ๆ)p้นžิžLเวน๐ า@ˆ„๕s?ฤqคเP80ฉt0แณดฐDึกœฟpƒi‘0@ ?�กplcqก ซ …เ ่@š Rภ‘๚*Pข`Qจ‡šจฐจ๚ขงŠชยƒๆ๐ื9�6๚G‘ภชhIฃฑฺ›ฅtJ2€ง!07 z6I)ภ$#7ทLHไฆsHfI€�p’40ก1ก ž๐<ฌ6–ณ / -n––k าเ้ ำณ`ŸฐLn๔Fqฤฐ ฦ:Fี ึั’€/piภ / G ฿0~"0a/  0/ภกู‘–สwึบ€ฑ)ฑซฐ ๋ฐ”ฑฉชฒ+ซ+iX Yเ;ฏzซฒl๐R�ซรI*ลR.•ภ2™ กMp ๓ภKๅ@7=่P%Qื‡ ตPPt€ง0�c`N@๕ฒ] a๙ep� ‰R�ŽฮDŽๆ”‘ภ…“ „ภ!0O๕tO๙ดHซดLป#ว2 ึ`.C ๘๐ฏูiŽ Dฯ๐ ๎๐ +n>ฐฐ?P ค๙~ษ ๛> j„ทh+น”kน˜ซน, ปฑ�!@‘žี s /ฎภ ิ๖ศฐึYš„ฆภ ปผ๋ป*0ฐ๕Wณด/c~EUošภฐ๑้U—๕YY’…X�•ƒˆั \5|ๅW8Cx/c„xX!ั๊ p๔0 :\}๕W๔X!ผั‘�VูปLPG็90ม†nP€7ม๚Ÿwp†‡๖}๐ ิ@ ›ภ`` ิ€ล k,Šœ|ม,ป/ ร’8  yP^ `ฃศynๆณำยร9paq”7 ฦฃ–ข A๖X= `๔฿ธD1; |ฤ'_ฑ$`F|7Dร'/๒UVLะzิฆ c87ม�E}lCpก"`Ž๐Ÿ*ภฯะ//๐”ภ ลp“hvzœ|์ว€ร•lษzยgAษpบF+,Jวฒคฤ  S8๛T)มดึษA�ุๆ^[gf๐)เลP"{h‰*3ซ,ญ|ษม,ฬฒซฟึข0)‹!1ฐ๗ส}เ๙ยไ0/ภRะศoมฬ‰8ฬ�์อl๑แ,ฮโœจg6มnม*ฆํ�ฮ)แฮC๑Tส�๋�อ๙ฌฯe1ฮ�\ฮ๚ฬพ8แฮ�ฮ ๛›ฬฝฯ ฯ�<ฮ�ฬะbAะmััgแะไœัa1ั�ั!-าhฑัแ ัh1ั’าo1ะf๑ั๐ั041ํฮ#mำ7%�'}+ญ!>� M+ำ3Mำ2ำI=า:อำqิ-ิO]ิ`!ีR ำN��า0]ี[-ิZ�าeมีBmี^ึSญิgัL-ั\ีaฝึeีS ืr]ำ0ิo ืd=ืyึ}อะjํัlญืUื{�ฮmmุ5Mำd}w-ะ‰Mุƒmุ~Mู๚ ุT-ุ|ูีœ-ึP]ืˆ}ุcแู™ุ•ฺ�ฬิH�ุ[-ฺcุœ�ฺc�ีGุ�ีฑฺ’]ฺงฺฟmษซญ�ด-ุ…=ูผm�Œ�=ฺฏMฺ@��ั �ำ ร—�ฺัํุห�/]ึฮ�ธฝ�ฆ]�ิM�*k�เ�ปmืู้n=ุศm�์ื๎]�๕}ช็ฝ�>ฝืz��n]ฺฺ�ู)`ี๑��ณอ�ฎm฿ �ข๘mเ่ฝ฿�ิ��๎Mื_ แEMแ๓ญุ฿ญเฮ’ ฮ฿T}�ฌํิvญQุํัmฝ�ดา�->ิA-เ๎แ5~€ ž���โ.ษ�4nใ?>t!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,z q5Hมƒ "จpaB‡.@‚ x€๓ฤ†ƒ>€๒GซŒˆ)>Hะ€ฦฃ|เ2Q)ฐโ4๛ส1มกf<อส5baใย+ฦ๊๛ร‚D‡„nข"…ำˆ!8|ฬศก็Dˆ!!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,zœ(ยฟƒb Hะ`ย„ |QโDŠ#JtˆQ#CŽ14hแ„ )L€|(าฯ2๕”นQ‰1ฃK็ิd้ิ/Œ„š�0ธ4ํƒฝˆa*ิา4.ดa6ฤ_MกDกJeVค�ีกู^4`€K)ำก๖uิ/ฮฯฏ—่อซPŠ•‡ !ABŠปgณjฤซp่ำมL!๙ ,zฃ5H๐Ÿมƒ!\จกC… :D1bร‰!ะa‡1fHเJ?]CŠ„Pก‚XZh่ #หึเๆๆE›!<๔xโสU?'B`bรUF‘Š�กˆ งPึT เ๊ำจ[ฒ มมร$X0L 1‰8ะน†ศอถ+b(ั้�;S0ฺlะ„*๋]จุb�€!๙ ,#‹!๙ ,#‹!๙ ,$zœ5(ยฟƒb Hะ`ย„ |QโDŠ#JtˆQ#CŽ14hแ„ )L€|(าฯ2๕”นQ‰1ฃK็ิd้ิ/Œ„š�0ธ4ํƒฝˆa*ิา4.ดa6ฤ_MกDกJeVค�ีกู^4`€K)ำก๖uิ/ฮฯฏ—่อซPŠ•‡ !ABŠปgณjฤซp่ำมL!๙ ,,zญ5H๐Ÿมƒ!\จกC… :D1bร‰+Fฤ˜ัข@Ž� Tจ A‰ จPRdŒnจX„(qหVŠ–B่hฦ(…จh;Lด๔"™|ง๔a้@ขe&LยVส†‹!€ฺฃถsG>r„ ๓(cX–-ฒัขeˆyŽQIกๅฺ-,ษW'„†ๆ i9ก:$hฐฒฅภ ฏŠอ๊๋D!๙ ,#‹!๙ ,6zฎ5H€มƒ�)\ธม‡"dHั!ฤRdh๑bฤŒ66๔8!†ต๘pu‘PไฟŽ'L .,Cบ„ะมฃ#Xน ‘ำๅKŸ6xP5ด่NคF0%ฺR$Lˆค6ญบ๑๊รฌSZE vซNซ!|r๐0‰ $H…€ม็yฐz3G๎ฮ >`(‘ส฿;S)ๆ"5ุ *? R1ๅŒs!๙ ,Hz�5H€มƒ�)\ธม‡"dHั!ฤRdh๑bฤŒ66ผpกฦฎR< ‚ฉษ*ุ€๙P™‘ๅ? @ภ1$† @€ A„ 6!”ฐfPXŽ ?ชๆฏŸ2%HYB๐„งi)5ฬร์—/pXHJR@%ฐ/x8T‹J H„H*ะ-�ธ+n๙ำฆ‰ฌ"-๚ +0ม-š๚รš๔g€Hๆ^ มรƒxAkลƒฏ†l๖%๓ฆณ=_žxi†ฉดVbh๒—ฯ‹ {๙๓G/‹ฝท5P๐ @6(ะ แyไ‰= €Wขi Cj !๙ ,#‹!๙ ,YzคhHp „&ฤ ฐ มƒ 2t๘0ขฤ†!Z�7Qร#Tr�ˆฐฃ kzl`ะ ั"ร p\s#B„Š–.@ึฎg=|ฟp^๔ฤH3B`‚์@’# \4ำ“ƒDก Jีรc$ษŽ ฆveูt"†ฐ|^ ตdรปฬ้๓ฆ-G44่ฒฬ_>Svๆญะเมƒš1:LLั๋F !๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,$Œ�Hฐ มƒ*<จกก.เPbลˆ'ฌ‘โC CŠIฒคษ“(Sช\ษฒฅห—0cสœIณฆอ›8sฺไัแDž=fิศS่GH“*]สดฉำงPฃJJ๕ๅลฃNฏ๖๔ฉ๐ชWฎ?ƒฅ@ Uc’5‹6ญ�ทpใสKทnZŽX›z5:”แืฝDตVค0vO6Œ8ฑใว#KžL'^—EC�ีj๐๋… 5v•โมน๋ล ศ`›ฦcภ,~iฯ @ฆŸ.ฒSnเเ…™-m$`@„Hˆ]รฮ=ตjึ \‘๗ฏ_8*6ณ”N�:vํi��ฦpw๏฿มM:_อฃฒ๛๗๐ใหœYฅX›๏๔|ก 6`>”†WŒเ 6„ด๖Zl5 เI?…pภ�I\ 6๐qม@ �ฯ/hงƒx`‚ัอ0 -�๐ƒKจศข‹6ไ „–g!†๊f ‚ํอ'ไD้– คB( € &Pมˆ B ฤ@*ˆะP\ชฐNB *(TANJ ’ 5ะ˜ฉB \ะ€n*นีE $Cไ€Ÿํigšฺm#?๛(a‚k�„ัg ษIง*tB ธIe—วiZๅ•วp34sˆQ�ะภ|ž™ย˜ežฉ‚œTJ€™J& ฌa( 0@๘ˆฒ(ฅ–พ‰้@Fjxuะ2„s]เฺภฅษJซถšzฉถ�z๛โ@ซถ๚๊ไJjยk,Z๔ฺ‹j h6ภ€˜@‚@ีJ*,šิซภฆZ‰ฅบา‚)q‹ะฏ‹้๑ว รืE:nจภ{X#้hไ=Œx%ฒ]ไใฦกๆำฤ'C ล@ํดƒ>`๋์Z�w็-ะ�qG‰ะt๛ญ@๚aM=��aZ–cฎ9 dฑLาฺ0r-๑ฦฟ+ะg’Xเ!ีd� Xnั๚๋ฑ‡;๙ๆฃBํะำฤYเำผ.r3D;~ก‚ Œภํ˜ƒ Fภ7ฦ9๎S†ะFๆภ4ภ๕Qpต9\ภs�8ฒš๐„qqภ.b‘ƒ |ธA์๐p อภ„BbAภ ๐ฤ4�<ุƒO๘`  Œ  Sˆ$๘ฑ† tอฐ…ฆ0' lฐG"–เ}4โ$๘Š‚!’Fd…/ฦ0P0ร 9่@~๐ mˆR๛จE;จ8,ข๏่6ฺฑ‰จฦ้่G2ฎฑ %ฐเุศ/ฎ฿h้ฐภ!rฐ&�๔#นะฦ/R €AเcผะF>œฦ~Œƒุ@š=ค…T€๗`rะหZ๔cฎเล)ŠHARKษ้c!.@ดlM {เ@$๐กdDฒ!ุ€)Qฉส3สธล5๘1อ\ำ]เ๚d(G9ภ�G;ฐฑ� ๔๒—มDN=๏™ฯ}๖3qํpE nม5ศ†~ฤ€x๔จr์เ}™4F3ฺมBะmฮT€_ มญšZ๙สXฮrf(ŒฉLgZ“DโGธฆ>โPภ0Aa…Œัภ 4ิyHแ3lIศg0   เผb3๘064’ภว*ะQƒi๔สgP ึ4ฤ€ฎ…0เf`"Hชˆša& Z ˜?bม€(`ƒXHมฐกRีhแ๙X&ฟ๐„t0ƒ Dจม5ๆqฤแ()OP ldขtบS๛! < )จe09M�� h@ฎPศ*dภ‡. p%Vฐฑ™@๖ฺืj„{๐\๎:ฑ €*๐ร;`AiO›ZsV@@๘a‹฿a“ Ÿ m ํ‰ 7ˆ Œ=‚œVะŒ}m๏{ใK€ฐขฬhG+(7ๆ๚#ž(Fฐ„k\6ณ›eด0ˆม~เพภ‡l น฿ฌถตฏMGLโณ„H "แม๔�ˆฑ?ๆ1\ย !ภ@ ฐก‡ุaE0AOo‘ThกcKHJ๐pคฃ�ฤq"ะŒUDณ๕จGŒ๑QŽ"`้/lu+Gภ W„€จE+๐šื5แแ�?<ะฃrิF~่ย^เ‡3ะ ึ}€A5]๐ร>าlD๕ภุ^ื&‘ผl#ภ ข‹œm�กvหPDrPg9จ& 4:@ๅ`uึ 3็Tcษอpฦ–A:๐ฮomศ ุศ†ฌl@ ”Q’ะ ื�รฮ AญฝwG�‚m๙ีจผ+tฅn1~ู+mj‹แ3ุ>ขqาสUเอภˆ3๑ะPx6๐ยํไA่nAฉ�€�l ๘"nฐ.�ŽLoฺน&NธยŽ^$ใe!ะ:oBšr>cพ๐กษdTm�HHRŽ:เ` ๖…ฎศ >๐ ฉA3๊เ„‹'&ภ๙Wย�ฦ8š9i^sgR๖ฺ\ฮ๘fƒ เ…~8ใ‡*ไวŸฑัD์c o>๔?ขQ-˜]-@ฮปๅ -IGw ษย!ภ|ศŒ๛๐43ด h3`�ฎบฏ@RVG�ฮžj๚=Ÿ�ฤโม๓3ธึ๕Pเ l|a oHށAlแqƒ๎๑ฒvmๅ—ซ›๗๓฿ณ-Pง็ ๕`ภhšั".€K๑ุ`|Eซp s๘ �๘XFูฯ�‚ 6บ@€ mฬษ€ &wบ�„ ฯพ๖พ*2ฃ�ฐ��rค@๖PBศq ภ†jุมฑื€ฌHnูCสฒศD๓FC3Ge6'tฐyŸ๐g|q6e/`F‡%IวfR:ภ๗ ) a|Uง `Xฬเเ•{cƒ…๖\ภIs T2/';ฆRฺ2'ฐ ฆ๕‹’+เp†`goณj€–ณy�€n"7\ฝ€ลท TgฺR)vAถ!ษ b%V4=€K\ - {ลMpึ…];ฐ]:๘hา*๖0 `]บ๎T†ุp†- Ÿ1ƒ๗•_๎eŒu_Jศ„`ƒ๗�tˆ‰H+ �0 ํ`G l˜ยS ฟT๘€ ,่‚ƒ#’jเา�ภNlรƒR„ˆ—�w‹ธ๘1dSW๘  60t€ง0c`N <8’ธ ื0 /'{_pwฐ‰"!—0|ฐYPŠ - ภ B€`คถPcภ Xx—c๐ˆtjF[Qƒะข 7xxบฐpH‰dP< า๖o๖,ฐุP7ืร]๕fž$%เ�”Jใ0p5�เ ๋ฤ x'gQD๛เwDฏิ �เe(๖bเ  ๔@ฉ(uบ…๛�ุ&เ…€E�ะeยใBฟ”�@C M๘ะศ0H@/ฉ P ใึ u“๎๔CY”C $๙•ใฐHฑภ$ค’๛ภ’;ภ^bI– _{N[ฒd[ถ–ฉ !ณ9ซฅ%mk+S ;๊m๛ถ๘8s›ปฏ&h‹qzyดwหcykถˆ›ธŠ;}ฑCซ7นqŸ"€“ถUฑ!วน Aน’ปน‹[บฆ{บ+น๔ท'กบจ๛บฐปฒหpัฒBซณ;ŸA%P2ถฑปะป๏ฑg‘ปฦ{ผIŸs๐<ศ เS€Œง)$ฯฝษ‹qอปฝr! ๖��›ถฌkv �D$rฬ0"%ฐ:จี:ฉฬ*(fƒ]"‹ฉ#qF@9 โก 'ภm‡ห๋-^ฐŸ 7$Tฎ% qญwOา†�8ฯีุ6๖•น$๑�แม‚ืฉม#ย ์ภฬฝ.ผแ‹_‘Wy!พใ� ๆ{ภ•ฑ7ึฦŒ `๏๐ฬƒ0€๚ึ ้05ศa�ฐh๒”1žช~`”พ;.` 6ถ7RX;&$0 ?ู…“t,‚k‹_œ#K{3p ฃะ�Ÿ1วu|ว1ฦ ’ยQf ”L๛ย†lkา&า�๒H‚.hขb๛šsขจํk›&fย)Xฐนย)ฏ:%ง๒eส"ภ‡ˆ&kโ&สืศบ"ี*า’)“,•œ(ฤJซฐzฬะO™ ซ�ƒปฑฟyb&@็หŒขญkาฏ!b*vโ;A/tp €ภ…P ๆSp� AW<ฺ Xx,๐ ภ๗ฐO0๔ฐƒวŽฉd“ฌษ๘า8p _`%ย๑โ*R_ažNdขฐJ๒ฑพ,)p๕ขะ็G-3)’’'ฐ*1Ÿ2' kr‚.ฃ"3,’P’'าุ๐ #ธœ‚*฿ร*� S‡Eฆ6ำ)!(๗Œ* ’ษจrำg 37•๒ฬษBา&tหัƒzศ/l4Hฃ4LรศBใp33s(`}ำส QQC- 5�i8ำ2Œ3X 0š0#3%ซd4สp `6[ :7ำ๚vีPส0ญ7bํd-)ฯk5wณ5Žฝ � Nผœ pเ6n๐ชŠ“4�๐"v^c &ร  Eภ�ู]ญ-  ิt†ร6O5๏05ๅ1ด0 rƒม Dm• PขT&๐6ผ`๓ฐhA ๛+ i_7ฯ๕Œh�9j-3๚ตƒษร9“–๏3X’<หำ<M:ฆƒ:ˆะฒ}C›G<�มะKo} hึ}๓ส`šSƒS8Œ0;`ƒ�pัฐ�ฐOทc�Jภ–ํ}8>“ณ€—ำฑ7}3k�ฮœ>?‘ษ8€ภศ1โฺF�=7๘0 ~ Cแ� แ’‚œ)AXSA ,ีฒ๋DP$ETGs4“p ฌ~yภ ภJ„V›1]๐P๐f6ว7 ขEc๐kb๕€7”Coฤมจๆ€8tWธ vp‹pL0d๖ไQฮU&น Y  •‚%U�~ๅY�Ea4FeิP‚ฐๆc๐f�”%ม8้ˆ0ƒp 6'–pr€ฉ ฑgต6u>เBษฐ` ้œ๎้<D้6yๅ ฃ†EZฤE^T0 อๆ‡'DิV>P ๅภ&p ๐6  ‡~DtืMB(ฆa`& �}\๎ๅ`ภA`ื@`;ะทม $J๋uODนOŸพM�๔Mเ!ท”K้ฐKCU๔ ึะŸpาQำ„jU๐b‰ิ ฟ`@oฆา ๏`šI“ด N๐ฉ ๐ิp9ฅEฟเ จฅ๒^lน$๎>Oฅšูฆ%�‰HŠฤH<  � m:ท นฐH(1_OMปรDF๐ะ�๑”ๆQ๘RƒไPGŽไฏ;UUuUMIfฎP!ฮ. f)ทต@็‡ซๆฒ๑wะฐ ญลณq€๖xTCๅ{u�m‹็fฑ๗ว>v€‹` xdt^ บถyฅ๐ขฺ ฤ€do๖ `%VdeV'ะ�@)@รถ๘๗‚ะ$เ์บฦะ(‰๗3ƒ๓~vMt?x๚ฉฯ๘>เ ต~U’"XฤUPี?%โ @•ใ`ฤlmละแ Xโว9หภ์ƒgํR�€ํ70oไ�˜เ*ถฤ-o๗Xํอะcg†�‘^ขeˆpณ< ื ิ๕†เ!ณ… 1Eำ=03šัรฒฟrJL ฃ™=&˜…O‘‡ "DT ฃ ุ0A›–G๙ึยฦลƒแยQฑัแBƒdฺ้‘!รˆฐฐนษฐ›-0ˆ‚-\‘N!^ล Bณ{L Bลฦ KŠ-ุๆ1มจ‘ฃG(๐ฐฃ 6`Mตr๕šU"E‹j๕ ฃภฟ};6ะด‰ำ้€Y€ โหCˆฌ•-_ฦœY๓fฮ=Z๔hาฅMŸNPโธtี๘I1‚•ซ!�เ™w$๓Š[�4…iAย„โล‰oเ€เถwศฐตโ1แฦ>9, €_?��อั8ฤ์�๐๐‘๚q aไ์ฺทsืhฆ‡G�6ฬŠุเ-฿‚3ก†k๊ฉง;|สA‰}าXo€c6ฑซฒ์๖Yรขุฐ็,™&บขQคส,› p!เŠ}ค(ภม ุฐC<ฉkสiไ‚ถ"„ฮฌcก‹T�YŒจ|�ฉ็”!6ุ  P<€f๔ƒ(ฅเ;$๎ ไ‰'f$ทหSŽ9็ ƒศ…�*ฬ์‚�๘ฑลƒ&( �Tš&/ฐษฦP�›)๎เG•ฒxไม*ฐq๒ŸœTย† *ภƒ.ป ๊ส*8๐€ŒtT„RPฉ ?ฬ3r˜@†ฤrภG•๊ย >๚›sฝ3OQ1�ชซ.ฝเวHpเ˜} ยKิ &0ข|ฺ๙งa๏R๖ชM;ญkฑY๘ICวDมฐ[oฒ"ฦะ�]L<-_}๗ๅท_�8เฌ Hœ:p˜ยNๆฃอ66ฮส,ดะคc2ฮธ <่ไ˜:‚ย่:๋ศCขŸ:h"šศแฦSe–iข œŠ ~ฮูม„ dkxรณ ิ๏‚ ๚๛ObŠ-vฟ:œ‚�"ˆธP y'คซฌปไะรKBไ“ฤฺ.› Aข1 ’i|๘šฦฐภ1บ%ฑf๘เแDอ”^  ฎfษJŒมBˆ ZHah‘บรฆR„ค๑ห0 ธAŸ 1 € xtณlใŽ?N@˜๓Xฬ0jดƒ=๛ -Tวm˜™bFษ`F‹Wอ˜œ0ดะ"Œ0XะดOพ;ภ๕{ค๕ ˜แm๊้ว Vฑ้ ;ี#’o…H้]}เu`y8"ึๅp}ณd๓ ภ‹~œ!vแG P™ฐV ˜ A”แ�โEๆ‡• (ฯ.๓RƒํยมิŠ|˜W @†~�ห�oaE8B–_ุE)^0)bhฃ… Hp€K˜}— ม€€เะษXq๘ด A€~Yษชs ฤ`‡PA  ‡ˆ'c@ม‡˜ภ ๘.ˆqƒ]ุโ/Œแ k่ƒC… X€Y ์ฐy?ไA?๑๋ฑ‰� R ! ฉ",โ A! `l"2�+ƒŸ2hดrb"ฐH0ษ+�าทพIฒ2ศม rO˜ƒLz�ษฐ#{ศแ Aธม lุ%ห‰้3ฺะ็ะv"‰Bj^3๘ƒ  }—QZ ฬ@�สO‚�๐ณ&  qฃึเ”;ฅม �ˆ083F�ธGL 3hK$@GจD `€0ฉ‹J–y ฤjV: Dภธ\ Š-€X;l†(šำ>ฐf`‚ric?ฐัD…Nศ@Yฮb€]hศ็ขฐmA“๚VHG*/พ3ž๓ฤ`cยP€o–CžษByฎ4ิหƒ&„jTฅ:Uช๖ห‘๓เรฒPส†ˆร ฎ$ภ{๘ยผะ ‘8|bล2�ภ…์:า1ูu&0:เใsƒœPล ^ซฏ ์` เzกIุ�bรŠฏ†uฌฏฤƒ>6ั…Š…ก6gM๋Z=บ!}ุข c„&0:ด#_จB3 ‰"ต]ฌbๅ›#ษึยณaF ุ8”t-Aุึฒ|ซŸ>J‘….่ฃั[L™•!PP๋5($€Jผอk^ A„{X2^zัๅฆณฐ E'ˆ๋\๋j:šCcxƒž yN1"ึ –.xะžํ ฃ�hD็จใฬ�<ˆภ�‡6๚มส`� €Z?๓ผฃ�PฦX‹t,ƒอhtš๐๗š|6๒ั z แข�ฐG4ถัƒ"ฌO%๚๛ัง�เํ๘\˜ไ�ๅ |๐ใร1ม"ฺแแv`ƒBฦ‡‡AŒ๘ข�๘…'ฒนไ&w๒ ภ…3 TฦเcตhG?แEฦxฦ5ภ+๘ก†0K๚ญj =hBKต‰šH‡?<�‡nC้ G z[Rํ�กG&ๆุทj%่ฤ;าQm�!่: z 4 น€R7dbลŒuภีน@=d#›šTšX†ˆGฑe๘ƒ A6vkิCohœ,iM‡€Th่?ฦมง4`ีˆ๕R€™๘Aฺžƒ๋Š`ด �ศ /“{๙h=�๗8า*ๅ�่v� า1�\3C[„5ฺัz”bŽ ่Dป�q‰ =ศ xศ€.ผฃ a’i>ยฆ0vฯสงC=๊R ่Bฑ๓aŠษ\ๆPพภ‡Xl๑๘Bฺปƒ<~aœเอึ Zะ€;dฯ" 0D>ธเig๛ุ้๓Kฃœ ‘ถD!๏Pด1D์‡K๛�ฯฺC;ดว‚ถƒ‚ล‰฿ _`~จฦ?�q1bฆ่BoW—sพsˆl€šธF&Rเท ,!qป >๐pw#slฑืมพ๗"„ $–ฉHวท#ฝš|&‡ญ``ฎ•ฎ4ถว=sย๑90�เ๘F� =|โ฿๘ขก@ “จ@ แ <ำ2๐€`ิ†ำๆ‰I"š‚4`‘'Œ�*�”วŸํGฑล&1|๔ง/‚(๛ฺฯ'๒ร>ํใ>�ณถ�c>p>ˆh€ ? ฟฬ?ไฟH€pพ จ€ˆพyฃฅ40ธlพ DŽท0‰จ> T€P้ย *Y> จฉจ`็ฤมjชHธม๊{นย \@ฬ˜ภ๙;คะก๎›ฎ €.ภ_‚œภ d>๒3Bุ€�K€ˆŠขธAp # €ๅk>!L�lpฏ๒ู‰(ภฺx€T \พ€<4ธ@€ภฐˆ ุXมุย(œย*džยKรDด&T๐ข๐{€;ฌ ฅ!Dž2ฤ@\ย:ดฤhร7|99l?QผŠ? ฤh็ปภ๎3คใณล[ฤลโณ †™ดอ@9Z#Žฌะ€๗ปŠZ„ˆ_๔ล`�ŒกQ…†ฑ Yย hฤŠ๔ซ i$ ซ`dฤŠธ- …_ุำ0ฦหภ€)ธ‡ภŠZ<๋ะฦ๒‘ช๖ˆ h=v\iควัธฅ๐ฏjควqคฦc ส8™~lpˆ…gฉฦIA0'ุฯฦ6ีQ]M 5ีS-ห$U ๕€<ธดz7่•&*ฯคKaฌ€)H…DหฤŒฬ ˜‚|ˆท(3ถ?˜Œ €;xีX5‘[36F˜Œี€\�UE;๐€๐ƒf๋†?0uห‡&ธ๚์‚z`„ 0:pƒU,ศผ�z„gqtWT�|ภท*#๘ุ$~(ชาcะ,๐ฆfู4ทZ€xฉ€…~(XU�*Q…Q`Hณs8‚ฐSPถxm15ญกSGา}€h๋ถ;msื้Km€ˆn€ถNc@ 0ขูRหคฆ,H€‡เ€ศิฉƒ>"PUอU]ีงลPTฅฺชๅJจีYู๐…1ƒIธ=ศ•(ƒ)ˆHเ‡5ุ#ภc๘ƒ)€ƒ/H@})ุ†ฟ$Ž่‚/€‚Nแภƒ}ุ„,p‹›;จ@เ‚9mซธฌ€I้‡|ศm๘…ธ|ะdH9ณ0}ฐ‚จ‚€‰Mณ๓เmศ'rผ…\ภ5’๗y,…%ทบฅq$~H„dsซ &Zจ�z_˜ @ฺ j‹ธŸห†#เ€Šญ€fœŒ=‚ˆ„s๐JQ๐‰ƒ[Rงธxเ$่„}ฐ‚ด„zธญkศ-K89@‚Tะ0 Xม„ล˜„s`‚ๆNไpk๘ƒ‚ณL�ฑD฿ฤฺฮPซuเJ~หูPšMbคร 8QWX Aบ HTBฐ;h‚ณŽ)าฐซ€bก{h„ู;0ข4ณกโฝLˆ8œฬ„`ฟ(าkเ,P€oฒ0„}0ƒB4ะ5ธ|h„:jh�€ น€บHณศ๐R๘่?�˜NแX�ืู�6<…=x7€…ppโ�� ˜K1)เaQีภfdH‚c`Šจ‚iฉ•}ˆ�Z/๋ถ„lxX{HD๊E*�ŠไINNm‡k0ฉํดXฐ˜)`�/Wไ”เX.UฆๅZ>>Y&Ž‘ด#Hีhื8ไN� Gช‡v($eKŽๅhŽ็ œ๐ั‘r„๒x’๔ฉ%ธ•m^ฺ1Dม(@ƒ7†ะ๏ฑ฿[ธeP˜I[mแbํ๐…}Hƒ๏sซ  ธ0xp‚ูUี๕˜[๐Zc:๎4R™Oภ“่Mš)ๅ�˜„v๐y่‡S(H‘i‘็AZ˜ นฬ_mก YโจH*‘=ฆN  € 0{.ื้ฤืํห„J ธ€€ภPcสฤๅถๅฃFjB+j]~& ƒA{’กไ_t$_`…{เื‘’ฏฅ•ป!ฐ™ชษ™pคฬษfŸพnถ�ษ‚|`„fุ…e@…ฯB 8pภ‡|ƒ : ั(Zภ4ฐ†ฯมg7Hภi๘๘HN ภlˆŽ‡šส žถ“+เ‡dุ่ิ,e˜X!h0"๕e›H(”™€๕2Ž็<ษ•›_ฝm�Eจ่ค€ €ศ�ก๎ดฮqิอP้a€0]j-๊จM๊๊ถnZ๊€!ข!b)žƒP`ฅ1"�๒HPฆ"ฆD:&ญj ฅvขCุ"˜ ีaŒ . ”~่ฆขЁฐkzจlุ…ๅM€@เ0~(2ุ†!p9‹ข•ศW[จ ห�~ˆ†g b!Ž๒จฅlฃƒ€*g˜Œ๏จา€Xก8<เ‡a˜#โRหdFW`|พ J๊†K’ํฬ ภˆ�Gb!A$#ฒเFbr�"ๅโ  ธdJ]เก ุศ˜นLบ‘|๖r�0s™ๆY ะrํญ๒�ž๎6ฝ๎9งs่ฌrจสซ นชฌฺ*ฺ€ฒ`�/่‚9ุ:ฬ€ฟ5ธ’+บฒซ‹zH„.0็2,ฟ,ม"ฌl\Jั‡ศ€สๅCฐ2 T(_จศธ๓„kNะ_0๓˜ึlฃvภ3่หlณhWU02$c^ ธ&30S_127lv…\็ะ:ีฤkh7�อ็็m˜€฿ข)*ุˆุ‘dเฺ7pฆ)w๒ (ฎ๖อ-x๗ํ;—้=xB๏~ื X ฟ้ปX�\'x™†ƒทํ‚N่‡�ุ๊O|/jŠงฮ:ว๘ŒืOU%U่#ถcM6 ˆMฐ†\๛V€ฯ^ฐE๛หu ๐ppP5R35 8CH‡tศ9ซัW‹ตYป๓Vห‚Q;0†"ธ0คS:*ั„Ÿ3C๘‡?Pn-6ะ†LะV๘ผthัปU ศ0ˆhท๒Xyฐศ2‡0<ฤSผใเึf€:X< ศฟtจ{ XN8ทz0†œ๕i๔…i๑Uเ…iอ z{ช ่B#@ˆ0…pyw;xฃX๐ƒ˜ี7uc7ฮ‡ภ๗0„sหฮ7่L •Gเฒฦ(๐ปภ็ุŸ}�โ่หbk�˜x8‡๓้ึ๘โ7~$w‹/,ํ้ฃลฬL@tŠน|ษ๑Sภ@.๒อ8๖๓ยซํwzห1๑.พฝชั�ล ;ฐเU*dภ,าผ@Bb‚@ย dภีฤG>�Xธp 6,‚LRภ„เภ€€‚r4w[ธlf‚:๑^�G7ถมฬmึฃึฺฐง6ม0ƒ�iž ๑vฉ;ั qIยžล’B๎ัS)\ ฺั|ธA>…™ &‚'œ}จแ `ƒ=Žเ‚fA๚H„–1ต�"šC H`‚”เเHG5๘Šƒ8`›เม•`ƒ HoW($k ฅม–ฐeส›a�ฬ—ถ8ฆzฒ^ษb๖<แm„-\ูไธผ2’T&ผ@V„)„a๙a3‚ธ "‰Lpภ1เ‹OHƒด`ม M๔รS+@*ภQ�Lม’ŠqŽ#,‚00AคG-2SŠ™ตŠ™<z0brหc9วณนKƒ}ฃž2�ุ.W%2nk$8•Bธ\คKลDŸ0^ะd 9๎ƒ(คแ฿9c˜…9><@M 8F+x`€]”โ๘@4nฉ DมpˆF €าวโ๑…ด!…๕ฑ�Œƒ๕•GงนM9vำx"]หผ—Mธ�-œ.m– =А\กา\�pA\šใ‰`๚ B`‡=|แ ^ศ$p‰y๐a Y(‡":|`ึฐ‡ 9Q.Sฆหฤจศ C–V�/m=žq‡LiE๓d|ิf๔Vบึทฝดฏโ'Eใuีร๙0r�   อม5 ธ=†ภ€(ไƒ ๐ภแะ#,b ‰t๘ใ�๘ƒV9p`xp!๏ZHž๔›zฝฮG‘ทื m!•kฦjTW;N๏ถvฆZ้ืๅJ†ว ,Bุธ€‰`+!˜๊ี•‡ฉ  ะ <าˆX—Sพ4ฐODเzbK}kQjฒ-น(คž๖:W=ึ๑�z�ฏg฿ทŽ”ธ+ฤ`6e๛ทฒ2ทม‚j๋‚oตGfRqJ lธŠ-CGปวDงอดตฟ�šแm}‡"โbU8J*b)1ใFvตXลม)€๙2ฟƒd๋‚,ไeฅ๔oŠšฌ5า!Š!`F‰ขc˜Œ�ืg†&™Ÿฒฒ‹ฑ<ไ-sKVแส‘ฆLepฮ)CZฮ2Ž๙tๆ.ณนอnFฑ›ใ,็9ำyBkฎs•๎Œ็=๓นฯj–ฒŸ๓ค็@บะ†ฆะ˜•๕อุ*xยf…ณpอFซีฌ‡พ4ฆ™่dQฺ๓�t1™wืAูnp-uฆSญj Q€&`dไ0ํbE)๛‚€D๓โฏภ!มภ v0‰lญ kw S–˜ศศ8 „ฅ็*ฝฮฎ T@%ŽXม�ถJ\]giš๊Bp5mป่fjkี๎~๗f<‡'ค%ึƒMล; ห 4ภg›=ัŒL $miMซ†?ข65ฎY kZใšืภ–ฑ‘M; iภrแmœขเ˜C pw‡jิC Oฟมแ นFไO3y ุ˜๒• ํ‚|ทคหmn[“มd= ๏ก]3…?hQd$ฬqไ$G9ห ขˆภ ะนฯ…ntฅ;]๊�ภ…สฝ.vณซ�ํrทป��m@‚>ไlภAlG๔ฑ‰,ฐ2 #Aี}บ9>5๏{ื�xซ�”๐โ๖ัqŽ,�J~ธจฮ~�๗ุ้ขsพ๓Šq ‘pdขN๚ิ๗พ๗ �x็EๆWฟ๛ๅo�๛_’C@"P t %HA 6 ๛šัƒ,€ตฝMภzAŒธ^๋Œ_6|เ�@_๚ิ�๕ๅ็ƒื–Tš?—™๙w^'หgฌŽ?ๅ=/�ฮท๚hุG9Š`n'ˆ< _‰ภ5ัEัUัeัuั…ัi@Qญั`�๒)๓YA 4ร{้K0 dม>ฌA'%QŽ ”เ ฆเ มแ�งญฬๅ=šn5ฺ~ฅŸฉ9žIปอ๔4ฑ�ภ)†� b‡=(‰)™*A฿*ตา+ลา,ีา-…@.ํR/�าภ�E�$฿๒U \ร{ญ ขเ ภ^@ 'i€ึ–ฮ๑เ ย_Vž3!’7๙ !Zิdœก$ชฺ\1ิƒ1�ม๋8` “:ฑqTฌละ“=แ“>๑“?”@”A!”B1”CATฎE =Hม๖1ภเ1Œผ�๛ฤ<๕โ/ฃ(ย“<แเ่`�,’�| ]ๅ"2IคeิๆM"7�ย)J4Ycะ”MแิLภๅ\`tŽP•Q!•R1•SA�ีEP•Ua•Vq•W•Xe pเA โ�uA฿ภๅ0�เž�๑]?๘�B ็bะ—ชFเT#€]c6 G๙Eb7–d ํำ้ลฦฝึaนF(cYลcEึdU–`–f๙Cg}VhViVjญVk๑๙ dA>มkีdˆœ5”�ค@0ภศญ\c…€ศ]D7ธ จDฟ!�84–อQSป�๔ ZJZ5� Eถฅไญฅ๒\ำ6š$]๒™พLFxL—CTฟ� จ€P8ŒTxx‰ื^–ืyฅืzตื{ม $[^|—๔ฤดลDต}›v@x$€ทอฺVŒ�eZกFžะ™ๆuไปึ๙ํnE˜6Z]ฦๆชQ#š0šF†}ภ†uุ‡๕Vc\˜cุฆ™ค ]ๅXq<ขญXซ€Zฃ=ส’ Rt�X�˜l^gIกe ™’1ใb–dคsbx†vž'z๚ี—ฑ ฦJ๕๘ษ igzฮ'}ึง}�'~ๆง~๎'๖ง�'€จ€(จ(‚&จ‚bdZฺl.(„F(ŸุGJจ…^่›ฝg!ฒ฿xbจ‡~่๖”%‡"ˆ–จ‰†hงq“ลW�จ‹พhชฌ›"๚ืXมจ�่ชhhE“|แจ�่ฬŒ่E)‘iงดT|�ฉ’.)ฉXd4–'“Fฉ”nFE|ฤC<\้Cค€Xฉ–fฉ–>[—ข—–J'Rg‹อๅ”ฎ)›JฦŒ@.�ƒœสร?ศƒ?œBœาฉžึžสiึ้ๆ้ŸสŸฦ้œช)ศG„จi›6ชฃ"ฦZมL*ฅ>มHjฅN๊ฅfชฆbjฆn*ง*AJNr>ชฉž*cฌลZจ qx†uภชช 9qขkฤ*ซฎชๆชฎ๎*ฏ๖ชฏ�*ฐซฐ+ฑซฑซ”ิœวb8ŒS์ล_fJD�ด�‚@LD[ท ฐดฺซ๑ ฑๆŠ–ฒ–๋uึ<” bเภ-`&T=ฬ�ณb˜ห•�ษๅ…ฯMอน˜œf†ผั[s8จsษงน&ฌ็ตš@ะB?�‚ภZb$_ย>๐ม!,ƒ๘ภ;$†A^D�]เ5žCŠlิโN]า-]ำ ฉยฦlIแ#XC5›‡ fม.3(<“m_๗M_๕…_๖m๔รg ฦB4@่^้Y‡ส,ึ!4=„ h„ล)@v Ue`ิๅ š ช "ัด �–`ุ@ฎิ฿�ๅ฿�—Y:Zึฎช@�•C้‰ษุ.฿..ั๒�ร ฦ�bCฮแฺaไN.3�m�"ก6�ฒ‘r�-I .้Zxภ  A.ดร/คฌ ฬฯ6C�ˆE๒ ํำ๔฿1”/ใ,ฃ1j฿1\ฤ๎:ญ3&D%^b&nbn๑h้6๏lb4€ธ€.ร๋,F๒๕ฯF$ฐิƒbDJdษ„์๘Zว`|c8Žฃอ1(q9ฏ�bš’ภช—4ฦคริฤXƒ1ภศVzภ<ๅVšภq|eL€Sึร[mBคV”&ไ�^๐ก.`|ฤภh@\4@ฐ)ฦฐESXk^๐ฺ_^Dถn&ตุ้]ฺM‚ีVไa0 s^œxว™ศ‘คp๖่ถŒn`ิฐ(ฃฑ7�jFฑ/17ฑ?1GฑO1Wฑ_1gฑo1wฑ๓ C\iDL˜JE?ฤW@ซภ๏ฅ ฬวฑ$บ@:*>€ร,Ajdัฑใฑ๛)๖๑ำ้ลสฑ"]ฐง8rฆ Œภ#G๒#W๒#ฏฦ"w2ัตš„2๙t€t„(‡2yœฒk”๒)ฃ๒้ฎฒ(ปWxฒ-ฟ� ์ฑœโC9ฤ@ ๛้?DV.3/๛ฒ.หฉ03/'฿ฒ3€ HชคbŒภ ะ<5[A ด‡คn3ม d๓6w๓7฿@8?5��=3<�šš@€ฌฺ‡šx@ซชI;A>ฏ ซโsr0ว>็p<๔žฑ๊ ณ*ฺถ*b ๔BชAG4›ํ„š@-ฯŠmฆ•Do๔D ฉZr4H๗•GkpH—t mP� ๔jvจIทดKฟ4LวดL]@ย9๘DฯดNใY่ภ>คมN๕–p”L าF.ฏ๓tœ€/0ดPGu”‘’๚œlAFม‚/03TKตWฦEzดฅ1Fิ€้�฿Wงตฎฐ๛Užc๘Ž- ตZฯ5hlI‰๎๚fbเDNำ5`7็Gว0^ซ[งภ 8nW6cCโไ!uLฝecœ- ‚56fŸ�ฉ‰�ซJู cœ,"g6iOสGฟๆมฮ ษ^iฟv•qvhชvˆY†�Ž\รถn�ฐm!'ง„ฬ�๕n๗ฆภ �ƒ ทsŸ‹q?ทtO7uWทu_7vร๔pภ!ฌ]v7ขฌE๒Ntƒทyc ๚”BnŸ7{๛ �แฤ;ทwc‹๕„L–>„A"หw`ำw„\W%ร'ภฮ๊๗\๔„\ภ็ม.๐ฮb๘N๓w_€ ภก 8๘~C8„`\ฏท…{๕H�vfั<A‡_๘ ขS�?คlƒ—xLY‚ฬIƒq^ศAƒ(ฐภ‹ร8LI๗•ฌ0�ไ€'๙…„‡ 0!(ช’Gน„p์Jน•_9–gน–o9—Ÿ4Kƒ[(@F๔xbฆy—๕—ƒ‡ยšบ B3qlww๓Žห{ธ|†G\!ผ@-O:ภ ์8วŒ7�<ะ“4ฌ€=„‚€฿ปด•pF\fฆU@/Sยฌ_Dzsธิ๋ด•ภp $�;“ฬกœฟฑlTxz]ฤ{A|งฝฺ™ุznฃoB.Eบv9 €กต}ใ7เ5 ฝuใำz_าย�S๖ฝณTฌ�w€—{ไท๔6–&ศˆPะ- บm ๒ฝ�†PMร�…ื(8ƒ�Lk'ใัwผa๊!๋ซ๋'`€%ƒลZL๘้เ~เ�เ#?` #๏๚n<%ฟf^ิB) >\lธ๓??I€•‡„,BŽ์Dšฌำ‡ ‰œ�p“x๘หtปqอ6ศบF00?ฅ2D%@„ะเN5ใๆt่ƒฤp…ศฐแ ~aB,คธ‘cGA†9’dI“'QฆTน’eK—/aฦ”9“fM›7qๆิน“gOŽ€ Yaย–v€l`ฐแ‚‚2คHมฐATˆศศฐ�ค}X:Ld8แก(}ฆUป–m[ทoแฦ•;—n]ปuƒ ‰a!} ล tไฎอฑั๑ย2ฬจุP เje,WN!brๆหž!l๎œณgั”3ซP๖nkืฏaว–=›vm�25ศ›;$ธ``]rร†-ZLค~€ flไ๚7]�?y�NIฏพฝ^๖้ึญcื^^w้ิร{฿n��๗ํ�Lๅ`~›~}๛๗๑็ืฟ6P�%/ฌ%๛hฐ8เ ˆ'l๐‰tA%œ0B )ฌ0C /tP ย๘ QฤI,ัฤQฬ้cฮEฅ† ฌƒฦฦŽ�ขฑFnไ(วkฌR,าศ#‘LRษ%ฤ“tTHD (Hn>&ฑฬRห-น์ง <Š'MZŽ/8ˆ? ะ๘"ฑผŒSฮ9้ฌษ๐ุ‡แR2lf๘เAฉeˆล๊[@“|สHม„…บ:ว2jl…fษ!าIํ์ิำOA ท*ฎ!ไ…ญPฺ€ƒู  ข=’ชD้‡ ” @‡}า๘จ12Žก"_Uูe™m๖ำ์ Eƒ�CI (8ม—Ql˜ f)…�xๅช@[AL@•ข &่ล, ิ๕…]wอW฿}๕หk_๎ธGŠ :�จ๖$ * @‹kธ@OxŽเเพ’p5� ๑ฅ|˜ภข82เ Œ5ๆ˜฿“QNูฎ�๒JO€แม_kย`€]6‘Yขะ' "ํ›เ„r!8ฐ˜ัc\Ž`&(๊iฅUฎฺ๊ซูbูY nฮน`ƒMสK€Kbžักd>N�เe4เ[–�Hnบํžปnฌ๙๎�o˜fn(vม$‡ฏม&)ฏ"™Ff<ธ ]US6รPqE"ฎ๐€ฤฦ\ธฦ$ๆ�๓ฟQO]u‘g–๋ยy@œท’vภ`ะี๔khตฯ0VTูfธg ด˜›ผrŠˆ7�๒ีก��๊ึ›}w�/๓j)ๅp†lึ�>>(งT+–ทM`่lQฺ�(�๕Ih_๚๛๑O™‡�๗n62์!…ตฅค15h†x –ฆอใ๕้ ,๐ ้ฃร2Š4๎# Rขˆ1ด�„หโ&Pธฏ ฤ`‡่ึ๓Fโˆฯ}ม ๏R€"ฺ่\จq]เa๋…XB%.ัNฅ;QŒ‚J™ค+_ หFศB ณเะ.†มC3ภƒŽเ1cธl‘ƒ)’ัŒLtใแhพ๘%ุ@Ÿ“DmdXcmบข>x F`ร<˜+�ีช#†D�ไใIIN๒-พRโใ G9aŽs2u%ู  rc!op9x€‡จลกฆH€Tส’ตดๅ-}$–h€@–(eฃ'๎ŸH ‰Ke.“™*f3กMiBOk4ฃ�4ฑ™Mmnฉš๋ๆ6มNq’h7z๑&ห๒"ซศlคIธิbฃฮ=Ž“ž๕ฤenส™’|b(๒�ล�)ฺ“ คฟxSฮjๅŸ๛์'6ึI‘v”' h€j.–$“!@€่< R‘โฯก mhIgึฯfD”+๎„็N`cด@ ๊ขG8 €Ž”ง=M]๋LิูiMฅ,@�_Zฬ๙xฐRKีHcยTฅFตM“ิh0�# ฦษN}VฑชฌšB *EL๊QฃN4ฉ`ม[฿ ขท๎€„ฌ\ใJ‘นึตธ"ทŽ%DaD Gี าฑ.–ฑห*ซYTกE�i)Eำ๊๔Ck ฺั|ŒŽ9š�g={ะŠvt@=œฐl์a  X›[�ฺ้ฑBฌ9๙้Ÿข2อฅIภ ฆ0…5 aPXฆ†!t๒ธษ]nsŸ�้2@๔ˆร+๒@ DHCP"มํnั›^-Iึฌ uhbูY\Š8‰G! DI_�—9";ม+@!� *ิiฤ!|ี›`+ฉทํี‹B)Vถ๖ddƒภฦZ [B๔ฯ#็]๐‡A"Ÿีคฟm<ซ…ิž0g�ุาฑซrฤร!ถ๑o3b7ฅ�Llต&ฬๆ<@&0เจoม8v๒“iƒNดฮ.$๛d๒Jmถ:˜หฦ@9eแC™ฬeŽ‹•ร†fbyq.ํ๒›แ ู1›™ฮu๖ p๕iYŽ(%ฮ}๖3dy”S;šะ๗‘ฒp;Rb|&zฤzžฒo iI็ไ›ดซ4~.=iMoฺาx๎ดง1}MNšิ)ฑ5JY,วณp ๒oQฬcบิต๑IAอ:ข�ชฺจ*–‹ฏaอๆเ~าถ6ถ‚บห’6ฏvต|™�QSฯมวถ๖nีŒ๊…j@ยะ~vซ;บj'ฎฺื6๗bEญฯƒุpiwธ##�k.ำ็ถทA๋}ฮ�0�ูo้ทN?บํ์ฑ—ส๗68พำj~Vvv�nหฟQœPz[9฿ทธ6+�้†V๖Ÿ�~หปืo‰›ืCM๘ลQ>อ“k|ก�๖ธ[ ๐‘?ธ›ูN๙อฃ™q]Wณ�เ†นวนอๆ™ ธ6ว๙ั•นc• 9โ y[ฺ]Tขณา:G๚ีแจtuŸXๆ—Mชฟฝ�PฃN{ฺ';ึั~ฯ?Ÿ•ฦB๗๚\œ-uFณ��'+O๛�•ธvบร;ห�y:[ข๔ฑ๛าvว๛แGจ๗E›ฝพ‹ฎ >Te์E–uˆทผ$ํ่฿^ž๓๗�็Azั�5—แIŸz{š>6จW�๋รษ๚๔8ืf<์qOOู๋„๊ต?{๎_ฯ�o�ิ฿�ไง\๖ฝฯ�๖มwพส‹�ไpฎฒ๏ŸŸ�e฿ฝ:๎}—Gโzํ� างฒ๗ปŸj๋_?{ไwฟ-นhส#Ÿ์C�|{w��๗๏?๋ๆ๏IฑAB�๘o๏g๗(๐ฺฏ๘„ 'Jnๅฐ)ะo๐"�ฐฏ9u.pe6ฐEkp6JpQ0�UpYฐ]๐a�O.ๆ-c๐้ไืขO}PNtP-xP�~ฐป$ำbp ๋ ๏ฬำ& ฅ๐Dœ๐� ะง0 ฉpฯ์ {P มฐDช'”0 อ๐Hฦp๖ผฯฐ ๛… แข �pG$ yo ้0E ฿B๕๐ํร) ฑ้C! qM ๛ร1%q)ฑ-๑Ytภ8–,-4‘=ฑ%ภŒe$Jภชรq”bbภ�pแŠ0ฑ7‚ฎ taแu‘WยฌVrjd@a|^hpHเmั�ฉฤDH$ช*ed& Z @ย*ถ1*L`d$@ข1@5ิค8าQ!,J5ฒq%@*ผQภQ๋1ฅ‚*ฌ$@ŒŒq@tD@Vฃ,๊*R"clX‘"<`XAsค‘!( ˆฬS€6ภ)<’1ช‚*@ฃ( $Qฃ$2ๆQXไ*Bภ€iSฮเเ x…$ภ–ม๊A�เ_1…มฎ(…’(ฒPB nมผฅ#6`Œ้ฌ!Tเ'ต!๊ก� V๑!)5B,ษา,ฝๅ ม ๚A”ภBซ๊�~`J าม่แ<`" ”q# C0r$*` Ra0๓Rภ<  †rR ฆfชฆNเฆ<เม�แฦก#0ณ03MA&9เดกž 'S๎@มhก D@*’ษvฮA ฒ ๚#< žภ„“8ณƒB@šR@ขa์ง#ๆ„ฃkx@8ๅ RAภ’s9›S<ษำ<1€๒เ9ธเ เ€Hภ‚ภ0 ‚`ˆdบเ  ฐ!P"1G~Fภ1iศ#Fฦฐม�` ฮ$๐D6! 4แ8 € ฎ*ซถ* ๐ฤแ ่�@”@…q$…,’A0 7ฮขhเ๒2A5AB"_ภ!zค2+1/a~๔‚๔0�'NA‚†ไ#างผณเrฦG `๎!V„t=๔ถดK!ว•จ ƒH€E‚Hrn€!`Zแ{toˆด9ฅt|`@6€R€"เJแ`‚ิ�๊kฐ +aๆุดF๊๎4O  \ed`โ@ะh๔�จไะ`สกLเ6ำง1ฺ€Šภ๚TxlงV/เVs^&JS�t}พKร๓Hy€,ขAยbW�“V›๕žต8`nม4! Z ~โM—FUเดแZai๖”1รดA;‚ป๖!h‘! HiไVc ๒,Œถ ฆzเาด r@‡ฬ]ีตYc๗A ธ(Uํzเ‹ฬ‡lเ&“YmWu๕]ิWีดชฐแ$าวTnฎ4K™UhžU"ค•!$ฒYOเfarด`Cแ.fe\= Žก‚ฌa]uV1๙”dAโ T‚์•wx dc ผ๋AผzˆJภP5‡3•–iv]'cขมว‡,๖ๆเ Œก�$ิ>Ÿglฒแ€pAH�ิ&`ภIp—p…4Œ –al€ฆ<ย0R”ugU$L:—qEืZI7เŽj1ภh๙เุTh–Aุtjjชคมv‰[—X€q)ฅ6เ`Z ƒMuQ` wNเ@! ่ฆ(€Mนห$wu—xืw จ ดมp๎็เNATเPahl์!บ๚!ข1A่เม � q/!๗ท%"Š%:ฆHUถLแ ฎ€hึqFทt9 €ธ€Sืgํ`พเ �ผ 0ainฦ๔` �@hXa�€ jO—O๖!ๆเ CU๒แ ๆเๅว8"๔พ  †๘B3tCร`6ภย�@4Œ*`แ nเๆuFภ|กhุ†uถย่}oAPUg/ๆกภฎJ �แLฦŽๅ˜Ž` ๒กb:a†ภcb�ภ �. ‚กิฅ2มฎ์๕"y’+๙’=`ม3คr!  ‚2L๎๘ศR�ภ[( &“•“GJ NำฎาAณ€LSSภ0๎ภสฒ(คล6& bฬ�` ยtEเŽ ขdู[\‹tมhิๅไ$ฦFDภ#7"CCส๙œ๏1& 2Mb$:|ฏ‚S zUc* �2 S@H’P�YBภœย!ตb+*€# !%BัฬV$z"ิน~Cb#ภš)D๒™Š์ศN๚ก๓๙rฅ�qxdภา ร้vvŠศไฆ=ษพ $ช<"๒Ž Gข จš. jฯrชb;โn@ hแVC$H คชฆ/NงGซ•hF;" ˜ก4aิHช…เ,ฤชัz'ฐ`ฃ๎ X ”"HpdH ^`ไเ�”"จ%ึ:๒”โญำzฐ[aฃ์kž xฺ#:€œ€ฑ@ †!>„ฆ ฑมGฑ%›ฐ฿ฏ82 d”Z?†d๔š0D โ€ณAโBแŽ`0(แพA F› A >[$N;ตbต[›ฆA;๕ฒส@mŽไฑ‹แv$DZ จ;โาเ(<พ(!œ€(ˆุžY นŸ;บง›ธ‹{ฌ hฤฅ%Bบrสีr'"WฑผJ‚,สAฤ‘6๊%^เ ึ H  bก๎a๊ฆH!ธเฐ@�ม Aœ€ท3ขช=ขภ�<มผม<ยูปฝ}ช1ชฎ‡%@ร`$˜ักq"Œำ$&�[kg#๖ ฐ@‘ ไb!ผA<~`†แ ๒#:X Zเaร�ภ`ภฮ‡0@ษ™�ษง:สงผสYœง zd4jE`%U 0ุp@ฌๅ๊�*0Jy)`4Tcdภ|ะU =R)*๒"…'"™D Z7ฃ ี)ฒล|Dผฉ6Z+๎9 U#AhRฃ�1(š ฯฑ&ร#ๅ\๐�ข๎�จผ`ฌ $๒ |x�(แ^@โภ$€บ)โ Š มจม�4|รีมผ”�8aถ="ืwฝื=ุ‡ฝุW�องirชี%3�เ๊แ ๆภ•a๑„A>Šษึฝ�‚–ท4O35ณ๒D ฌร�c@0ต™Žข1�ิ9ำn r0ำ=ฒ’฿sกำ•* Rแ��'๚ภแ �[ArผR1ไ’.ํา( ^่x…ภA่9@ ึo}$ฮ<Vแxืีabมˆ€ย@งˆ|ภจมหฝ<6ฌจา@ฏ่€^่‡พ่>้—พฎล]คฌงY\า‡|a ฦ˜ม r?Aภ�ด‚,๒ํใ~๎HิDแ`ฬ@ฎ ‡ƒ ”@y=  ฆ ึ€`างH7ข]+h์  ศ…รs่!C#?ใ-ง๘ฮไ พ€(กa แ…`แF  Tภ’ฦโ๓ๆณ>I`Xภ๑!_๒ C ลไฆv�ึ3X2@H์cม Rฬ…ภุมG!า`ะA๊�โุ๋?a`>b๚ซ�๚y=๛ทบ?�ฯ>šา�ว�๛eGมƒf˜rpธ า4Tแj”y4Oส… $Hธ@เฦพ50tqa P”k�"ฤ†ช\ลlุP€B†&ฤุfงใEs>"�๔‚ม‡{tภ €Yา^t์Xแง Ž@e๓ฑฅZ 8๙ฐธ8Fลฦ"dMR”$’ยส–/7xุr‹อ๑xZภห=)!ข†š!รR๋ผ�ม ,2ฦูกร )ะะนSวฮปั์จQ:ขรeœ‰7~yrๅีดk�พ;ท๎�ผ{๛� <ธ๐แฤ‹?Ž<น๒ๅลxฦc‚หFYู$1 V+ž:6ฌ~=๛๖ =nฅCฅฅEŽ ฬ 5ŠGเาUใ')ๆฬš7qv็I�เใO�ฬ3D ื่‘ƒtๅHโ�ิe|aยSC๎ฃFCฐaฯ.4„&๚$ขล2J˜ฐ‚yš„ั &$P}๖๑JC์ฒ x)a{$a่†AB|sดC!"”D� RJษIhVฒƒŽ7|L™‚์ญVไ‘I.ูไ“ช1‡fšjฎษf›nพ gœrฮ™œฯE็’$เล$w;}7‚u|jวC(`*๏Œ3„„NคN8h1O(ศD“M…๙๗g{ึAƒ4ั„ ๊็ƒ6a…ขbจ!‡L8p น|" ดฐ๐โZhา1'N N8LaO~!0ศ9G,B &ๆ�/>๐–� ิ�„ส PฎvD(ฑฌ"B!� C–ะHมวบ_จ๐ๅj�~๎ธๅžKgภLpมŒpยh๊dฮ |๐าž!๔ฉa$ cจง}6@AG ศะlL� O‘ยป”โ๐ั@L๒ซ ฉ–PUXกฐอ!-ธ๕€ &Jƒ7ยมS‡M%ฬ Ymณ ]Iใรฏ83Mณ˜๓Iฬ”„วlgภส-ฟ\ุQXG4๔—ภXผเ(‘p๒‚/œAอ7”ฅdqล>ิe<่vต๎“ศoH�'{๒ล wเ&3†๎ุ]2KdQŽ"'mภAุ๒ฦญŽว>ฬ๑ฦะฯ)sŒa‡$€‡>ฏwกOS†Gภ�Hวe„? ื 2†A\ƒ‰? €I่ƒ๖๐…'xก๓œ=้…@)ฃฐ†=4ขดคs๛Tn0เ!ฯ๘†;พัˆ4ไํ0/๐฿~P า@รฃแ฿j˜˜๙]…*dก a(C!ชn‰LlขŸEำq`yฐ†?�ั9ศฬผ@>€ŒLจ 0ˆ&�‘S�ฐ`\๔"ล8=Tฃ@เศ Šท# ใŠฆhAV4aลztใ™J >ฆ !eLE:า่จ !�$L ณ=์ัใ˜C!ณ‘(คขh4Efเmpา“Hภ่1D!\€4Y zd‚!(ฃ&าqลAžไ 5ภt†ิ"n8ำnธ`ฆ|bgศภœy(๕แิ ฦ&˜[Pล8ูrอ’H“šึฤf฿ ฯxสsž๔ฬ ๕H@*ุภ$ย$`*X(เ(@"๐ง$0คด PาCSฐศpŸUก๐%ต@�Ÿคจ’TQ jก"ˆI&\4�T@E! ‚–*ฑ!์YภM“โTง h?ภ€œสL๙„P{Y…žT^ไŒย*จ0ภจ"่ †`&Xม/ศWๆH๑g๔แ`xม(มbœ๎คอWร:ึฒึsฏ|ํซ_�J:€�ฦฌฤู้ &Xร(M)—"†.<`Bแhฮ%aAD`ุจ€ลp„๚^ฐ 3ผ n(F!0‡›ษ’อฒ˜ฌk_ �ุสvถ|•ฅ5า!Š!„.9™น[]ณู>๘ #L ว^Haฎฟ้ญนh �่Jwบิญ.sž‚�๖8,�ุn69„Dธกซุ-pบk�๔ชwฝ์mฏ{‹๓ษ� Žก๘‚of฿‹฿�๊wฟ�m๏S�฿ xภ.ฐŒเ+xมม๑‰5�\œ„งจมสdฅ k*0r€ kภ6"�p‰7ฬเซxล,~ํ&@เpกpŒน-œœ*.ฐ†lเ]จ”ธ6;ภ‰3�โ$+yษLF]†=@ปd8 † hXจะจŒƒkŒW$ํภ‘ฯŒf4๋xศDNณš์ๆ†ธ๙ฬMฎณ๏ผ฿ �…?Rึ@ �aฌGร0ฤ๛กŒ-ํht=๐๑‹ ˜yฮ”–3�Š--bS๚ศp�ดงGŒ็P‹zิัอ๐C๘�ไ €ภ ษ๐F๒ภŒ?pแ p` <#๔ Aุ\>=b5gzอรพ ฐ j`“zูฬn๖^๕�ฉ˜ธ๚�AฒQ *ธฅผ$8ศ‡Tธฬ ;ู™.vง/}˜qำivถป฿ ๏ิAีฎถซSไตศ�6ๆฬnvู�E66›ฎndว{แ oธภๆํ)�{สย"–ฑ๘ญ4ƒU?7ย‡M๐ƒ†ำฤNธยŽ๒”ซ|9–อ€u3 ภbV้60ถฒํ฿ekฦ–:ขI๏Xู็Frปฃb๒ค#|ๅLoบำy�ฃ็๊]/{$ศ๐†pง;ซ>0‚คP†Uf=่ฮQt.zœอnvt 9้๊^๚ำ฿๗ธ๋๘‰|‡)เ์หRฎ—�ะ%/อ�€.,ฃ”Nถง?์‚ง›ํeoผศๅ๙ศ3|ห7ฅj†ชP†N‘ฉJzช™+ะœ3ิ‹†฿4โ�n๐ร๚๔E—ผ๋_?y9/'�%7จแ|0$#�๖ฦ๎ ษWc๎W ฝ๑?`�๏�๑#W๛n”ฏ�๔€Fพ๕ฏŸ^wฺให7zนuŸํƒฏ™แฮ@ J‚”Hdภ€$ฐ  ๖๏�R_บุะx๋ƒO�็|๓ิ,ล@� ไเ่๐ ่@ กเ2 l Pศ”๘^Q๑ฅ&ะd‹€Œ‚EW‚kgz8‚ี| ท‚ซ๑‚n1:™มคภ๑เ<่ƒ€รเซภ lpH@1ฃKุ^เp`�ฑ& ˆ`ƒบQn—Qp&0‚ฆรiŠƒ•†#ฐsเ…�gbฬGTh…_H'H ไภƒ๑ภƒิภ— ร@ไะ Q€€LHˆฒ•a๖a€"(‘ตV 6ƒฌ'–ะjใืfFw“8 9ึ& ๖ภ ฤ‚�wˆ˜s๖g๒๐ กaึg‡�pฒ‚x"ึˆฆUCเLšh‹ฝแ‰ (Šวม…ฐƒv่ƒ๑ะMส่๎@ C@U…Hฑีo>dePฑ7 œศ}HrT‰Xศ†@Ž<‡ฦA๖ฃ ล(pทัc?ฆeiถ2€ ฐ‰ ๑b16cฟF†ด˜#jxf�ˆcZต๙P‘ŽยัŽ�๐Ž‘ELP สh‘๎ะ‡้ zเ%ี่‘LdR2%…d p f_@_31 ฃ๐QAepeYฆั 0UAg“B‡b$ "0S QTJำˆ8‰S*`‰�q A9SHVขืR(q79Sลง ม�OAWv …-เ•’C \ๆe/’a6feV”N5FŸ’Žฮe‹8mX9’)aBuฬ#OqbEี•0|ษ“3ฉฐเ%3U@)”yู†%S&0|`ูฏR––‰™๐PpAe�dXะžแƒฯุƒฯh‡ชŽเ๙‘ณ9:จค ้ Ho —dyเะn๐‹ึhํ€๕๐ 1 ื๐8Q๖6hN่ท@Jไ(vq{Tใœ>ฉ{ค๚›)๐% ้ะ€'€‹RZ–‰—@ใPเป “๔ภ%3h@เ€6เ0�๕ึ้(๖`๘ dG–ฉ„›� › Qh‡–h& œŽi’fžภDYึœจ{q5้ZŸชe…ถ �p ื๐—G–†` - ep๔Pฮ ์๑ต@œ๘๐๒๐ *žโIกไนจ4x๕  n€™1ะ ช4PZISZฅ�€ ผTท` (h@ ฿๊@ /D ๊ภƒm ๊ะƒๅD›uj:–pr€ฉ ``’‚PˆD@ aื@ผถ€o“€ ``Kชฦj�F˜โ ษ ฬ`9ภ>๛ฐ ฐ#;‰7l|€ฐVเ(#ะ_�x€ |ภ=๖@O@k0ะ”9q ๖] �0vฐœ\@ด:’Kเ๑ฃ˜ฎฉcฐฉบ 5 =๐ฎข“˜ง{ฺงP=ฑ6kตvkนถkฝVuฎ ซฒJ@…๑:vj)บ!พ๊ฌ๑#>€ @ฒš#ฑ(br>ฐ40=pi@”๊ล+;A `ชจชชฌjX�ชYะ �!เฺสญ�กฑ๋ฑ'กอภ) ัฐ&ฐฐฆiz?`ณnJ5[ณ฿เฆล teงA›0—0 /@+p‰Z๑4]๑ ๑m9�3 ฎ`d8ฑิถ�ฆAb,๓0๗`d 𖐠F‹ด‰๐†ปะ žŠมtEŠ” g9ธ@ ์ v&0€+P( ฦ0];แuGv@ัึgไจถI+3ุฆmoัmตแฆ]oซq;ทOฏ8ฑ)zธ‰ปธ'ะ�@)@P‹†#bp๓ภ[K๓@‘บ)$aฯ‚ถ™หถKดI๑ฝ@ ผดบ$๛ผJ!ฝdง X๐4ซA3รฐ ซ0 ญ้Šเ฿@n๚ Lp_BKฟoข‰ Šp!k`urPปqฏ๓C�ข[{ฉ‚‚„R>ื .aHf‹ŽD.ฃS„‡  ๏€ ุ0ๅq้1h๑9 qmภ (b ’ ืPคไ๘P‹Šภ*ณeWฝ—K‰œฟภ๘ฆo.ภwขpมผมถƒŽกา9œa ๒ย"ร(€๛0‹ทห‚BNฐุฐc�ปฤ k!๏ฑ )มำรฒyถ ฑ&\6รoฬqฬ s�bZ >d2หš)ศ‚์ณiXฟ‡L'D�'ฟข��! กqz ภgoผฃภ าภ2–ฅบฦŒ\ม,ะ วPAึฐ*…(Šย(�ทŠŒว(�u`*ฆ’* \N`*D@#ภ|f„’fNฌ้8 5,ลr"’ผq–jฃ\สงฌฤฉ�ฤ–๛ฤอหปLŽๆƒ>Nณl ถPcBถ&iงa“K�ใoฐุkLม1ฑuDA<vb>' �ฤm�–๚ฉ฿ใจ@=ึƒ=ฺณ!�`� ษำ�0tะ™๐Uะ 9r/ฉ ุเ�K�>|<ษƒ�€-ุN้ซภ*ฌpaUๅ็�ฌqy€) ื แ�…G๐ ์ภC�@ คเV6{ใ6.b@Rฆ-ไiฒฉ ~ฤž™เสจซ$ng)x8e แR–wiดF\ค_F�t‡้ �ะ5vdE~ไ<ฺ ™p~ึ ห๙ฺ๐)ะw  ๒utGคˆJs\ j๚iI&เ„Z ใ`K, ผ`o4FivHonw0€ๆHพๆสw~—]P>xิ%๐ๆธ)็zyP1เH๔)ˆคHj�่œฤ€'G•”Ed !GEะ@—DFfคwG{dw-P้jฎไฮK๓PŸ๗I{ �“nAษพ์tžuP ะ R}NAZโภ � ิฐV"`ณ�๎ํฯ@ เฦCฮ๎ฤ1š�špJ•งS#z8‡^mŽy,ๅPฅ•u–`ฐqฐ=f๗๎๏๓ฮ“Pq๏๚”—ปz–”)ƒป›/"‰Q!UแาO OWPm>t{yS)  ๏ หyNU•Oq๏๐T\&™ตROีB…R๑๑ƒูSษ๑ด.gm 47i6ภH ลR11น5’+Ÿ�ฤ�๐ๅj •7ฟ๓)ฏคO™5  yญdTHy�D โภ๖ฯ๐ Žภ pฯ๖เ� G€IํŽ๗ผ5bฺตŽ#ที—WPS ฃ`Mฐ๎˜Aๅ็}‰E$ุไ]�N๑ฒwn๕ศ{Jร๗ด๗…�wŽ้jEๆ๘อ‡zผ๙ก๏”ฌ˜M๕,ม๎”/๋>๐nฯ๖ๆ>๛ล Ep๗yฏ๛๔ด†๐ํฃ`์�Ze‡?Šg็{‘XsyฮPPb@ ณo�โ \6ณ๛�ฯ้�฿.p‚ืdMส๏&คš&“/๚7€ฯGŠ๓T^BCfภ|Pf )ะแ��$XะเA„ .dุะแCˆ%NคXภ?Œ3BฐุัใD !EŽิ๐ัไษƒ!A’<ฉๅK˜!BจP๑‚GŽ&Hภไูำ็O A…%Zิ(LI9e*ไำฆQA()ี๊K ฎnๅฺี๋Wฐaญ&ีธT,ฬง,ฯฎe�ึํ[ธqๅฮฅห์F‰P[ฆuช–แศพi]ึ%\ุ๐aฤ‰/Žzฃู‡zM ,า!Kม–oๆ�ู๓gะกน:�™`fฬ•Q๛5จ—ฏ๊ีซEฯฆ]�๖m�œI›0U฿ฟบ–�wnไษ•/g�ฉc�ฐQK—๘oใมo็��๛๗ูป‡ง>ช�บฺู๋อƒg฿��{๘WwW-˜ฝ�๘p@ \hพ”ศ�๏ธ…๔nA์4ซฮ@ +ด๐ยๆฤKP=ฯใหพ1qDKLLรึ8 qC๔TฯDc”qฦั ฏ8!ฬ/ฝใ^ค๑G ƒา#๋{ํ?wŒ<‡t๒I(Ÿ,๒ด{3R3+๊i0%๑‹๒K0รqส�ชผ‘หฏ&sM6�d/ !๙ ,#‹!๙ ,#‹!๙ ,zฉ5H€มƒ�)\ธม‡"dHั!ฤRdh๑bฤŒ66๔(dH‘�:^4‰2% &H@d‰‚8OL`xHS$„ �ั*#"EŠ=7BHะ€ฦฃ|เ2A ฒฅล—ะ์+Wฤ‡ช5R่งYนF,l\๋๓ภc๕Œ�aAขรวŒV�:%DEŠบ%ู* มa็Lม9p๐˜ด"ษปญ>nฬ1 !๙ ,z 5Hฐ มƒ*\ศฐกร‡#JL!๙ ,#‹!๙ ,z *� 8มƒTธแฟ„ !*”ˆะแC‹ flฃG‡†|!๙ ,zๆ5H€มƒ�)\ธม‡"dHั!ฤRdh๑bฤŒ66๔(dH‘�:^4‰2ๅ… @€ BDˆ1gชP CK/ฃคz็/ฃ!�็oœ›'EB(„18_�|ฉ0AP=Dc]ใรใงYา^XKขBŒm‚ึ•อวฯอ0๑ Hษ>5ฐฑวไn�ฝ๛� <๘ˆูYย^t ‘ƒ[ธrAIณ‹B€+�คa-๓fBืk`ล’�4Uฝขฆ:เ๔ฃญู้ฌ5, A„อคผk��I๒cFโ$Yv!๙ ,"zผะ@ฐ A�*T(p ม‚.ฤ ๐!D‰+Zิใ?Š-vฤr#G 1pเBˆ$%E”y€ฉแƒต95=‚ค๋ฤˆ&*0ใ”คภ-ีbภษ‡ลEณ&๕l8b5ˆ&}‰ด,c๒#&Žมษ๕IZส‚๐สูดณฬ}Šซ@€H� ˜ิะH‰ �ก7„A”|\g   จ!YaI‹:งิ(Rn@!๙ ,#‹!๙ ,*z�ะ@ฐ AB๘วฐaC  J$˜Pกร‹NวดณO„€Yx๔ำฯ-ํ๐#‡bxีมผำอ,rฆ๑aXŒ  -P|AF3ญ๐@ฏqpiซเ‚>a ™•06ิ€$ช`"cLะ@ˆŒ�q…'๖)œOLEIุc‹†5ม ษ๒ฤN์ภ–l‹6๋l~F+ํดิ๊fิณA•—๕Œใ!Tƒv๕hH BU0E*้๘“R๖M0E>™xB ฺ๙Cฯ๖nภ�z[ghไข/#๖2y‘บ์๚๓ฮ)0x0‚ึL๗‡ ค’OภืE=Œlpย#ี๘ำ-šFฬ;ะ $ธฮฦ๔ฐ >J0็UF|!ย ๐S มzีฆ>XP?ฮดW๔G ,€AภาO!`ซมjชŒย rฮ%XcŠ p๐ˆ6;ฐ€(>๘๘‹ Y้ฃ@oคp]w€�ใภF๚ก@ิก�ช]$Ps>`€Aภ๓v ๛A4uHž๘ฒMjญu่Vk๚้จงn“่๙ภŠ/cŒ1ษ5z7BSD ?k h6ฦ�1_ฤx‰0/LฑMOะลPเ \แฑฯ&Y\ฑhฺQ \LŠ VP6�ไ“‹6ฟฤ >๚ “N?0ˆ>VฌPE p\6>เ…6๒แ„`` ทศE.B`~*@˜ณฐฤF0YแG"Hเธ‹\ ุ E่_„ฃ6ศ\PาVƒK๘Bู8ดถ จย๖ �ุ‰s๘`H†(�<ภh/O‚ฎs x๐ ุ‡&PAิ#Vƒธ๕(s9 !๚ั,Tฆ”`็`ย =๔#\ใ6ŒฐPฐ ;ภ`/maฅบ>๚๑€”ศลย Whา๐ฆ0   ,hƒ\€) มจa ;ด Ž’{ภEฐ‹Vไ@ ฅx๏ัˆ ฤ Ž‰ ›9| Bะœ ุศD ๐)้เ�ภ‚–fbf8D;ะ€}จAžภG#(๐€ด`† P€vะƒaใG,๐ิN*?๐€๔ฦึjเIภฦ)๖๐7ภ"NH!ˆ.p�>™C ตlV oศ$แถg๖† ศ`qnฐ5H.šดD6^@€�#โฺ† :@ eรฝแจGq#2–ญื0้0“ฮQภย9่fjยvtใbฑ™้ ‡ศข๕จง*F*เ:W(O๓8BJ๐pคฃ�„C๗มR๗$+๕hว&xะฮ!เฺx2ฐQ)่!xค$€„~เรx๕ว<†ภ,เแl`ขz'ud8ฐ4ิะ"คศ€;ดฃหPDrเปZเBํุ €_”๚ “pจ„€A๔N _„‚๓�ูย=-๐ฉOฬpภ3๐?lัa„Yiิ$ฺแy๔ใ r%8A€+�  ภ *z�xmŽบŒ[รEภ{2[b4Q' 9)]ct*œฟ€เ…ุr@ขahงย”ชGค๘ภพ 5ภิB>• ๓`BRŽ:เ` ๖d๐ั ~ี…ฌธส๊NฃA‚5ฺJ;ธส•:่Gh ก *ธฮl�ุมz๐‡=i\ทมŒ)d!Œhฦ.– x ย!ภ|ศม•ฬภ† ส Lั~€๘@ƒ5œ์ฮ‹4*ุฦB`\ะ` �อศ&P‚pl…e~็p~$cAภกฉ&‹ ก"H Dโค}™(w1bœ`ฝt�‡ฦ[�๓jeฮ—Vฤ’ฦำข  ˆขp}ห�—๘ƒ@ทๆ,ภถ ห /�ัš` `1ํTYภ ึเ0=‘1ม ภฃ9S…ฒฺ๐)@'p้น@?EB03\เzดฃ๐xe Ep,`c.-ฐš b�๐*01Gฺ่ ใQ� ํP.™`80�`,ะfAQ6๒` �€๏3ภšp “hfS1P)`'ฃ8 ้pŠฐ  &c yใApXR„}‘Nชภ StลบP'ไฑ`ศ ฆะXุp�P/Bแ~3sะNั8แBu `ถ˜oฐ๐[๐F†#Qn 7กŽ์(Ž~p„ๆุญ2ws‡kA๘๙ณั˜lฃ๐;’JXญ-c-าxฑ"*ฐ ๐ซA$` A0า"ฎB=pqภA(‚$Žm*@%,้!ขa)  �0(น‘C*‚G`ยU [˜ขAฅ1&PCแRf-ู$0ฉRb" hก�uP dถZ@ŠQู%๖ ะ ฐ"er‘/Yrฑ7 o!ะ9WCwฉ’๙เ_i“†@Y๙jƒA“†ณ•y‘ํx\hy-ฆ’อšน™x—™! รsgY!EาNืแBง้™™™W€;Z0 ๖ะ^ฅžiฑํ„˜6’C‚‘ฏฐ ๘`-`uา9(9A๕›ง ั› ม+๐๚@† AFpZ@ ฟSqš�๐›!žไษ* 9 ฉƒพ™›ri๛8ร!HทIšœ™Ÿ๚™Ÿ €9ฑ†๐ํฃ`}า้dค2)๘ภ ›๕œ่I™7ง€Ÿt ์FQภ ๕  CŠ– 1Ÿ๏1๛™ข*บขจ#r’ณ›ซƒข0š{wŸ3มœใ)„(*9‘sžMฑwฌRสฃEสขHšคJบคLฺคN๚คPฅ"ฃRฃคฆ:&-WZฅ\ฺฅถฑฅ^๚`Š:$ชฅTฆhšฆL1ฆี‚-�ศGคgš=(eŠบปi@^๖ภm’Ÿ t@ ™˜  šะฦ3Vaf’ •86‹@I&เ‹eปพ๐›นๆ{ฟ|4ฉ๖๛ซ›๛ญuสยคKฑ์ซฌ�๋ซN‹ญจ๛ฝซต7๊ย๘›ฟฏ�ดˆ)"ญ„<$•AUทR็J๋ ำณ`Ÿ๐ฬ วP)ว–l›ำม0Q` p 0jDLน๒ซฐkบ}5ฟ1ผบ?|Ÿฃ:[ๆ�ยw�พ7œฌzจdฤต�ว+ฤ†๊ต)<^W�ะา@ฟgzญp�Šข%,ชDะ๑<ถแซ’1�บฆ2�[ ำถIง” คqšŽZ@ บ ฎ€Zช1ค)ฑž๛ฮDj›6]ี‚ŠิDแ^๖Œฌก ำแึ๘IฉM::�ีS]๔‰พq:Ÿ8ิvฐD-ืปอึข0ิฆp,ฆF!ƒํง,ค‰ํk‹}ืŽอkืฑ—!jุ‡B๚อุk]™�ุž�ู[ ฺข=ฺคุZ]ฺrชืจฝฺฌ}`ช�ฺ๖yฺฐ=�ดํGฏm`œ๋ำx]ุป}ฺใq�\ วAœ�ต]�ด �GeาZK� อ�6-�eํ�œห�ฦ]�๙‰uรF‚ั‘ฝ—�9ญ ฃซ)p*ค1&ตส0’ซ"ฐซ˜ขs^ก(ษHเ?""{กจR"ืก€a๒�อ฿��v์ฝิอฦฦ�หƒš�ร ิึ�เ้p๐k1‚!ฎvˆd�7� ฒ่š0ภฎ๎ ฏ%Cฏ๖:.bx.โยฏ�š; ฐฃ งP เ0Sโw สaIฎยแ€“N=?�1|“n jฐหcฺำ)Mฉˆ,หอ��F}เžๅ>๘ เดP"เ!Zฒ'๛);V„0๐ฒ1;ณ5{ณ9ปณ\๐ด, ดปณE{ดIปด*น8๚ y€ pฐG�€'๚€=#0i�ฒZNxฐ่Y  เ่~E’N=Ž–ปŒเfํเN๎ฯสฝาlJ๊Zพ๊ww)4๐๙™P! ทr›8 ๒JP'ูpทyปท}๛ท6ธMภ›$`ธ€ธIภธŽ นd‹ณRอะYภส$—J� ฤpบNR&ๅ����ํแu'ตฮพ ๅ1Mเฃ.ฯ๐n๊Ÿ๋ฦฑ�จXฮ๊๚พkXwhฐๅP& €1ภ‘i ฌๆปภ+ผฤkผศซผฬkฮkUX%ฝ@ฝึ{ ุซฝp]ึŽํ๖`p6าiำฬ€Y0i|'Ÿ€� ๒,๏๒fฬ mๅZญ๊ฝLฟžซวYอ�๏yขmฝ๏Fw"‰อI,ฝa๐๐ Q+ œ �ภ<ม|ม&ม†a|! #ฬ$IpL0๒ู.5@;< 4฿๒�๒5(�„ฐปฬj8Oน:_ุ<ฯq�๓ถœ๓a[ื`{๔ŽฯkpฦPม3ZI�K บyRQ<ลU|ลYผล]์@ศฆJal'dlฦhฌฦA‘iญœ]H@RP๎ ฐธ@ #ภpNœHธฏ๛ผ฿๙Q—H$|าข>พs์�Eญเฯ:ห<8\ู_�}„ทp B %�|ฦศŽ�€ๆว7/หC–Œษšฬษž สข,ฅlx์%~ สฌ์สw‚ืutu]พM]4๕ 3b‚ zˆฦ บฦ‡‡@‚Y&\ุ๐aD<~RคH%Mš™าcG•#Yฆ,๒ไL’4Iถฤ™๓ใL”:wฦ� TฆJžEEšT้RฆM>…U๊TชUญ^ลšU๋VฎSCpภะ๔ย…(ฉ๊๙หgชC‡~ด7ง€w่ a% ๖ ๓็^&!(ฤะ”ฮ฿ปn:|ฝP0T2ศ’ˆๅผ๙šl๐pวZ=eoR„ะเN5qƒพSฒ›�ุฎn-ทƒOBƒๅ๚ๅM™ร{_iดๆIโม‘็Tsน๑žวavลž]๛v๎�ฝ^|vœ^ฐ "D„ั`=&4Pกข"8xP ๖�ƒ‚ P€ฝ�4๘ŠƒŽัƒ‡–ภO๖๋oƒ ” ƒR(I>฿› ฝ˜ำ@D๚ aทเ„›ฮ&็ฎkฮ%—ข;ล ^ไQ8žˆZฎ8฿’ปn<#D2I%—dฒI'งŠqธ™0ภ 9”Jš >ภA bt๑ „!s<ŠJงจ๔ธิฌDQว(iาฑงŽเTฎฉ7—บ1)ูฌ๓%<ำิP<Ÿ$ดPCE4�QE—ผ“ชผฌIG”!Hศ’(EืlTษ<ฺtQO?5TQG%ติ๎ๆj1ฌ๊~๓tฯD…|*SSgฅตV[oล5W]wๅตW_6Xa‡%ถXcE6Ye—eถYgŸ…6ZiงฅถZkฏล6[mทๅถ[oฟ7\qซ๊๑งr{ƒu\uืeท]wc=wฬsำ}ท^{๏ลY"us^้อ7`&˜ิL�EXึ‚fธa‡ทณTM6ญKุ�"ฦ8c7†*โฃธb่8&นd“9v5P‘y{Ž฿“_†9fŒWFXf›oฦYเ„[ๆ1gŸ:\Lin้น F:iP780A๖žฦเiฉS�Aฉู‹๚jจถ๚๊�ฒไิๅ˜ไ[iณฯF๛ป Fศๅทๅ๙GNinป๋ก�ํธใžป๎ฝๅมปํท๙6%๔ฐป8mลg\ซฑ‚x"rษŸุ๒ษ#ฏ�rฬ-ฟxแ‡'พxใG>yๅ—gพy็Ÿ฿ คุp�ค๛$เ่ใืถ 8เP‚ู\ฐƒ๏้rำ8 (0a๗U}“็๘ห†พ~๛g•žƒ'เXห(nฉ‡64Q zฬzE1_hชAš9คเ)บ8เโ†ตภฆP†˜ภแ� &]x&N�ฯ„'ี๚p€:ะขฟ๐€๛x„ftม๛เร!–ฑเ<กศ&,‚Œ€!ˆD‚8ฤ0ภ'%…?hQ|จJDำ ตธลOQ ฐF5A˜@LEกaฒ@)JPยซ๗ Rผ  ่1NƒmBwE6|@G;โ‘… ๎CƒGไ™(ยฑ82.Vา’MA3่ค ’D#ิh66c Y0I่PDจภฑƒ%เwpwนo~๗�_ฝม ภ�…›~‹ ร๚6๘ท`ๆ‚เ_.&„),a K>๑oื็{`-ธ@‰KL‚€R@ล'NฑŠcุโ?fถ!ฦ๑Woเ_ทแฃ1 ฐ��q— ูว@ๆฑ�ˆ�œdแร9†ฒW1p@rXยN@<มสVhมnน.c'ุr—ฟๆ1?กฬB€A2ฃwบื�๎qฟ‹!8๔๎฿ุXhจธ๐‡'˜fQЁ#�๑ Hฤอ๓xส7‰฿ิฮJ^๔W�๓Fบผยชr-%ใ8ใ็Užะ‹ž*ฐlvัwฏ�๖Yi=ฮท2\�ƒ๐ท>ฤrtญ`เ~๒น2|�oๅ–๓8‚๒ฅฝhึฐฎว nภ&ึ~๚฿WศrดฆY“{lR็ฮ>@ Qฐภ๛เ‡ฟฤิิu&… ฬ Bโฟ�fกวฬ รแฟL3Q(DภTภdภtภฯ7ฑธ,๘๗+Š๛่ |ภ บั๓€Gh‡9(ผฉXCจน0<TAZ�ญฃ่ธwะxxƒ มkX†e�[มD9โSŠธ^{hJฉมP GโA„ย~‘ ๓า‚kเ‚๐fƒ'lŠธ‚{ƒฐ "xŒ(4C)‰Š ˆ_8�H๐`เBฆPผXศX?0Iฝ3์มDŠ 8@ ๔ €)xะ@8=P‚�น(€ƒ0แC(๔รฃ 1 ฐ0+:“`Ÿ„šxฒI\A์C H#ลT„—ๆSลV> tลX์ V”ลZฤ=Zd ฆ้ฉษฏ�ฑEฤล\d›ใ›ผฑ›ธฉตHฤ`$ภ\ŠวัสแœษูOrฦlLŠินูก™เ(mว™ˆ<Šุฦqดล(0Gฃศ;8๘hFuT>ื ุBด๔€Ah‰‹ผ�ปพณวฤ<“@*ธBxฉA8€Aุ0( �มใฅ๚ฃ@๒ฅ€K Œฑ*ฝำ�CฐLพƒุLศฐูฅ^2งYงงZง“€H˜†๛ =ฺรห้Sฒฤณ4‰๐=‹มDงฉฉLอ{„ล�ง"RJฝ 2"ส$Šˆฦ‹]2(Nฐ‹พ฿TM?TรmhCOฺซะา8Ÿd ืศ’-!\E-ูพ๎ฃฮผดฮ ุ‚v„ด,๑“LI)‰_�,(C“P?๖sฟ๕ >Yม€ิ‡@8 H{วํ|„kAžฐ?�ณ]„ญูšชq^ดPฎษะ ีuฏzะ๎29๚a.่วงะ-ะะฮ“๐?f๘œฟ้›ฟQฦผนAฦฟ c๔Qฟนลัย9ภU4ยbŠnค Hะ�jj”ฯ้*�œiดRkฤRjœFะQRสหปิฑM��ฦ“ฐJ;ำ�ืซnd(eำrฬฮ ำ]+วจ@G5”๕ั$ญำVcGwฤSGzฤ @ƒ/๘ฮ?ล5}ไวf €H�ิ;พCอPYMศ‡2H˜‹_ ถฑ‹h†UacTeH‡„H•HŠภHย[PBa€…~เz*dSถขฐฟcศฟcKถTํ. A?=aI_pษ“ŒI฿x…_๐’ธถl�ถ™˜€ ่…๖ร€lีถV5V๋ฒIœิษe�8ข4Jค„8ฅ,•H‚Šฤึ 8ทtใึํ‹ƒ เsC7r-W้๒ึ”เ4-ญไJ๐J:�” 8rPคzณท�™ธ%x`‚ืกXi X่๒ฒไJF7‰หน$ฝ0ฝ‘•ne…–ห’�ไ ˜5 ™ํXีพ๔K‘Y฿Hฬลิ€ฦ|ฬ ะ@Wxฒ‡‹ธะ1M7ศฅDZ‰ซY›ฅLณ์ศ! อึิำ�ฝก๕VP…'๋ธSHZฐ �5ะำจe-ึผVƒึ ภM�ฬใ 8Ž๕<คงVU9–s9‘BYQ0ฃ“ุ�–{9ต}ฎN)ธKิ‰Œfxฮ่„พQ้@}ภ‚ี9žsส�‡0HGศ�ž3�็บฮ์ิS๒4ฯ=ิ>๎XEัpห)Iบป€ค\สTš�ั%]๗„OŒ ��ไฯ=�ฯ๖K[๑ yฺBž :ซลณ…ปๅต[�Eฎ5„ญ€ ๐ว}Prผ€๛ห?P๙5}เœ‰ฏ ;ม;อฑC_ฑฃ^็:ั…Rจ๐€ pQญž @?=”จ9ะฟู:ปด๓ฝซ…พc_ดSป๗u.'ญ)(M4•ฐธ�21ำHKŠZe`แชเ ฆฤชฅ5Kท�7–ฬ„ผ9๋๓ :‰H‚{ศุDaanฐ�ู๘ŽžสŽaDัa�.g�'Yป‘ฦ††ja๖ ์,๓ส0G 8โKb•6)bi๛a†แ%6ch1น6z(ƒ(n$ฎโ� ฟŒฃ–๘ayํb…<œฯ ,ษ’:–-ใธฅ`[˜€ธ‡4ธค๘แ4�.!yบ–‘b4ฮO%VHdIIๆ!๓ฝ€Iฆd“ฐd0ฑธ‚ฐ�‚tX‚(˜"ศDCžbD๖.E&92q:†š5.๎aบ0ธ้90ฑภƒvh|`Zนๅศๅ]พ€^�eฆ%$ 'Xlุƒ1ˆ8ฅ8dVฦ.WF-X~)YFLH.‰X)˜‚5‚์€‚)ƒ!ะNpgr6gtVgvf ‡8x…< D0cGถๆ์าๆ|*โFพTZ–QoŒญู9@74 fhาบถ�Wa ‚Z€…็9ƒ่Dฟ|-ุbo�๓l๘ƒxfB˜�“จๆŽฆ.,–้+�  J8ฎeํ@=ุ'‚tุีล]จ3ฆb˜Ž้๊;-šfƒN„๎๔xpฌศ๊Tกv้U6๊kKu แกn( ๅห๓4�Xธ—ฮj›ay๑๊wฒ%f>๘1๋ณŽ.Ž๒‹ญทž•kfŠญ”W๛kฑk„�kฆ๋๗นแWI๋ย.งLa\qlฦ&.ฝLa“้8Vง ›nใ}‹lษ�"๎š~c’FœFฮึ=}ํหัึbฌ~dฬNอ6 Ž้ก;์ึNต�ฯ~l7&๊$ฆm$ฑํ’˜แN 5�Žฆลพนอn“@ํ%‰n—&j–บ�mๅ~฿>Š”ภํูฮ้#)n‘žโค6บิnhโn”{ Y๖‘๑6’๑ถ้=ู‘ a๎๔^ž๕n๎>c‹5ํ#™๎�ฎn๚ถ’ฅ9�พค๛fม˜๘n€๏๑๏๒&๐คž�Fpƒษn‹Spต6๎็o&)n ˜ožSผpSูpfAแ๎†•ช�6’็o€ฆŽ(ฑ๐•o–�ม4ajแxp๑ฐm#ฆq4˜ว๑EAr [™๐_’!qG์#_๒$?”+/.šY๋๐ำ&้/j’,,๗�-฿๒ผ&?ฺš“ฤF’5นดูบมฆ3฿ภ๓–�ภ�;w>ภ„@๗๓ี๋เA7tัYc74?tไhEgฝ o๔็zt๏P8๚a๔Io์Dทtงใj4ิt5ฎ๐ Šลฺ—R—c๑O๕๊ช๔*Gl‡:ฎฮ๔V๏ญWWk Aj2_uHท๕ไยuฃั๕icc๖�u๎ ๖ญF๕j๓WYตg;vd็๔๎`mhvํ vํฐaXฌ๕kGฌl๙ู8V๗๖ๆwm—๓๐+wญu_๏tr_wๅ‚wC™๗xท๗{ว๗|ืwvMแงn฿w๕๎๗$้*€๘๘7�wI?๘ฏ:๗๑(x�†oxšzx๑ˆ๘zŸ๘ฤช๘E_xŒฯxฐฺ๘Hฟn‰�x ๙๏ธxw/๙~:๙wl_๙™jyj๏x•๙gšy๎H๙›Ÿ์„?’็yแส๙W|y›z.zmฏ๙ฃ�-˜ท<ฃg๚จ—๚ฉง๚‰'ฐNเซซฯ๚ญงŠ0‚๐MŠฐธYฬฐ˜€ฐ†ภ\่ฯชธ‚~ะฐOบท{ผ— ฐZMА…iธV่V.ร\`1น๗>eุข‘่N5/ม‚Hะษฏ|0/์aฑ๐๚šสฺ์Y &ๆ|๑IหW€ฬ฿�๕ |ี‘๕€ (#ฏt�๗Pำง}P๘ ’ธL{“๐€`ฃๅz“ภฉจญเ€ I€็�&ฎ€'ฆ€ q•ค‹฿�`bธ๖Pt�b้ ZะถX ฺPรถวa0_๖we€�่T(Œˆ[ถR(hฐเ†ฦPy่๐มZ& �hKWฏ�›bX๛‡OXร‚+^ฬX๐ƒกj�๚)Sbbฒv2๋แ๛uกC‰\้�ั๛ใ!DA•ฆ๙8ธมƒ*W!€lZฐย”T;๓!Jแมรj๕”อI‘ภฑ@+œ Wฦร‰Gึ�ฝW†œ;{�,aอ” i+ยม)เภ‚.l๘0โ�ฤŠ3n์๘1ไศ’'Sฎl๙2ๆฬš7'�สญ2"Rคภ X€ฅsjฒt๊&„‡O\)eŠZ5kืB่hฦ(…จh;Lฎ0‚* ์ฺฤใถ$ฉ๔ม{vํ ฉฃOฏฮ!ณ?\พภaAยC#อ ฒƒ€]พ@มƒ Bง๑8h<้RƒUPุ๓วp|‘Bx่ณIšธV็๘ฐ ๔pO ฺCศไมP|๔ู‡_‘X8มษˆโg1ส8#5ฺx#Ž9๊ธ#=&–@4<’8™ผuฺ%ำผภภฝรB h[’K6IฬO‘ฯ)๚`ั|�€ืKrหใœUฐย=‰\ภ#L”šl&ย‡ิBEi@ธะŒ9ะข`L ร>q˜ๆ#ฒษ*ป,ณอ:๛,ดัD ฑO9E˜๐—Sจ๑wkฬaƒฅJjษท„;๎L‚M)>+ๆqepๆsI๒ภb4Š์Vฎm่๎๛Aฟpฐย-�hF $ภ zPบขอ;ศ`ำ ฅ“%็ฅ1@l"ปTก<่n �QไCฤ ุ์1Pซ€จด5ฤUŽC.9ั‘e5๐ม ?<๑ลฟุWSฯๆ5๖%ูผะธ< จ“ภƒ ฆ)=๕ึCy(ฦ`ฑฬ"6ˆุQฺร๘ขอฏ"K]�๖��“0?ม๕‡๐ภเ‚cPŠใร �คทeโOจๅ€JH#๐1X๖hม�อห ุภ `ŠคเVฉฐ๋เทE0จP Pุ‚  ภŸFfI,ฐ$x`QVm`"ว;"“จฤ%2qFธล)„ P=z๖HD ั8 zกร5แ†7์๏Wฬโ—’‡c๐I ื๘ƒ :X)lฆ๘ย˜7ภ๕ฯ~ฃษศ?มูa_x‚š J1วzร๔ฦŠeผ 5pฅด@ฬแ ๘เwฐˆ7ฬ!LNฉภ๊x /tก” zP„\3‚� ฤ์*X6มƒ ์_ธม๖‘ˆ ฬอuฐ$&ƒŠm€ Fl"4ฃ)อiR๓ˆ^”ใ`าDyิ€ HAโา‰wผร0ะ&7ฝ™‚,!uส:1!€-0 ๐ƒJ�‘, มhYบ‘‰ˆ“œๆ์็?:P์�เI& ฉta�ศ‡)R‚qZD'b Œ�qI[Lฮโdk)g Žrk\ฤ �โภถQ„D!\ฐม๖ iDุจJ๊แQp �ะลMช)ีฉRตชVล‘^“&aˆ@ อAะ@ *ุjWฟš‚ 4�@"เ@€คฆ7$ซ2@ฮu๐*ขD2ึฒช  ่๋_%$€ฌn]Jค*ะ>6bลซZ ๒:TVฐdโ`(ะ<ภซf�ภkฺ 0 ๐ZHณ`ภe!ฐฺ‚tฮiภๆUƒ+�แทธApๅˆ�ฆิำค‰ฉg —ƒทi˜n8หฃ[9ปueฬn Z�Bัu 2Xใฒทฝ๎}ฏ—[๙ยwF0 L˜QM !ฺ5 „ภ‚๓&ๆพ๕=0‚ฌเ7 "ภฎiXำ่ ›ˆส^ฐ 9ผภ4)Pฏb aH˜ม�&>1ŠSฌโa –๎ž้ฆ,pยŒ …aฤโล0P‚w ำโ‡3ฦ9^1’“ฌไ%oๆgศ\ฃ ฆUt๘X"ภBˆ,˜„โG€ %พ๑ )<™ะPย‘ 3ๅ*ไสY�/“ใ,็9ำ AD 9&ฺธ?ะ2 Tด ศNyAžA x฿ %2เnPฆT3 � hAฺะu�4จC-ฃ[]ฒ—c “ย08 hม.LvTkศใ�a๕AXTI|2jrฅซb^p†uP AbQŽ{ ฃ0จ.ข:.ผ ๊p‡; !' 9ั.�Œฐ‰mld+›ูฮฦตจำญ๎uๆVUภ0Šโด3 …i p€ \tฑ @๑;ฤLปิฐ‡|`กน†มภboแุ๘ม0†q�6!`A Z๐‚F`�ิภB ` ˜ภิ๘ฤ+ž^Œkœใ์พ9ฮAmุน("xำh?Kืธ6�5อญ‚น~าSฒ๚€เ}„๊ํjMใฐโRpกo*@Y๔Œฅภ ธถ^ง$}ฐ* ป]�žี\ 8์:+ภv@XWkฯE๐ษ_$^ุ‡ใึ {Ž๗%~๐ ฤม๐tA�ฮP h@ริ๐ฦตฑญoxƒœ'พ ˜ล7�๑‘Ÿ|ๅ/๎œำพ๖ ^@.สK–|0โ*~ธE=�1Ž9hด#�Fภvฟ฿๘ กZุโ–„ผข๘ภว?ไก๛่Dฉ฿d�œ ฒy‹ นเI๏ฃ4‚่็‚X;s/…TผใขŒXJ๎แปม`PFIภCฬ  „Jฐ„KlD๗iร7m8ˆ\%$ฬำ`€แ!�ร�@ ฌย78žใฉร:ฤ‚#„pq€85”\ษลถกƒ ข5@CtXwi zเ:€ ’  ข ํ !š˜x0์‹มI�‚qฐ‚/Œม€3˜A x<ื=aNกฏเม‡„ฌศ€าB|”มD$๐ร�ฤ„ŒคXฮSL€ิ p(าs =@HพŸห@๐1(ศ|AL€ ิ"Œม \~ะ,œภ4 83่ €‡x‡yl ธ!สแQh,  ภ+$aแž์Y` d€B† ่–ส ๆ™ขคม0 ƒ บ;ฤ;ฐ ~‚ญ�b๊"/:^,�ขใ0!7vใpa�ฎ‡+Œ‚`€'Q LP9š#:2ร"อJญ˜�|pฮ ์รภลn\ภ`€0€ม5B฿dฮAxLซลภ6ุมŸฬŠ9๘€จl‚•�C#๔`ภ,TะŸ�ษFlƒ‘O๋lA5ฤ0œ ฐ‘ ,24AŸtัP@ $ืล\รOEภz๐@ค€�ƒุโ`<ูย:xรˆxH@‰ีUผ€@2ช;(c3ฒ5Pยจซ %Q%R*%Sฆ™7–ฅYZี กrM*ลAPวเก;r[ั.แŒฮ๐L๒ฬ€>D ภ#€C:T?HBA†_ๅดcS๕><ิCอS ˆNˆ#ฏนN.าA4ี>จAแฑ�=ม &่C"hม2ธยครย4Œ $@ fa๒C(„4ิค| _&=0ทF. ม7lค\!<œฝs2''T%t:ฃ7 sฆภฯ ฦp'Y!งržฅw~g5ใZ2!].…].&ฅดฅyยe˜]์ฬNC@>ฎมA@*€Cเ€ฬC(`€๚)ๆ2&๔Cะ€ะ@pโdJŒeZคSdๆฉqฆgJh2ไย'Hhะk.@แ๔รแ˜€}‚Cเภ์ะRภ ภอ"ะ๗E_�๙!Fภ5�Z„› ฦ„B,ฌ‚‚7 รU:�#4H ้จ€รฦๆ่Žf@�(xfฉ–"‘P˜ร ่Z#จgœงE‚—rdyŽ)\�Pษ€Š†ภ�ี# คs ฟ๐šeะaุi$ T lร!ขภ–จ„Be~ืDูEฮ‚0ผ˜LXG~$(Hƒภข3LCฬ‚9|‚ภ.เ)ม fซE5ภA4‚น$ภ`ม „b`pย ˆ€ฐQร7Dž ``ภ ่B ม <ง36#’ฒ‡๕.`Ykฎ๎jฏ�๊Q ๋–n+ทZอ–B(M˜ฆ)™$‰ฉ]R@/�R0%p€8&�’ั\ย<๐มdA9๘K”„า(•Rแแม&uาTเร)ฬมุภ+k่CEบ\\A?Hƒ‚”A$AbQข%๒@Š๐C!ภ$่!p@า!%า"ฅIพ๎kฟ๎“Œ‚5ุƒญ4อค>ภ*P8‚A<ร7ธร74Bุ๊ก๙ฏ�@1\e็5#7่๊ ๘ภ“นu -ั-า*-ำvซุŽ-ดpภไมKuรเ‰+๐‚ž}2DP„”&\”)ฐภดํ�ฦญ L€P=TQล๋ญC•@ล€&ฌลE�A`TJ”HฅIู•๙๙รEpมถ0”C ฿ภ�฿A�ส�F!สHp.ข$ภะร0€หph๎CัCD…�๎„>1ฎ]@ ภ›สB>ธม์5(็ |‚ฦe@bงi๔ม7P5l€-P:ร—mฃS/\o๒./ู†ฏ๘* ล�n๕ีH…ฉจภ^2šlนีฯxี_ดoไึnะ%€iฉ@{’ึvšWำไhภfUึ#B@iฉีฯ(€�ม]`€อ Ve0��๕ผž0–c‘ํ^ž้…จHฬA0@ผc`ภ(งX#ผ@” ๐โ3๔ม €ม �%pC1œ g รล ว๐ �/'๑h๏a1dhํร1‹๖6 “่'1่BJฎš\ฐ@–A1€eš#<ฌ†™ม ค€C!L+v๎จฎxฑ€ฑฯ1ื๑ ณj:ˆยh๋e ญข ฐ@๘ภŸ09ฬม ๘œฑc๘1ฺ1$Gr$GŠท ฏcฐภ๐zื$‚žŸศqa`ฒ$2)—rƒrwํช@)exฎ)ฟ2,วrfDŠหฒ-฿2.็ฒ.๏ฒŠนšGฤSฺฉžšณ1ฤป]|{@�พีp0TึoKึุ้Yฟ4aKsr8U๛๗x๖‡๘cCt[ฯดvกตS3‹†‡๓L8c8‡ฏธœ w‰ธรr‹7๗SCuq'FŽW๔ ิ๒�๙7๕3งuqรtK๗๘BWs’�๘! ๘ภP%)PB` 0@ฐDลน™ืฑZงyŒฃต4ƒ๘“ฏธ›O9 คA1P9ธ:|:C(๘ ่่€T@pžนกณ]**O†h‘†%oxS4๚J:๏๘šwถAHบy#yœ‡V8zด ย2บ บร เม08ย*p�€:ญ3—ภ˜หŒ4Tลฃ7yqภ ˜ภฃ; S[๚ฆงดAŒภฬฑ๋x“#;a๐บฏ‡: 9ภ`<ภ 5ฐม%$ร09tB ภzญ�x1'๓2k!=๏�™0›‹;yi?ตทถ”ฏ๙4†ทิ่Œ€ุ'จ์ผ็ปSฐ{<ด]ิฺญ%ไซ3J›ิ�œZ†GsผŸZชญš๘ Ldผภผe๐@!,ใถŸz<@oษc) ฐฅ{‘�u9วั\พ๛็z3ผีhง|SฃŒปด“ำืฟcฌ.€<\9›3C�[ภ œ6๏[E<L<ดดป�� ื�A๊Kว =ัSCษ‡ฝ;ˆปุ{ƒ\งหฯ๕e-0iHsF๔ธUงŒภ(Œยขfั$]/ ๘v™ทเ๛ถ:ฃU� Y@หฤ฿ฯUฯ‹฿%pีjIณุ=@e๙��jq—ไ�6iหสพฌ}�๒3l@ ด+~„ซภู่หผฬ๓d๗�ŒVๅ—�`€้ไg�NW€ฯไณi RL]ี>โg–`˜พ^ัฃœEB HฟWuัฮ™W๕2ž๚ b�ถฟ #๘ภฯฆ=M„E์S1็8D�Lศ„๕ีร/ว(\ร;Pl4@�–ใ‚† ��ช็o�6xธƒฬ_ฝqn:tะฐ‘#.้ำWฏ�›”ศ•ฮใNผ|7ฎ /q~ผDo\=p€Rl€&zŒ<„เเBpˆlx@จ@ร‡ฑ�`ํ�>aW54๐ฃ-I“?ชๆฏŸ2%& k7ทพ_:จL็�ŸคTšๆใฅ†ˆช\…ฑ๑`SŸPmฆ�ๆ๏ึตM<4fีภฑ‹+œ WฆWTQซ>|�ไ�RRคู“9qŠ]VQ™bp๕๚ะฃn�พwCล"D‰[ถRเ~‰าทx๊Hน๛FJ]=ว‡h˜ ‡{า  ฤ1ๅ€j $ˆ ”่`ว ฅ)T#‹N๚ #์ฤS1“]ถูBะกFR%šL@ฏƒ์ฎ;ใ‡sป#ว\sฟ้ฎ)Hภภฟy้ญื�{๑อW฿}๙ํWฝKฆy€๎�I€ถค%ยpก=rFqญ ่‚ภH4qWF๑˜ม„‡Ž)ๅ… >ธgc9;๚ศ’l&8‘Œ0x`#viๅลAๆ‘โ‚ Hˆ—ใœ@ส€&ภ…˜˜ฐ#ฤ8&*h@cFนไ“S.Q3ƒ8 `N๕)เ ^๊ZจH ’ๆเ‰)&!ฃz�9่8-%ง �ฮsฃฎฟ{์zุ†ŽH@เ˜หŽ�˜ฃฎ˜‡‰-าY"ŠyˆเใK=:š€๖Ycol๙ฤ™๎;gเ๘ b˜cnw๏�๗ค #ŸS๔มba๔H� 7žaVYe˜xผsGGพ!งปo˜ศ�฿๑ษ/฿�๓ัO_�๚๘œญh!‰ุ=-ำผˆ'@|�q์R‘Y‘  ญเA šม‡Lๅh&%น๑f๐ใภpmผุ0`zp‹t B - ศาp"7ี]  ฬ@A๚ฬะ๓B ฎQ…๘ๅธP-6€ ฉ-Gžใ๖d UPเภ nแM„ก$�๔ ถ p ƒ์ uฤบ—ฑSภกgุCฬ/ ภภ6‡™�mD~๔pย"ฐฑ‡1ภใ~ ร”G0ƒุฅ`��ฐuž๖๑เh-,�‚ ศX)›‘จ‘ฬxไ&ภกR๘@^โสฮxฐJQŽ๒]&่ไ๚P™JUฎ’•ญtๅKๆ‘ภO~๛PC�์q?ไo9๑_ศ45 †€€ฤ!ลXIh ‚Z™เfฉฐ วจC€` R€ 0*f2 ๐%*ฌไ ๛Q!ะ`MP๋เ„u#`� P@"ฎ-’€)$4แง˜,@ š่‡1�โ‘]fq#ฉๆ5ณ ฦฤ�ัˆ+#<ๅIzึ๒–€#็|ฉ  qxEhi๐@^~ฤษ๋YฮFsZS*d%IโQ’…–ดฌjs�z”@๖�บ3สPชƒฉไ๑A/_UฉN•ชUตชG ้๘,aIk˜4| ์RPง"aŽ$ํDy*ๆสšฑ‰—”ฃY�ž�yHE@s‚ธฺ[@ค1@๖„$m๊>,€ฤGกฝQภ&`ู'^ฐทค1n˜ฬิ]U็Iภ&Hฺ3๕ช˜<`o 8วšภต’€ฏ‚Hฺ[=U iฬVK™-X`Zฐ,™ๅ €“0คเฃr์_FpX€"Mจ,l'  ญผ มn 0˜Œ)@m@ซ;œ5€RC”oI+�ฆฝ€ฝ๎ AรŒ…e,ยŸ9O๚P� v ˜่ะž#q=uฐcม์ภฤ ฎa O˜ยฆืy€ โงjP’<"Dsศม#=O�๖‘ˆ1ผกญŠyซ"A+"M8Z†ฃหŠeผ 5b๖ˆ/�เ)NŸฮฑ†.0+ ฅ>N1‡1ุม แbƒ>lQ†1BmET1=wื p€vรH€aลุa_x‚ฤ„)๋aละิ1}l@Oล(sะD… @ผa$๘T?lก".O`thG&พP…ธ:ทศ$6�`<BmŸJ๑Š˜ฬdจอjN/!ซtฅ,‘@)d6sฉ/‘j,ลA1y8F�ดpL�Ÿ2'8ย3ุแ`Sƒ/8ื„lqˆAPต๐ณกmi[uฉF B€LŒS,เ`ศ์๚ด.,ใ%0qFpฒ ฤ@�ศ‡)Xภ&^h @F&Tฐ"้ศE?ม๊0cฅฺืM72QtยK๏ะฦRะ€;hCG&`ฐฑ๓ˆe-่ ดB„ &hL๒!ƒด7พWต6 ,�๏0 Žํ \*{ _2ม”4`�๎6ลCh^–‡๋NE:�” �ฐy คา”Žƒ iภึB’9DG:~{:„t”ผR๋n๗ปYะฃ�"6oAฮพmจ‚„๓๐ PฆCs›ร€๏~�Iฤ—:Tjฐ!คำธA�เ†8จั‡ˆเ\•ฏ�3HฑSO›๔ฅ7�้ษ—€จ ฿€2า€@*A4’šฌ'f=uA(เ(@"ุภ^ฯอ,@9�ำ(0ซ8\์ณXQ=๋7เzุw@๖€Nย๖ผ>A!gqf� \อ�–€๚%P�ฃ Ÿ๖ศ๗ฦฟ‘6ฟ 9โˆ์i/Tเ๚Z๏๕žE€๖ŠO1P"�`Cว/�ศŽ›†ั๖$หo่ฏ๖T`# V‹�ศฮ—  ฒ� mภ#‚๏o/ฦฌ€�p๕ p๛้„AH07f$z' Š: €๕าƒ๕(Aค๐žม8ม ฅ๐๒žแL@WP๏ ม0 ล0?rpxํ=ฮ/= /'เ ส` ด`์ก F/'Žล 37๖†>๖p:ะc C*=ด+lBJ<โ๛๐ฮP:„bmุCฬŠ(ัCJŠH>q ‰D0๑ '่ ืฅ#|เจP =ฯ‹AŠ  วฐ]๑aQ•ภ�กาaฬ€ฅZด�#+๑ฎ*1>|๑^:‘=Šั>. ๙ฤ€Lฑลก|ภดbq�ฉฑญั?0เfวูพะฦาp? q= +ย์>ย๑๔๘ฆฒจภ ๘€ ม ” j๊้ฑํ๑๑ั_ฮฑ=‚ั>๖๑๔ƒ๕^€rเค12!r!’^pค!ูC!r")ฒ"-๒"12#5r#9๒ฌ†1?xฑ'ฑf:ฒ$M๒$Qฒ"Eฒ>B๒#=’$S2&er&iRฺŽFm๗pR8€hฏŒhPฒ$PD ฒ* †2D€™˜๕ั%@^„0ถIƒะ  +)ฐ&ม2,ลr,๏ๅhข �ม�A1$ฮ�.ย‰A�B>ภๆภาb-ฺโ-€c%�๊A€8ภA”0 แ๑ ฑ#` rมดแชฦ."๎ ๊Aฦncฺ’!0"$ย213‘,Iณ4M๓4qฃ’€ˆพเ พภาLe >Œ่N`� e๔ภฅP…6€�0 ึ "4†ภเ€aŸคค@ไ ฐ๓`Dfฤ šlfณ6ป๓;›eQ3=ีs=SาfA iุ al‹aฦถ b๒ \เึ4 š@o๘F)$€จ"b ฎ l 3†‘:๗A € z ์A Xฦe๊wN€>ฆaๆC�_ DงูณE]๔ERBว+8 —Ž@b‚@๔!ด`�ŠาaŠชศ าก๘!<ยvแ2T l€NศQIเ0TC9ิ (‹zฒ…^(ฆ>%—บ”พิง`4MีtMฏQFฎFทซL˜ kเ >A่ธˆ”  กL R๊ฆภฐCaจJซRช”’เ˜ C7ดC‰‘z*Lีภ#ฦ๔jผดง‰MIตTM๕๔�S๔‡2’ซ>ฟสžำฆ!fม>A)Xซต^‹ `\ฆ‚Kรขภเ กSŽŽๆวhD ่มC�[†w@jF€>ฝ ภ EหพN๕[ม5\ญ*ฎ คม5หเ 0‰ร<ฬAx€ป๘ก`๔t…อ� ฮT$ๆ– สก‰B wFม:/ช#เJฅ€Yฑด<ป5:•/a7‚ฒ ˆ@๙Z6š€2ญแ2Mb1$n-[˜–$|?6kตvk๕ฃ  !'oo ›Pุห๖–0))@UZejณ‚สภ๖�Ž+3 �ดRวv.ภ+หk7qw>\ฒ0B &7fำ๐Xzด€tA๊ฤ1 %R=:wqYฉJAwt-ฒqOLtใฃeญ!Da‡t๑uawvี%pki—๔€qwฟ1w}๗w—|xƒทxฅเpY้hถqŽ $Ÿท^˜‚ธQชฐy•ท?L•†ww๗{๕ฆ/2@ —#+ม”–ืเภะs{อQvฅื}แ—Eฉ +าw}q—>ไW}บ—๕ฅเยw ส@nฯw#�b๘1zW‚ฯใu๎Z,ฝ่w>Žfๅ„�>@์�8 ฝภจ <"ƒ๗ั‚๙เsSยไแ4๑%ษ‘Kฃ†o':๘ƒ7Fธ„OXh—†1.ภƒ „{7‚7ุื†ๆ'๕ค_้= @”ก�Aธ@ไ‡ˆ%i‡mXำf”I†ุ„๑—>z' † ๘ชŽ๑)ุˆ฿ƒผ@R3ุy›ุ=ކ n-�ท= \uกˆ_ยฌก7ๅล…ษI%k@pฃ‰‹ˆ+๙’7VธŽ ‘9‘XW๘‰ัฐwmJใฺz๒x!๎๎ุ?&่=h! ฆ Š นY’:“!}’I h˜‹น?� ๑ย`ŽงE7เ%ฎY16 ›�ถ๖Rใ%พoŽ๐6'›ฉฑB<|@5FaเVฎ2›_"GAซ๒&เ_“zAXภ&.@lq๏ihO ฒ˜๐๕V‹๕ŽR Z€™6† qเพ ๘๒m�Y�ธ2ืb๎jo:๑�^"n™้&ทrl™PU@1Hบlโณ� 1ภš•าm%š™ด๒�ฒ’#j:  ‚ นo+yา'!(W/โ‚(าm๗ศŽ))๚@4ฎœวvYฑขkฒคฒธH`'๙=†ฒ(‰—๒š;y@Hงฟ=T๏ขร€)Uเก@ข�ว:ฆ{๘iD๐c0=ฤzญ�ึฅัM:บ)เ่ ฏ๕๚XฺถE ูธ๏ฏ!Pฅ7๛šn็™ŸP�j/โpะo~าไจy|๘n,^B/Œย/B@฿มฌŽ&0ไtภJชŒ†�ีฏ!-๗(ณ๓2วŽN>ภฬs฿๊าBฤตกผ0ต�%ึฌdึ,&RŒ8˜ฤI คฤE` /†7๛่V๑D|แ ฦ€CภเE๐ภ;3ถYจำ:ฑS;็ม<ุBMi8• เ 8ี9:—๙(Œแฆภ5อ๋xฤG€dฮqCQš!*ค‚เ>…ฤศา์ Lโ์<พ]„d@Dแ วเ จoJfไ8‡D>Oi๋ป~ พ�(ฃจ^ežูฟ@)�d W~%X0VcC`—lA�� าฌ\f’A”œาษวi ฦพ G€FhฒฆhŽ&์I&f๛‡ `J.&ีG€TมŠTƒd>eT�C@E  Y)•C=tLrŒQB์lS�>ๅ“์o”ีb?Z€คดlเ‰งb‹ฃ]aœTŸ c๔K�w฿'€Dํ๓azถ’†g|F๒G#๐+žs๗�iฝขxŽฯขโfwn‡�6"�ีฆ๗ใS>_5[&?"œ|X\ฃbcฒ&)0` โ  (ืhƒ‡-ืธุˆะŒ ฬ(E„Zบl�‰มมฎ.pๆ1DkDŒm‚8*›+‹ฎผเแP-* †เ  %Uแ  (ค !เRถV�KิกƒQฃ.ธhฆ'G’*xPๅ*Dฃ*MใฑrยN;$Hdฮว�ต๓์๐3จ 7๖ลyXใขŽmzมเรฝF๊�อปทƒส— “จ08ƒ‰ร‰ ฬ’๖ยตJ*ฏqุ!D็R/,่EŒEอ๘ไh[8ภ•}`&ศฐฌyณ๖ํ�ป{�>ผ๘๑ไห›?~ํฅฃฺ0+bม"m๏akลฃB[้Pii‘ƒwhเะJ/่’I๗ศWฑ’�Wิ‚_ pภ๎ก=RัLYุcล ุิS?�เSN!pเึ5nไ@H˜ฐ๔า+�€&aด@‚ Šhฯ‡อ ดมx้ีาJo้1F8!~-)ศ ‚ฎิื4E€ฤ>ฟi๖a#MฐBŸ}Vjะฅš.ตๅ‚Œ4าyษ_mน_’K†ะ’–�5มั(žzึุฬ/น+•yๆ[AhขO"Z,ฃ„ ;ฆใ#&$Pย#เคS ?กด$ำ&ํฐ‡สแ€!i‚ยŒn[ฌ�H(  R•ฅ+#cส*:c˜…9>qG‡,’<0ภ.JัE9Oˆ‚5เ@0 OŒขZด'“Žเ๖p†NxR˜R*f3mมฃ๙˜ƒTย/HFฎ Pด ะห�ล:์เB`ม~™HhF ธ)LPฐC,�0ม7ใฬ|‚ย””น—ทcฦด Fƒi‚ีธ4;ข<„ R๐!’`™tฤU(ฃ0xเ!)ุ"ะEๅ€คcุณPบ‰็ไpซ\ํชWsh‰sฌก ่GpV,ใ \จม.Z!ฅ=โ 7ธร>Q๎`�ฐ…๋ฆุ‹a’จ‰Z๐ MtAnaS5ฆ"•ฉฏc „@(„) iนิ๚! 8|ก oศ๖๐…'xr\›€pB†€ื@„�@ต `ๆร�0ยJrN‚ฝไ`![1‡ภขถcฤ5๘0ฏฯ†vด�I๋Z�๚V;๖นsxƒB ู}pv@t ๋Xหฝdˆถถลํ0!j?๔nVŠdาฎhI; Lีฝ. ~"“ะ!ˆrฺิฎ็𖐅r0ส˜…5Št`ฏ}ฝภัด=ภ �pฦ F0เwL;ฅ‰ฐj)/์ฃY˜N#ฎฒlใ_่ยŠ(n�O;เยVi่{��ฮหนะ}ร*@|œbcฐƒNส˜+วุบฮฑW๊่ร๊ƒว>ุ“ke3ฃน1ศ๒–ป�e้€๖ฟoภฟ’QY�ชyY`ฌ^Pศ์ �ุx@”ฏJzา”ฎt�wเ‡j๘ฃสpƒู:P‚Nผ๏ะฦRะ€;hC�G&` I”†ศGPืษKฒpE AK4โ ^ @F&ธโ;XƒำoHฒฒi€QศG4‚๙c\h.ำ"]ต(ค�ศ‡)<ฐ= ร G&X@‚)ค#T-‘ทlภ | ๕x‡)`pฒtaๅ>๗‚–b{ป๎๕K|๙ˆkœฺ% @ R‘ฃ`Z�žCฃ. ๎ญd@ก~G:๊ajYใๆ๖@๛m—`Zำœ๖ดโ0Prร`ฉF *p‚ndยdZฺ8p'ง"EๆVหฦ;�ž–�C v>บํuซศ�สฑธ&าแwtใมQ ฐ!ค3—ึถ&˜๖J`9ุ่เ0†ห1 จ[t-Hับ?ธN‚ l ึ๛8ๆ0— d…๗w x๎s  �{g าแ†ฌ็ขึนZVžŽ–w hศลฆปม03ๆO?|p๔ck๐hD๋๕p}ฎr�oุวa๓ค7}=Po‚ถ„ผเ/‚‘ึํ๏hDูฟ๗๔JVนฃ ]Hเ‡.‚c้์k๛\m@ ๐ ศ&kZ‰$‚T €ภ๙ำO @@; `€ D0—-]ru‚(�ฏ7p~Da*p �โ—~ ะ* -Q๗#  Gณ \๙โ: ๐} "!  ‚๘hRข‚ืƒ ˜(%์๗!(x „2€9tัะ&#จ้Gqษ“>ฉ40kq7 ด๐ Z•ŽO •Q)•+Qัำ4$ICฅกjC�””V•าำ•“ฆ•ตภ•.3–ei[โjiLๆ”ข0Rถ–0‰•Si—w9Qภ ๕  C †x ˜i—�@˜…Y˜๓ง2R—6t+;9—‹cC2• aI”i™6คs‘™nX‚ šกYq+F)šง‰š[e˜ซ‰˜ฉ๙’Sไšฑi—ฐ)›ตi›ท‰›นนซi˜ญฉ›I›ฟ)œ•‘รiœว‰œษyCผy˜ศ๙™ส ั)ำIี๙UฬI˜พ)œฯi�้฿ žแYุ๙ฺ๙›�)ž้ฉž๋ษžํ•ไ 0™ˆt‰ ž)‘ูCŸ"YŸ๙ษ–๎้Ÿ�  * ้Ÿ๒iฺŸ�‰ž๓I—Š ‹9  ก*ก๊Y  jก๕™ชก�9ก๊ก ขนYกชกJขข)ชข+สขRYก Jข0zŸ2บ  สŸ-Šฃ9ชฃ;jC/jฃ' ™&Yข?JŸzŠ5H๐Ÿมƒ!\จกC… :D1bร‰+Fฤ˜ัข@Ž� 6hแ*R€„๘แQฎt��eRฑRอl้™2ฦ ‰š7›๑ษqแ‰ŸbˆะŒ‚Im‚`ฦ‡G‰Q— ตŠฃาฉNฏ~ฬบ"�บN„˜@A b/z%ุaaM+!๙ ,Hz๒5H€มƒ�)\ธม‡"dHั!ฤRdh๑bฤŒ66๔(dH‘�:^4‰2e‚*TTจAฤ @€@‡–ไ๚%๓D>F<๘นUฯ฿ธ9~ข„ ภ0P”“Ta+_cฦ€bf&ะชWณnํ๊j” ˜aโt€UฌZueๅ*‰ตZัŠ๏Zฏ}C|ถ’น>”ำ‹8„โภƒEBเe_ฉ,W๖Qๆkyqๆ8lศcอ฿ปns8xpล @ˆศ2ฉบgbnุ B จ0;U  Y6')]s@!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,Yz]hHp y�*LˆA`A‚Jl๘ขฤ…+jˆx‘กรŠ;fูัฃฦ%�|๒bร[Nฬ“dษ•eb�ศ2%N‹7yๆ๔ DLขŽ=ฉSแOƒ>!๙ ,#‹!๙ ,az�hธ€Aƒ†‚ Xศ„#Fฤ 0กล„ >”(‘โลnไัใG‹!’œX๑คย#Ištฉ!eLŽ&)4x @‚„)8lPภ'ะ›+V˜’*ฟ|ˆRx๐pงZ=esR eYก‚lฦ�L๓%E<๚6eัิ/ฬึ’*Œ๐ †Š Rˆpฌิ‹ z๓ฐ.๛uaฑF3=<8phรlˆฟย.@C‡ฦื _ธ@นH=ฬ6œ€ชEŠั$๖๖}ม`ฎ^ �ฃXaรฐพt™Cโlฺตm฿๎Vš…—5๏Lฅุ@ีฺี7Zu็l เัปˆอŽนฅมั(` (Rป๙“6ต!๙ ,qzปิ@ฐ ม&„ €ม‡Jd8bA‰ ):ดHใย†;z�ง1dฤ‘$Pก‚`… DŒ$ู0!c-Bhจp‚^™™ ฐ9็c =๎ฅฺะภ•yGถค[eฆcDษGdถ=cเม Fzq^ข…(›ฌฤFœธ*•Z l๑ ;bƒื?*a#ดe77&8pุณญHˆ(้ธุ {A&L(ภl‚,ว'=– 4 !๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,~zš5(ยฟƒb Hะ`ย„ |QโDŠ#JtˆQ#CŽ1Pุ @*T€|ˆมร‰Gึ�ฝWd%ฤx์z๒ ›  š'ๅย$€fฌะใV:TZZไP๚O$FPอBC Hศฐ'Iฦฅ{}นqg_ขฎ ๖T๓็\ฆŸgซVhpR‚„)บZิ ุbื€!๙ ,#‹!๙ ,†zž5H๐Ÿมƒ!\จกC… :D1bร‰+Fฤ˜ัข@Ž� &h‚!B€„€C‰\้�ั๛ใA%Gˆ#บ|‚+[bx มฎV9Vnุ€เถwศฐตzำƒ‡Nว๊bm๊ส '– "Ab@-ฏ7'Œpๅซ—Gฉb„XขS5๕ด�IฑR`’$HHม๗ฆว‹sฏ !๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,zจ5(ยฟƒ Hะ`ย„t๘๐`D‰(Vผ(QใCŽ =BLะ@…  @ศ ฏขลึLไ๐H —D:็cย‡dขD"ฤ ส>0d์‹#ิโ„เธฒOJำ&Tอ@คi>ฎ^ฌัฌŒ6L9ฤ j80iqbH ?]:จี@@†ฝ4ลธก…ึ/2สล…Y=MCr^!๙ ,˜zR5H๐Ÿมƒ!\จกC… :D1bร‰+Fฤ˜ัข@Ž� „0qa#GE8X มƒG–^ž์ ฃF†,=^ดฉ3gอ‰7๚\yฒ็ษ€!๙ ,#‹!๙ ,#‹!๙ ,ขzž9pะ@ฐ „&ฤPฐก†ƒ 2tHbD„)Zผ˜ัแฦˆ~”˜  จP1r!Ž[๕ดiชFoNKŒ@šu1ดฯกeE๊]ฤูฌGzR€0kโo่? 9ฺKฺฬ‰ะกP‹•โขSงYฉุ่ !'•ซอŽ-๋ต)ึ)้”˜ 8ๅšท(01ƒ� xAb๙ะ้SŠ c!๙ ,จz�ะ@ฐ A  „๐ฏกC‡œจ0!ร‡#œhฐโBŒ%r,่ภE�4Ž$้๑$H•+ถDูPe‡ @PกBC‡ $ !E —5†ธyœฟqn:�๔pงZ=esŽาL)p A๏tx๔mสขฉ_ค!(QRMP@e๓แXฉ ๔"ๆaซฦ%Y๖ฉั€!{Gj4ำรƒ‡6ฬ†๘ฃฉฑG–{k:†\e0ฉวYเ�บ$๐‚’ฦื๏  p๕ย`X —ฏ๕ฤ’5‹V-[ทp!ฐฆฤƒชO็tฑชตซoดถn`ยD…œ;E,�pA(QฃษkF”บR &{ol?“s@!๙ ,#‹!๙ ,#‹!๙ ,นz �ิ@ฐ A  „๐ฏกC‡œจ0!ร‡#œhฐโBŒ%r,่ภE�4IาใI*Y,๙cL @€ B…†8(H ABŠ5C ผ€๓8ใ�t๊แNตzสๆ EูPๅˆ‚๊!3่๐่�”ESฟ0I!&<c› $€สๆ#ยฑR/,่Eฬื” Qdูท†`6๖Žิhฆ‡m˜ ๑วUฅโ}jC–Œญ2‡ ™‹ิ๋,ุˆบ‚Hไ%ญ๏฿ เ๊…แฐื aว–=›vm�ทqปvscโgซP็tฑกช5ฌoถฒ�˜sง !€^Z๔hrฤY ฤ ๒�M๕.}!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,$Š�Hฐ มƒ&ิภกย‡#JœHฑขล‹3l่Pฃว CŠIฒคษ“(Sช\ษฒฅห—9ยœIณๆGŽl๊�ษณงฯŸ@ƒ ๚S&ั8nLšs!S‹L*ฤ9P)ษจSฃ65จ๕จืฏ`รŠKฆีฒ*ฑ:}Šฐ+Eญ[—vxVคฺถnน�Eหทฏ฿ฟ€ว|๊6๊… @€ @ˆ 6ๆภaqˆ $P‘€b˜0Qก‚$คHQ:ƒ DVทcํ�y ยล››๎^‰ธƒฆฺ[๗o‚ปๅ&ฬผน๓็ะk — ภ๐…(ฉ�๙ภ(„็;เŒ�sำร#e0k๘`mŽ†ช่งL ้นาีำˆu pขp$๓ qตU7pผฑฅWq :�‚ๆ4]VwœoF็แ‡ †(bUตฦPภU@�วo|Qม‚ิƒศƒ\#ฐœ0B*8ภŒ6ไมฬ| $lภBSD ?klเ&ฐ aรฏรC…`vX‡T=!„†ฉfr RทV]$.7โœtึiงXทฑภย,xขVฬ"อ $JB1l#Hก€’[T๙`แย1Tุฐ2Mค€$„pHภ ๅ4bๅืpaCอ�ยรŸkZHแ›p�ฆ\™ี๊+qiฮ…ซฐสy็ฑศ&ซlZZ†G;ํเใFHฌ˜|ูH์ฃCฐaฯ.4„&๚$ขล2๖ญpK:š„ั &$Pย#เคS ?ก0ไภ.›๐ภช6T๛๋‚ร‹\‚mบQ…ฦฅkฤl๎ชpฑsHqฦหv์๑ว วษิฌฤa ‘ญq€ญถ q๋-ธโ2แภ1pไ๒‰4tะยBฝ hกI?ฦุW@*เิร๖Hr™ƒœsฤ"ดภ`‚z3ุ0ว[ฬฑื [5qpม:lๆ˜ƒ๑๒ฺlท]gmj€มp*ฬ ˆn�3Pฃา๘ภฅ3ำD0‹9Ÿ„ภมŸbย1ญ๐`ภ.ฅผ0มั8ญมQXG4\–5ย[gฝฑQpŠฌ0aฃ‹1รdn%gลnืn๛ํeภ�H๓bo@ใ57ๆศภO!L!„`ว_<แE3ู pษ<|,‘E9ŠtŒb=R�บึ`w…prjฟnฆต๋งNzฤ ท!๎�๗๏?PY; v๊แ|˜E ๐ƒ6�1Ž9 (w ว…|pAุ2ถCLฐ ˆ&าแwtใโใภj€ /q ~er��ย†•ูฑELฑkูฦN— [ตo‡ชS]S�๖๗ฟ"๑ˆ+˜a4ฐ€ฯˆ@—!ˆ  ›จ@ `ภ9เ$`1Oผ Lฃ€ล\ั1”‰ภ1๔3ฺ&~๙cZ–ณ1ŽฒsฃsH?ฅจอ†2Dข IH2W-�c้š๒)…t@ ไรˆก‹๔l$+ Z SW•�‘mlKฏเ8!RrEc1Aๅ`F •Bบ๒•ฐ%'M‚ฆ„Pะ้ลH๐ศดœ๎(v,›"cIฬb“˜œศ#ƒ— q˜>1%D’yฬjZ๓šุฬฆ6ทษอnz๓›เ ง8วIฮrš๓œ่Lง:ืษฮvบ๓๐Œง<หI ˜€9LbฬH�ล}2F”ฑŒw<ณ™๘S"MiN“šีดๆ5Uผ"๊4P<เh&Vณ  ๓งD;ะQ/6ด3ž๙ฬ{9ฯ–บ๔ฅ)๑ž`‚fBD€ู)`wBะ๐0<ๆAฯ@ูใ๘Tร๔ฑข๒ณŸ� @J 7฿4` น๐‡6NQ pฬ!Vบƒ|”1Tซงเ๘)ŠยZz5๘NZวƒ"˜ฺ๕ฎxลศ@แZ”Aซฑ้AVิขลhF5*�5๘ภ๙HB"’‘�ภ%1ษIP’•ฌ„%-q <,๔!‡<`:B๐ Mdahaม‹ฃล�"ตฎ…ญlik<ไ๕ทภ .AdCƒGไ™(‚` จA๊P‰Z ๕จHMชR—สิฆ:๕ฉPชTงJีชZ๕ชXอŠ)ข�‡€ะŒd}jฌ\ะ bœ บyฃฎโ๛‚๙ึ๗พŠสฏฃ|เQแ๘ภ/ญ็ะฐrม.ศห"35$\ใ*ืนาตฎvฝ+^๓ชืฝ๒ตฏ~iเ_Xม˜$�cฝํ}ฏjะ 6Rฆ ฬ@A๖ฑš‹ฦ6ๆŽuฬใ ‹๋Nฒ’฿IเกฉbAม2แTุศ๖ฐฮtฦ3ŸMhD3า”ฦ4ง…jRฃšีฐฦ$แ�L`ฏ{ัWƒkฐ๑0C�๑ท4€แ#ฐะฦศณทฐŒไ%๚ะใ<ภŒQc�I,e๎,๎–ทฝ๕Mบ€�+g8ฤ)ŽqŸz\ไ@9แaNsœ๓่Dว™ฅO�ข‡ิษเ‚#ˆ.ฃ@8�2ภึธึ๕tyํƒ"๚ุศฦ&nq !p†— ู]๏~๗† OฑŒ @$–ื<}@Ozิณ๖x =๎y|โ#Ÿ๙ะง>ปX 1{๗j[��าศFตอ6n_�ุ��พMถภNLล-๗!8% จ@:06œ`น€ nะ�`GXยขq…-ไม ฃย€,ไƒ/�ค`>šVkธ๕ )ภLOฺ@๑ตผ�pƒ ฑ:ผฎบะ‘HŠฤต‰`„"OUJEู\1‹[์โ!F’Q @ืุฦค0ภŠดัิEฐ |TXMuDJล \ภคชA)�3๔บ��LH/#9ษJ“๙ิ3e˜๗ป�๐พe.w้›๑พ๗ศ}-๕ฑ๖1?๓ษ�๊—��๛ษพ๚ฟ—พ๖Q&‡Mิtดw��ุด๛ํ›ฟ๔�ึ๗ซ/~’ ็๊ฉ}™์Oุ—ฟ�ัฬ๚ฟ�lลื:๛7€ขg6ยคDพ‚+่ตGD๘€‡—}ศฑ€ุิ1~๏็€ธ ทdด ‚ ˜"p$(‚)ธQ'XF$xE,}ซฤJXƒฝท# �ฐƒ๒๐๒เง ƒ=8„๕„;่ƒ>„Bˆ„๒P„:ศƒIh ิ"M6X…V˜‡Oฐ…\๘; …]ธ…_†b†a8†dจอd€Wุ†nX‡qำLอิx<ืrุ8P‡"y8‡y๓†‚8ˆ„Xˆ†xˆˆ˜ˆŠธˆŒุˆŽ�๘ˆ…”‡<‡ฒ‘ตR"ี$ลpงv)eFšXR pR QO๗‰จ่yภO@g8p ๛ก ี@s๐hฅV$ๅbๅV_WนHWปุ‹oW!S4eyฉธŒBWO pu@ �๐ €Oม^]`๛ภ‡ฐ |"๏p๓–[�ถ[๘ึ[ใศoaEฑW}๕WลŒ๒HpM๖ึP ‚@ ๕w!gY k@ภ J ๘(`ย_�e_๘5ly ๒E_ฤ๐AG\ฦ…\ส5‰l ะ ๔0ฐ2ส8�ˆ>์ๅ=ƒ†}d"'d9ถck@h*ycฬP6PŠฐ` �๖`–‘Ws!ps9ทs=GsT›ผ่rท’)’ฆ9œ†vฃฑ‡ ะ ลtแv(wฬIRo7ŠqงEGœุ‰h !x  (7q��Y~ูYžๆhž๊)\่IM๋๙ž์I |2Ÿ|D…๐™Ÿ๚นŸ�ูŸ�๙Ÿ : ส6๖W:ฟ4๖็€ไY ๚?ิt0ศI๘๙ Z;ด3 0u`$ฅ‘‚Wดก!(Z ช" ‚$zœ?tก.z;*.PˆเฐK@ฃP(A3Zฃ7šฃGุƒ<ชฃ=hฃE`ฏำ /บคwBDภdฅa&0Q:ฅQzฅQ๚™-สค`ฺ1๎9๕ไf๊'เpฆg*@lŠ"jสฆf๊ฆpzฆ(Bw #aฺง;q:บƒ๘P1คG๘$จ‰�Zจ‡:จ;ธจZจEเ_๊ง˜ฺค‚เZจ…X #p4€O๐ฉVะpZhชX@'@ชฆŠชชสชOเชB`5;IŸ™ฺซษ2K�๙)"(x•ื8€ sธ7pฌPฌŸrฌrำ8สš" โซึŠ;มJ‡แญำr๓ญพ1‡แ‰ฌUๅืzฎš�šIQxŸx–วง่:ฏ๔Zฏ๖zฏ๘šฏ๚บฏ�ฺฏ�๚ฏฺWp> œ{ฐG:ฐi€ฐ๋Jpพ WCš{ฑทS‚เ E ง๛ฑIิž๒ PCp ›ฒ(!ฒ# \b (ซฒ2k,+ƒ�QxG`ฐ3�ณoมฒ#A3€”๋ณF๋{ํ)0ด€ ๔rดP๛ณ5ซด#@หpคQ›ตฒด ๘G6้yP.Œฅตd›6�็Gc’Hh�ฒ!]ณeทžค?Kภ๓ภ<+ท|[p๗ };ธฮฑท„{ธˆ›ธŠปธŒ�ธnศ+ซxYR“Ž[น3a‡ม^Dbธ–�น แ R p๋นค[ฒZ:ปซฅปบ—gคa€คฌ;ปŸ๋&@>ษ๐ –Jปผซern—ป %E�ปฦซ€q.`g”{ผฮ๛0ผ‚/;บฯ[ฝ๒3แ2…fฝ�;?ม7�ภ๋Xผ�{ผVq9ฤ ,@พๅ�ป{dภ Tํ�พ˜ .ภ „@-๕[ฟ-ำHiูฟ<ภ\ภ|ภ<ฆqงฑ์›N'Œภ�z|fๅะ๎มนฑ†Pฤมผฏ *ด๏  ๐๐~be‡ืฐ ห0๔;ย [กP—ภ B`0˜ƒฃ 4€\3Lร‹Ÿฅกญ’^ภ `Pฤฑ;๗ AD >F�ฐบ‡ˆโ ‡`)€ž P, 9@@I–”ลKDp€@qES๐00มq{ ๘tPm์ฦ;ฆf *อ4F๑�8๐๖„–{^Jศ�jรก‡”ŒธJšษซภœ์ธž�ษŒสแ(H‚#ขฌวขŒฏ˜'ฏ8๘„Lจ„L่ƒ๕p@ฌ�ส๗ส€‘…d่…fุ…;Pyบ<ธqx zศ‡Q(ล,ทxy๘Šอ์ณk P ยฑŠ/" ำ|ดซต„ะฌ,์ƒะ ŒีL‘;น�|ฤUp „๐;uƒฐ`ภ™[c6€อ๋<ฏ+`�ขขHŠŸ๘O<'7 ฑฃฦบิ�ฯ�Jw๐bํฦVพWrฅ‹ Q ฤฎra9{ช ั๙ส0ฦm”™ๅ8[็x™�9ฃ-ฏป$�ํฏ ำC+ ‘&I` ก=^‚‡ธซป~Yำ๖๊/ป€ 9@+1ษ’D–”}ฦฺ6 _ยE ผฤ‹ิ๚zำK}^ Qgwๆ”DfaQy!– >Pสหผ\อฏ'อึโkภv—˜Fl�qP ฅะิํ:ฝqฝฏ@่clใXoๅ๘o๙p‡Acfภˆท�;ุ๗ฺ7_ผ2psสs�1s?ทwkŒ ^ พโฯ–=ฏ3ฒํ๛ผูำ‰vž8wฝ$ฐ๛€X<่ซพนœฺLŠฌ�นญZa%p ๎a‡ฟ™rส!˜ส$h‚ะ ‚า‚ิํ‚ัฝ‚ู}ขฝ�ํ�Vศ \ะฌRv[ & ู ŒฟGbฒ<„ด<„ถl„P๘ƒFH฿Mh฿ณœ฿L่„A…� �Žˆฬjโ“ฏŠรมฬ…hx† ^†ฟ�เยแภ�หjHเฉXC๖ษฎ๙คB‡aVfฅฬแ‡ศ�H$>&>‡{˜เ%Žšโฺฌฒซแ}kŠMใqKh๐1‡ใqปše&๐HKฐสx++9pไ๋ใ�ส �ภ5…< �ฐั�˜’ หฐP.นw !โฏ๐ m ;ฑ[ึึ ๊ห7Kฑจๆศ็> ๖,หฑจ=แภ�ฑ฿�m็ค็C 2'>$รs&หF.๐ภ*>้ญ่สว่bmฌเดฝ$ุA๊a๊œ~็๎gณa%จเ ์ˆณ:+Xo=-&๋;›๊ษ‡ฌ๎Vย ชภŽBKดู Zm,5์k่บ�yอ๗!jœ*zบดM๛ด€ปขp5AํNปsอพ{žภฃ…โฌๆU{ตึH๎๛าผดV‹ต฿�้ฌCส}Qv0`ถๆ์[€บ"m�>ถ๓๎zcnษ๗๎O๖ฤแถ^าะณ` 9 Œฟ้?zฐีฝื ‚ST9Pณ"ฐ๚ภ+\w›ท™หผส˜๒z{๑ช็9�เ๘ฝ„Dh .๛K7"Pr@ฟv๘ท๋pต %-ธ‚ ๓ฉ็หdHแ>มภ๓=?1#-๐#�ฎมฯNyซ๘‡t่โมฬ[[Ÿผœคh?|kฏ๖›�๕า‡๐›ไr๗เ�๖9D๗xo๗ำ—็ฒ1ูผ7�f˜L๘ฏผG€ส๗ž—๘ฝž๗€ฟ–‚ฟ็pๆ ๘๐ว๖lห๘CGๆqC‡ ™B+R๙,แtกๆเ< ขOนB|œ฿y๖n1พ๘คO๘-‘†` -เKeะ๚’ฯGnฟ๗ณŸlศ1ท_�ุซ๛ูlŒาโw˜ผาiิ"แBฐ �@ะ๗ฉ™ˆŸ๘H1ษO๛ห}ฎ?๚Q๚…Ÿผz2�‚ฅ'|r๒0�zR�{า'ักภ•yLถค[e<„6*64hxx๑bฤŒ#jฤศQไH’%MžD™RๅJ–-]พ„SๆLš5m�ฤ™S็Nž=}�:dศ™ hาŒสQˆ’�c1!y�๚ษแเย<ํฺแs“ใ€ฉUฏf�ฺ๕kXH่9Y„mฯxGP&ญธ๑จัŽC!๚ X๐`ย… FœX๑bฦ๗ี[ิ่Eผ< 1มŠ)Sึ qxกร Sย Iั,gฯ ตŽ.}:๔�โผสC ‘4Mโต0ำแรGž\๙rๆอ?‡�“ฏฬกz5Lฌั)ิˆ0|เ;�C c!z/�wy*œxJ•ZฐlๅP�›bpศึW.@$ฐ@Dฐ9ใ^ชŽ(เดร,ฐ *›?Zˆ‹NบN)�๖โ ภฟLฐDOD1EW<ฎม˜D|8ฝ.ใ.จฑ๖ุฆˆ€H‡ B(‰29๒ ฦ=b1I%—dฒI'Yt&#ฑ๛pปฬ€๋L0AR๘1ศ๋๖)ฤ้>B๒I5ืdณM7฿�ฉA9็ŒR"๕ฒr0๑ ส$!ณ#’Nกฮ„ณPCE4�Q(้dtNŽd�ˆฦ+“๛ำ†2ฝ42E7ๅดSO?UฌัF‰ษ!Œˆz๎T๐L:U8A…5VYgฅต%9I ดV]wๅตW_6Xa‡%ถXcE6Ye—eVน<›…6ZiงUVRjฏล6[m฿4ำฃgท7\qวฐHฃพ%7]uืe๗าŽะ|wฏxใUk�ล7_}ฑญSฤ~๋ี�}&ธเ`]ta€๑Œะ`‡†V…'žlแHŽ8c7fsฒฟๆ๑ิๅU`ŽOF9ๅO15Ye—_†YอV]ผ7f›oฦล™l9gŸz9T3zจ็ F:้ฦjVบiงŸ†:jฉงฆบ�jซฏฦ:kญทๆบkฏฟMM#4lฐฯF[ั3ห>ฌlถำ†;๎&ืN3ิบ!“;oฝ—ค{lฤ�พ{oม7ฐ๏ก��mยg\่บ' pฟงผ๒ฦ ฟ<๑ภ-็ผ๓ศ฿�0ษ๗ผtำyยœฑัOgฝ๕œR_luืgงAะ']s�k็ฝ๗#Q…|0ู}'พxุํ๖;๘โ—/�xฒu'y้›บ�งวพ�ะณ็พ๛ย”๗>|๑q"q|๓ฯท |๔ืgฟ}๗฿ฟiO๘็งฟป๚๏ว?�๗็ฟ��`8@ะ€D`ธ@6ะ„`%8A Vะ‚ฤ`5ธAvะ�ƒl t`NpHŠ! M(“ 9@I ฌQ•i๔ˆ5�\tเ„ ขp…~่b…($ข๘XC6ŸH €b>xศ&@เ$Xbล๘ Pภ$PIH `𐀅 ?jdฃแX HŒ H€ Tะงˆl`}์@ะAโฑHA โ8ว:�qŒไC๐ ˆ€K€ฤม5�  R‘k„@)C‚๏เ5Fภ Wฌ%h€–๚Il@ ุe )ศ‡L(" €/“,จTe)H\€™!�0yฦ8@ภแ &ˆโHเ‡e๘ฃสpƒ *ร รw$ง9ัฉฮt,A na‹d#0*�๙kdBใิF:๊ั7H’๘p'<บะ†>ไ†จ†?๚ก %˜`ศ๐J;๊_ˆฆนH‡?่๑œP•ธb!= ŠZ†q$˜B*RšDคภธC5ฮ9aย-า{่Qœเึ๐ว;ฦQ„๏œ4ฅ+mฉ Mกpเฺ(BVถIภ€ยด(ƒ"ฮ‡ภ็PC:ั0„ภ=x‚+jฺึทฦuฎa๐กšมˆˆ";0I*0Z ภ�.6มทžCHH…>ภ€ปๆuฏจ์e3ปYไเยเภx Fh!ภ„ q]๘๐€ >๐H/ญaDKS[–dBFภ†1�08|!ภƒ>6‘Mะตl8‡@ƒ ๔เi€ฎ=๑ียเ!ทอํn{€HdwH†(<0ึ.’ศ82Aี’ธr/`ภzA |�“ธ.}%€LŒ–€ ๙8…>ฐะ4�tฝp,d1Y�พ€+ธG".ภ ŒเภmM๐‡CฬขTHมwH@‚jบ zศ ู <เ!ุE+๐Z˜n"�ฌbG�เ Pภ@ @ Hc”โ Xภ€ @#$! ภ ๆ!…ญฬ๘;=�qs€*์ ม>โภV๚๚ฏŒ@ร>สQˆ•#nญแVฺภŒ"ุษล4Mhhqุ(…่ผุฦ>vฆ์+y๐�h(ย‡‡vฉ%jศiOs`ท๐‡&ยะ$6ึƒ‘7ภB๏@6Zadเ๙! 6 m๖‘Fค7ๆAWƒ(ไƒšK]*ะƒ[คZhA Y๋[็zื8‡"†ธ)ฒฮค@๐ะŒr4‚6ะfD�ซ่ บะภญฆ]่๎ึ็ฐ‹qGเ �U่๘าึ๔Nะ้Uโ{� gธ4 …๊ฃฃฒํNฃA‚5v�ึ"าฤIษ็@<ึุืˆ๕ขcะ�ผโบษ†สจ# BชC๘iว?๒oW@ั0@$bznเ ฦจ‡rYkny_"/hpA`{`ฐ€มIเสซg}๋๘<(Œ…e,ยHษผ๖ ำ�@ฤ/0๗ฐฯ๏ภพ‚cŒ็ร fฌ๐ebฦจลศ€JHC๑h์‹'A ˆ=Šุภ `Šค ฬSฎ2€ฐ๕œเ }@a 4€3ฆ=$มxว“�๒’?vด 2=€ธล)„ )Sฝ๊๖HD ั8€ฑซtธ"�๐†ฑ_๚าง�*๓p kแ€b�9ฐl˜โ W`ยGwEฌrฤุื>๗�^�ุa_x/hL0ฒศ2=ƒ7P8VX†7เ‚2#ƒฎ}„9x;šต<ภ@xƒ9จ0‘จ€ `?ๅ๚‚.๐@่’.๊ขซุ€ ๙ศจ€=„/ธ;ถ 8_จƒŒภถ…m0(>ใ๛Ÿเ: Wข‡yจp„~"่„wxS€&tB(LX‚|จ Y€N˜‡!ˆ7ŽX?ศจw`จ�X€T†xnศ„w *ดB,|ร8œร:๔€=@PฉLภ'ีh€.(ง|0…wยC…า†?B ˆRฤ–บรTˆ*SBลส^€ช+LนปkXจt๊*ั‘(่X€=ศ(ะ/๓*ฬจzxD)l ~ะั0B๊*–p%a`ŠˆMJฦa„ค˜€ %8%g‰ฐ=fาฃูXAฺ>’€Dา$T:ฦaz2nดซ_b&RZ%ีจ€\zt M"Fุ9pวod&ๆ3 \RLzฒ ๘ฅ` ุ’.ศuดฦeดGNBHX9{H%์Ešทp’Ÿ‰ไ�m‚ศ‹|็[๒˜ฦ์ศŽtŒญ ‘�H—8€ะZ๘…A: ศoŒศฒH”จIq™/’ˆfจM‚€Hpพ•ศษ™DJDมX‡` pˆ3๛< kxUƒpˆˆษ–Xสฆ€งLสฑt ฆ„ ุ'ศ€ด$‰`'hK‚aˆด„%0I“0Kด$ตœKฒค/Cส€ €F'กฐUะJ฿,ˆฟ,‰…g8‚๏ „o๘)(LR€%ฬ“Hฬล|ˆฦ|ฬŒฬ ๚'D(ฐ)หb๘3€ ThฝŒˆHƒg  ๐h๘�r „ pn ๘@ะdุ”MฺดM�}ˆp}f*ฆ ฃ`บSYขฅUบˆVาด;ฝˆจ€ภsG?M 8vK$9ษSTR=}๙์ชk I ค.DฅNโ’I ฆ5ี€hT‹/ุ+๐—ก ฅจฯuVo „x ˆ;€๏“3(h€o oHPUo๐npึgเ„ส‰^�ี`ึb=ึdีP4�šศ…–||`„Ÿ๒ƒ[จ‡9`D๚a <๕€wืy}งะ{ชจšชz…ฏภ‡u”ฒล(ผน;0.‰�@ƒ\Pฉv  ุ\เ‡F่ิูŠ(H…w๐v]ฅธp€qpƒฬ{ehฉ‹ จ9ฐ‹สจ๊จubXmˆยuQ€ฦ€„2๔Ž[อUŠั! „U๘`VuX‡Xp" ‚0๐#I๕ pjธา+E‡ฑEj€†4ะส’�ฆ}Zจ•ZชตZฌญJsอ๐`ุด•„ลb_ƒ1f0ƒp-๐„บพ๕[ภ�5ร๑"/8เR + ภ %=(ƒ)ˆHเ‡5แช)‰-ฒฤ;จ@เ2ภ z˜.ะูงเbhฎ7๘‚ ˜�AจDƒAธ†‡ X8hpfx"ำB-ีbญ `ฮ��ะ•;M€…!ฐxผตU\ต‚Te”jย€ Rh�XฐNฺR!PึyฌHƒa@‡ฏuvˆv`‡ฒ�2# ๓E_๕V๖u_€฿rฅ�ฌฑ[ผ•Q๔\,Wภ€ๆ%พjข<ฤี€žเ f ณ1;=<%€ุ‡5ธ€ ภ `€m„†;9Gีˆ^ปฺ;˜ฑ03 ฒM€€F˜U91€YฐผXงm่ฝดหน-จ†€u๒ณE@†&ˆ1 ฃ  €~แv“ป-ธ.ฐ�ˆ-ฐ�๐‚{๒•ย�RXo๘€เ xJGIคhธ_u`‡�ๅ_v J8€H:ถc<ึc>๖ใฯ\`ธ€ปอ�vใ�Z๒ฝศใ‹ฐแ ž%O�=ค6kรถ|%€8aHxpH‡jเIแˆญแำี€ถ่|DA,ร€9า�"n5&@ฝh‹}P[e{8 Mะ‡Dะ‚e่จSKUc5H€˜ๅZๆ‡Pธ,0c%€ท^N{`X˜๓‚ohRh(ฆิZy�gN8ไ๎_oHผN6ๅK๔ตgfส็}†ฮK�š�ฮ[ุ�R^ฅSe^๎ไŒๅ  ž๓นqบ(แW‰Hp(ะ‚y ุุ–c e_ฎ‚ๅ%f=xˆcถ�d~]fFg†fxv€c€ƒ\๘i ฮ็‰ซธŽRipจ˜�[%€[ZปTmๅ_ X‹ษ †€ ศ€0…ล|ฯ#…XX(o@‡D๎_h>ฐ๋/P ๅˆด^k˜MธM‰Ž›—2‡8ฯF่่ะh{‰„วžฑษ>e�ร=เ๊บBฅ`Hศฒ2N“ @Bฏ“j3ณ—สปผ @m8„า๋1.กฒP(พvƒF�#žax ƒส&†ˆด“๐^g˜†˜s๘$ผม+<0€]8ํผรe ˜€(ฐ8ˆ†@ แ๘,xwšgเ„๚ค†oVะ1ม€ะ…@‚่�ๅ฿ฝf‡ฌ์]ภK.๔Vo๖v๏<ึ1ฦNค-…,ฐ\ษฦhสehถิlPฆดAL เ€N†ฟๅพธ„yเƒ%ศ‚r๐ด-ญ ์@ฐU< @ œ€  |8…9;p‚ษ‹ฎ๏} โกฎŽ "ihฎีผ!Aศพแฝ>˜ผHเ‡B€IะB0!�@$@W2qWq ฐQฐ{ ณ�ก>€Tภ๐ฎiQมG"ˆƒg๘w๘†FH๓Œ๐๕�bHไgๅ_nHo>/ฬ/MK8—s:ทs<ทC?›^สƒO์†9`1Wเึ�d0จT•DM8YS` ภtี�tƒšWDศ๐๐ญ0‚@ผB9ŒM€ช…๚ƒNล€ภฤ+� IคDSะค‹5งn๐DAœืญŽ‘M…zเ@(yƒDJ€; ‡!`€(.ะ€? ฤAฤงOO)6ฬu$ผ€๘ะdำ0„|pƒ~.เ็๘&อ๏ภัดtˆ>๘j †M`0ฐj@‡bจฬˆ z�{วw}‡๔�ณูiONาฦC‚ ม2ศW฿”#R2ค>’็ฯMB“6&H�G‡vษIี_izี๐�<’E_Uฐ#O€ุSฤ‰P…ะ๘ฬ:S๕sLฅšงD}SbBB ฒX `$๐€ม€!เgฐGx ศัภม๕}†>x0x n(†3˜�”่๚ฏ๛ฑ๘ธQxฑ›ฑš!Š่q>Wq—๑ ฒŸP๘�0—&]ฐDxa’ภฤ>3xLีะั%^NGpธJ3xpƒb(็Kห�ฬDฯ{ม)ŸLมNEูvkHQ‚ฟ �ขH$๒6#แ�>๐cr˜ƒ8&){[แ“๐�ื‡�–P U`•เ ะZณ๙ˆ๘€!H„QZฺ๘ถ$ม�ู�๓๗‰ู้‘‘�๐P๏R๒๛ๅ๛ๆ7๔G เO’]ฝัฑ]ฒ (h๐เม *ิ€๐!ฤˆ'Rฌh๑"ฦŒ7r์่๑#ศ"G’,i๒$ส”*Wฒฉ€ร–2+6$Xsbอœ -ๆ„ูp&ะ B‡-j๔(าคJ�4H@‡=NBะ|ู๑ย…ปJมดxภ•6—^ดšQgฯ‡hืฦฤษv'ูธr็าญk๗.�ผnอ pฅŸฎฉU zฤZ0�Œ.ฌ้ฑฎ�Ÿ฿BNHนฉฤหm๕r๎์๙3่ะขQR `‚ฤ[˜ x !C *(€ฅ…†ช@p �DA H„H `Cภ1$B�$0aย8ŽknDˆPฑWณU๐ส%๓�~9ข๘๒ ลn๏�=�๘๒z€๓ฤฤใท �,๓WO™ฒล`อ?๘ใAr์ื฿VC7จฐB ทุ’‚r•@เ?ยไpAR �(ฃ„ ำ‹๕เ๓ ˆ่ฅทำŒ์ D5f™๓๙๘#A ้ใ ๘CK"คB~9 `ษ9jdัI?a„เAOธโJ!๔d”�SVูA:4รH ขDณƒ kEไA xrVy0๓_ภม pั !`ฑq2๊x(Z๋U–cีhจfCJ:)ฅ•ZZT ะ๐H>เdRD“5 pษ4/0๐A/ฤLธมชp้ฅฃ–zjช„๙œข –(DX=t‡ิB“@ธะŒ9ะn"zํK’๑D™y็u›แฅแŠ;.นๅ2Zฺh์SN&pเค%ำ๐€Uฬaซฎv๙eผ๓^P๏ฝL0 6ฅ๘€Oเ๒tม ท I-ะฆฯ๊มรXŒ๎˜pLุ’7žz฿bฆqz"kk.ส)ซผ๒h�Qะอะ 6\เ$ฉ�Œoซฏ~™3ฝ๖ฺ0Aต`sฮŠฆU– , …&�ƒข@ @‹ฑC›๑ธYxŸ2E?ฌึฃๆqผ1หiซฝ6�Dp…1๕๓Ÿ โœอ 0€‹ชฬz0 ,0œFยจx๋อ7 Š1X,ณˆ -ภ*6E‘˜c<๐€pL+{� /,ซ๕ึ^ึุasK๖Œ7บู6ํต�~;G�rŠ*ค0ํZฃฺ“H๔q™ำq "nผA๘%ร|—yƒฌื�แฐฑ@"cน{|๑„อ`‚๕ป˜ฃวox™ุ๊*ถ์� งŽฃ๋lแ�?xปp TbSศจ่1z€Jะ‰wผร0Hเเภ,!u๐า:1!�Lั„? ย‚ ์)คG&&”œtaศ‡)ๆg-‡๗ใˆ�่ืร๎ๅ๏d<"“. 1[•0D œ%)d5€@ TEd เ:)๏ฬN":,6 €ใ@€;]JNZ๓.€*9jb�x2ฐ๕oi} โถLจฤAฒ๒ม_Ddล„%Œ4ˆ"Y9=ั@็โ‚Hƒtฬ ฑI๋Hศm ‘eณ–!SฉJตEา"ญ�Ÿ ]ยว��Q#F4WzภJฎฒ—พ�ๅสฒถษ’ “lœ\ศข6Rฬ๙P‹>8%(A RP" X’ภ6D`L&8ร)NU.s‡$)g|:ภ‚4ƒไp:พb„ย2ะt`€ |sœ��'|ฐา9j… X’$Ibะ%ั.kYh Zั„ฮ…ZL ;โแบcx†#Vม 6�  @SชRะ v่RPD0 %€ ๐ฃฤ์a:]‰LiJQนจ“ไะh<4J 6\"ร 9:L Ÿ+ฝ*V•๒•ฐค#`…ฯb‚ญ”‚ษะ“ไ…NŽU�$ญ>สSข"l:ู<�!/„M`@ย™0‚cfีศXทrุ‚œ* CxdZ๋‘ธJลŽ%แA!2šTŽฦƒิุฌ;�AŠ!x'ซฆ=-Qำ„yฌ๛๋Œท1-Š\๋-๒J ๘0sํˆjC‘ศ ˜mFเ…ยa ปร †ถŒํ`ขศ๔60–% Šฑู๑บชไ๕†DภKิฒทฝฑขk$ภPX*๐ސ/pGฌล(`_�Gฟฯy V €+�77�Tภ‚ ปํd‹SŒ(ŽS๎ti€0ƒ}’ศKD๑ย�i@6็ฦ$Xn1ƒ๑‘ู@๘6>1oโ;฿๚J@ึมŽv๚KญL]‘ร!่�n=๐ีWM๒ 61}ฅsfถZcig“0ว99`ํเ}„!ฦ–/†#฿ืฤ†ฦ ฐqตึ�›ู‡8ฦนฏ•า> cฃก�3g9บQG๘เป๎=4ข้๋mคฃ�x†pp๘cn –ŠXิ|ิใม(ฎ๑ŽJฯ!ุะ?:๔! หทจฉด�ม…๕ฐ4ตš2*}่รัึM r‘ะใถฺภ a ผc B]A8:๐ฤhศ…?่ม[qภhx�58axภีฐว4cฟH�Šfดฏ1,ขj๘รD(สt‹^tS ;�ฦถฃ*�ƒฬZ_v$ึท(n„‰h�ธล5ช ‘ย’@N@2 Zี ๒j-6ศใZผฏใ็่๐ว?ฺ็–7H@šP….$ Pโ๑P)�๑ RจCฃDญ:6ZŒค%บ้‡“ }€ก#D=1†A\ƒ<๐ำ55(Vฑยoมภ8ษIญhญ/ฦ0P0ร 9๖ฑ‰,\a˜ฐฎ/> ก๛ฐ‚Fะ…/@ุ๐:�ํAˆ'่ ›๑�ŒฏฺแˆเิวƒฐaxK๐ƒ>Q ธ"๎sฏ{V>I,i‰ษ€บิฉŽุ้\ะŸ�(A๊ำ‹o�ใy@•€ห๋ ^5xAHozิ7bภบึoXCGŸ ็๐มh@ด{คq๒D๓ว€๔=A‚:0ชม�๐๎~˜คD%+ั~K0ฝ=”่฿˜”ษ™คษšGDt@ะ���€94`~CัƒภP9ฆ”ฌผฌภ=$ยƒlƒ t ,N6๘@ณXL„ xี(ธ‚h€0&|ศ0ึx•+Œ‚`@ ฎOC)ผภ|ภ=�…�ฦ“เอ† ต`ภ์B+�� ฬƒ B�ํ]‚แL฿œ@ lƒd€ ภ ˜ƒ8ภ1T@ˆ‚1Œภ๔เกdั P@ฯ�_>a"t›ฑ K็,ห B‹ดPKHaATแ๊†�ฺ‘ยกาแ ๔ภ6ะม๏ @a๑€์@\ม<0มครDม<ไ ภVภ ์ร T4ก‚`"Dt ญจJไ‹ํ#ชƒญเŠฎ๐Š J ,`< ร*ฌย0$Uh)‚#|9�70ม(n 8๚Sda4( -vŸ=@XŒ(พ�’™ฺิB่|�I, Dkq ฬc่ิ้ไภ @9H,aL(9*B!ภ!hร; 6„N๔ภ-ค*hA |ˆD(Rะ @๔>๘ƒH๚รีภ5ิฌ๙>”รl€kํฃ๎!B# ๖KBv4Œ?@Œฤ  ;„71คCBd่D[,R"๔€IขคHฎ$  c8Rt‰"Dœ#=8ม"`รŒ<0�$:ล ค#$ๅฬMr_ด๕Kะ� MZ�‹ะ มL"Gะmิx�ๅ]ZเO…#_Š#Yž@9žใ>จ:ฒฃXอลภQ<†?bโใb6ฆIFK@$เIBฆBฒ@'C�XC่”†*4�œ�[zd?ิะ@l6 ๘c8AlŒ œะฃL"e1�e9v‰ำ@ิ HA ๆaอZg~fhๅํฅAฌeAุ&nา€n &aภTŽ" @>ฤม+ไ- ‚4,าtฆ_AะขXFq*ย�ญฆผค%ฯ$%Gบeภอั03R:$Uัแฅ€ชC๚ภ7๖ฅ‚ฎB~@9Ž  ,หโHCกไ@ผ`dง@€ๅ˜Ž†บcg4ร&แ”C#ไฮศKBNภ ,ƒ  แ<ž, ศ€=H‚Yย็ฌ,1ร”แ!๘˜† �แ'ผภ!’ูฝJcNฑŽเ,KƒglN็|NQZ(ˆยจŒ.K”@T‚4€่„u�$ะ ฮ†“*ห –ก ภ@ |็Ctภœ,€B04A-ภ‚-ฤ™~่ฒิั�™� คภ•สgN�์M฿จiเ ŽคŽ0.Nใ„ฯไc—4ฆD=!ค^้ฤฃ๎)+,รpVดฦํ |ม �มฐz!žรtม�ภะ>œยŒ8Aฒ>ุBŒ� A>๒ษ˜Fฤpภ๒4ฯ๓ญฬ๖d%!8ฌฐ+NVf2ศ�ฟ’ฅƒB๊ˆIๆ%ฟ2๓@ฯROX๖hpAม3ฐƒ78-5ย 8เ<ญีŠƒจ@‚ฦ*ื"ัคB0ด@ฦuC&ฬฯ~Pบฑ( ๕)™+๐ต|2d‚ x ฤ แะ ฝญ„€�า-ฒB:คC.๔C#JN|mุŽm&�Œฝผƒ6�A Hš6ศƒ?x $‡š๙�ฝี�„5ภต๙G7p �-ˆไ8pฐ@0๐BA�m�NDMPม€โŠํ -นตHย ัW ๙C�žฺ5ฺไbุ�ฆB:ด-UDP*0›)คน}›๊rn4ภ|n7ฬAš!Dณlotคƒ้Rล�ๆญ)ฐ@`…ธPต@๎2๎�้ึ%(ตฅYํRaP�rQEะzDค€ˆ7pƒ8PCผ€8 +๐3ย๔hืfฐ�™U€pPK}ฉ€˜>6€Š‰ภl@H€—8 ฌx„qG GW,ไ@ +@ผฬ= Oติo€Kณ�\˜ ุ…m’œ™†ัjoผุ€ุ€ฅ๐t„ kไ{IG q_ษู›ฦ™๐•1กYAฆฌŽ1X 4‹฿ื‰ฅ˜Ž}/(€u(˜ ุ@ห๐—๑พ6@L‘ €๑ใ™อWcัษ•šy‰QุฑD�%ˆƒ'?ร38'ˆฒ'3๐3ปhฐ*Rต4โ\žอฅNfD\ALŒ‚=4#rdาz0sฤ0W’พ’ฤPต๒03PpA03ํ’„2ฟ๒n ณ�๎BPา+D๘ภ€ฒ'Op8ƒ,ดหปฌ2:‹Snม:-€!ผC;ค�ร(˜zฆ๓\T๓Fๆฬ6[ฤผฐB8ด8๔œฦZู3Bป\ภ@mmB—‹ฒX˜๐A!˜คภkอาCwดG๔{D—�ื ๐@ผ€A{Hฏt…’Jฌ๓'%qด,ษ4Y E็ŒK๏4๎ธtJภ๔%ัtC uP�๔R๘O'๕ฺt _๘ดGต2๕O๕QMTSS›EV›“I”R‡5ห่d8๕YI๕SwuH˜๕�Q'ฑ๕ึD๕VoŒZณ๓Acu์ˆต^หGiค4a�ซ9™tˆฐฉYๆpภฝ"๛}‡ษF%1šiarƒๅฤปH�E%2GYŒQ,ฌ‘]วŽ๖�ะHํHSdH-)อj“ตท์ะxฬvjˆRฃ์5o‹F}�G“เ6VDA*ŒZ>0B—H8ฤฅqภ#(ƒญฤXรุภผ•ศ‰ˆฐีƒ68ะƒ€ƒ(„Q@‚,ถiร)T8x/ะZ5�‡๗‚ˆrWฺฅuภฌ�‰(รผ"ํถVฬk�[๘_ƒŒJรฒ�ไตnฯNษุvm๗ถ„ƒF‘I’ Z@๐1ภมผมะ*๖ืyภย Œmึ`c๔�๏ํ‰“ฒภ-G$๐รฬš&ภยุภผ0`MM่ƒX�,7ำ้ร�EMุ!๖แ*ไแ’g&€•Lฮต4๘[gyขฬ5k๘ฟv˜'๘˜3uค@๘™Oธš๋EฆlJง|Šy„ฬย™. J((คเTC ภA>` \ ,24AฒวPภf3€@ภฌmม5p ”่หค๙>H4Cd=Hมกำค ’  ข Œz†-@/ภ„x „ใ๖ก�—8˜ป‰€ƒy™yšŸ6�ฌนฐ฿…หP@บฌ‹9oy‰ฎฯBpgฐฎใณ&่C"hม2 HรคONL”ภ#€C:T?„‚@8ภ.–ค+อไD ม=p�บงƒบ๘ใลdNฝxคXJ฿ะ๛๘|ำฝศ:�˜นท�ฎ๏ญ#Sn9xY/|,๑ฏ�๋ ปฤห…หภL3จhอ�Œา,;๗:ชมณ3ยA.|‚4ะ›^วำDอิ˜@ค8ิL=ิQ.า,-–$ม=0AงzจOฆฮ0พwงพื6่๚ฤฟธW?8ม›นม๙ปั5ร“นร�บ–G๘ฤ}RผM�ฬM� ลœ ร 4#ยไ๙ ‚…๖ธ3LCฬ‚9|qฮ–CAภVD4๐จL@XDC ltCๆj€UŠ:พ๊๐อ’ žS�(ซ.ภzฯ#uุณ‡ถืh�ึ#<ฏทถKƒ>ูทพQ่๏๘๐ E�…4xx€ภุชึ•xก๒C!ภ$่!L…ภข๚ฤ%ฬ,A”ƒ"่ฦฉŒ‚5„๚พ้Dปฟป@œใปใ’w“›๋“—๘่I๙’ใืzภ# ฤ๏:(ผฃ �วฟ๊หด๚ป>�™w 7@คช€`‡ �h๓7nŽมw่ a ๖ ๓็^&!(ฤะภ฿ปn:เpก6`<8ะ0“ๆLY๒แ abพ&<�ฑVOู›!4ธSmaร@…่FCMซWฑ^%ธ•�ซฬฌ_ณz[“`ุฑVลขํZlฺณmืบฅน๕-Vทqๅึี€—o_ฟ<˜paร‡'Vผ˜qcว!Gฬaง� €`ˆ lV!"D‡ Tจhภ@…J lAi Dซะ‚r„czxฐฝสตn ซGoุ@ฎ E“๎ฐแs )่๊%{ทฏ๖ฏyฟƒ>v�๖ต{ร‚๗พ7n๚๒ู๋ใ…_r}๛๗๑็ืฟŸ���bo.�4ภƒฎฌ๊ภฆpะ‚]<@Š.แ๘š‰ช;>๚tŒรฒ(d ภE ŒฤรดฐB๓ธโPญ Al=iฌัฦq�ฬQวyŒ,ซ‰ฌIG”!HHฉว#‘D๒ล๎XDQป$กŒRส)ฉฌาJ_Lฉ4 ฏ์าห/[| ฬ1ษ,ำฬ3ัLSอ5ฟ„‹อ7แŒSฮ#)h ์ปเ ŠLO>”๓O ๚Lณฮ;็LLลMหD R.ƒ 8าK1ฝฑ81แQย.๐;<๓STR%๔ิRำ�ดำO3ฝฒ8q4ึ F@ค Rีื_๑ภ’i|Hฌ‚Xq…ีลธแ>ŽMvYษ๔\กBrธฌ1tฐ‡z=ภ*r (Ze{ๅชูg ฤซkไ๙g`U์�x็�ดฺks–ุตธ๕�ภ๖�ลV�1H 5=7›ถ?ƒฌ”้็eธP$–๐^y้�q‚ส‘€�6]ฦ๘ Œ!๊Vๆ4ธ$_ฤฮu1ผธ‡‰œW~LO2Ž!๗1ฎ่G—ทrม=l8hผx๖9fฬdภ”![ ๋ญ‹ํj่ข๛ต9du“^:่ธฦ6บฬwƒiฒGxaมา‡อฬdh cŠ8Šhiฅ™๖Zkฎuœ…’ \ํศX"Ÿ0fฎ�อš…A‚๋๐ต“๕ด_:ฯ!๔ไgถOyสรœๆ8ว=๏ฯHะy๏๐D&๏@ฬ‹๔š—ส4LB PX ข7ฝ๊)Pไ^Dpฒำ�ฤy��บโ€c๔#ี-X@๛ตฏ/!คž๕vบ  ๐duz@ 9w@๖%aไ  …ธภ๐ €๔KJฒ7='ƒํฃ@๗pำ?j-ขƒ NPŽF๐{S�"้>g฿e‘6๙ห 6P€ aศอt—€%&%4๋sƒธ’†™เ`–s�$jFqิ€ภS r‘Ž�!B8ม#ฌQ’q!}ืxAๆP›"q๐L]@p >งvฆlJi R ขฬ!ฟKวZ<4�เ@Dิผ2๐#”าSR๑ไƒž๙€!˜าe(ม8ธE=ดก‰jะcˆ5�axภH @F;เ‰z�uถdHiธหY�E•@\R๎Ž…ธม ฯŒๆ4ซูLj’œ”P?�)มe6“‘œd%{yฮtฎณ IG=บaฎ(“™ฮผ4ฅIMฯ(E ใ (กŒไึ˜ƒ ฎ™อmšภœ™ง%อ QT*˜ว�๖ฤVบrmqภ.คฐเ†9d้@ษ—œ๚C›�\@.~qบP3˜˜B*4™ZีL๚ฉQ`ธwยณ๒ฬ„9ัฉNvrขaซrมFฬ”/WuฉAะ๘ˆ\คดškร(�‘ ~ฌQฃz5Rb‹ะ•VฐZ J:‚ผข๘ภว?ไึ\๎2—๙€b5๙ŽLจ ฆฆศLตQ„˜<’ท๛ฉ™=ัHะ0$Aพ<`c–ภƒ=๑ภ@–/�€I` 9Pะfั[\€u`ษ#ภQž^๗ cุnwซMdA}๔ €ป?่ฃ มLภ�x0a&H?ˆ‡/ผแ ”ษ3�ภ…/ภ!‡@ธF ฑ>bEฐAžเ tฤA0ย5† ์Wฌภฎ{น๋]œ7ฝ&เง?#V ขˆร dษ#Xม 6ศq“ป�Y"ขใJ€L฿wธฎ,๑Dฬ*aCHH…>ภ@(‚@™ r‚�๓ุว@ž%`q‚4A`ิแ ็p,(รข ~ฌ(š€ลl0€Wผ„ เ/y๛"C8,9x-Dหๆ/zสณ„)L‚๗๑@q%ใžฐaŒ?LAม)จ<๘€„N์ร {K‰ะŒ@�จธธr–{ีdsMภ๕ศะ L8น+k๎๑ฆgI่CรHภ ‚๓t=bH›ฺึ~๗Šd+[ศF3ƒy#ƒ+์#A‚:฿๙ึ7 žA3lก‡)Œม ขฎฤ9|0ฒdˆ"˜ฝuxฐ.‘4`ธ ฦG0๐‚ `ญศA1)่i+#pล(.b็Jk€˜A)ˆ๐฿ณเŠช มzOžrfTฺ7ฅxAะ b /ปภ*ะQ#‡3Qด-x0ฬB มีIภขTpH 4ฃY ‡€ภ %ุ@ดชทž\ะ =ไ �ธจsE0U^ฎ ๆ7ฟ๑ผ$ƒmข$8(ฒแƒฉKใX/R2พ๑ŽหXุ]yzฤj&qŠ[œส�ตlณ`๗Hฏry‚,พ๑X7็เฏ~๘ฤoก1€C>ฐเ‚ขู`ศhยืร~ `@ X\#€ฒ…kpมธ:p—ร<�|มภSป‡y0แ†#<แ x์Vl[๏:๐C0ู‘zq’8–/@๔Q ขŽ๘่KŸˆฌr๎rง{๔9 <๔‚ วo์เ๊@ฮ| h&เ๛ O๖๎๖!ภ๎๊ฎฦ76แZๆ๚+pฤ:€(ะ]O๐์ �L`๊Z๏๊TขY๖a ๘$z๎็‚๎โBเ฿๘ [�‹`�ภ`d ฏๆแP1ๆ‡๔ค ˜lA�ฐกx zเาด r g:`j gBA ค.\๎ไ *ฤ ฑฐไ&8ภ Qภ์โbฐก๊a#๐กŠ€7.ภฎม r`'"Žฮ*.`nม4! Zเๆฦฎ์์ํš lW+โ8Rโ ห0bึฐ ฃ๒6f$ภ์แœoูธเจะ ฑPส p+เฎ–ฐ ™มร8Q� fdขA: vัcโ™-ี`ฬ๐๋�โ.4AA –›1"qญงาก๘!œj6”oํ๖$%ฺ%~dˆl@่เ†> fP[‘/๑ฟ๑ๆFญิAฺ๖A�‚mXHํŽ1๛โกo+†qZP ๚6b#ŽŠj๔‘€™ภ �๐ ขฎa๎Jอdฆใ๓F์D27๚!#�แจœ`f`ทBƒ2œะร@€๖i+i๎ม / ™า1~๑~’:a๊ €ภฐFวPแฦaDKZาo็ƒ๐Žภ ‘]เL8€๑@ \`-šกœ@h€ˆ �L`'๒`MA^Q.€N ๊ห”€*ั์่4q"{ๅ฿@้ึา3ฺ’ ฬ-Io�๔1?roR"= Žก*ฏ๒}1 โ2s0}ัl€QY2๔ๆeNเ=6ำRข4534๙‘ $->A่‡ฌgณำ ภกp` ์Aณณลุ’,5ภๅ6ก โ,.o&€ฆ-oภโˆ๎d‚+ฌ€ ะั@ฎnAN<ฅn„แdH 0€7ฒว@@เใŽJใ2๐บฏWชyIณ€ณงnฎท‚ฉš‹ [&5๒hs "ตs€K@;ฏiCJc…Tรซต6D$ภ(ฎoฺ7€ใ9ผŸ๐"ฐํค—*`sยŽท๕๙ Œ8ปช๙Zฑ๋D่:T`XCˆh;[+๛บ๏$]ข็ฐ3{tN›6t ยณใ‚ทOถfC|ใ‚:ถ๛~จy{๔[pvพ๋›^`†�€=ˆwะง:‰sฤG;]คอา 3•บร/ฤ/โฑ1ลฬ�t€า@A�cฤฉˆร_sQ๔ฤย VT|0ฦ“0&ฦฤัG^�$‘i-B|+๊ลH v<ฦ๋ใวืยHZฆA„ฐรX ษƒไ’|ศ�โjXœŠ#ห3„+Žห Cสงh+เด€~มr=|อฅคก;นฤ=ร€นัธ�?�ฮก„ฮkมฮ!ƒฯ��F€DHˆ$วู|Fฃ€๊A†เถ�ั„2ฺC,จC๎�?$}J๖„**ำqDK–า;�@๖DฬE�ิQ=ีU}ีYฝี๏ฃIC+ Cึๅ„ึฯึ]]Qpฝึ—$ื}�?z}0l]ฤƒM†ฝLŠ�ืc}ืใ„,�NDูก]2˜]F4 e˜FืๅK’�0l};Ž�=ไใ�ฑฝI�$;ฆ=LถฝJสฝ?ฮxฮ"ลwฤูล}�Ÿ=ฺE=�คฺำๆฺc�g฿—EะB�ท}�๛=ุษ�ไ/ ^�รใ?ฺ]แแขแ=แ#โํอตาGฝ+X;l Œพwdโใy*บ% ์b{Œฬ&@ ้cW�„ œ�[7สK-Œ‰๎คชw‡sV^J‡H(`9ยˆ-๘วXEƒ.`‘์ฺูฏGฒภwจBi๐~�Nผร�ป>Eบ}เ็#ลฟพใง�I๊ํŸdE�์อ#�ฝ�B,>๎ฟรํ็c์�ีร฿3�WDๅ �ส@๎„ฎฑbหิ ~ถ฿ยC'ฎ\…กAƒK็ิd้ิ/L‡:š1J!*ฺ$�˜ˆfถ๔Lใ„„OภxL@�QNR…ฌ|Š™™@~ฌPม6cฆภ๙’"}›ฒhŠX€อ9 hL่q/R{„ž|#แˆ._ เมฦ‡G€HS'|H&สƒO@šu1ดฯกe;Xz‡hฬ ki•2uqฤˆŽŒn๚4ฬมน=Vnl9aGสœ5l๎�YsๆฮคK›=Z4fฦง}ฎ�:๖�ืฉK7 {๖kู—wวฮอ;ธp�ด‹?Ž<น๒ๅฬ›;=บ๔ใ ะx”\ฆ".ƒpiฺ zcbรU* ?พ<1!ษwJ–$€kู 9\p *ฑไL2เส(`เ�3˜๐DูLพ€ƒ P S ,`žWฬsฤ้,ลปด’CU์ฦ2์Gw•ัีLYะ#ฬ(กD5‚๐w(ู๘๐a)/Œh� ลe)"‰็น—�hซ'›žyffmรฝูšqร๙gœtžv›wค�๙miึ‰ ฆMGhก†ŠhขŠš€ฦ>ๅ`”-4 ถมL6จว�d Y’้ฆฺ0ม“`SŠฐ(4C ภ/•ำศLฌDยตดb+e ™ศD•ี€pภง1D‘ƒ`ณว๐Q�AทคƒŠ-ไฐมขอ;ศ`C์1€ฃW์#EOœีฅค=M6ณD๛จฐaฯ54ฃะrŠB›aฦฌณ Ÿสlyบ–ฆ›{gฑฦpชy$›ลแู&Ÿม]L™œ๋ึ๑ž|z,๒ศ)ฯฌ›(ฃ์2ศ1/สsฯ>� ๔ข๖€G3ผฒ`ร—†ง้œz j{ Wชำง^U 6็ณG"1“–{ ๅบ๋‚ฟ$,ฑ”]@ภ``ฏg5\ใฌฉ1๔@OฏB "W:ช€จผ3ฮxะษ1u„5ํV H4D2คแปค.$™ล=kt4pม5`“0V7L��่[ํiล%{”ฬ5ohom šฑr$slr๎0ซ�หท็|๓๏วULณkฒ��ฑฮสkt๔าOO}๕p…1๕ล qฯ^6/4ภ.็‘ภ“ภƒ †?~๙cVi ห,bC !�.k€<ˆ-€J˜CWเ•ฏฺ“ถb๙ค8,@ั‚ะศC `พœเ j [๐ 0ะฑ์!‰ œ`‚0Pยฌ2hcB—klะ“บ@IJช’4ฐข๑`‹ก ˜.h>๔ธn6)[�วœˆ;&6Qx˜™ำ™ขHผ*>4พร™mŽW'�@๑MZ|ž๕ฮˆฦ4�ชฑ8ธล)„ ‚}|—ฐG"บ‰~ฤ!€ฎ7ผA%uผcใp<ƒ )ะย5�`ƒWก้kตผฐRda^ฝข ‡ฅ@ŸT`[สพะ…9๔g)Iฬฎ๕,„@๖ˆ/�เ๛Hฤไ‹:pก;ฒŠm€ 9จแ sุ .๘ฅLaX‰˜0ฑฏมfdD5ปฺ)‹แ$็อxE�๘ษœM\g๐ืงt’ xณำษUF�ญqŸ�์็ฯ๚hคำ€‡๓เ& R๐ ” ๏x‡)`0ะ‚‚ใ  XB>๊ฐฟtbCPฺ$WŽ9D๒�#ุ@เwtc้s/4‚ #*›k*^X#ฅฆHม™B:”`‚„LมJฐ๎ ีqฬa"? j=ˆšึŽภ3u*^%9ลAฬy๕|'>q+}zqž๙d'๓๒ูุูLžจAํ*Kุ0ฦฮ‹�์ฌg?K(h•๓;—†Pึ€@)PxL+ ค h€DภTG*˜]ะZKr €€x[. @`#ภ€šH(เฑmํ. €TW‚�€ฅL ˜ภPญqe�ีิฎ6ถHAGฺf4Tฒ๏x�ฃ€ŒทผซUBภ@์jฝชแซ€ท๋ฒyบ.ณ ^“cน(<*.Vฐ”�:�ษผั~ร˜•Y,–ฉk -ฑ‰Olู‰j”“*รFจไP9ดฦฎR<$ึฝ›—์ย #Xน๚๊๗e… @@ี{gŽหษ—ผ<-ผphlXlธต8ลZฃ]Xำcใ1Hหุ€๙ะ{แฒjส  & {e…ศhi™2Gส…˜ฎLฎ เ๗lTl\ญ1จ“H‰*bƒแ P8Qฎัั<�!โk‚8ฎ}!ฏโ{๘๑ๅCPจ @"T€_T„‚ $CไpะP ุ *ดเ€xA‚๗1ุึิWaƒ›!ิ@< A)˜pะy๗ๅ๗‚ .(‚Tณ…ะ@ VŸไ!ˆ"’˜‚ เจใC†(d กˆŸ ๆ5€ฃxvฐ|ด1Tมธ OไจBุ)˜฿w-Fy�“D I์รฤoQะภAๆ˜dwd€pX"Pง‚}*ฉb็1ศม๗…`ยqMrเE;๚€A€๘๔“™u#ˆคเบีิsq bL WUp=exเ๋จ๏Œc๙๎KOฟ!8kŠ p๐ˆ6ET�Ё หPwAhค“ ์Qฯแ&TชƒะณI˜Zมvิd4ƒ !)!`ฑ`™o�๙็ � (eL1ฦะ๑#Gุภษ&ง‰๐€ ็๐มh0�# ำณ!ž๐…'"„ƒ่SŸ#ฦด$C@Hœ่ว%@ šŽL`†Dเ›x>pFL �ฐ H€A˜ร‹rA3๔ƒเ6‘œd%/9์#˜$ zHอ่A์!… \@•^GเŠQx`ฦ็…K]๚‰—พฆ0ypศRผ -ฬ^Cp‰i(๒ฝ †ซ0) `ญศf!จ๒“ซ‚5๑‚Q`€gJAˆไŽ�T ข0 (8อjพ€+ธG"ฺ‚ ไ›แ„ฅ,๏๑สk(A ‘ฬ€ F้ฐTๆ0ๅŒB‚@งLๅ$ูู๋๐DณเJ 0#9 xrL_€>ฃ@ Hะฬ$`ู4ภxท…t,! ๓ B%็แ› ’ภ, }(8ฅW…}€a(E%BŠ€ๅ b€“rTศ…ิเœ์ฬZ€„~เรx๕ว<†€M๊7ก+>๎šืฝžtr8 ่Jั™ฺร )f๓fC‚ิข}‘์.)kYฬึ |ศ\%šiZ"xCi3จƒ€Ehใศภf#เยฉ�`ืภ#†”€<้จ?$Aื}Œศ็[๋-เฃEจาใNหƒดDCxBภ!^[ฬฮดฆอXB�ัผ๊•ฏ!ภ Œ›6ฑ')๎ฒ@{0มฑฏlFr:ไ 4ใSศg�š7ีฦ ๙ ย ฐฑ‡1Dญ=ธE:Pก…ไ 'ฺๅฎlซp(‚ย‘Pธ|`hx‡>œ`"ไญ-tะ:ะ@4Bฺlฐ k?โpใ ะ  9x๏Y$PT“ฒคeฅ4{ฬu6ณนœฌมฎ cัz†-�ฅ&j/ Z๊tโuฌ1�ฺฦๆ�ถฐp…>˜…คuภม์Šโช!ษMเg๋เŽrqBฤ\�\—,8sšื์�XfชPฮ‚>l,#๏xฤ!eฏ[‡ุ%๛™พ <ยwฅเไ(‡" Ei J๛bธ๒ ตฬˆA่‡W‚ˆˆ)ฐcTดm[“ดšู\]Fร‘˜ฦj2}Xw่ชณC†Bดjqdถqˆไ„|ฺ |’‡(ศ0@ศPร๗ lข(พ‘เส฿ ๆ0':ญ|ูพ�;เ+-ุWฎL�Dฆฅ;๏*h6^ะเ‚'8ม2แ๎ฬณใฦ Zš ะ๏�่‡ b€ป<๓ัDทกˆTโ3‡�ฤ ฬ2Iˆ0ฺบŠ˜€ฦ9n‚+ร’R€ๅEญ!ˆR้N๏z็‹XŸ2ฑAม61ษ—JCฆ๚ีLา›ฑOค?ƒ(ฬ=”I• ธ๘Np Pƒ ต…-xะ Lาฟ๖๙ะ'yๅ7ฃ ฺภฤ\‚ๆ|‚ t… ’c&ใAฅศย๕H|œbcฐ‹ฝ sศa o˜ภ่ะŽฯ‡žลJŽ/๖!„0V •Zเ>zูlนเจ�}๏‚+ ๔H„#hํ]ข]HyๆฮŠeผ vมlp๚! ๘•แ พg�ั/ุ`–˜ฤ[Ž Oเ�"ฎ‘ภ๊รe „ ่วUณบ0งิg}ุN#ฐ ๖*ฐุ@�ด€๐7pป7ผไ uP€˜Y ฐ เ‹็fต"b›q.{ .์2฿ก+#รmี]0/๙` @นข*Sh6d� €`ˆF†ึฟA1  ยb ,ฐฎภ V๑ศะ3Wม†n˜…q8‡ux‡>s‡้ �ะ%ๅ|๔0๕�S)%ะ ม๒ฺ๐?rƒ-Yธ?s๘ ๔Pz8*#๓GQ†,ฐ ฤ 1x5\0b  มะfำ ™๐‘8‰•˜SJะVS` J`# +๏ะ T๘0๖Wี–q'c•8ี-Pม8Œ"a N`ุHผภ6ฆ็rึ02๖’7{ฐ ั้ฐm ฐ�่เ P„/ฐ c‰ถ‡�  ƒภ,G ษาคถD! า !p�a'Q4'Tr‘๏q$ะไjี'ง"'Pไ‘8R)Gฅำฐ’ ะ’�๐’“9P“ ะwf$f— "p$;rH ะ"8™“4ขรฆp-ะRา"+ภ 2˜7R‘ม'>S8B&Ji'๕‘n๐“„๔O9$๐R๙๑!9’ Rฐ–…ิ– @H˜S ""^bKzb'ฟi~๙Oi9$Eา%d2`iPy แV๘f`vก™QŽฅyฆ™$W;Z0 ๖ะƒ ŒFšั!ด™[ฐS ชฐรxฉ>ัKฦxฆq#v'œ๑ษCมล้ฯ้pZ@ ฟภcœYœโ)ข3I0ฑฉุ้0 fภด)›�ืž*ฑ`อ ๏€ TไนŸLฬPš!!๙ ,#‹!๙ ,zDPศ!„ ฆ1€!‡8/`๔ a@!๙ ,z ,+$*ฌx1ฃF P8@@!๙ ,z 8Pศ!„ ฆศ๚ธy๘มๅ#ฤ฿{7๙ซ%�‡Y}๔ืXdŠ5^lํIืUxฉ}& |�ั— … ^—q�ุPaกฝ‡ิ|"‡ o^ธWษ=จเ๊‡฿]6A๘�mrุaoศ”t�‘X „ี5ธc‹Fึ†#n.F[~I™aˆ ๙”ƒ-Nน‘รM'ฃ”ณัศเ’๗5ycc๑iฆP&ไใ„ทeนีœูั)[‰pน&™ยี๙Ÿf๊šmŠyžŠ้ฅY%€^"jขืM ฉ [–ฆaŸ/f๊g‘†จ™ bjgg)ษฅฆœYigฅ‚ฤ*uฬ,ช่EQฑ่�ซdี็pr”^GTฎ๚#ซธย‘ช๑๔sส.›์ณN…๗P@!๙ ,#‹!๙ ,z 5Hฐ มƒ*\ศฐกร‡#JL!๙ ,zฌ�\B ,aM�ฟi<’ะaฉ9รย๔Y0D—ไ #qย8pแ"v  ฆกMHเJ?]_ล ŽXcP)&(Uฤร เ’ใ ฯ @Pก@‡ d�ฌขB….๔…ัฬูดh  ๔0‚•ซ`w ธ„๕ kXZxFุ „ฐ 7Ž<ƒ๒‡๘�#ฯ/ฅลK:๕hH mเ*ท…•่ๅSN ๓ q~hCb7o @whCก2s๘่PSค’Ž?๙ ’‚�QM=Dฆ€!ฦด U'ะS†NXaG”0ข?๔�แAT™ข<ขMx†F:๙8ัภ๕ไiB yž`ๅ]mพŒ1(ฬ˜‘CxุCศ_ภƒศpล>„ŸyPฦQ@ยฯtPม,โฆŒ†�p`Oธาโ‹–œ#ฉเทช ืd๒Eอl2TCฆ1Lว)@ชฯ&YhาOฐqŽ ะ0A๗คฉค”ZŠะ]|ุ๐มC‘l;มษˆ‚)B (ภYฺI`‚ ๙๋ฑซบ2Š8ภ &๏2>‘@‚ฌ�ฐฯ;EuมHภ ๅ4‚†ญบภภ]ภฌ�z2o/ฐย=‰Tร6‚�‹ภ1ฦ9‚/ ภ h)ม1ฅผภฝcภ๓ฑE:KD18 ๑๏„ม!ฐK+VฑศฐOIๅห $pC(ฐชm!0@-ญ๐�PAทคƒŠ-3\ฌP(<N:ี๐#ษศ-ปšP�)V––`5o4Š€ฤล ภ๓ฑ 1 v"Q)TC3z๐gฬฤE>D ‚อcภsฤ฿^๘ pˆ6๏ ƒM฿K๚ฆRิภ๙`ม๏่ใ„ &_#มตตธw฿„f*๑`ลŠ'T@*เ”ƒ๓„‚A$ป�โ๗ ๅo๙nž;่Dท5 ฤt= ิ H5ธF์|Bป๔€qxล!hˆl๘€ๆC฿8ิ็NฃA‚5˜WADร‘QBNข+เ�แย/sผ@bw฿�xะ HLu๖˜\&pี Œ)@P^๐9I ๐ภ$hมt จ„4~8ฑhฐ€I@|N฿ฺ†"$ถณ.BฐE R1ฃ!ํ X.ˆ1‚�ม B-@a "‘H”ฤNฐ AHl|วz]ด‰$…๙D' APมIQHุฐDŒแ Aฤ &)ฐ@|แwh%80%CQoะ�%ๆม‡%dกŠˆส8lโ s˜๑ภฉ9ผม3*ฃร5แ†`ถ๑nAล61<\รŽ ฉภถฐฌ?|ก ั”ตฌ†l@wPม"ฐA�ฟี๒–นLฤFเ _ิ @้�‹@ฑ p€ม“ ๙ n|’ฑ*„1ะ“Lม‚ ธ‚W�22ก‚ ์ก�๐82ƒ]๚ฤศ )Z`3MLจ�๘CVRฆTศิGUR>Lฑ&”ฉ๏x‡)`ฐ€Tƒฆ'่F&๖Hผฃท8'บง db2E Ttkเิ โภถQ„D!:\ภISบา– @i‰�เ#ั๑Cฉ  X€) v8 ก€D#6uZš rPอT'eเWrJšhฆ*ุ้rXิะ( ฑ–ซ,R ๎ ญฐ้Tปfˆ๔“ซ ‰Ž›ก] ;ีก๐/ั�ซ่น์’›พสภiHe}Pb…์'fฐE!BŸ—l#าEHMd‹โma Sะ.่ฑ=@qทป แ Dพ+‘�@ ดั10+`?�ั๎`ฬ Tภอว;\ฬภ‰3๊ก สค2 !๙ ,z 8Pศ!„ ฆi๖ z0!Q•Dq*ฝจ๓„‡IC๚œxิcTฃA—jุดcรฃช†ญJถ์ุฉgง&ต 6-Zจeษb5๋6๎ฬฎE‚ตห7๎�Ÿ ัŠญ›w0�พY+Fˆ7oเฌ„ํFVผPp�ลBcฆ8๏ไ‰%ฏพผท;D๕๔qC–~๒ฉ๘ัฝ'{‘ ˆZuไ‘V [ …gั@‘$^lั�—ู{_}ิVBnๅ!‚�}(ั†!๙ ,$†�Hฐ มƒ&ิภฐกร‡5ŒHฑ"€‡ 3j�ศฑฃว CŠIฒคษ“(Sช\ษฒฅห—0cสœIณfวŠ8Nฬษ“ขอŸ@ƒ JดจัฃH“*]ส4%ร {JสณฉีซXณj�สตซืฏ`BŒJตฌYaำช]หถญ�ทpใ�๔)ํฮงฯโผ;Vฎ฿ฟ€ Lธp�ˆ ํ–ธXoYร#KžLนฒeŒ๑’uฬนฑโห C‹MบtXฮจ-š^อบต๋ืฐGฆ~:›q์�ธs๋�mXbmชžm๓Nผธ๑ใ5ฬŒน๓็ะฃKืJwบ๕ุ๋ณk7้[๙๖๏เร‹�Oพผ๙๓่ำซ_ฯพฝ๛๗๐ใหŸOฟพ�๛๘๓๋฿ฯ?ฟCŽ๗Fห=ๆ™€ฬ�'\ 6่ f�iเot‚>U๗เ†vˆ`bฆVWˆSyhโ‰�ฉV ‰จญศbO(ฦ(#}*ZจX„$๒Uกz้˜_3)${5!ฒ่ขY>๎ตุPF^‘<พธ—†Uvge_Rv้%tXดe.6iๅ—hฆY�g›ตXˆดU•jึi็j�eคๅ˜ rYf’ ย(เ„Jไ‚bšYRžd๊่ฃ † ้ค”ชeฅ˜fช้ฆœv๊้ง †*๊จค–j:ุร !…ชชฌ’�t€Tไ Q ึศ๓ฯ4<`ภ1X๓>ธtะชฉศ&KW๔ฃKฌE1๋,ด"น`6๘ชะ2เส4>4ม4เ€ .$ซ์บ์R@ $pิ@< A)˜PA (€E t ะผ๕�›๏พHn จ ‚ภmฐAยt@lฏ)คฐoฟ�<0ฝw<2H ‚ &0Œร5nˆ ยรำ มฬ!„€\ยซถx0+ฎไฌ < @ยsฐ 0ร ดo"P๐ด(W ภล(ใยลspส!˜pปlห็O˜ดB ๘ฑŒ?๕(ใ†พม�+Œื}w�{WC7จฐB ทุ’BŒ` *€`M&*ิญM:๕t๓†ว}ใ๓wเ~l�๙็}`H5�๔ฃŒ&,€L;ดืƒฯ/tPB.้๘Cฯ+@%เดชญnBL‘J๏๙ ’‚�QM�sLmˆ1-ดZม ๔”แม Xใฯ;ใฑ๓๎ฝ�<ฎฆจภhSm็�� ๘CK"ุ่� "KœC Y่D?ย๔เ ฎPž@ xภ.ะX:h#R Šh์ภฉภh t`ปุ(($คB`ภ€!(Aฐะ…0ฤ๒ภŒ?pแ p` �<#4ƒ`ยโ5‚.| xภxะชแ๑Š "Lžั˜W#`ร˜พ‚เA›ศ‚&X6œร  มzp4˜ั„xa &>1ŠS @$�8$CะŸ"ูs1<"เศ„๚าณiผ€่19- ฯg—ฬ$1‚2ไใ๚ภBโu๖ข„'<† +๙ฌเ‰ธ(0ON”ถฤ%ˆ0l์a ๐8Bzp‹t B -ศฤฆZีซfXเPณ๖!ˆI๔ฐไก@๐ะŒr4‚6XGqŠR‘๚๔“ฝHE:วZ`ใGPˆ]กŠhึt–8ภ všณห�’Wชeํุ …5�vAYrผNฃA‚5ฒ:Aโ=•ท฿�โA.@€†ย„cฝ†SQƒะ#ฏ8-‘ ธKF@๚†@=฿Wธญ€ ขa€Hฑ๐ฯฎ`Œz€Qˆ†l6^ะเb“ห๔ภ$`x‘ g๛ํ๏wP หX„ บwใ )@€M+™ำศ๖&ฐ9ิœ=เ;ม1ˆ‹�๒แสTํ2กฬิ‚ธ@%คใxu`˜“  ัlเฐE RPeฎณ XภGp‚[€"Tจ(lมƒP@™_ต‡$dLcุวd-ƒ60aซ๘บ9�;ธล)„ ‚tๆwฒ๖HD ั8คk~tธ"�๐†_"ฯ{๎sฮ๒pŒcjแศV…9ฐl˜โ W`ja๋aEไL—tกI�้œูa_x‚š‰งขะzรTหŠeผ 5ภๆSอธ@ฬแ ‹jฐˆ7ฬ•ฉภ* ฦ/tแุfDฃ8‚ ด๕*X6มƒ ์_ธม๖‘ˆ Œภพจฎu=APl`h๓›็-pฆ้=ๆQpโqม@ :๑Žw˜๚ๆทฟSะ€%ไฃญZ@'ๆ1ษd~`�;<—‚ค"-๘^728œเ�‡วA.r’{`ศ๐‡๏2ก85  wห‡)7๐wpN8b๐<ฯไ%Hล๙L๐fๆ3x Žwktno๓�ร6Š‚(คƒ 6XภX็H๒g]=€p บศฝ็๙yคgยิB0ค@xื;วR0ศŒ5๓ฺA:เe”,Qvุ*@ฑภู์๏T3'ๅ%ะ@จ…M9X’๖™ๅฌ๏{ภw`๚c๕ฝฮ AšJfฮ @Mjkƒ€Xึ/–�๑ƒ?Yส‚€ๆฺ# ๘ฆป๒ฏรc "ฯwˆ๓r†Dฟ*์>7˜Blš@��Sj๑+d๛9ภ ด@‹_<,#$ศ@ล–O�5i`๚<ม€œ“H„D๕  Cษ'$ ,€~ั๕ื€‘ม'ุ๗X ๆ็+,พ2PEถJ$เ+/ฐ r๐พ’๒๗Xpธ‚r‘!a.ˆ(ˆ8ฐO8ˆภNภƒ ร 7J`~Qƒ7p:(„,…๏!1ˆgซด "จ-"€qะ„ ๑ก๐ Gฐ3”๐ ฿ UH ะ P˜Yธ…ั…_X€Rx‡โ9ˆPฐ<‚๑ƒล๐ จะJx/ฯ@ �เะ๐ ไ@ เ�@ 0€lpศ8ˆ…xˆ‰ˆ‡ขธ•kฅp\!Ia0v,ยB,b…vนฒ+ฝา…T’ 2]A6*/p๋@ $PฑP๗0 u�'M0ภ/€๊เ๎ "เnาคฟŒรXŒว˜Œหh‡ฃXŽGPU€ ภ.#ม1.ๅr.้"�โ-๏ล€ธ]ปธ๙€ท„? ๑เ B@Œ๐ร0 g2ั@!ภ-ะ/ะิ่ิ๐/0€ ˆƒy  ้iŽ*I™w5" K ภ5P5“€�๘|ไงA “ณ/เ๚]ฃ๛๒“แ+BC4[ด3ฮ7 P'ึz็5pŽ5หuฏzŒg3eรhใz รp6ฃ2พว/K3x.0ผ(เ๛`!3?Œ๋ฐ—�@ ?๐v ก8gP ะ �@ �0ิจ�เ �˜ฯภ exxฉ—|้—€)˜„IŽ+š%ฑนเ~฿“Œ@=~p ๕เใ0<',� 9P} ถš๕ ฐ)ไu้9ฏะ๘€� ฆผฺ/x<]ี|ฤรŽƒนเ;ฉ‘S>� �ะ\iฉ�๐�€šงwเเšn๐c  G–36 :ฌใ:ฐc8ผณv�f8เ ˆ—@q€sY—ซ๐ {น—๊ฐฑเD@a 00ล>เิ‘ิˆŠิ i ‚ๅ๗ช  ฺ กฺข๙ข-!ž 9ล’ Bฌเ c0 ภ fDžภ+ ฃ: =๚ฃGzฉ2pฝA Fๆe0Q �ฐน“E5Dช0vP€ภdภj+4๔F\๊ XIภฤ0Fo๐e*๕€c0ื0Et '0M ภ ุยC>�DBด,ฅ[ฺฅวฃ ฐ060ฏ@ฃrI—Vเ‡a6B@ ( V2)…p“ร€๊์์ภ ๚ �„ขJชฆบ—จชช0ภช  ฃฦฺ2Jฃ๛ˆ‹ฃ5 €‰สffฃccฺฌฯญ<ภO�ิd$ ฬu๛ฐ;c,ฐ  ภะ*ฎี�ฤภฒ v L�d>ฐN›J๗ะบh0 <ฆLสd8�Pf ึ][P 1้f‹€ M€L.@็šฎŽu<[p `GฤS^pRช Q…?@ ๋เ ? เ\ *˜�๓R ฒช์@ซทสิ@ G vจฒ,๋ฒ0+ณ4๛†วฺด#13ZฃŽ…ฃEcf7ฦbŠตd:4U{f�FWv…Wตฉ0แ๊ €้P � ญ•\ห๓ฎd‹�€2'sWา•หš‹ ฒญFnทj —l`GะLA  ๚Zฐ ฐCT้pTIี2%ฐถmหก B<ะฑJYล—๖ภลฃ*฿๐ะ0PX€/Pปตห =›ปธ๊ /Vป)๐’ ฑบญ‹2ฐ+ปญ๊ดศหษ*ต7สต9ใตY[คฮ^[^โ5ไีฎkเ� เP8 ๓ €rKt๋v�P4 4‡สทN๕ท�Z‚‹Š…{ธ9–ธLเวน๐ า@Bึ2ดe[ฐพเP80b–30 ตดP`ญRถฟ ‘0@ ?„ก ‡ qก ซ …เ ่๐ณธ Rภ,�*0ย"Lย`ย(œผBŒฌ•`/p‹@ตฯ{ต !HF<ฐำ๋ต^ฆL pE๙ฌ$($.& d ,P c €�pKFM,ณNกเทเXWx�* /ฐJHฐ าเš๊ ำณ`Ÿ`o(�–b+ฦฐ ํคSp๛+Q` p ฐE ๐X๐%ืœ๐"๐‹ิ๐ ~)ัt๐บC๐ธ‹ซทร์‚}  Xภดฌ,สคlสจ�ฒซ<ฤฤฌZ Y ฅIลL|ฦl@nฒฆฤิ{cช๗mแ6n‰ ฐฌษฐฃ…6—0|ฐYP<U;DlฦึญgŒฝ๖kPt€ง0c`N@ั†ฬ] �ช€ะ,า0Fe0k ‚๖งšญ‘ภ…“ „ภ*ง–jซึj=#ฮไlฮฦ‚Iฃ` ๖๐Osฉ€™Œฒม๋"@q๐ ฿เ฿ะi๐ษ�๑>Pส?P ?ซ˜ทส ฃlำU’8จา,ํา0-ำ4]ฬJMN“Qื s0Lฎภ }๘ศ€9! Cง ๅi ,ฐSญ!`ี˜3c'sffsFsr1  €7�๐๓'pJ'u 1tEทsŒGtอ๕r1็šs๐žฉะš:W1šcุ“w@CภjลAs2G4[�;gื๖v5 Žf5 `๙เลZ\0ป/๐ ™ผพา฿@ ิฐ Lถ@ ่P exผ๑ฺ;�ณ]�Kฝ�U `“ yญ๒N*0ถุ/2#1 ร�`�9ะwเษฯอ4H้“@ผ4#|uถืืหz eZ0ƒ'1 –`‰kณ6ำz_ฉผุ_* รไy7฿ภ–Gู‡๓ณNNE €1"0ณ+Vเ/ ร ฆ๚ }๐`๐?@ �P gเขชkแ;ƒแฮแฬฝ�ฤ-&ยั0n$ˆBBญญ]31˜Tพฤ  ๏yกlr;ร_ธใศ‰Žฯh/nP …ภหมKยฅไ"ภไ1�ๅๆaูึข0รฬำษ I,ะ> LL@s๐X#)>iNข`พ็๎!0ฺุใ"มญญ„C1crg�>‚ฮ็ŒŽ&ˆ สค<37�่–^)3้—พ้œ�้N๛*ซ‚า"แŠรR,วRАŒŠ 1+ตโ้ฎžา๒,ข๒h.่r๊Ž๋ศึ‚-€�๊ภ~๎/๒B2ฃ/l2–W0๘‚์ ณ0 ๓~ั”5ฤ07™“R™–+ำ2 ๐2133•์โพo7 )8xฃ7|๓Šฃ30v“๎…s8‰ณ8ำt†<5„+�0›ต9๔ู:ฏ;ณS;ท#wใ~๐ผม?�@DI„@ ฤ@3A9ำ|oAa€AฤAd8ฆ[คC๚Tลจ?DCTDG”DK4๋�๒ฆัH�I“”o „IšฤIr�|7/JคdJจคJ์Lฎ•}ฐUูGLฦDฑสฤLฮM�๓ReQฅQ0hR•5R;–Y›ฅRภR.u}Dฟพ๔ูGTF…TJ%Mฅ๊S?๗ญกXŒๅXใถ๕—ล๓`ŸR๕YGภE_\W4๏T[�p[Kฅ[N๗ฟ๓U_๗•่t ๆ_d"ฆLฦ_šฯ`aึ=AฦC&P9 OผLpbŒฬb.ึc๙๙ถOq6guf๙า3ˆฦg~foj„fhฟฏh!ภhŽiูRยฆฮว†ธะ�๓Sัชฦฆ๑jฑ๖I�}๛�๖ึฺ ืo�ๆh‡r7� ืp—ี' อtyอtฑี]�ีš=s57—saสC D˜PแB† >„QโDŠ-^ฤ˜QใFŽ=~RไH’%MžD €‡‰. !ABŠ4€!… —0eาœะ@„4Pกข ˜"ผ™SEM<(P0”ร r ‚`…€ ‘RํZถm�พ…W๎\บuํ�•๋rฆ 185จ—„–’ฤ@๏bฦใVจpมฐcส•-_ฦœ9๏ฅฝŠ ๖ฌY๔hาo{๔`1น๔jึญ]ฟ†[๖์ส�:๐๐q†%Rค(ฅม"ƒA6l"l`I[๙rๆอ?‡niŠQ#็�7tฤB๙‘กƒ’KGŸ^�z’�^Kกอะๅำd฿๑>ี’๛๋/ถ Rุ‰ว�aY…6๎@‚€ฮหฏB /ฬ๏เฐ#ฌต€‘�ใจ81D !a9R4 DUด‹:Rศ)0žฉaใ’d†!‡œNข@a WD2I%I;ภ*r(จ‚XqลCƒ$ซa—R๘IK๖Šศ)1!๒rฺ"@{8แ -Œš|๒กฌ‘็Ÿฮ˜ k�ม—�„ƒŽู„ห‰ฐิา�P๘ Œ!<+Sั‹า\ณอx(„@Œ‡j6uวR†PjISOE•1ฌัรยคคาส‚$ซ`ฮ์’3I๓ฉ‡@c‹€+๚ัลRŒVm•ฦ‚Pฬ,h‚ hภ\HขA ญˆV[q-ˆ%๒ ใ ม6–Xc?bข˜M฿u'HxฝัCีRล7_}'Š๊ŸจŠ ‚ค0ชฎ๙b(6a”QX)ฃผยaˆ“ฌค’9ฌ8&xกžbš‰ชฃพR!ฌ (๘8โ†F�ท *0 ญŒ๘๗f R2ฌฐh!d๚yhœ @LH ƒ�Pุถ“!!�ๅถ ฆฌาอ›‹๘f ๅกฃฒ Dh‚Š/N{ƒ\ะ' Ža.น!ข3HมŸZ่ฝ๛†L&l›Nชl„:่C…œำ tฤ‡l๗ล<๓ฬ๐C›t๊้ๆ €๏วŸq�ฐmdฺaŸz~‘r”k�1}ฑๆ�็ar0LC?nฉง๖6๐เd�ฉ็t๘rI}@ rIวz�๐ „ Nxฤ�ง]ขgœzภ`4rฑž‘์9๐ เมAฤ<๘=๘qๆhก ๔ไ“0๚7ดฮ}.tT๙€!ชแ~(C &Pฺ๋Q|�โ˜^๕ฎ—ฝ‚ �ะ2๑TัตYลo~๕#Lทแ[\ƒl I€!Œั‚ดTเ๔(๎tวปด ํภ>�!_๐ไฮƒ^ ?,yสpƒ฿ˆ๒ŒsNฌฅุ Tฐ %ธ…-๘ใJ|#๊ …;พA uˆขR‡ŠqอีัŽ๙๒า9ไ€„T่  ๊ˆ1 โ|เ‚`„k AุรXแ‹7ŒaุC O€ฉ‡“ร@ม 3ไ xุว&ฒp…}4Bh q <๘€„N์ร Aพ<`‘งด!ž๐8ภเe—ฐG"บ‰~ฤAJvx"ธ@†f`‚�`C2—เ}4ขp…/B9สR.ฑ„=x‚+H€<๎ฑ`ภ๒ภŒ?p˜, "ะ GB’ศๅ.{‰ศดxะ“Qมญ„Mmrณจ€ ูหB]ฎ ุ<‡@ƒ ๔เi @8้I–ฌ€•B”ะXฮฒ–ท4g-q5dก�C<€NuZ)5ฝi�คƒf0"ขˆฦLภค๑Œg๘AT�HจB๕m,†H ฌ;vีซาัห ฐ‚{$โ�DHpPdรธ€ šก‡„€ฑs…เf`‚w0(—คไŠQx|ตfŽQŠLเ๗pe�Lูi‰lˆ•ฌ‰ฐ P์ขฆฤ<ค l•!.ฉl&€ bœ �ฐCL€A˜ร8F x@Qc# ฌaปซYq€ ]gXวZV๘ขTJ Wนา•ถ้€f9ู๋$ฐa˜B�„[�๒ึ'่ม6่ €P<ˆiฎ0&l!Kˆย<ˆภ~K’!ภ ๖ฑ†๗„ภK•].f%VF๕‚^ or9๓‚#{ C>Nก,จ•!$pj<†ฑŠU CGกR„#พAŽ6~ƒ ’�jŒeฬœH96Š |ัlาี…๏'ซิิข\๚ฎํ�Zไ#ื |ศมPPI``มบฒฑ"ถ‚€Chใศภฦ‘+ะƒ[คZhEฆ+ษด(ศB?๐แ:๛cCจม5๊<เฃ‘Œ เYโ†น>�ฒ�ฌเ�ะDZ@‚ฅ�xฎ\B—ฝ ๆ#wะฟเ=ด›๒ผg:๛:N๕�˜ๆ˜NX6๖0x๘8ษ!ภ v,เฦYข�ฬŒ"ุ ยˆ�หฐ‹-ญK–ยสjjจ�5mjgF3ฦvถi#˜ œเฦ9�‡xlƒเาcแZX˜ŒไO+9๋ฮ3]ฅLๅW"„��ึ2 :qŒ:ึ82Xf�T€oz]sฒ/เf8๗ฃB ฤ› 'ืม 'Fภ๑ภศ….ศฑ�t๏‡% ะ‚&๚aŒฤ�zภด๖�๏wšํyจ R๑‹ำ ใเ7ถไ„๙ˆร+๒@ DHc07?(ผ๋b฿ฒฒ7›Nlc๋œZw๓ฒ{P lœรวฆ:tิFjฏ]X๕Œต=wบc†�พVฤYQZถJรr07”3pืtฟ๛ใ„9^PZXum�hฦ&๛r4byฐ์๕ปฝ Aศv�  @i {HB, ง๐ˆAŒผ๖Sม–ฆุOผ 1ฅ•คใฯ M�00Ai๏^๒˜%1 8Fง�ไล“`๓{๔๎P i4Ÿ`๐›-X|(6ถ—๎]_+ค`่ๆ์ภN Pฃ ต€…-บ €$�๚!˜ภ ๊fXค`๘Šภ:sน„ิbึย'์๛=๎Cญh€|ฐ`+cภ‚eX ก…`œb`‡ dวI1G01u`dLxบCมฬŒ�ซ€ „k $CBค‚4sƒ1xƒ฿Šรณ9lฺ‡DภมฦSท€H e2„n‹ชบฝศ2๕c…ex.ศ1�@๘‚ธƒฌ7s™ฉ5่‚   |8…9;p‚ูb�}ฐ…2@‚ฦรงukˆ เ:€A7x๘ฟฐฐƒ=๘‚'๐‚jโ’ิ์ถ(œย*ไ’Sฺ‡@˜ƒ7 ไศl„7˜ภฆ~ฐ…P’ร :h‡L๘‚*ˆ<ขCˆผค?ฐX#„GdƒฤAF›ทdฅ>�C&�บdZฆfย–•ะCDเC?D&ebฆ8‹<8่า‚kpEม€#xv๐kคRxจ๚k๔Fq;$วrt‹H…`h๊†Lะ+ฮ)�Yž่'ส.^ฐ@†LH™ชˆM๐‡{dpโ…%ไG�žCH‡tศ…~hlYtTGvฬ�ร(N wะ†?Hธm‡L€ใœสข("˜Xไ้.0 ‘:.๐^ฐฒ„�G†ภ€Œ|‡w0 ศu<vผŸ=8๋ษ/"zดGภ �œŽค ˆTH‡{ไ  L…๏1…ธŸš4.ˆส=Pษn˜ƒ1:ธRห"่ H™L ญH‚์ษ0‚ใ!ส8J‹ ฬฃ‡y8Ÿ๔1ˆ ิHข„๒1L๔๙ศqฉƒดX€Nธณ{9ˆH7nเq †>xˆชฮ์ฬg … เEsdอึD ฤY˜  �ำK›ฒ8 ุ€ ศ H‹�“Sฒ ฌŠ�” –ฮ(Nะ๓จ8ธฎ…€อ M  อฃ๙ฒ€˜ขYˆฝ1ชhA๐—’‘Œ–IŠ�Dš‚XNฬLˆ€ู‰้ฌN๊Šฏธอำำฮ� ‹€)3ฮ๚”™ฃ™ ธ2ฯR™ณเฮยP€ฅัฐ‚(ฮฌจO๐เ ิH•ิึภ€ xq์'T้ฎฅก3เ>(3P‚ภMEีTUีUE‰ฐHŠเxHeีZตี[ลUุา\ๅี^๕ี_ึ`VัhRMึcEึdลถb5Veuึg…V…8Z}ˆŒะ�์€ฺT•ศ +ฉะฉะOฅ๑› ภŠ์ฌ€ต!˜ณ)•„ธO ภ ะ‰๐ ํPmญD่Šึ}ๅW^�'ธ6‡Œ(HฺษFศOา1ิแ€GP€ฐ†9ฐ�Z z �กžzะ†๔๙p…ขHHธฬ�h€%`m8…j‡ถ$ž;จ†,jKรษxDxู˜U†™ํืž๕YV�P๐Z( †(€$เb€ƒ/xƒ/hA‰*คCโ:€…‰ใซV™งzบ'ุา(€~XโัX€W†B+$ะ9ธD8 ู#8%}P%• ƒ฿’Zผ&<ะ�,เ�S�ูรE\ื< x„|(I๑a˜…๊+-ฦ?พk+ุ‚jˆ8ศ,pf|Edh‚่ย–  €we€)kโู‚kเ€ผXŒ/“ h†ศ{ลbฌ�“s0ืB+ตb+ท๒��L�ๅe�ึธ@ƒ}(‡8ตQ€ฆA9•c9(€T‡:ภ)(ฝฐ€A0ปE เcBH‚{`�ี]�768 7๎ฑภ†�บb{เ&vโ;:€+0†z0†?ภ'“\axตส ะ;ฬ๕ปถu†iˆ€Y0‡O˜�H ภK>0-™ผh0=<‰k€ƒhร•:�5 W๋�ลJ=ึj=ดc8^X<า'fไFฦธ…S‚ฉˆN†€a‘†ฆ-ƒ7–ŒAชญ?~(„˜} „6 ฤA,Dkr‰yเƒ%ศ‚rP„?a”Qฐ�ฝ …ศ฿�ล5�•‚ผ�„.เ�1�[P�ฝล)=vไeff๕˜.• ุเนGฺ์œฺ‘š; ‡!`€(ศ.`J:ฃ‡ง ผฌžw่†?๘ธBโทญ�pษ‚| –่ๆ|h‚—ต†,ดษ’Gํ)ž}๎†(‚ๆfF่„–•8X€ฃA‹ฺฤV�um@�„Ÿ๙คš๚คr�—™P,˜k@ e�์อ|ๅ€นึดุ€ ศืU่™ฆiบ�ีล —ภ- ]€XŒธ้ฆhาฤ<่š6๊ฃฦถnถ†t…!จไ?๊1A๊ฉฆ๊r$ฑ(j‹Œfญ๊ฎ๖๊ฏ๋ฐkƒุCตT+ริn9๋หc๗@kู(k\๕๔่อ ศ€ข๋๕0ษ๊ƒะQfŒ๘๋Yไื์ภฝุZ•‘ก๋@„2XยผV2‹ยยŽˆป>ศ์’Œเ'5SfuJa“l“`ŠฯVฮๆƒ €9ฉ“Aๅฺถ“Bfั&„�ฐ์ขS“ิŽˆะํ,ี ุ”Œnีl‘ˆ”‰(P†~xeเศฑJq�ถํธp,z๋ิ^`G้kส‰ฌม์ย๋๐vํ‘ 2hF(แ•b=ํa)–lAWั€๗F๖ๆaagก๐gน์h–๏'Ao\ร๏๐พร Xp๚f9Qว•x^`ดfn]™@Zƒ)ˆƒ"htษ๏eiฟ‹yซฒ๏~๐ๅžq!๓ž ผม˜šชQ–้่คˆQุ™pA l๐„[ ๒™ื)Pyืx�™ฦ“ ่Q`Œ‘hฑืฅ9 ™0 ็่ภแ™ฃ(ˆ�„ฉ†qˆลA๒[yR @o{�ห ุ๒‚A™ฐภ]ฉiน๒,ว๑๘2�O๑ sฝกt/—tP ๙xœ)™@oˆฆ9˜0XU๓ แ™='ำ'G S฿™n™ชak๐b™ €จผAืuๅh”กMšมฯฐVฉเอ 8ภ)Ži๒zล ˆ˜Luธ๊ึ™ด๑#ฟ„๒9Lชศ ๋มํแ๏Ÿ"P๓ูฉ�dC@:ฤ6 ฺุฑ๗�‹œ•YAGูtPY–mหฒwฬ๓ลdดซ€โ%_ �`ฆb‚Jฒ$Lšฟ˜Oฬe‡ N:‡8]�JบคLfยลฉธ�บฝ[†J„m๊ฆ ๘'†&(ˆค]ฺฆ}ฺ•่Z{‚|‚k่Cุ>8„eˆ"6zQŒว„€‡>๛iาOะŒ-zL1†$จtŽ|Œ(�7#5ฺx#Ž9๊ธ#=๚๘#6R•อ L€ 1,„€ม !ฐK+90ศRัLYุ#Eป`๒B ˆb b afหfฬ"อ Xbษ‡ิB`@BJŸฬ(aCkชจm.4ฃG!d9ย(ฎศ๘e ฅนfI5.ตู1l#H$J6>HJฉฅ$ลค“PvภฏM=บูE™ไฉ ต—L๓+�“�]ซะฒ•ZZฐ‚`ylฒ[T๙`แย1Tุฐ2Mคเ)จP€ ๘ึˆd[\ร… 4C\0f™g�‡ปtว๕‘Mบแอย�ณD ๎$#,แNุฦๅฏb& !ึ xภ~เ{ุ›ว๎๓ยฮ jgTƒkภŠf’@๐€ าwD>๚แGฒM!ภ ๖ฑ†“ Oห๛  @ภŒ›d-เPฎp�+t ‘ป%.sฉห]v๎s<�่Xะ‰]ิ!@ฐฦ๊(@๏วZc;„/=:"เq„แU$@ี\ค�€้ผ{HมU๒๊เ!ะ€D R–‡]Xฦ#ž4ษ�๎ค…C $ˆฉ\€๙€i๖ป฿ˆ๔ื�u“šT6ตiห๛่‘O1T [xGtโล๙ษร˜ก�๑7 ˜\ ค˜qอ์ฅŠื}jG]ด~Ncส%๛Qฐ3|‘ŽIศชำ#TrขM็ฬ|๓ศH๖,h,NKุ ะ  9ธง GJFข•ชคXำ‚#ˆh ำ๐/c+�ูาถถD�ˆAŒœ`‚0 0‹ี5€XzkT)2M˜=ิ-3ุD†Vฃu ฬ]]แp‹ค=‘ง๔ฐยœB๑‚)m0ฺ*ปf!Œ+ˆะเ)#J๒ซ)�xภ“€…Vว†ลๅ ณZn5๕ HคpA<{’T‚YYŠืผ@! ฝญทฝpฉoฑ”`งTุ]�4ฏ(ะ{[ไ6I๒/€๋38ย4(Vฑค:L๗ยkX�0fœ1ฬยŸ€ฏง\_`u2กT0(Xัธฦ]้ކcปธ‰^agŒร\�0WYu๘ฮทพRช„9^เขr4BKฐE Rะ8_š๐ฎi6 ๊(š��7ภ$ …-\lใyf’ zี”ห๋ฬ^�"m�ฬนฐŒl‚ธaฺ’=>„ Rะ$เๆ˜ฝ;NK&p}„มŸJAvYŒ$%I,)(A^บ‘™ฺถุฦ>6ฒ}%–5tก ชภ XฑŒ7pกPb�๑…�aึ�qg>C์•< ฝ.6ิก.|( ์๎%๕”ฯŒa €BBภง0H๗q�FOส๐†ๅูa_x‚t_ก_� ฎ7ผมYภ.ฬ!‡1ผAš�lnน<XศรŒ3ษ†*4‡7L&oOบฏ๐๏€ณ<ฺำฎ๖ตeˆ•ฟ!]๚�ฟี2ยs0�ู!Š">๑Š๓9Ž~�yะe(s€Aเ-ฤหํ’๎H๐ฃ˜„>ม�เ GUๆม‡%dก(ด๑FaA…F!แ ผสxฮะ ึ๋ขq ์_PผฐRdBuมˆ๓‡/ta3ž™ำ�๑:š:6ํI—8ล-ž๒@ฌ|!มว)ๆ0;8Aา(1}เ�“5ฦมฝ6R7ใIฯ+=๔ดทฝ”'@ิซž๕x›:อรDณdิปโ๊๖ˆDฤ ิขD ุฦ6จ ผdsฟ๛�—m�P ิCnˆgJะ‰w๘ใฺ๘C pmศรเศ $ใ ^ค๋ศศ„ ไ›!ไรV�™Iจึ2ม™่�…€� d�5”฿คdA>ผ0@ไCHจ?Œxฤ†hSสจYคย๕ไƒ)xภ์2`=dbLA:๐Bเ ุ`ภtB=ผƒ)ภ@-5@,ƒ?ธ� l /ลชภT€ $ ฦ„‰<ย5ภ฿IP@ คB:0a„Ÿ6Œเคะฌ` š‚Gจ฿;คC=ผ_ („‚!†ภ A @๘_๙_ๅ`€! ,@*C @[7d‚Œไแา†.!Žก”แ8œa$ภะรp`>pมBศ ฺ`xก&คC๛uร \@ `ƒf์! `VQ Xรุดƒ1x@„8˜!—กฤˆข?ิ btKXC๛]ข�T@๐3a ""*"#"Jเ@:ธมž„ ฎ!zD†แ Ž!ไ๙uธ1’ก0†@๋ ŽH�†(ึร%M8พ!ฮc 4@:ฎc;B" J"า]€ฬ ถ@N`7œŸBฌ@:ไข-?่ย}GvคGI4<€�อ7†$@€H@ ค@ข<€Jฒd pภH$ฤจภษ5จ€๘@[4<้JMJ@สไdlภ(@”dK2@จ€ (O๚d+*€ค$Uฆ—ษQ‘L-€HB€ˆภ๒4ภV’ๅO†€VFZงดๅVdคd Teu5@|•eทd€เไ่คPฅ๋ั†Vถd.“”%B $๘ค�„ๅX*ๆ/ŽๅJ&(“VJฆค|ฅI $Iš�ไI0f]&@P%HฌdPlf€%ˆeJJ&c:f&Nฅ \ฅYz€SพๆZR@h%WV%าอ‰�„fO�ค6š4@KวJ‚Jl'ั\@คฅdา$Zฆd ุ% %Iฒค ,@OชsJภ0ใtถ$W‰ฆv"ฆdพฤHVๆ7�วs"ฤ4@YŽ0ๅu6fUช…zึ็}ชวผงLาF,€nฦ4ภSz'S:%|*ฤy Jผ‡=คc}ค†n(‡6…%UQ†fN่?คมq‚hŽเ็$]0’ „(�Œฮจ„�จŽŒh‰ž(๗ัF‹†c}( †Zิ๘จ‹ย�(iSจEฺเ€ƒ.๘โๅ$Y0iŠช(๏iWUQŽ(้€)$Œ†จ˜คc!ึ�€ะย/œ\‡ฦฉœฮ้ฑ@Um“lyžฆXฦ™ขฤ�) j@Ž�iŸึ–žึŸnฮก&๊ฑq 5คƒ( ลั)ฅV๊lE3ิƒ& มjZชง~*จ๖ศ๒ุV)DกšO ฆ๊�่ศจvhฉvJ็ผ๊ฉ�’G๘ฯฌ†*ฎๆ* ฉ•๊ชฏ�*ฐส) ฺฑ๚ฉ +ฒ&ซฒ.+ณ6ซณ>+mัhเ(ดVซต^+ถfซถn+๗I+P+ท†ซธŽ+น–+ท€ุ'Œ]ณ๋Ž�้Aอจนฮ�+ฝึซฝ�+GฦR?่ป2ซ ‚kŽP+ผโ+มฌม,น*pแˆgBe›๔ฆ`A @’UŠไgฆภร6g&U๒จgre pฌxV@ˆ€็ํgW๒่eโฅภ'MBจLŠ์�GษRภ@Zv%$€Iˆ@บl,mถbJยำศ่I$@ฌd|ฅll๖็มฌe6-$=mƒZ&ย^-ึfญึbŽภม˜€˜†Ÿ�a›ฤ€5�>CtXฅŒญ๙•m7 ‚’”ภ-ุB|ŽฦlO<ฦ•–J-1 yฒ@ดœF”‚”ฉฤPฬIฐU�>�4‘ภˆ5ล[ญัF@น3ฤ@ม `รŒ6ตฮ๋ฤฮ์tKM๊ฬŒ+ม…HมBม+�๘I3ฐ uŠY�2๓0ƒ๓ˆาba3*Q28‡ณ8ใ*๔4ฑ€ h้IUVw๘�’;�ื6XU ุ*๑@ำเ๏Ÿ[,Ÿ%ม•\)dS ๔=ฤม+- Bฒ`f3=ำ๔WG}2อ ฒ–k+ต ฒ9eม=ŒFฝข)ฅิ6—I+OJ ฤ8วดLฯtGภC=b๐hถ๐ไฎ‘ฆšŒ๕4์Z‹1`ม2,‚ l๎ฦ๔?{Gœuฺท5JDา �Yž5Nฎ-’Œภ �(ิ(@Jq๕ Dˆ™D5 *ฐi&ซท.\~5!๒๙@Wต™ห†๕ต‹ั4aถaว�ย)AชM*EUลUdE€ส๒๔ำA6VhBไ�Aฟค€lแ‹nฬiEต )^๎ํƒ+\MฺD^qPžๅ�pŒภ๔๒จภ"`ลT@ถm[ท%ยl‰/ิฮตBJ\8pฤฑฦk 7ƒฟ0\3pA ณ‡ s๔อ6LPwlศถๆถw7x๓H„๔)Uะร<ิ8B|๎#!–๗yงw�-A>ิAB,@';w EZคIะไ2ฆโ`ส�๕฿�Q่}@ฃ4ถŸ)คภCึCDฦ์ม6pOคฤ,ภŒŸ�แŸ ชŸBฤ!^RFึ>ั(๊ L5œ Pn<6๘ƒC’�_>*q๓B$ †ทŽ๏8FซโUƒDhgŽ�e]๙ห$€šๅl๊์}Lhำ<ƒชคiึไM๎์_ย้hlงtg]>hีB/(@คมeขค$Hภ}น…b่r3๗�ภๅ[6@คyc'˜๙|FRS†y�9 :๘RV]ูาธ๕้qb้ะ†‡N.T‰฿ศšถiเ"ึzสŒb€…ชjบชn:ง‡บช บฉŸ:%::ฒ,Ž`ชฆrช-�+ฉืบญ฿zชขบฎ๏:ฏำฉัŽ)ฏ๖ชฑโ:ฑ;ฉ๗:ฒ'ปฒ๛*˜๒A+ชโ่ฟ6ลฐN๛ฐ๊ฒgปถo๛-ีฎŽธzจz;ท;น—๛hˆ;Ž€;จขปนทปป'�ปป~ปดณ{œย+ป—๚ป็ปพ‹3ญห๋ปz๋ภRชฝ�:ตc๛พ<ย๓ฐผพ๙ฟ<ม[ชฟผB{ยWผล_ญบฌร?ผภ�บฤรyผ_ผศ�ผ๚{l�ชz๊ผsMƒ„oฆคไc€ไW~ ˆ@ไW�>ๅW>]b~ZV~วvคฑใ{ดGฝ่๗}ฎ็<ใ;>๋‡3Eไย?ฤพ<�C�์ฯ>๎ืƒํว>ํำพ?์>๎ำพ๎รพ์๛พ)h๙๗~๊ใœท�ะซ�เท|๋O8ำF<๖g�์ภ๕k?๖sฟ๗๗{?๘‡ฟ�jl)�๓3�คณ�ึC{Œ>;�Ÿ;๕ื�$ำm$ู6@\ธ€ ;PธฐC v ฑbE‹1fิˆQCGhiไF“'QฆTน’eK—/aฦ”9“fM›7qๆิน“gOŸ?:”hQฃG‘&Uบ”iSงOกF•:•jUซWฑfีบ•�kWฏ_ณ~ค(ถ*YฐgัฆUป–m[ทVอ†๔w๎[ปw๑ๆีปภC„4 แม*thะ„ร‰7pP`pŠ 7~�!๒ไส—3R `bโ�ธqy’�(R#jพฏaว–=[่Oเ4ฤˆใV=mšชั›B๑p�ฦนiธมรj๕”อIq@ใศ•sn�<๚๔Œเ<1aะํ้บ>Wห=ฯq=m๗๏แว—@ด‚uh๕๛ๅกดE อบ0d>Yfy‘1น†ภCŸMฒะคŸ0FAzl๐ม'ฌ๐ยBภ่P�กฅ ,#/-๓JRจคๅ’‘ตY�ณัฦีxฬ๑"ืๆ Rศ!‰ไŠ‚ฑฆAˆม„/๚ฏ‡,่‘f”PbI‰%"8ฆ”X bNˆaAธ๔L1ษ4“ ฌƒภhx$p2)ยEด`๔ฑว…x�ัฌmฏ" ‹lิัG! „f่ †:๘ำฟfฆดวสf–ศb5 € {Žจก=xเ€ƒ6˜AAิ52UUYuึ"lะHดะุงœ"Lเเล๕+Q๕ฤJtูTฺiฉญ%$,งO“คคR 8อโZฐ5UlrฝVYGญ๕ิsำ�ตืะzภฃ™raม๊Eึ฿๔–ิูg3�ŠึฺƒNุั<8\ฺ๙%ŒLเทข�จฐ!ก�ฒ\’บ”ๆอ1XbFPSC๙…’Oฆฃฎ0ฆc�`„„ิ t,e ี‘P eิq`TTแฅ™nฺ= (h@tFgŒ0ึ€�ธ@C„PB -ฤp‚ ; DฒGฌณ"n9ERุ™-Ÿrึ$ƒ๎�๏ฟูrh ฆHG šย%l`ภ9ไๆ่ ๆ๎ฐฆžn�@ผธj —œrห1#ษ9ะ๔๎‘�I$‚ูฃ๐ืa�.0q hภ๖ sLโ6ธภ3 ,รฌw~็Lxสˆ #W๑�ย=fำkMiูฑฯ^๛ซข!ž3๊>%๑แ“>o’๖พ~๛๕ูo฿�ฃฬ—Š๏๗้ฏ฿�๛Wr}+�๑๏฿��@€4เ˜@. tเ๙?๘)j~ดเ1H ล5ฬเAยกlฐ(TŸQ˜B�„„D1!�VCF% hLcD  Tเ†7ม#ไ yศ'า‡ �‰–ˆ:ฆะ•ดdODใMz !ธภ&7I†Q”�$(๙3สRJ|q”ไR(yIYฮr&7๐ข๑QŽœj‚-{™ห]�าŠa&sYHสFŽA‰%-กM”`เ.ย"Ÿ€!Œเ4ภ6ƒ`…เื�&ˆp‚n~๓šแg9ŸpN!ภ Xณi&Pž)M}๎๓"@A"ภЁLd )ˆA!ะJfพR)๙ไ็E๗ ๒9  !่A์‘…ิtฅ‘ัใDb”ฅฒ์hHUฒั!�3o*ญcKqšำฅั4ฅ๚CฉNิ™�++๊Q�‘šTฅ.•ฉMu๊SกUฉN•6€ฤ9|PRชn•ซฒ €๖‘†ฎŽ•ฌฑซภ |!ˆ'Iดฌmu+ตv(_(“ญoต๋]‰ิ0ƒUx๕๋_‡4€Wุ‚€5์aซŠxAซˆu์cีB€Œซฎต์eนRะ$@fAZฌh–หxฃhQ›ฺจ`\Rํka›ผ…ํmq๛ฐaLhln�–เ๗ƒp‘›�˜�Vนอu๎sก]้N—บีต๎tmsX a^ื๕.Ё�G6`๎wัซฝ๊อวณ(…mำ฿๗ญW>Zฌฑไ›_๖…7/ ˆ‚>ย�G�8v�ฝห๐d|n#๐ƒ�f`ป\ xyุw+ a Lยพ€ ฎQ� ˜iถ KXฏa&ถ‹ฉๆqฯ˜Z.~ nภ‰e˜ฦ=Ž�2C,เฑ‘lฯ'ฮF d`rd)หศmกŽ ˜AˆXlส]vฅQŸf.{™ฬ\r™ั,ล*ง™อ\s›แผภ7*)tvI\B;ห$D`ƒZH`wไ8บ%sฆ/?ณ|๒ํ˜ƒyฉw#?+d†จGไฮKhMŸQ(9H$�i ‰Zoกฎฉ="ูwh/เไค้ผ เเหXฦขiQ�ชำบ>ฺฆใหบ‚‘šืŸnช��kdk —เ…์ัป1๋gdูภF ๐)ืหV–ท ์๔๛ุแ^vนมm๊tk`‡ZธRเf€!ซC]Ip…{€aw $ทnsฃZ�มwาvํkฝ!\เลwbเ‹Cุเ2žฦH’ลš๖ฦ"ฒ.<[แo.xjUำ๋E‘ูรนรf(…\เ€ ย4@)ผ|พ7kNฐ%LไP€Cศ3>r’Ÿ�ป)ŸtตษMp—7ผไสฎ‹B ไผ-ข๖ะฤ‚Œฆb‰’ฎ๔ƒ3�บไfธฺŸvธ\�ิ-ิยก>w–'šํำ];I๔w˜�๊ ฟปสWN๘จ๓ฝ๏ั�ิๅm๊A�ุjฏ๚ฉ๛ฬ๘ม+ๆลn�uyz€9๑๔}9บ฿pฉ�„-_}ฒM�yฬ๒ตg ฿‘v่๕GE�Xzฏžกฝd �‰|฿้ฦzฟoMกํ|lู็ฟwฎ ๆีrํ\ ‹๒๐ว)ถ฿�+ึรฬŸ#ๅy�๕;OŸ�>ศ5น๘r™ra]์�ิฌYค/}ั�ฏ�P#ย/บ็/*โcJ่<E๚&ฅ"/อขO% ฐ%�ย�T๓žH๊าว้$pฝฌY\โ“ 2อ"�lพ€M&2ฏ๕–Z/๖Dฉ*Pั:๖ฐ๏�า๏""B๖Š%˜cšแAฤŒด‹ป/7๗$�๎�*Ub~X/ฑp#จฃ ฎ^€8Œ`๖ f๎ศ า๒. ๎ [ฮzชะ Š๎bฐ�,ฮ`mๅG`์!4@ไ4 3˜ว2hไ04 4jะjzฅ#ฺ๋ฝ@ะำ�ฐ๔2Q๎โะ๏๐ๅO๊็�ŽWƒเคเ๑ท:ค็ฌ 9ข#*€� l #์๋8@ุPฏ/๏Lฑ=qฉNO๒NOๆŠ๑๑JN,n๔��ฆ>‹ฬ€‚9์…�,bถjK!ฺหr คdธi3xŸpOปำ๗pBQวAก6@DƒD}H4Bku  สดยช๔8)cy‹ ฦ+ฤด•"vซทJ๘,lp‡ืBb%–+^4F=`˜ด๛œT�ไ�<`lG%๊สฒŒJฑขช@ขŒญˆหธ<€ kปJ*๋ห”Œฅ)K฿ฏL฿TM‚K1ข{,•* bdรGO=j#ยx*�—‘๊Nณ๔4์’BY”!%Yƒุวุฬ„xƒgŸP“•ล•Y–cY/ฮ๘%~ั—ƒ$#๗๓•kน+๏/kL8 ๎!j�9mษ^3ณ&@98^a0˜1ั+‰y>๘/ใy‘iYœณ™™ัื™ก๙มLgฒšษ๙š’œั2œฯฑ›{†ณา:]๓:fฟืŸณxฐ!clคะ™kแMpฑ2@ถN6 :c”ฦn๓๙&Ÿฑ1‡y?yึžหŸ๕. aศwปtI?ยœ฿3กี 5ษ" มZ@ไฮŠส`ขฯ๋่›ฑ๓ฃ=๚Yธ9จ5คฟ‚wEบ]ัr•“๐บ Z.J๖™?B :โ=ยชU$ดซน็ีTฐjh`zเา` œ…ง›Zจบ?ูy0SฺJš.F:;]ฎ�_z‘๚ุ*z^Zšชณš›ฐušฐ๋:โ �ฐAฑY€“ เ zk าa ข`ˆ@๋ึบ {‘ฬr;๋k‡บnฏถฎปbเvถไœ‘ฉ‰บดqถฃWŽฅ™อฅ;Bฆฑจไ ฺก๐arqท�Aทy๛|ธ'—€œ`ฐaฦ`ฑކญ—Zด?w๓๔ฃAุดOตตBตีต+2ฏ�Zถฝ๛–“†ถงš ฤbฆ` ึ`ถŽWฆ †S4 เ[พ้[์ฟ๕;@่!^!hคAVฦบน7+ณ›ผฏ{ cฎ+aผนขฏ็š๑ๆnปฉŽ=��๚#Zฐ›<G0ฌSœ V\$&บล5 fOเ@Aจ `ม†๑QJ�งbnŸ;7ถั{ฎ9�ž9w1�๗ฆ๖PฤsุxบถQ\จ๗ฎ6+`ฐแZบ Eย_�ศUฺร/ผจ“�*Lzk[sอ3�ยณึ{Cผ`ฆผฝ๕™ษ\๖`” า\'“��ฎ?œQ๚ื"Oำตำ<ต็pศ๙™ศป�๒ผ7ฅym#‚<ฐ�ปอ o5p๎jวrgWฝƒผ@1\ั;/ฝป›ฬ�EvŸฮZ^=€YP ฆ&ฬY!N<ำ‡O'L‡gœ<ืo&BMรดาˆSณศ[}ง�V]/„ฝฝญBุยŒmlด›]atY/dj+บ]H˜=�ล]Yท}�อ]ัฮ=�ี=ธ„}ื™ย�/ชŸx�=1ฺั™)ค=�ฃš�ฃˆ]C ฝั}Z›cโฏ›๙™฿��/ฦณ๙]อฎฆzยC;�qข�^)เ"จฝแ5wี‘ถ?^ž[ย�~)๒]ใๅใกhwCจ �คsยเ"ใ“‚ๆBๆU~†6๗•บWแฮฯŸ�&,พไ1�โbใs>…v>žษ\ฝUอƒืS�œ‰>)N�่ง>้Uˆๅ[�ัKฺ๋g“'pl�(ส~์ต^w#ฒ๋™ปฃ�'†>k�่o>๋ำ^ํ� tร� ๏อฅ~฿ห…๎โ๊๋๐๏„ธ๐ๅc๛�~ฏฟ๛&ฦพ์โ์�๐ฅ*gSฝึแฐ(โ>แซพ๐็�๒�*๊ี‚ไๅ)k์^๔฿*�`ใ�ณ๖Y๖i_ฮ่š0้Yไร›เ5่๖๋‡๗มข�{ ๘มr@‰?,Ž*’}–ˆ†อLL๘—ฝ๙ัฏ&เ๙ฅ=ธฉwY๗„ฑ�1ถ๗I?'h่›ยู๋้๑:L๚5œ๚ณŸุM๏ัฟฒ�ญ?ั��/ี9™a้๑ub�ล�€ภ <จ!กย… :|hp!ฤ‰+Zผˆ1ฃฦ;z�ฒ"C…Gš�4`ศ[U๛M๋ฒZึ‘อบžู๊6Wว‡+ว�Yxq๎ฮFo.N๖๑ภZ9๕m<1๊้ิซ[ ๚หํ‚G�;บำฒใeถึ-ฺ+nไ่Ÿ+ี\|฿๓’™ห'ฬ>{๘ฑร‰็/z�}๘]G`˜™x%้ิาw%�U‚ฦ›z)ืะq�E๘—v?iล!†ชลU^ูq๘_s$˜Ÿ„๖ญ(\‚าว]๎๖ข„ข„`>�8ฺƒ‰๕ O๖—…ข8ู~ด-‰Ÿ‘6B๙�‘™ม�Eป1i_’3^Fุ“ๅ๑˜ŒBศ”L�ญ‡e„ห gœrj)`pg�ุุ‰jf\ov๊x[]zจกฟ)๖'˜๕ฺงsŒ๊™4ฆ‡ฆ|)ฮ‰iฆš2จy็ตI%ฒ๑นpljhsiŠxhชBZ้sHfษูฃ‹rJ[ค‚Jคt๑m lฐ>๖น*‹]ย'y_%๖jขฎ;&Œ๑šก› =หZฎวโ˜�"ญ`v iฌ\*ฺซeิๆ‰ขถยฎหฎi`…ๆ ฑ.ช๗š{n> –_Š \ฝฒ๎X่UE–ฅง�สz0™^�วpธโvGn†๚n�iบใถ‹qฦpษ+i›จๅฅmลุัวรชห"ษํ]œษ3‘ฤgฉฒฬKrjุ�ŒsU+œนMtั2ž&๔&๗˜tหKฃ๖ฆัRO-2ีV_='วA&…ฉึ'/ˆืJ‰uูSซkvฺjฏอv�nฟ w�rฯMw�v฿w�z๏อw฿~� xเ‚Ž๖เ†ŽxโŠ/�ใŽ?yไ’gส2ล8k๓c]=ฌฐฝ*R�a็๑V>y้ฆŸ>�๗ำ–ำตpยทฎ ทฐg๕:๊ถ฿Ž;ึ’ฮ๚๎็ฎ๛ŽyกภใZg๎ฦ|ฦป�ฮช~ผ<{l ึ60้ษ_}๖ื-.ๆ๔Eฟ4ฟีgปฉkj~๚๊o�ดฟ�’K<สโ?้žŸฝฟ~�๚๏Ÿ๗๑kฟเEŒ~‚ขถ วฟ*p0ฃž‚˜*ฮ�วYร9`๋ˆม fฯfณ๚_ฑีฆpDˆแท๐uA ชp…ท๓ธ<่ฟฃๅ๋i 2๋*7:ž…<์ก๖Lดtฝฌ%saKส\6ฤ�n%7๓š ใร(J}ช“ก�š6ล,j‘‡า*ศ?พF0B`qX�ขฯจภ.�$Œlใใˆฦ8ส1}jร่ฦ9๊q|T[rG1๖q„,คฦ�€@~1†lค#yฒจ%R‘Œ„ค%/‰ษัP€& ฺ๙Jfr”ค,eF<‡'˜Kฅ(M หXส๒ ๐-ส ‚ค€•qฅ, LS& 4xD>ภ‘‰"๐๒ พ ฆ3Ÿ้ศ„l๒hุG9Š`0“’ะ์ฆ7๗(อเกๅh lpm๒•฿lง;yx€+ฃฦ๘ Hะ—5๓�์งp‹SA)ภงผ๖้ฯ„*4!เ+?ษอ…Jtขุใ$‰PŠjtฃฆCdF9 า*ฮ ฃฉIO*ธ€!๙ ,#‹!๙ ,#‹!๙ ,z 5Hฐ มƒ*\ศฐกร‡#JL!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,z *� 8มƒTธแฟ„ !*”ˆะแC‹ flฃG‡†|!๙ ,#‹!๙ ,z 5Hฐ มƒ*\ศฐกร‡#JL!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,z *� 8มƒTธแฟ„ !*”ˆะแC‹ flฃG‡†|!๙ ,#‹!๙ ,#‹!๙ ,z�5H€มƒ�)\ธม‡"dHั!ฤRdh๑bฤŒ66๔(€{œ8„H(๒_ว‹ฎ๔ำฅ’ฅH @x !C *(P€ฅ€67โ๔ณฬ_=en€ฦฐ๖Ÿ0HCถ„ ภา95Y:๕ ยC'ฎ\…Xฉ๕ฆ€Kำ^0๘ะ‹‹FจRหถฅหฎำx\ธะ†Yy๗ฎMZ๑mเม…'ๆห˜ใ�l/0ภe—“`ม0A‚„_ฎ—์%๊ฉ_!8p s ‘›7ฆทพฅ7ฏ8@)ภPขำปwงRœ~+L„ )‚ฬ ‚ ๅบแ๒ภ๐ะ ‰หตs/๒t@!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,"zฌะ@ฐ A�*TˆA Aƒ.l8๐แA‰Zิใ?Š9zLrcGŒ4€๐@B†&NJL้g™ฟzส�ฤ๙Q K็ิd้ิ/ŒฬŒ\š๖‚ม‡^ฤŽ2๔ii ฺ0“Jา็าซY™I7’ขาl/0ภี‹kฯค—์%๊ฉ_ทLมPJo^=p€RเMกBฉ0$คฬำ์W ๑zต ๙bู€!๙ ,+zเhHฐ €๐ฏกC‡4จ0!ร‡#NคXq!ฦŒ7์่๑cC"GvผheJ’,?FฌPA‚!$PกขL“'Tˆั ‹%nูJqp%ะ;„ะัŒQ Qัv˜hZ1&ฦˆB “๏”>,HpU่d&LยVส•]ŸF|;กG-l็Ž� ฑฃ@รฒl‘!ท"DžcTRhน๖วษ’“,ษW'„†ๆ น Y&€ Dˆเะ!Ažk-๊•xเ�มN ‡ู๚k@!๙ ,#‹!๙ ,>z๙5HP€ƒ@๘วฐaC #&DธะกCˆ NSฐlะนP๐ @ม?†82๚?!ซ฿ผ!๙ ,Pzฃ5H๐Ÿมƒ!\จกC… :D1bร‰+Fฤ˜ัข@Ž� 6h‚&L€„๘มP5�”)Qษย… y˜�แ๒ +/x8T‹J H„XฉแยŠ[�4…iA‚&Fˆ hัิฯุฬ•8Svๆญะเมƒš1:LLั๋F !๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,$v�Hฐ มƒtุใฤ!ฤม‡#J˜paร‰ก’cb k๒�Mใภ„ึ�แรีม!ฦ—0cสœIณฆอ›8s๊�ษณงฯŸ@ƒ Jดจัฃ; \้ง๋"า‡J™:ญ้ยš%#.ิ4'L ..Ÿช]หถญ�ทpใสKทฎ]ˆ(˜ ๑ฒA$dHaขB ฐดะA _ภ‚ – m‚*T46ธaๅ4ศ�8ฐ„) #Vฬุ๑_ำจ]C€ A„ —q\s#B„ๆะ!๘—Ffเa+Wฤ'Rh๐@eิ6(Hp=ลๅฬ g�AAป‚ูเˆž=<„่ฬ.ฬaโย�๛๘๓๋฿ฯฟฟ��yว&,Q~,ใO=สธQJ* ใมf*ศ ƒฤะ *,„Pย-ถค๐ะ#ƒส„X“‰ j“N=�ผ‘„๘HHก.ย(ฃ@R?�(ฃ„ ำฮ‘๕เ๓ห”K:�ะ๓‡. PษWmเ*ะฅQSคe>ˆคเwTรเ�bL Up=expย#ึ๘๓ฮ8E็$”RR๙‘)*pภม#ฺม€Œ6๊่ฃF*)~€โ-eˆ€š `ษ9jdัI?a„เAOธาฅ@ž‚**ฉ-้�ะ #)ˆอ&,@ร=�“ฦฟ๖๒Dถ0d.บ๊ฒ@$O๐A2ขx0้ศ$—l๒ษ(O* ไN&}B„�4/0๐A/ฤtHโผU&W๓อฤP๙œข๐uะฎฝ๘ ์1รฮ�+�“ศP0ฯฌ๚LตีxpH-Tค` $ฤ็B3zไ@h`๐€@์าJฝหC–[;�ซ๋พ€ƒ P Sส ,€ณWฬsฤ้,ล`L ร>qpš๒๋ฐว.๛์ดื”ืh์SN&,jง#y�3Eุฐณณภ๓ <๑6ˆ5 6ฅ๘เ๚@ป๖๚๋P›GฃHKวำ›|๗฿sฐย-�hF $ไ ภฺz์ ภ pˆ6๏ ƒM+๒๋อ7ืaภ๊ั‚ิ€m<0ิ๐b…|aุุรเq„ ๔เ้@…Zƒฮุ๚ใ฿Iภกฅ์C ›ฉ WศยบฐQ่šQŽFฐภ๖๙�๖–Wผ๐๕,x^๑�V lœใู•+T๑6์Em{8๗ˆใรฎ$ŠStฯด@0c I @ @ดิ‰cิ!@ฐ�Xu%๙ฑo๔:ศฐบ@�ส€ืˆ฿๒bะzฤแ‡ "ฒแƒผ(ภจุำฮTฦ3ฆQ„D4  ,ฝ๐’˜ฬค&7๙“\ม๕ศืถRจรlผ  ภEฮาๆIภ{!rL‰JUv่ 0–ฑภ้ Zr% R€€์อŒ{ศโZ๙สX“|ฤyภŒ‚cฌaไร ะลemจล€JHc›|้@ุ&A ฤาuุภ `ŠคภshK�โฐ�UŽเทE0จP Pุ‚  ฺhIt๓›$็8Xm`ข#œฬจF7สัM"เง‚ GJฺ#]€D?โ€Cัแˆpรdy‰“ฆtฅฤษร1สฆ…k�+ภไภฐaŠ/\Oผb2Aฌฝ4ฆ3}ๆR‰c‡=|แ ^h&ไ,s่a oˆ"+–๑.ิภn๒๛ื>1‡70†~yภ �0ฅคjพพะ…บ�+`#ี6ม?จ`ุ *ฐ@|แwุG"&0W๘ขfE+ซ@ฑ p€ฃ ญhGKZI…€ำ{r่1z€"Jะ‰w�ผร0Xmk_›‚,!upศ:1!ไ๐ ๐ร�ฃ, มhœบ‘‰ `ถตฝ- ž�้Vื{@†?ข”‰5ฆ]PP>L1!ฺพใEฺ๘Cl)1ญ—Jื-A*๔dŠุ๊* ผศำmS ฅ;XF2ิถQ„D!\ฐม๖๐#ผฌb ํ๊฿ุ �ะ“JKโ›๘ฤq1TL# t‡ @ TภbŸ&h@o8œ๔ค MdRภฯฬl ŸกPpf�ย!Yฆฺ{D@œฦT€:่ qb�bRZvIŒG‘้(6„ปภvบc(เ�6ˆนอ•…|cูะฆฮ ฃ=าZ๛๙ฯ€๔PŽ9=Nบ วํณRRูhz›‰�rŒsฯ„าi4Fp-ะโš‘ 2A›๚ิจNตI‚‘Cซ'"{H˜QM !Š „ภMร$ึฏถฐ‡M์@cA˜. 2P’ะม3i$(ษ V!‡”$ค–ษฑ“ €e๛�เทธg‡@ { ุม2€๎‡t€N`7ค0ŒXœJภ4Fส}nูช[�ใธภฮัฮd <ฎ]าVaํฌˆ q่7D^Šgม8”๘ฦ7คppR@C —ศย.‡G<ื�OนสWn2!ข ๐R์�]Œ„*Z ๏ƒผ ฯ <oƒp7(ปŠœ5ฟyฮw�s–[�๊๗�YKแFhเ๋`Ÿˆอย0„\Ÿ$%+)`†A"V ์a/ˆวส!‰ึ@๎{ุ๕๗šผG3yมึA d€eˆE9๎1Œ:ภาpƒ^€uธระ@ธMD๐„7<โฯxวฃ๋จO} =Wl,แปุ†‰ˆ…,fA‹@ถาKพ]๏™ ่~วปใ="ฒ๗:฿5p“์!X8๎พ‹xxC‡ฯภ†1Œ3ะๆ!ฆ � Zะ‚4โ๒๎ †b`๔ฺื่ฆพ๕ฑ?๊ํw�๛ชฯฟ�9ฺd๑ˆk pก‘€ท†เ9 ่x๑Dˆ๑€q. a0แqึqc ุฯA_g„v Pาf…ณp5หท€0fT (€g(๓_ื˜ั[มQrฦ�แะc*๐uเ๛`ษวwฝ๑ƒทT่ ”๐/ q``1xล ะเ ิเ –wy๊เ �ภ b๘ œpq…SX…W˜…[ุ…งทzธ‡ฐณนjr’Œp&~p ๕เใ0ํ•� 9�p ่…xˆ‰8!๒„'zย'$๒ ํ€๘๐๒ˆ1๐$[_ง%<w๏โpืh Q2ˆ!@"˜˜ �ะ0ธ|žฉ๐� ˆ[vเใเ๊๔ส@%_—"s`=๒#A2$BŠฺ[ขภc ลuฐ„Mธ|สv๐ฐ ฿@…Tจ๋ Ž@D•ก€ฒ็Ž@ ่‡~๑py่ะ่@ ะึviๆˆŽ๊ธ์่Ž๐(ัฦ‡๙ด#ž �3|’ฐ+ฌเ c0 ภ f�ž0_‡‘ษ‘Y:x1ํง2pkA ๑ไe�0Q �ฐL"/]ขŠmT’`๕\@Zต7ƒ@ร“ธุ1XIภฤภ/o๐0‚Pˆ0ƒp Aฐp#ะ*เฬpีr-ูฒ-ภ9น“=ฉ%š C`๐ ฉ„Lh<ƒ`B@ ุ้ŽV@์'^ธ|ฟาi0 ่๎ภ๑ภ์๐Ÿะ9A˜†‰˜T ‹)Yu™šช9)I‘ยGwJ4 €jyQ๑QN$ฉฑ9›ตษšร9๘D H7ฐk@i!p€ภรืTG๙uz๓u'ฑ v€6šc>8›6�p„1hณpNhฃkฒ …K„ดีp}.ฐS6ฐศะfƒhqภœฮYCZฒืa.< •เ๗ ำ)˜`wp?@ ๋เ ? เ\ ห&˜Œั/ ะP™๊ภ—ฉ™์@ ”p*kJกŠกสก!ทš6zฃŒ2Y‘5„‘ะQโฤิ9”บ้?*Q‹uAดAจ0ฦv €้P � ั™Š`WNŠ�€ใ5^ลUyิIžหw [๕u๑a๛ Jศ๖pkš ‰ ห0$็“๊ร>ธQUzฅ� _็ยย�ช8ไคI`Lภสw…)฿เ}ะ0PศFโ๘ข*ชœ`ขฆบ™� Mขš�—šฉณมฉžŠš8ZซถzญY‘p‘FJH:คน้ฃพค ˆคHBœQส|Pฉe€Z0ก€ณ(B™›ึu@B@A€–fช‘ฆRนฆK้ฆy†q:ง’๊วน๐ า@ํ„[ิECโฌเP80 E0GดดK<๘คฟภŠ•สwิ๐ กะp1(G ฑฐ "P�€(บ™ะ |ะฑ_ าwฑ€sKrท:ณ4;V�b/0w ฌ!๐ซฐ18‹6<‹ค ตP2ฐ&q8›$มฒ8�# pZญtฏ ๓ณๆ„N€�p๖$7ท‘8ก€Q5ฤcๅ9 ย๐Iรl๋9ธ$ >ฐ—ฮ0 0 ๆ๐ ง5MิdM<`ปตษคฅ0Q` p  ‚€/0! wœ๐" xิ๐ W(o#{๐บC๐ฅบ™š)ฒ์Pm}  XPฃ1น•{นgน›๛65{ปธ๛เX Y “;�ซ=คs*Y`5ดAJ๕X‘•ภฏ™ 9Sp ๓ภKๅ๐=ณH-rEWย๙ณx�ฐVm5@๘p s0vเไ0ผ�๚0ž็สwฐาภ/0g‚S`y |@N‘ภ…“ „ะUuUYตUศQฝื›ฝ-a3ฃ` ๖ะ9็zฉ€‰๛ ‘‹Ž Dฯ๐ ๎๐  |๐>pน?P (:†šษ •หย่๖ม!<ย%|ยึ•ป@ฤ?‘y `�0aใ ผs€ +ยƒ๔ฅ ฟh ,ฐJ<ตMผ"@aใ™อ๋9F ^ท%]1  y#ƒณต_ทฅ|๔e_ฆเฏธ �ภฝ^โ…ˆs‹|ท‹ฉPq^8ยว ‘w@C�ภQ\ z<^๔P^!ลPย\k|ZPฎ—@`ว†nช‘ v\๐ฉ/๐ �—�8Ž}Gi}๐ ิ@ ›ภ`` ิ€ลpqด๚งlฉผสญ,ฤฤ\ฬ7ฑ6˜ดadžั8*ะคC‡ฝั”Aฉะœ1F_€�qPฌgซกึƒมx_๗eaถ•!@`ฺqฮ"ภ1h เ€/{*@eHˆJ9่R&TVษHงล‰?pวฃ!{Wส0Ÿ*Vเ/‰ฟืw่†˜ฯะ//๐”ภ ลp ๙=ั}ัฦ�า.=ฝl*<๖�มฌฑw|ฑ—ำ?vฬjำ�ฬฌ%แำ:�w๊1mw6#ญฤ  ๘๕ำ!ิ่๖ฒกรืโH$uŽเ$0mf๐)เลPฎ๋ชRmT-8�าl�ึ01ำศ‡ำฅลศึข0ถห6 Žิษ7Y�>€6L@s๐ใ!&-{�}ํึู๛ื—‡1q|,ชฎ,ำ Cผก�)•Œw™]ิ’ฺชป8‹:-ำa๗นฝกฒ›�ญ}ฎซ�บ=2-;O(ื<อwR ‰pืวW�ป�สญxีฏ#Žฝ �ฅl�ฒ�ห}�ท��ม2��-Z๗ท^ทi‘n๊�๙�Qสฮอ�ศ-� ‹�๒�pMั�๐}ฌ็z0QWกู�๑�ฦนแ�๊]์=฿ žyฑ๑ญ~1…q G่uฅ๎C–a€๔|ีq€€ ๅBท‘ป1�ฦmเ๊ ใี}�฿=เ ๎�0Žใำฝเ<~uB ค,˜ฒ ๒ hg#=˜ DŽ!ย!")ฑ๑�ะˆ8ฯัH$F‚$Jฒฒ?-ใ=-ๆ7�ิ9฿gๆ๎�ใlพr•r)™ฒ)‚ู*ก2*ฅย,ชขธt�*a+ณR+ทb๚!้ แ9m� oษ-�.โB.๘ํสd>้W-เjพใh^ๆnเm�้ท2-๓2}2็>c38ฃ3ุชRc๊ACS4Gฃkƒ.G`พLcS6g“6?nSjN้ภ��๋๑mใšฎใ๊้ส>n้Lธฃ;ผCฉ|—<จํ๓u๐#คฬ‡�rMใมw๚ํ™.โศ~์›^ฉห~๏เ–ฮ2ิ โyCฤญwวTํ>D;”ํCิEtD-๏yทธ๙ฺ^$่F ๎ฤ๎ำ์�๎^ใ �ะรพ๐œ�โ้๏๘^๒€ๆI $JƒณtJฉดJ๋ิLh�ำ๒ต”3€KบฤKพฤม็-‘`a(าดท�#ฺู„N่ํํฟ๑ฟ�๑๓ฮ๐ ฟๆf^์&_๕ฆ๖Q!5R+ฯ๒5…R*ลR�T2ES6๕๕9ตS)ะS?ไNl‰0`( ŒU้ช]๕Uol๑พ๔2๕ฅm๏OOเS?๋V๘vZl๏๑ฯZฎeŠ#่^ฺฅ[‚ [พ\<8\�˜์!ลQลแๅ0ษๆแ้๗e์dฎ๔๒4!๘ฟฮ๚Pฏ๚$๘ถฯQ‹b๏ชx -ึญธd4ฦ๛๓<9ถc=&๛I/|Ÿ�แ€a&• แY&‘>๒~ฏ๛ฝ�๖o���o๘?๛ท�ค…ฺแj9mh�ร)‰Vเ7!ฺ–mไเQ�6q��฿๗เ๛๙฿B†ai@เ@‚ D˜PแB† >„QโDŠ-^ฤ˜QใFŽ= pi CŽ,๙QๅJ‡\พtฉฆ††3mยฌ8“ๅN=zฐธภS่PขEEšT้RฆM"ค๙’gฬ–4โ”z‘๊S–:๐๐q†%Rค(ฅม"ƒA6l"lเภU๎\บuํ�ล›W๏^[!ฺ�่—/ล,าฃFฮบo่ˆ…๒#C*ฤœY๓fฮ=/ผpฏE)4H‘"eวำฉ?๏Œชฐu žณ�W�๕ส„ปx๎zป„g˜ฃUœุ�AB ๆือ?‡�ใp์„QดDช?V€ศฤm้<่�๓ะอ.จ‚sF๔‹๕ๆ�๏?0ค๚QF Oผz๐๙ๅ‚สgo�๖จ$า6๐€*d&๊L๏xึ+ ๚ถแ[\Cg<3„1Z œ€eฐๆ4wดิข�๘ภว?ไ๑ ฐฮuๆใ^A๒ถŒำ)ร W๛>ยทตถ๐…�๋*X‚�ย;(๑xจƒ๎๘)ิั%~Iพ)ฦ๎6E*VQ/:‡ }€ก#D=1†A\ƒ<๐@Œp B€!;˜+|๑†1L`ศAAเ‰iภMพร@ม 3ไ xุว&ฒp…}4‚]ซป<๘€„N์ร ขAพ<`ใŒ‡ด!ž๐8ภภ_!ฑG"บ‰~ฤกAvx"ธ@†f`‚`C*—เ}4iฎค ™ƒะ=x‚+๘.Kdq‹]ฤ๒ภŒ?p”,� AะŒ6พ‘Žำ$'=ษƒ์๐o$iฑ#\๊’—˜@ย8ฦN in์dร9|ฐL ๗H‚ฐวs .+`d ‚%t $’คค%ลw,ช! ่GBpฬd.ณขอ(tะ Fค@ัุ า#๑ ?€้ษ๑า—~c‰ล eEž๖ิง;9ษ ฐ‚{$ย{�DHpPdรธ€ šก‡„€ …+< 0šป€9๗ไวธbภ@Wmc”โ๘ภ=ู.03B%j"ผ‚ธปh…!1)„:ตŠABrืLฤ8A� ถa‡ ˜ ƒ0‡pŒ@๐  …1FPึณฆีซO"ศ8@จs"มkQฃwˆZPA5   *Uช’ภ+เซ@�ุ‰• ำrญ@6�ูฯ†๖=ุR@‰ล@ฎ0&l!Kˆย<ˆ€ฐ๖oHก!ภ ๖ฑ†ั๐hCwjQKCQุ&ฉฤุ!r=Z฿�w C>Nก,(5!$hi<†ฑŠU ƒ>^R„#พAŽ%~ƒ ุ�i†5ผaŠ8 ฎัPฐ^|ฺใ.แํuษ๚ฆ  ญซX]โ&‘ภล0ฎA3๘ƒ   ’ $G๒aEp`8„6�� lภธ=ธE:Pก…lpxz M˜‚,๔�ณ?ๆ1„\ฃ ๓>สว7ูฐจ-ˆ~ืษฬ!ฤเภ nแM„ก$P)R,ธ YษL†1Hฬ{�ึ.ณฬgณšQ0โ4เGึลฎˆ้แ„E`cc€‡฿ต่`$ ˆฬฃแqˆYfFl ็�นึฒž”Kแƒ „ฅ^zUฐ…S๕pุุวF๖Ÿ๔4€Xฤ๛PC‰OฌLUล4ฦฮcl�ว,�/ๆA™ฉฺใ?ฒฎชnv‘Yะ‰cิ!@ฐŒ) /# โwC๐€ฒGk.๗ฃB ภ› ‚�ืม 'Fภื� กuž†œn์$`ZะD?Œแ>T[C ป� ๏Dบ „n๔œ ~p$�ูังฎ{  qxEhiสธฅV๏LตlŠ฿Sโฏพ€–e๑ํ;้ฑถ?ksˆ๚ภ”@}–(lญซใฆ>ภpฒล>๖žชฺฮŠ8ช rหTiศ ี�qฎ๊ํh�%ˆ„9^[lื�hฦ&�๚r4Bu71;ˆ™ฝ ATึลฃ@n {Hย่@ bŒ@ฒ‡Pj(2ถ~โฃษmeฆm…Pgฐ€ r›xE`ว`ฃม1�v๏�‹Wป€TBพgW๔&A ศžl ล้q{Uษ )y1;0‚ภมhB-`a b?๏ศมnภ4ด’ ดŸณb/มX<›ส‡ฝ๓๛‚ิฟ!`*cภ‚eX˜ „@›b`‡dต‰0Gp0u` dLxฒรภ ,ปe;ป ˜Aธ†1*ฃ3r‰ุsƒ1xƒัzฟƒฑปcƒ}H์;m€H U2„^€ศฺใ>VX†7เ i…ฃู@๘‚ธL,ลjฆ5่Œ ƒ˜:ภ‡S˜ƒ1ฐ'ฐ,6ะ[(ƒ1! 1lbฝ„P-:A�7x€ฟฐƒ=๘‚'๐‚Z4=PAfB"4ย!9ค}„9xv‹<ภ@xƒ9 \๊[ค2œ€ ƒvศ„/จ‚ภ›9ƒ๐@;๚่4Bฤœม7ภ3rK†@zร8ผฒTZฅVสเ6D7„รK€EVŠ์ศƒcฐ--ธ†P�) 8‚g`oHFj …€ฉPฦh10 ดฦk�•H…`h ๊†Lศทผœq˜ƒรk€.`ก|0˜q^๐Š@†LPค€ะPGุ€vิ€€GyิCH‡tศ…~h„L้›ะFn๔ฦLุœ่„w€wะ†?H�ธm‡L€vAˆผQŸzp!•๊€Xžำ้.0ฺณมแ``เา๘วyT €ศwxS€…์ฦ๘F์ูำižLุก9วt\วส‰HพฉศฉวTHuฤK+T๐SH์™0.Pส=ษn˜ *จ"ห"่ H–ฬŽzผว|์€ะ0ำ้ษส†œ(๙ฃ‡y8žไŒศž„โLไนศQฉƒ์X€Nณ[9›pqเnj่ƒ˜ฒLห|Rุ€หรฦา4อ่ i  ๐ ŽQ œkƒ– ุ€ ด8ะ  H—�ฺ�€`้Xศ฿T€ €Š฿บŠ™Hอ Xอึl˜ฯบ๑—ฉฉฌ๑ภ_a˜ ุlฑอŽˆ�ดฒ„เ˜P้\อR€0„กœฃ™อƒอเ˜š€y ฮ�ดrIจ*OŸ!ƒ๑Oƒ PฐPNฐOีj€˜—๗Ldกฮ`™/๙ ˜จˆ๖,!€šิ˜€€P–@P„@J‡}†gpNศัลฬg8ำา!uŽญฎ^Kศฌ(ˆ'LR)ฮ๔ผ‚2˜-{hา๔5_๐‹-ฅ/ˆ^“LŒX#S0-Š1%�4]$3ER— ข‚ำิL‡๘ƒญัฯ์ำb…"R&%าB5Tฉซ…HT…XCx‡vH‡Q0ƒ@9ิบฉำั7mˆ เM1 …>Uq่='ญิSEี*ยั ฐKUTล-ก3เ>(3P‚€?yี^๕ี_ึปq ์@–เxRฝ‰`eึfๅ wŠE %%;^• kฅ‹ ั€ัะVSuึoืkลV–ึพืc+ืŽHWน่ึv ืw…WŽhmRoฝj•W| z•ˆs%ˆeล“ชะW† ืD๕ื‡เืŒhWmื†uX มŠEMXŒ8X�‹Xื•˜X„ญXw X%T‚•XjญุšธุœXุŽ}ุ”eึySVช pนื„อ“๔• Ec“ =bมฮ์ิ˜ˆ v้ศํ pธะPuอdA—z๙ื“ํึ'5ˆ—NŽตฺฎURฎ�‰ืซ๕ZSตฺ'-[I�‘Uูถ-ิ๊x‚๑ศŠ (H…ศษFภŒ(xeภ—๘k˜@Ÿj๖q๏™zะ†ไ๑pMHˆฬo™‰Xๆั†Sจp8K j ษณ พœย;P\e˜๒ชืตU�e]—ฅ�บโีฐี †อฺ�ˆeุต�ฎ�Zโํแu�ไํี …2ิะ�›(€$เb€ƒ/xƒ/๐ภy€ไ:€…๋ช5™ฆjบ&ุา(€~XาX€W†ฑฒ‰@}E„ฬ5}PคŒซB0#22ฃ[ยฮM๒ม�Mื€ตเ�ล“�ี�ด=ฏฝŠ�uื6^>^ฺU�ฎT”กGศ‡Ž m5€Y8พ�๚&จพตk*ุ‚jˆ8ศ,p`Edh‚�ส”  ฅeฺ‚kเู9�ใ6a&ไ!u‰y;4ุ‡rTฐ ˜<[’Š—ƒA“ช ะ}H-X๗ัณt่ณ? ™xpH‡jเ‡Pp ุ!‰b%ฐอฑ‰@‚{ุโ.�b+ศฑฃE-ใ2Tkง#ุeƒย Yฃใๅาแ&d>ฎZ>�ใqฝZfNfฟ˜ๆ฿=[๖]AไB๖ๆk<ไภƒf(<ˆๅG~‰Hๆ_ d h'&ุ,8ศ…O:`พนธŒ�8(€T‡:ภ)ฐ‡ „ช[Zˆฝ ž H‚{`.๖b�07ค๓ๅ,€6iซlะƒcฆc…เ�@†fe~ย‘ๆ]?X‘ei”}f’6�แEแoฆik<€+0†z0†?ภฆ+ถ‰†0ฐ’ศแฅ…ถห_g˜†˜s๘„ๅx€ัxปcจˆหำ€ ˆk€ƒh„]m่+, ะด0nซ \€ว๒<คาแถ[cภ7f่ ้๎ใ—=fB]ุูีๆk^าฝ�ๆภF�š6lฒC€[8!=฿๊ึ iศ�2จ‘ Aๆ๓ใ‡B€IะBP‘9ฌร;ฤธ„yเƒ%ศ‚rP„I’Qฐ0ถŸ กe[v ณๅC2เ.@`�+œ'ฬv`–`‚>ๆป†ใf๖]ฝ.nlถ�ๅfnม^W >์้Nถๅ˜^;พ‰บM…zˆึิษ9™; ‡!`€(.ะ(J0ฃค น฿้†?ุจฺ่�‹ a€,ศ"pไ๒ฮ‡& ;ฐ’�›์ภH๕!Gpk€nSม6nป^ๆh�c—ฎf —n•�Zแ๎™ฆ๎฿0Z๔ฺ 8NcˆฝM™ืคœขํ?1‰ž๙ฌOญP—rY`ซCn€Qฃี€�œOศ‚Yสู๒\aษ๐ ฿ุใ†r”nn�อZ9�๋e~๎–ฮ� ืr๓ŸBgd๖ˆm%�™ ซ$ม- ]ธJ.�ฺแ SM]Sฌต๓% ŒŽ๓่D dถW8๏‹=d=i&M,2'์˜๓EOY�๎๊t…!€NF7Š’}rŠ�rJื๔w…ึก:฿๔่ศๆP'๕R7๕ฬ์SWu™ ‹๖rUŠx๕V“YQ็hu1Gtฃฐ \ฑอ ศ]๕า๑๕ƒ ๋ˆฟH๖๋ภา็h๖eoŽb/ถถe๏8“iAส~๖a?6 •‰่;Hฏ{เƒq_‰ะXnZOT9‘ั”YjA„(w>`ำ€กŽุ๗กิPwvwƒˆxwˆ'„v็Œิ œํv�•pˆ(P†~xeเ1)Y‘๏wง€ซr๋€ƒ_•H’%9voฯŒK•tG/€h˜ŸŽ `œwr'Cั”Daz_&ƒ—y&่ตFy�แฃ‡”p' ›วy—g็?๙ึszy แFZ|^`X_‰Sy@Zƒ)ˆƒ"hTแyPzคyฒ’—๚ˆ•Ra๙๒x�D™๚œว�ัภชQ๘ื4‡ั™ตZ๐„:qœjqs™Zi€G๖˜�ภž”^๘–˜]๑™ํOŸE™`1dม ‹aใh–g‰–iฉ–k๑๎a|วOˆ–ษิ�]๑d —ะo๑”Y�yI™ฮ๗�ฝZ‰Q}ญะŒใG�’}r��ฟY˜3ฝศ”๑I�์ ฺG’ฤฟฯ„ุฌVA`>ˆZึI๏'�%�๑g—ำx€…ัๅˆ),h0AืยdกขB ฐดะ  .Mใก`ƒ ˆ"„ม”4€A… ‹"*p้ฐรE•6PP€ย‰r Jคh‘เL‰6Uเ 9ฒไษ XบT!bC… ๚…ฉชbŸ$$H๕Aี ภไpมetาญk๗.�ผz๗๒ํ๋๗/เภ‚ nค7ฎ8�@”ศ•ฮฝ?Bl8๑ศšฟwใŠl1ฺ๊;‚sBtะ ก|qNšnํ๚5ˆk|x๐ ฺ฿ธั6xธSญžฒ9)*Lงา4ท€Cdใฃ้ 7๘ลษเ๖B”T๏�ๅct๒ƒกj�๚)Sbวญzฺ4Uฃ7งB k�๐ ๓sฒv๚๑ี๛Uม๖vน‘fPlณแ%@%ำ๘`Pw€ƒ›8I•�v�‘ึุc‘MF‚ ช$uึvXbรล7_}95เ‡6้ิำอ‹buืQศw!4๘เ8r๐ˆ2๐5sุžxไ™Ÿc๊)8ขpะAฬ3ฤ ฆฺIv9ฐ‹4�๐ฐภ‚ ดฐ@ยŽโd’ใ•gยน�๒ะ �!Wมฉ<–")lค>/ฦ˜“N๘้W™ภ'}๖ิแ‚ ก‘ dŒIWš=โิ#นT˜#]]ๆO.� ubฃ9q๊)Ž'50iฅ“นucw#Œ๐J;๘เ๓|ฒL6๔๐„+ฎt €Aq !`ฑCg 3Œฏพ;UP๐ม&HL H=ˆŒ1ˆl<l2ส*ใ4nน็ฮ†R‡X๔qศ^ยญทเ†P๔A-€%็ศD*๚€ANp3Aะุ'ง�…ึ\{ ๖ltภrยMจเ3zุเ2ฬ2Ÿน eL$�ฌม›&ฐ aรฏƒ5ฟ�์ๅ1p,ƒIะbซใ_‡M—Tœว<3 x๒๚(”# DF`cฬS ์็%๐๐D'๛XแจNEัL JA�zฃDุีimG=€pAF3˜ธRใ]๛>�2่#Š่c•o?๐!\แ^‰.ฎ๏}™`ๅืถ.5ธF)I* ‹<%˜๓ใ‹|๔cอ�ธ’†UB€ะื ถ-onl0ร@*8A€+�ร จsbC-๊Q“๚@Y๐[tbuฌY ๔ฤจะฬl•�“ฌwตพn ๐#ภVถ!X๏หหเ�@ .PI3๊เ!ะ€DˆKs๓ฐ‹ี•Fฟ�˜ต01c(�ฝคณฑ^2‹— ',๏EŒฝ^‚{ุ ฆ |9์a๛29ัแM'Ž๑๊X 8ม> %ดอํŸšมf75%| G%แ�ถมE�M๏๙๎;ฮจ].>! :˜ษล w?ศm‚คuภม์!‰“๐GX-``‚ฃโ%ศC.ร2PA }/ฟv ๗ธอR*/‚ แu๊ L\Cฃหปลผˆ5฿H่Gh@ํ XŽP!:ฟ๑ ่{ซ$ู#U�žwรu‹ฌ}ใ`{hะ„hผ nmtA�๎๐I3ฃ <…ˆ ขa€HxจิŽ<ไCฝุซ.€ฤ8ม �–!ณภl(€Hงหผi ์ตบธ‰&เไื|E M#oำ๒€ฎ”ข๏๑mƒX 6ŽdฑJรrยฐ 2€ Ž$# 8)๙ฮ‹P 6๐€& KyอAP‰�ำำž �,H6เ‚<์�m•&"ล)r‚Bhฦ rฉ|ชk‘๓ถ๏๛“EŸ(์œ�LๅY^-’๖q_KIX—Y] !แŸ�a€�ัŸ�ตŽ3LCฬ‚9|๓e„๗Aภ.ภั„อ�OE5ภA4ยะจ๋™]J4ิ.ฬ†ผ‚=8EำEูฯ…า4฿ 2QT�‚9ผ€ ลภ(ด@ H")ุ„I C…กpTr |ฤย*กภ6‘ ˜]lเ6ี>ะ^ธQ(ไภ6ย”xแ๖qž €Uš8‚ คq�ๆ_๒NH‡>„ิS ุ,ภั@ญMJุVt๎ฬEไ}"(†"ต๐อ9ฌAtWTภ ฐย2ผิภeึย�ภ\ู์ตยทi@์�‹๋™ฦฃ Rlฏัžu €NุAฐฟŒม‚„ภiฬWดžภ๔ƒ4 Lผหุม|มxมIฦธำ๕…\"ธม  �ภ.˜ƒŒม่๙\0Dสƒ-0KAจ อมLภึภใ�๐€_qฃ7&ไ*ถโ+ฦโ‡แมAึหZฃL‘ข)ขb aผc<ฮ#FเI0Gฮ6vใ|ฃB CBŽCF?BL‚>„€8’ฃ9สฯFฬ,A”C…MเŒ‚5tื์@/z"]๘€๐ƒ3PŽL>ฮ๏ฬ�d9NPxม>”BlะPŒภ<ฯ|AฬมrลกA„O5 Iส#=d ไL >œยŒ8๊ฤ_nๅ�ไ€•U�ฅฤร˜%ZZ^ยฃ^6ฆ๒อศะA`faš�UKF$[‘T2@ใ<Аฤp… ๐œ€ย6lเ–(ึฆm�ๆ]ฌˆx‡˜ภ””@'lว;hรจลhƒ<๘8d ๐†+๐‚„ภ ฒPใ–ฌJ๘โ๑L9'tJ'u~EoXpฤˆAะฅrื8pมEจหฐ5—iLH*w˜‚lภ ƒpัC&œษคƒyฤ: p|@'ิร;˜ |Ztม2ิงP/เฤt"หT€ pงA,า#\Cqฤ€žไƒ}vภŠhnฬ…aG*—‰^pพƒ‹็bŒh‰ฺgธ็hิJ๘มn*CoNฬ่‚ยภค�B0ด€*vC& ๚จ|ยจ)4‹ฌ(N$ภะร0@ไM~๎g†ภˆj‚ฑtรศ•[ิ6ผŽ^็ีฉD‘‰ จ"8รdฌฐ(˜Œ)d๔' DS`†f่Fd/ ช)ค@’.i“>้ŸคƒศF)ƒ^Žฆƒ‰ฺวซPส‹pมaฮTŠbih–j่oŒi=ไฦšip)ซฦAŽช?”ช\ธ(•Šไ~.h � yvCoฤ คƒ‘€š|๑ƒ.|nN+ตึๆYH@Y-U@ภXD4Ztk pภHPฃฉณh@0€Uฦฏฉb4�B\˜F4EsฅklภˆE”E]ธิฤKฤ„ฮย@ยV|jภด”ห4มBEHฤX์ฮaภZธD ์ท>ลUl@HJ่ซผา๋ฉ€THภ"EO(ลn๋ป4ฌM˜„Ž8ฌธยฌDP,!%,Hดฤ`ซ h๊ถ~,BภVŒNmึ\เ์ร๊,อš•4LดซU,’Oไ์I$มยฤ6ฑŒ•ƒธ+ึ2”4€ทVภXHาีศL]@Lฌฮ’ซฤบD ,Hlั’… ,€ป>�J€Lอิ� ฯ%ญ฿ฦ,ลj ๎ห๊ฤ�.b”„Tฺฟ๎�mมลJ8์วN@ๆŠภๆfอLฎธJE,ุ†@ณฌEท~Eฟ&@ฯภโชฯๅ=คมcV+๐/ฉC�๎_˜FJ€๐C”AฌVoJ\๒aอhฮฝU๏_ะ%`(/๓V*ไIล๕>‡NoAฐ]N|ˆ/ž‘oถ˜ฏJไVแ€ƒ.\Š`˜เ_ภ๏^HQ�ชoฯ1ิ^Ho^ฐ_ิ™๑�ฎGฏNเเ�€ะย/ค๐Vฐ_0ตภ ุaAoต4K#ญฮ‡šsฐm‚p-ˆ๐ต ฐ “š—ZC:ˆยะ#ืฐ OK0C=hยDํ �01ห่D6ถFถd� F�ๆO+c์้ใ]iŒpWฑ gD–๐ฏo1wฑ1‡ฑ1—๑ใน†` ถจ1ณ1ฃฑ_๐k˜ฑวฑ฿๑ อ1`ิ1ต๐1๛q ๋1Wซ 1gใ 'ฒ"งฑ†i€ุ'๔คด�Z‰Qติ1๒.ยฑJ`ฒต€rAXrJp๒'›2!๛šจyr^�b๕Šฒใ2*๏1+/ฒ-หq-๓ล#oฃ.P๒`rr%๒sฒ0ฤ,๏1ป^)3]$๓ด3bๅฒ]ธ2"Cณa�โ7๒-wณำ็mฒ]๋ฟฒ!MP„\ป-ถฆยฎ$-�-SŒ๒kฐ ลKค€€ˆ+นŠY่3[tlU@CPภ(<ฏ$€ร:ฤะดล[œซ\ณEลH-Š:ฌHƒBWฎJ˜ดํ&ด5s3Eทด_3,ซ1L๋qMcฒM;3=[๓๎ีLw2O๛ด+๗ด({๐3ณ4#๓Pห๔M7uNืด7G๕ y< (,SืณD(o" ชคˆiฌW ฉWw*Q �‚-hชภ‹cdฒฤษœจขx€ควŠฺ‡*ใ‰žpGŸ`ฐ‡>‚1ด5ช"=”XขnFF`dHF”€5˜‚ ฐฬ#< | � zจ{ธidJ„์Fo�†`Ÿ.•vฐTก*๕);๕KณSƒ๒lCตP�๔m๓ดmqK+ต๖55๏to๓vPืถq๗PKตr[ห€‚?ะBˆ€@`ตภขdH�อฯ˜iX7vs…\้@30B ˆB4์ภaŠ‘๗ก ฑP๑ˆ”ž%Œ/@#(0ƒ่‹5[Jฯคj’Pฐ - L@�Cจ ป<ฬภ€i น KMย ั๘%�Lฮ์ฬษ;peบเฤคfุ ]�ทjR#๔ๆฒ8wkทธqว8Œ�๖kwหxk฿83 wl๓M็ถ6#7Žyp/�ท‘๛…Eำภ#ไรrVว๖ไaี]Qืuw5I91LFA>œ‚>`A.๕ธiฌท^Yแ{3๓คั#ถRภผ†Iก”Jฑ]แ๎ีัฬรlA:,Aฬ8™น >€i(‘Vม>–`A•,›ฦนa’&)TƒE1•ํูีๅีลœczฝrฃ#ฒoguฏxl‹9‘y575qŸบuฉ 95ซ๚Q๋6Žร:‘9ฎ32ญ์C9€oŠ฿0VชIZŸu๗– žB�K)๘@2 TAนฦา�?ษ  žภ8�•ษ•ื่[q€cลภ—ม `รŒมฐigy�hE“ุeT dฺฆํƒดนภœ[wQ’Qvฦ‚X0Fณ๕tเ…๛ธซš x0+ใ๔pC๕ร72ซ›บ,฿Er๙q 7* ฒ&S|Ž7sL‡�ฌ#ตษฯ8ษป6ว‡|R็บหใ…iP@เA3 ุ๕Vwร๛รํ”•;รx-`ƒอ…z{_ฅตfkL™ต—1ํ‰ัŸ=แ|;žฑlฑว@ะCผย!ะ"๔ญ)€ญแ๖�ภษฺHiจโ1ž�ฺฃ;[%eม=(Yฺ›BZฑ|�aƒธปร;ผK๑งบ%Ÿ|จ[<ซป4ฬ>‹ผฌ+>จฏ:จผงงผสท�ผŽ#/ฤ?๕Ž'�ส็ธQฟ<้ใล\1ิ๔œ ฟฟF3ตk%*R๖ํแบ1@%†ภ.,ร"ุ€ag~ด7รฺ,=�ิ@แำkปk์ฦ H!š๙œOโภ �(ิ(R่ž ศ�อ_ถGฝ€{hƒa๘waœP’คS:๚€$R"1Œ€ฆฟP?ฉ8@h8€4˜aC‡ >”8‘"Bˆ ิธฑโล;†d๘‘cBAR$1ไส"ฒ4 “ๆห‘.qๆิน“gOŸ?:”hQnข"‰\ฒ—จ ค~qH„เภ�ฮ5Dn�ˆ:ต๊ี!๒ฃ’Bหต?60€Œˆ3B๘ี๗D๐`Dกƒx็ญ๗S’8ฆW`SO}ฏาV�‹จdU!๒่;œ,%ISMGu๕ๅ•%ญyี๚Zพ4gœูƒoฟi-ฺ่ฃ‘N๚ั๚ขš†‡ธH‚ZฃฆŸ�่ค�น Vbิ'LS่‚ซ?ย@j—ธ^งnะ‚–_TH'ณ Šซน]2๛'ฝ‡ry้—ๆ๓ูf‰–Yp�็ฉ๐ฏมnuงฦ/ต?ศ็(=˜ƒZ� กWฺ๓ฯA�?ชฯฦ‰tลMžี๏Xฃ`ฆM†ศกt™Ssอ‹Zi๏l๏�๗฿_ี๘แ‰/�xž•�”rXอพภlพ��;�๏ๅ'ว”๚�1’^๛ํฟ‰๛๏ม_|๑g฿�๓ัO_�๕ูo฿�๗แ_�๙้ฏ฿�๛๑ฯ_��๙๏฿��<ญ€4เ˜@ข์z tเ!A NP(ตƒ™ฃฌG3ฺญ”ย™เHตม‡edUกโQ˜BฎPVแy4๘B๓x ?,sก fA๎‡=๔ก ;—CๅฅLg๙)ข BD "1„?tโก(ม-RขขbฬศD็ˆZœbมF1๚๏‹ _˜ล&�N^๔�๕“ฒN‘Žuดc�ส(ฤ ฮ๑‚&‹กฯฺ่ฦ ~Pfิแ™HE/†d�Yฆ�จฤฐ ’doL#%-นHMn’“ปž#UIT�”‚ผ มƒษCv’•ญte๎pืลIF๊�SI&g‰ฤร�L”ฅ4!_La3zŒ{#งP™ห_ฦฒ=๔ฉb,ษฃว-๒‘˜ีดๆ5yฒ ๐๋W�„ผูอˆ€›แg8EN 3Z\[4ฒ8x6ฐซd‰ๅๆIO›`“Ÿ�๔งF60‚\�ƒ ๒๘‡<�qЁ”ก๕P(Azะ„.4ข๒p่@ *QS  ธ�็GAาW-O0้IŸฐƒ’ขิค*eiKWสR—พT ๅS฿'EšS๎ิ'c�๓โb7’�ํงฯ€P�?BT็จ �๔ษSฉN•ชUต๊UฑšUญn•ซ]๕๊WมVฑŽ•ฌe5ซŠ ผmไYลzภขฅV‚A ฎใ@วVนึuwูฑrB ˜เฉg5์aญz<ws nQmhข๔˜ƒฦpp๘cnธ[ ชQeฬม^ศ์f;๛Y%…vดฅu‰เ๐ุฑตตm5ซ€ิ�๘… ๛แtม๛เร!–ฑƒไผƒ9ฮŽt0ณฮŒ@9อ}NtดSณธไ ๐-ส ‚cั๖ถๅ5ฏ")ะƒGXฃ‚ ‚L๐ก•งY ‡€ภ %(กฝN=(ฒแ�ƒฃ/(W/ˆq‚lC�ฐ€ lเ ˜๒-�ณh๐ˆ||ฉไ=๏‡A F4ƒa€A :@^๚fม๘mฦฒฐ55lฐวjะ =๐`+m` bผ†‚ิ๘ฦ9�q™Q–@ร>สQ(…˜สUŽข.SŽLa'+พฏ šQ฿{Dฦ16Ž|YฦCถ๑™ำ์c%ถxhF9มอส}๖ณ Cเ !ํ๘EB`>oD8ฤแšp๖�^เฟา€pฐ\c ฤค+}้dzำvษฎ`Œz@†N๙๓ซa=A ฌK.ะ…0Zํ’F�วIฮršs]่R‡3#˜@uƒ��์rวยAŠR˜โ๊XO›ฺ ์ภOsง9ฤNxาŸ�(%YcPO’๋ž8๎D•{ถh3Pตแ๏FฬGฅ˜ล~…1…ฌ^zฝXฦ6&ฏฟ" 'Q’๗มพ@ะํAธ�D�‘ˆ2แท8U้จ‹oœใฯ8ษ:r‘฿ฯ>›ป]MHxŽœๅ-w๙หas™ฯœๆ5ท๙อqžsฟœ„ข$แ2งiœฺs็EฯนGq(I”้:๙Goซ`ง ข>u T�๊Y๏&ฑ๔ๅsข7์๚F\ŽŒโ;X‚ฺ3J!ค}�ํm;D *wบG”ํEฎ2W^vม'�„IeมKƒเรณ4&โxว#~ป๕tแเ1pnDฐ๐ผ๎ๆ |�๓Y๑H๚ั“ธF�็๏ฆ1TŽ=๓ต‡๕5rธๅˆม�!๚น๋>๘ฝ��๎ :|ไ๗žหอผํก_๑c…*้๓>S>ตฉœผ้œยi+•?อ3•(n# ฃ*jข*๊ ๊มK@!t!Rฎ'H๊ฅR*ฆPjR,B9T(|jฉ๔ฉ6bา:ดDyขจ�ุฒ Š๊AMดEรา R4,9พ Y\ิEฅcกฉ๎าก #.ขqซ1G็Sชเแd(๚๊aภ@l.`ปIแs์!tD ๚ ฏหด.Fิ๊D(@ Gม5 BษQKแ“๎เคภMภtตD‹ดฎๆด8หณ�ค( 2ซQ€แt@5@bS1bุคkจ ุœ‹QOdva5็ฑต4ยR1๕M4`ภ ์ภฬำ,ภ8` ข‚ฏm๛๑;•:ยvr@TŒว8`อ‚ฬอn์  œFlไ2!gต7?๕Vy@W#’�ฦฦW�ฌฬ�์ ภ€ม โ!#๒H—ี4u[1‚TGMำ8อำ@ ,ํCฦjกrต . ห1\K3ศ ล,lุบ :Œ ู&UE/ วฬ/4ย#A_MS5ทก5ฑr�”แ RเC2 �t‰*ฏ  $O’aฎ@!จ&` ฺบ‘฿2`L๗ เr๖ HS#Z๒%cRdŽd!ะRมแD%"IแV„$ฦ'‰ƒ?ปษ?ฝiœœ๖W Vœิ ช๖Wคv�3nt6ซ4€ฤ๖^wˆ ธ ฉh\ถ@ L+7โj‚๒-”ก”ก๔ก2 ก� o-Joดo+๊ข๐Nฃ†๒kฏŠ0aqhฉุริ*o0@+8COjฆdสraสB17C5๗B-ดฆซ 5ŒJŽrdQE9ภงย2,ET#” จŽ*r“ฺ๊@4จhw#b—ฉœjtฟVฐ�6)}WK) พ b‡wV@๒ก RภธFyฐ|ฦfšr@z{0yฉ“ @๚ ฺM‰ะ%–V-r€|‹{W๐™Pฎ~เ~[OC ™๐Imvz&1 —ฐ ูQb฿'’€J B ฉ bJ22€ธ ๘I้}&เสA�Dญผ ?ขฦเ `๗ƒ�ษถ‚๋‹โg&€1m0๒„cx#f…9†x}’ Cพโpฅฦ[� J๗‡้ะ†‡S๗|’„Tก๒๘ะ5โj’•ืฆื @‘˜้T~pฯžิQaUโk#ฤ8ท˜�.ธ}:€@ฐภGOc$‘Ž๗! XดŽ๏˜SืX๐,™xเมสฦี๐ตtฑF‘๚�7‡ว'I์L3?BYฑMgมdg%4๙„#น้cjฟ ่jW~8QgษŸ 3€g@5`ื6ข’เHธ{z8l@ก฿้์ ฺๆ,:Ÿˆ<:a:ข‹G vMกลฎ 3:ไzฎoฆฐ8:ค‹F+อ๔4* Dภร\ฆ'ๅไ์3ฅY๎งg%>:ešVภŒก>$ ้ก tšขIๅ–จ7ฅำh!ŠบฬŽš`ทoฦๆซฝZญภšl๎ฆฦxph`zเาภyrขค-Z„&ษช5ซ}zง�มีก!:nYเฏ�Zj�Zนd๙;ฐ5bฐY๔ เ nq าa ข`ˆ@;ใ๚ฅ+zPืU๐ฺฎ5ฏณร#-ๅ๘Zค ฉ ชไ€วฺก๐aˆฏต�AตY๛\ถ‡XM่ม Hcฦ`ปŒข‡h”l ด=ปฺ�๙q~nด7ย€๛Z#`ฆ` ึ`ˆดVฆ †+งปบฏ;ปทปปฟ›d€โเ๒€Aช&ฎ‰[ณมfฎ…&น’O:"๔:ฆฃ[EJj  Dg๗œ๊j$๘^„ jไ๚”xiล5|ไนˆmพ‡ฌซeๅj๖`JBฎไŒ3eซั#“–ปล?;ศeˆั0๛S@œVฎๆ๚e_เyหSซ1›ฎYeh„�ๅผˆ$R’ผhf%IZช}:‘๛ส�,หcœ8บM{xยœงUฎfฬห่žฃฆžฟๆๆN—v�|ะ ฝะ ป�ะฃ’อฝ'฿ร$ฝัSนœฦ%�‡จ$๎IB9}ำ;‚ั/}=bZN&"=ิAฑe์่4๎ิQฝU=ึฎ=@�ีCqฅMŽhp=ƒ>ศีm] Iฦื�7zอ-}ุ�T*�ฟ‘}"�Qืจ™ฝูgฒึฅฝฺญ�ฺฑ=�ต}�Y0uสq็�น�ๆผรHžฬ}�ฎ�ซV,H�ีๆุฃ๓sณ�=�‹n�{็�ำ฿wVื๛]@๘����฿�a � พเษณ>@ž�~d~แ>�~โeN฿m'โ5^’�โ!ใE�ใ=ไ%��I>ๅM^�Q~฿Wพๅ๚ๅ;>ๆe~๐Jtr�ๆyพ็}�็>่…~่‰พ่�่‘>้•~้™พ้�้ก>๊ฅ~๊ฉพ๊ญ�๊ฑ>๋ต~๋นพ๋ˆtภ8ข\(& ฌแ๐jvllใMyโŒ@'ฝพใเ �๚Aศ>@Xธ๛pฒ‚`ซศ•'\ภธ1๋>ซ€I‚`�ฅeM ๊๘ Z€v!_$ฟ๊Š%+ขฎpข— ฃ๒ฒbGjๆJเหข./�`YโF‹_ซ`Kถ๊ฐ�`�AดžD5ำ„A–q_๗y๒c PกยJเlเ\"ITก‡ ขา�„แp?ภธค”aN^ป๊~กƒg�ฌบ๋ปยห;]"[ฯA ฒ ธรzเ \A๚uร�?�9ใOBG3F)DE�a€ย… +ŒPๅ*D…‚€๐4‡ย ๒0๛รๅ $*iFL$&2|� 3ฆฬ™4kฺผ‰3งฮ<{๚� 4จะกD‹=Š4ฉาฅL›:} 3A๒หTรL—ฆฝ`๐ก1!6<Œ่’ซWฐb=„@&฿)}X: iขฤTสธัรกZTR`ภ@‚‡ .š้ษAขƒdจ”+[พŒ9ณๆอœ;{� :tR  ูWฎˆ 1Xสศฑ ณ"6๒ขU่๖ูด'L˜„ญ”ญxฯ๎]่๐ฦท�i ำ‚DB๕h=ป๖ํ�ป{�>ผ๘˜zเiVฎ Zwๅ{vmใi฿ว๏�ฃถsGlฺ>Ž�_ื)”ภZhา1JL‚cŽ�a„NHa…^ˆ!M\aL=ฦ�AR[]’อ 0€หXŠy0 ,0˜W%ž˜"Y€b ห,bC .ลฤ‹ดฐ# Z‰9/$ฦฆฐฐมศ๘ใ=™%b],ำญ)งย oผ๒ฮKoฝ›5ฉWยˆ )`ซP @A *่หฏฟ)Lะ€"pะT*จ`“ภงฉ( €รl @;,‘ˆ4๐$ษ?ฺหrห.ฟ sฬตพGkฏ/ฉลCอาPsQ‡ษ tะBM4ผ8๗์^FHอtำN? u†�LM5ีดชAึZoญAิ^ vุ-WM๖ีฌrถุjฏอv�R“m5ึhoํv�v฿๗gpว}๖�Z็ xเ‚>ิ�S›ญSฺE๙ญโ6ั €฿Nyๅ–[n๘?ˆ็ไ๘PŒGฮ5NกKž๕ๅฆŸŽ๒:�™oฮ้tw บ๋ฟค๘� ษŽ{้ฉ๏ฮ{๏Bฏ.S๎บ ?y๐กว9๒ฤ�๎{๓ฮ?/๐1฿๘๒4ี~�๒ฒCฯ}๗�w)=Lิ+๛๕ูŸฏฝไ฿ฏฯ~๛฿ญ;ํยW?ฟ๙ษ#๛็ป๎~���ฏ๘Mฏ~ใณ_๑๐—?์ tl x”๐ษ/}ใจง=jpƒผ‰[Gม N}ๅ#�:ˆยr๐ƒ <ž ห็ย’†"Tก oศ>า/†!< ๑๖‡มโpˆD๔•7ฝฦฏqทSŠ๎jrย"JqŠจ;b grละD‘Š\์bเ!๙ ,#‹!๙ ,$y�Hฐ มƒtุใฤ!ฤม‡#J˜paร‰ก’cb k๒�Mใภ„ึ�แรีม!ฦ—0cสœIณฆอ›8s๊�ษณงฯŸ@ƒ Jดจัฃ; \้ง๋"า‡J™:ญ้ยš%#.ิ4'L ..Ÿช]หถญ�ทpใสKทฎ]ˆ(˜ ๑ฒA$dHaขB ฐดะA _ภ‚ – m‚*T46ธaๅ4ศ�8ฐ„) #Vฬุ๑_ำจ]C€ A„ —q\s#B„ๆะ!๘—Ffเa+Wฤ'Rh๐@eิ6(Hp=ลๅฬ g�AAป‚ูเˆž=<„่ฬ.ฬaโย�๛๘๓๋฿ฯฟฟ��yว&,Q~,ใO=สธQJ* ใมf*ศ ƒฤะ *,„Pย-ถค๐ะ#ƒส„X“‰ j“N=�ผ‘„๘HHก.ย(ฃ@R?�(ฃ„ ำฮ‘๕เ๓ห”K:�ะ๓‡. PษWmเ*ะฅQSคe>ˆคเwTรเ�bL Up=expย#ึ๘๓ฮ8E็$”RR๙‘)*pภม#ฺม€Œ6๊่ฃF*)~€โ-eˆ€š `ษ9jdัI?a„เAOธาฅ@ž‚**ฉ-้�ะ #)ˆอ&,@ร=�“ฦฟ๖๒Dถ0d.บ๊ฒ@$O๐A2ขx0้ศ$—l๒ษ(O* ไN&}B„�4/0๐A/ฤtHโผU&W๓อฤP๙œข๐uะฎฝ๘ ์1รฮ�+�“ศP0ฯฌ๚LตีxpH-Tค` $ฤ็B3zไ@h`๐€@์าJฝหC–[;�ซ๋พ€ƒ P Sส ,€ณWฬsฤ้,ล`L ร>qpš๒๋ฐว.๛์ดื”ืh์SN&,jง#y�3Eุฐณณภ๓ <๑6ˆ5 6ฅ๘เ๚@ป๖๚๋P›GฃHKวำ›|๗฿sฐย-�hF $ไ ภฺz์ ภ pˆ6๏ ƒM+๒๋อ7ืaภ๊ั‚ิ€m<0ิ๐b…|aุุรเq„ ๔เ้@…Zƒฮุ๚ใ฿Iภกฅ์C ›ฉ WศยบฐQ่šQŽFฐภ๖๙�๖–Wผ๐๕,x^๑�V lœใู•+T๑6์Em{8๗ˆใรฎ$ŠStฯด@0c I @ @ดิ‰cิ!@ฐ�Xu%๙ฑo๔:ศฐบ@�ส€ืˆ฿๒bะzฤแ‡ "ฒแƒผ(ภจุำฮTฦ3ฆQ„D4  ,ฝ๐’˜ฬค&7๙“\ม๕ศืถRจรlผ  ภEฮาๆIภ{!rL‰JUv่ 0–ฑภ้ Zr% R€€์อŒ{ศโZ๙สX“|ฤyภŒ‚cฌaไร ะลemจล€JHc›|้@ุ&A ฤาuุภ `ŠคภshK�โฐ�UŽเทE0จP Pุ‚  ฺhIt๓›$็8Xm`ข#œฬจF7สัM"เง‚ GJฺ#]€D?โ€Cัแˆpรdy‰“ฆtฅฤษร1สฆ…k�+ภไภฐaŠ/\Oผb2Aฌฝ4ฆ3}ๆR‰c‡=|แ ^h&ไ,s่a oˆ"+–๑.ิภn๒๛ื>1‡70†~yภ �0ฅคjพพะ…บ�+`#ี6ม?จ`ุ *ฐ@|แwุG"&0W๘ขfE+ซ@ฑ p€ฃ ญhGKZI…€ำ{r่1z€"Jะ‰w�ผร0Xmk_›‚,!upศ:1!ไ๐ ๐ร�ฃ, มhœบ‘‰ `ถตฝ- ž�้Vื{@†?ข”‰5ฆ]PP>L1!ฺพใEฺ๘Cl)1ญ—Jื-A*๔dŠุ๊* ผศำmS ฅ;XF2ิถQ„D!\ฐม๖๐#ผฌb ํ๊฿ุ �ะ“JKโ›๘ฤq1TL# t‡ @ TภbŸ&h@o8œ๔ค MdRภฯฬl ŸกPpf�ย!Yฆฺ{D@œฦT€:่ qb�bRZvIŒG‘้(6„ปภvบc(เ�6ˆนอ•…|cูะฆฮ ฃ=าZ๛๙ฯ€๔PŽ9=Nบ วํณRRูhz›‰�rŒsฯ„าi4Fp-ะโš‘ 2A›๚ิจNตI‚‘Cซ'"{H˜QM !Š „ภMร$ึฏถฐ‡M์@cA˜. 2P’ะม3i$(ษ V!‡”$ค–ษฑ“ €e๛�เทธg‡@ { ุม2€๎‡t€N`7ค0ŒXœJภ4Fส}nูช[�ใธภฮัฮd <ฎ]าVaํฌˆ q่7D^Šgม8”๘ฦ7คppR@C —ศย.‡G<ื�OนสWn2!ข ๐R์�]Œ„*Z ๏ƒผ ฯ <oƒp7(ปŠœ5ฟyฮw�s–[�๊๗�YKแFhเ๋`Ÿˆอย0„\Ÿ$%+)`†A"V ์a/ˆวส!‰ึ@๎{ุ๕๗šผG3yมึA d€eˆE9๎1Œ:ภาpƒ^€uธระ@ธMD๐„7<โฯxวฃ๋จO} =Wl,แปุ†‰ˆ…,fA‹@ถาKพ]๏™ ่~วปใ="ฒ๗:฿5p“์!X8๎พ‹xxC‡ฯภ†1Œ3ะๆ!ฆ � Zะ‚4โ๒๎ †b`๔ฺื่ฆพ๕ฑ?๊ํw�๛ชฯฟ�9ฺd๑ˆk pก‘€ท†เ9 ่x๑Dˆ๑€q. a0แqึqc ุฯA_g„v Pาf…ณp5หท€0fT (€g(๓_ื˜ั[มQrฦ�แะc*๐uเ๛`ษวwฝ๑ƒทT่ ”๐/ q``1xล ะเ ิเ –wy๊เ �ภ b๘ œpq…SX…W˜…[ุ…งทzธ‡ฐณนjr’Œp&~p ๕เใ0ํ•� 9�p ่…xˆ‰8!๒„'zย'$๒ ํ€๘๐๒ˆ1๐$[_ง%<w๏โpืh Q2ˆ!@"˜˜ �ะ0ธ|žฉ๐� ˆ[vเใเ๊๔ส@%_—"s`=๒#A2$BŠฺ[ขภc ลuฐ„Mธ|สv๐ฐ ฿@…Tจ๋ Ž@D•ก€ฒ็Ž@ ่‡~๑py่ะ่@ ะึviๆˆŽ๊ธ์่Ž๐(ัฦ‡๙ด#ž �3|’ฐ+ฌเ c0 ภ f�ž0_‡‘ษ‘Y:x1ํง2pkA ๑ไe�0Q �ฐL"/]ขŠmT’`๕\@Zต7ƒ@ร“ธุ1XIภฤภ/o๐0‚Pˆ0ƒp Aฐp#ะ*เฬpีr-ูฒ-ภ9น“=ฉ%š C`๐ ฉ„Lh<ƒ`B@ ุ้ŽV@์'^ธ|ฟาi0 ่๎ภ๑ภ์๐Ÿะ9A˜†‰˜T ‹)Yu™šช9)I‘ยGwJ4 €jyQ๑QN$ฉฑ9›ตษšร9๘D H7ฐk@i!p€ภรืTG๙uz๓u'ฑ v€6šc>8›6�p„1hณpNhฃkฒ …K„ดีp}.ฐS6ฐศะfƒhqภœฮYCZฒืa.< •เ๗ ำ)˜`wp?@ ๋เ ? เ\ ห&˜Œั/ ะP™๊ภ—ฉ™์@ ”p*kJกŠกสก!ทš6zฃŒ2Y‘5„‘ะQโฤิ9”บ้?*Q‹uAดAจ0ฦv €้P � ั™Š`WNŠ�€ใ5^ลUyิIžหw [๕u๑a๛ Jศ๖pkš ‰ ห0$็“๊ร>ธQUzฅ� _็ยย�ช8ไคI`Lภสw…)฿เ}ะ0PศFโ๘ข*ชœ`ขฆบ™� Mขš�—šฉณมฉžŠš8ZซถzญY‘p‘FJH:คน้ฃพค ˆคHBœQส|Pฉe€Z0ก€ณ(B™›ึu@B@A€–fช‘ฆRนฆK้ฆy†q:ง’๊วน๐ า@ํ„[ิECโฌเP80 E0GดดK<๘คฟภŠ•สwิ๐ กะp1(G ฑฐ "P�€(บ™ะ |ะฑ_ าwฑ€sKrท:ณ4;V�b/0w ฌ!๐ซฐ18‹6<‹ค ตP2ฐ&q8›$มฒ8�# pZญtฏ ๓ณๆ„N€�p๖$7ท‘8ก€Q5ฤcๅ9 ย๐Iรl๋9ธ$ >ฐ—ฮ0 0 ๆ๐ ง5MิdM<`ปตษคฅ0Q` p  ‚€/0! wœ๐" xิ๐ W(o#{๐บC๐ฅบ™š)ฒ์Pm}  XPฃ1น•{นgน›๛65{ปธ๛เX Y “;�ซ=คs*Y`5ดAJ๕X‘•ภฏ™ 9Sp ๓ภKๅ๐=ณH-rEWย๙ณx�ฐVm5@๘p s0vเไ0ผ�๚0ž็สwฐาภ/0g‚S`y |@N‘ภ…“ „ะUuUYตUศQฝื›ฝ-a3ฃ` ๖ะ9็zฉ€‰๛ ‘‹Ž Dฯ๐ ๎๐  |๐>pน?P (:†šษ •หย่๖ม!<ย%|ยึ•ป@ฤ?‘y `�0aใ ผs€ +ยƒ๔ฅ ฟh ,ฐJ<ตMผ"@aใ™อ๋9F ^ท%]1  y#ƒณต_ทฅ|๔e_ฆเฏธ �ภฝ^โ…ˆs‹|ท‹ฉPq^8ยว ‘w@C�ภQ\ z<^๔P^!ลPย\k|ZPฎ—@`ว†nช‘ v\๐ฉ/๐ �—�8Ž}Gi}๐ ิ@ ›ภ`` ิ€ลpqด๚งlฉผสญ,ฤฤ\ฬ7ฑ6˜ดadžั8*ะคC‡ฝั”Aฉะœ1F_€�qPฌgซกึƒมx_๗eaถ•!@`ฺqฮ"ภ1h เ€/{*@eHˆJ9่R&TVษHงล‰?pวฃ!{Wส0Ÿ*Vเ/‰ฟืw่†˜ฯะ//๐”ภ ลp ๙=ั}ัฦ�า.=ฝl*<๖�มฌฑw|ฑ—ำ?vฬjำ�ฬฌ%แำ:�w๊1mw6#ญฤ  ๘๕ำ!ิ่๖ฒกรืโH$uŽเ$0mf๐)เลPฎ๋ชRmT-8�าl�ึ01ำศ‡ำฅลศึข0ถห6 Žิษ7Y�>€6L@s๐ใ!&-{�}ํึู๛ื—‡1q|,ชฎ,ำ Cผก�)•Œw™]ิ’ฺชป8‹:-ำa๗นฝกฒ›�ญ}ฎซ�บ=2-;O(ื<อwR ‰pืวW�ป�สญxีฏ#Žฝ �ฅl�ฒ�ห}�ท��ม2��-Z๗ท^ทi‘n๊�๙�Qสฮอ�ศ-� ‹�๒�pMั�๐}ฌ็z0QWกู�๑�ฦนแ�๊]์=฿ žyฑ๑ญ~1…q G่uฅ๎C–a€๔|ีq€€ ๅBท‘ป1�ฦmเ๊ ใี}�฿=เ ๎�0Žใำฝเ<~uB ค,˜ฒ ๒ hg#=˜ DŽ!ย!")ฑ๑�ะˆ8ฯัH$F‚$Jฒฒ?-ใ=-ๆ7�ิ9฿gๆ๎�ใlพr•r)™ฒ)‚ู*ก2*ฅย,ชขธt�*a+ณR+ทb๚!้ แ9m� oษ-�.โB.๘ํสd>้W-เjพใh^ๆnเm�้ท2-๓2}2็>c38ฃ3ุชRc๊ACS4Gฃkƒ.G`พLcS6g“6?nSjN้ภ��๋๑mใšฎใ๊้ส>n้Lธฃ;ผCฉ|—<จํ๓u๐#คฬ‡�rMใมw๚ํ™.โศ~์›^ฉห~๏เ–ฮ2ิ โyCฤญwวTํ>D;”ํCิEtD-๏yทธ๙ฺ^$่F ๎ฤ๎ำ์�๎^ใ �ะรพ๐œ�โ้๏๘^๒€ๆI $JƒณtJฉดJ๋ิLh�ำ๒ต”3€KบฤKพฤม็-‘`a(าดท�#ฺู„N่ํํฟ๑ฟ�๑๓ฮ๐ ฟๆf^์&_๕ฆ๖Q!5R+ฯ๒5…R*ลR�T2ES6๕๕9ตS)ะS?ไNl‰0`( ŒU้ช]๕Uol๑พ๔2๕ฅm๏OOเS?๋V๘vZl๏๑ฯZฎeŠ#่^ฺฅ[‚ [พ\<8\�˜์!ลQลแๅ0ษๆแ้๗e์dฎ๔๒4!๘ฟฮ๚Pฏ๚$๘ถฯQ‹b๏ชx -ึญธd4ฦ๛๓<9ถc=&๛I/|Ÿ�แ€a&• แY&‘>๒~ฏ๛ฝ�๖o���o๘?๛ท�ค…ฺแj9mh�ร)‰Vเ7!ฺ–mไเQ�6q��฿๗เ๛๙฿B†ai@เ@‚ D˜PแB† >„QโDŠ-^ฤ˜QใFŽ= pi CŽ,๙QๅJ‡\พtฉฆ††3mยฌ8“ๅN=zฐธภS่PขEEšT้RฆM"ค๙’gฬ–4โ”z‘๊S–:๐๐q†%Rค(ฅม"ƒA6l"lเภU๎\บuํ�ล›W๏^[!ฺ�่—/ล,าฃFฮบo่ˆ…๒#C*ฤœY๓fฮ=/ผpฏE)4H‘"eวำฉ?๏Œชฐu žณ�W�๕ส„ปx๎zป„g˜ฃUœุ�AB ๆือ?‡�ใp์„QดDช?V€ศฤm้<่�๓ะอ.จ‚sF๔‹๕ๆ�๏?0ค๚QF Oผz๐๙ๅ‚สgo�๖จ$า6๐€*d&๊L๏xึ+ ๚ถแ[\Cg<3„1Z œ€eฐๆ4wดิข�๘ภว?ไ๑ ฐฮuๆใ^A๒ถŒำ)ร W๛>ยทตถ๐…�๋*X‚�ย;(๑xจƒ๎๘)ิั%~Iพ)ฦ๎6E*VQ/:‡ }€ก#D=1†A\ƒ<๐@Œp B€!;˜+|๑†1L`ศAAเ‰iภMพร@ม 3ไ xุว&ฒp…}4‚]ซป<๘€„N์ร ขAพ<`ใŒ‡ด!ž๐8ภภ_!ฑG"บ‰~ฤกAvx"ธ@†f`‚`C*—เ}4iฎค ™ƒะ=x‚+๘.Kdq‹]ฤ๒ภŒ?p”,� AะŒ6พ‘Žำ$'=ษƒ์๐o$iฑ#\๊’—˜@ย8ฦN in์dร9|ฐL ๗H‚ฐวs .+`d ‚%t $’คค%ลw,ช! ่GBpฬd.ณขอ(tะ Fค@ัุ า#๑ ?€้ษ๑า—~c‰ล eEž๖ิง;9ษ ฐ‚{$ย{�DHpPdรธ€ šก‡„€ …+< 0šป€9๗ไวธbภ@Wmc”โ๘ภ=ู.03B%j"ผ‚ธปh…!1)„:ตŠABrืLฤ8A� ถa‡ ˜ ƒ0‡pŒ@๐  …1FPึณฆีซO"ศ8@จs"มkQฃwˆZPA5   *Uช’ภ+เซ@�ุ‰• ำrญ@6�ูฯ†๖=ุR@‰ล@ฎ0&l!Kˆย<ˆ€ฐ๖oHก!ภ ๖ฑ†ั๐hCwjQKCQุ&ฉฤุ!r=Z฿�w C>Nก,(5!$hi<†ฑŠU ƒ>^R„#พAŽ%~ƒ ุ�i†5ผaŠ8 ฎัPฐ^|ฺใ.แํuษ๚ฆ  ญซX]โ&‘ภล0ฎA3๘ƒ   ’ $G๒aEp`8„6�� lภธ=ธE:Pก…lpxz M˜‚,๔�ณ?ๆ1„\ฃ ๓>สว7ูฐจ-ˆ~ืษฬ!ฤเภ nแM„ก$P)R,ธ YษL†1Hฬ{�ึ.ณฬgณšQ0โ4เGึลฎˆ้แ„E`cc€‡฿ต่`$ ˆฬฃแqˆYfFl ็�นึฒž”Kแƒ „ฅ^zUฐ…S๕pุุวF๖Ÿ๔4€Xฤ๛PC‰OฌLUล4ฦฮcl�ว,�/ๆA™ฉฺใ?ฒฎชnv‘Yะ‰cิ!@ฐŒ) /# โwC๐€ฒGk.๗ฃB ภ› ‚�ืม 'Fภื� กuž†œn์$`ZะD?Œแ>T[C ป� ๏Dบ „n๔œ ~p$�ูังฎ{  qxEhiสธฅV๏LตlŠ฿Sโฏพ€–e๑ํ;้ฑถ?ksˆ๚ภ”@}–(lญซใฆ>ภpฒล>๖žชฺฮŠ8ช rหTiศ ี�qฎ๊ํh�%ˆ„9^[lื�hฦ&�๚r4Bu71;ˆ™ฝ ATึลฃ@n {Hย่@ bŒ@ฒ‡Pj(2ถ~โฃษmeฆm…Pgฐ€ r›xE`ว`ฃม1�v๏�‹Wป€TBพgW๔&A ศžl ล้q{Uษ )y1;0‚ภมhB-`a b?๏ศมnภ4ด’ ดŸณb/มX<›ส‡ฝ๓๛‚ิฟ!`*cภ‚eX˜ „@›b`‡dต‰0Gp0u` dLxฒรภ ,ปe;ป ˜Aธ†1*ฃ3r‰ุsƒ1xƒัzฟƒฑปcƒ}H์;m€H U2„^€ศฺใ>VX†7เ i…ฃู@๘‚ธL,ลjฆ5่Œ ƒ˜:ภ‡S˜ƒ1ฐ'ฐ,6ะ[(ƒ1! 1lbฝ„P-:A�7x€ฟฐƒ=๘‚'๐‚Z4=PAfB"4ย!9ค}„9xv‹<ภ@xƒ9 \๊[ค2œ€ ƒvศ„/จ‚ภ›9ƒ๐@;๚่4Bฤœม7ภ3rK†@zร8ผฒTZฅVสเ6D7„รK€EVŠ์ศƒcฐ--ธ†P�) 8‚g`oHFj …€ฉPฦh10 ดฦk�•H…`h ๊†Lศทผœq˜ƒรk€.`ก|0˜q^๐Š@†LPค€ะPGุ€vิ€€GyิCH‡tศ…~h„L้›ะFn๔ฦLุœ่„w€wะ†?H�ธm‡L€vAˆผQŸzp!•๊€Xžำ้.0ฺณมแ``เา๘วyT €ศwxS€…์ฦ๘F์ูำižLุก9วt\วส‰HพฉศฉวTHuฤK+T๐SH์™0.Pส=ษn˜ *จ"ห"่ H–ฬŽzผว|์€ะ0ำ้ษส†œ(๙ฃ‡y8žไŒศž„โLไนศQฉƒ์X€Nณ[9›pqเnj่ƒ˜ฒLห|Rุ€หรฦา4อ่ i  ๐ ŽQ œkƒ– ุ€ ด8ะ  H—�ฺ�€`้Xศ฿T€ €Š฿บŠ™Hอ Xอึl˜ฯบ๑—ฉฉฌ๑ภ_a˜ ุlฑอŽˆ�ดฒ„เ˜P้\อR€0„กœฃ™อƒอเ˜š€y ฮ�ดrIจ*OŸ!ƒ๑Oƒ PฐPNฐOีj€˜—๗Ldกฮ`™/๙ ˜จˆ๖,!€šิ˜€€P–@P„@J‡}†gpNศัลฬg8ำา!uŽญฎ^Kศฌ(ˆ'LR)ฮ๔ผ‚2˜-{hา๔5_๐‹-ฅ/ˆ^“LŒX#S0-Š1%�4]$3ER— ข‚ำิL‡๘ƒญัฯ์ำb…"R&%าB5Tฉซ…HT…XCx‡vH‡Q0ƒ@9ิบฉำั7mˆ เM1 …>Uq่='ญิSEี*ยั ฐKUTล-ก3เ>(3P‚€?yี^๕ี_ึปq ์@–เxRฝ‰`eึfๅ wŠE %%;^• kฅ‹ ั€ัะVSuึoืkลV–ึพืc+ืŽHWน่ึv ืw…WŽhmRoฝj•W| z•ˆs%ˆeล“ชะW† ืD๕ื‡เืŒhWmื†uX มŠEMXŒ8X�‹Xื•˜X„ญXw X%T‚•XjญุšธุœXุŽ}ุ”eึySVช pนื„อ“๔• Ec“ =bมฮ์ิ˜ˆ v้ศํ pธะPuอdA—z๙ื“ํึ'5ˆ—NŽตฺฎURฎ�‰ืซ๕ZSตฺ'-[I�‘Uูถ-ิ๊x‚๑ศŠ (H…ศษFภŒ(xeภ—๘k˜@Ÿj๖q๏™zะ†ไ๑pMHˆฬo™‰Xๆั†Sจp8K j ษณ พœย;P\e˜๒ชืตU�e]—ฅ�บโีฐี †อฺ�ˆeุต�ฎ�Zโํแu�ไํี …2ิะ�›(€$เb€ƒ/xƒ/๐ภy€ไ:€…๋ช5™ฆjบ&ุา(€~XาX€W†ฑฒ‰@}E„ฬ5}PคŒซB0#22ฃ[ยฮM๒ม�Mื€ตเ�ล“�ี�ด=ฏฝŠ�uื6^>^ฺU�ฎT”กGศ‡Ž m5€Y8พ�๚&จพตk*ุ‚jˆ8ศ,p`Edh‚�ส”  ฅeฺ‚kเู9�ใ6a&ไ!u‰y;4ุ‡rTฐ ˜<[’Š—ƒA“ช ะ}H-X๗ัณt่ณ? ™xpH‡jเ‡Pp ุ!‰b%ฐอฑ‰@‚{ุโ.�b+ศฑฃE-ใ2Tkง#ุeƒย Yฃใๅาแ&d>ฎZ>�ใqฝZfNfฟ˜ๆ฿=[๖]AไB๖ๆk<ไภƒf(<ˆๅG~‰Hๆ_ d h'&ุ,8ศ…O:`พนธŒ�8(€T‡:ภ)ฐ‡ „ช[Zˆฝ ž H‚{`.๖b�07ค๓ๅ,€6iซlะƒcฆc…เ�@†fe~ย‘ๆ]?X‘ei”}f’6�แEแoฆik<€+0†z0†?ภฆ+ถ‰†0ฐ’ศแฅ…ถห_g˜†˜s๘„ๅx€ัxปcจˆหำ€ ˆk€ƒh„]m่+, ะด0nซ \€ว๒<คาแถ[cภ7f่ ้๎ใ—=fB]ุูีๆk^าฝ�ๆภF�š6lฒC€[8!=฿๊ึ iศ�2จ‘ Aๆ๓ใ‡B€IะBP‘9ฌร;ฤธ„yเƒ%ศ‚rP„I’Qฐ0ถŸ กe[v ณๅC2เ.@`�+œ'ฬv`–`‚>ๆป†ใf๖]ฝ.nlถ�ๅfnม^W >์้Nถๅ˜^;พ‰บM…zˆึิษ9™; ‡!`€(.ะ(J0ฃค น฿้†?ุจฺ่�‹ a€,ศ"pไ๒ฮ‡& ;ฐ’�›์ภH๕!Gpk€nSม6nป^ๆh�c—ฎf —n•�Zแ๎™ฆ๎฿0Z๔ฺ 8NcˆฝM™ืคœขํ?1‰ž๙ฌOญP—rY`ซCn€Qฃี€�œOศ‚Yสู๒\aษ๐ ฿ุใ†r”nn�อZ9�๋e~๎–ฮ� ืr๓ŸBgd๖ˆm%�™ ซ$ม- ]ธJ.�ฺแ SM]Sฌต๓% ŒŽ๓่D dถW8๏‹=d=i&M,2'์˜๓EOY�๎๊t…!€NF7Š’}rŠ�rJื๔w…ึก:฿๔่ศๆP'๕R7๕ฬ์SWu™ ‹๖rUŠx๕V“YQ็hu1Gtฃฐ \ฑอ ศ]๕า๑๕ƒ ๋ˆฟH๖๋ภา็h๖eoŽb/ถถe๏8“iAส~๖a?6 •‰่;Hฏ{เƒq_‰ะXnZOT9‘ั”YjA„(w>`ำ€กŽุ๗กิPwvwƒˆxwˆ'„v็Œิ œํv�•pˆ(P†~xeเ1)Y‘๏wง€ซr๋€ƒ_•H’%9voฯŒK•tG/€h˜ŸŽ `œwr'Cั”Daz_&ƒ—y&่ตFy�แฃ‡”p' ›วy—g็?๙ึszy แFZ|^`X_‰Sy@Zƒ)ˆƒ"hTแyPzคyฒ’—๚ˆ•Ra๙๒x�D™๚œว�ัภชQ๘ื4‡ั™ตZ๐„:qœjqs™Zi€G๖˜�ภž”^๘–˜]๑™ํOŸE™`1dม ‹aใh–g‰–iฉ–k๑๎a|วOˆ–ษิ�]๑d —ะo๑”Y�yI™ฮ๗�ฝZ‰Q}ญะŒใG�’}r��ฟY˜3ฝศ”๑I�์ ฺG’ฤฟฯ„ุฌVA`>ˆZึI๏'�%�๑g—ำx€…ัๅˆ),h0AืยdกขB ฐดะ  .Mใก`ƒ ˆ"„ม”4€A… ‹"*p้ฐรE•6PP€ย‰r Jคh‘เL‰6Uเ 9ฒไษ XบT!bC… ๚…ฉชbŸ$$H๕Aี ภไpมetาญk๗.�ผz๗๒ํ๋๗/เภ‚ nค7ฎ8�@”ศ•ฮฝ?Bl8๑ศšฟwใŠl1ฺ๊;‚sBtะ ก|qNšnํ๚5ˆk|x๐ ฺ฿ธั6xธSญžฒ9)*Lงา4ท€Cdใฃ้ 7๘ลษเ๖B”T๏�ๅct๒ƒกj�๚)Sbวญzฺ4Uฃ7งB k�๐ ๓sฒv๚๑ี๛Uม๖vน‘fPlณแ%@%ำ๘`Pw€ƒ›8I•�v�‘ึุc‘MF‚ ช$uึvXbรล7_}95เ‡6้ิำอ‹buืQศw!4๘เ8r๐ˆ2๐5sุžxไ™Ÿc๊)8ขpะAฬ3ฤ ฆฺIv9ฐ‹4�๐ฐภ‚ ดฐ@ยŽโd’ใ•gยน�๒ะ �!Wมฉ<–")lค>/ฦ˜“N๘้W™ภ'}๖ิแ‚ ก‘ dŒIWš=โิ#นT˜#]]ๆO.� ubฃ9q๊)Ž'50iฅ“นucw#Œ๐J;๘เ๓|ฒL6๔๐„+ฎt €Aq !`ฑCg 3Œฏพ;UP๐ม&HL H=ˆŒ1ˆl<l2ส*ใ4nน็ฮ†R‡X๔qศ^ยญทเ†P๔A-€%็ศD*๚€ANp3Aะุ'ง�…ึ\{ ๖ltภrยMจเ3zุเ2ฬ2Ÿน eL$�ฌม›&ฐ aรฏƒ5ฟ�์ๅ1p,ƒIะbซใ_‡M—Tœว<3 x๒๚(”# DF`cฬS ์็%๐๐D'๛XแจNEัL JA�zฃDุีimG=€pAF3˜ธRใ]๛>�2่#Š่c•o?๐!\แ^‰.ฎ๏}™`ๅืถ.5ธF)I* ‹<%˜๓ใ‹|๔cอ�ธ’†UB€ะื ถ-onl0ร@*8A€+�ร จsbC-๊Q“๚@Y๐[tbuฌY ๔ฤจะฬl•�“ฌwตพn ๐#ภVถ!X๏หหเ�@ .PI3๊เ!ะ€DˆKs๓ฐ‹ี•Fฟ�˜ต01c(�ฝคณฑ^2‹— ',๏EŒฝ^‚{ุ ฆ |9์a๛29ัแM'Ž๑๊X 8ม> %ดอํŸšมf75%| G%แ�ถมE�M๏๙๎;ฮจ].>! :˜ษล w?ศm‚คuภม์!‰“๐GX-``‚ฃโ%ศC.ร2PA }/ฟv ๗ธอR*/‚ แu๊ L\Cฃหปลผˆ5฿H่Gh@ํ XŽP!:ฟ๑ ่{ซ$ู#U�žwรu‹ฌ}ใ`{hะ„hผ nmtA�๎๐I3ฃ <…ˆ ขa€HxจิŽ<ไCฝุซ.€ฤ8ม �–!ณภl(€Hงหผi ์ตบธ‰&เไื|E M#oำ๒€ฎ”ข๏๑mƒX 6ŽdฑJรrยฐ 2€ Ž$# 8)๙ฮ‹P 6๐€& KyอAP‰�ำำž �,H6เ‚<์�m•&"ล)r‚Bhฦ rฉ|ชk‘๓ถ๏๛“EŸ(์œ�LๅY^-’๖q_KIX—Y] !แŸ�a€�ัŸ�ตŽ3LCฬ‚9|๓e„๗Aภ.ภั„อ�OE5ภA4ยะจ๋™]J4ิ.ฬ†ผ‚=8EำEูฯ…า4฿ 2QT�‚9ผ€ ลภ(ด@ H")ุ„I C…กpTr |ฤย*กภ6‘ ˜]lเ6ี>ะ^ธQ(ไภ6ย”xแ๖qž €Uš8‚ คq�ๆ_๒NH‡>„ิS ุ,ภั@ญMJุVt๎ฬEไ}"(†"ต๐อ9ฌAtWTภ ฐย2ผิภeึย�ภ\ู์ตยทi@์�‹๋™ฦฃ Rlฏัžu €NุAฐฟŒม‚„ภiฬWดžภ๔ƒ4 Lผหุม|มxมIฦธำ๕…\"ธม  �ภ.˜ƒŒม่๙\0Dสƒ-0KAจ อมLภึภใ�๐€_qฃ7&ไ*ถโ+ฦโ‡แมAึหZฃL‘ข)ขb aผc<ฮ#FเI0Gฮ6vใ|ฃB CBŽCF?BL‚>„€8’ฃ9สฯFฬ,A”C…MเŒ‚5tื์@/z"]๘€๐ƒ3PŽL>ฮ๏ฬ�d9NPxม>”BlะPŒภ<ฯ|AฬมrลกA„O5 Iส#=d ไL >œยŒ8๊ฤ_nๅ�ไ€•U�ฅฤร˜%ZZ^ยฃ^6ฆ๒อศะA`faš�UKF$[‘T2@ใ<Аฤp… ๐œ€ย6lเ–(ึฆm�ๆ]ฌˆx‡˜ภ””@'lว;hรจลhƒ<๘8d ๐†+๐‚„ภ ฒPใ–ฌJ๘โ๑L9'tJ'u~EoXpฤˆAะฅrื8pมEจหฐ5—iLH*w˜‚lภ ƒpัC&œษคƒyฤ: p|@'ิร;˜ |Ztม2ิงP/เฤt"หT€ pงA,า#\Cqฤ€žไƒ}vภŠhnฬ…aG*—‰^pพƒ‹็bŒh‰ฺgธ็hิJ๘มn*CoNฬ่‚ยภค�B0ด€*vC& ๚จ|ยจ)4‹ฌ(N$ภะร0@ไM~๎g†ภˆj‚ฑtรศ•[ิ6ผŽ^็ีฉD‘‰ จ"8รdฌฐ(˜Œ)d๔' DS`†f่Fd/ ช)ค@’.i“>้ŸคƒศF)ƒ^Žฆƒ‰ฺวซPส‹pมaฮTŠbih–j่oŒi=ไฦšip)ซฦAŽช?”ช\ธ(•Šไ~.h � yvCoฤ คƒ‘€š|๑ƒ.|nN+ตึๆYH@Y-U@ภXD4Ztk pภHPฃฉณh@0€Uฦฏฉb4�B\˜F4EsฅklภˆE”E]ธิฤKฤ„ฮย@ยV|jภด”ห4มBEHฤX์ฮaภZธD ์ท>ลUl@HJ่ซผา๋ฉ€THภ"EO(ลn๋ป4ฌM˜„Ž8ฌธยฌDP,!%,Hดฤ`ซ h๊ถ~,BภVŒNmึ\เ์ร๊,อš•4LดซU,’Oไ์I$มยฤ6ฑŒ•ƒธ+ึ2”4€ทVภXHาีศL]@Lฌฮ’ซฤบD ,Hlั’… ,€ป>�J€Lอิ� ฯ%ญ฿ฦ,ลj ๎ห๊ฤ�.b”„Tฺฟ๎�mมลJ8์วN@ๆŠภๆfอLฎธJE,ุ†@ณฌEท~Eฟ&@ฯภโชฯๅ=คมcV+๐/ฉC�๎_˜FJ€๐C”AฌVoJ\๒aอhฮฝU๏_ะ%`(/๓V*ไIล๕>‡NoAฐ]N|ˆ/ž‘oถ˜ฏJไVแ€ƒ.\Š`˜เ_ภ๏^HQ�ชoฯ1ิ^Ho^ฐ_ิ™๑�ฎGฏNเเ�€ะย/ค๐Vฐ_0ตภ ุaAoต4K#ญฮ‡šsฐm‚p-ˆ๐ต ฐ “š—ZC:ˆยะ#ืฐ OK0C=hยDํ �01ห่D6ถFถd� F�ๆO+c์้ใ]iŒpWฑ gD–๐ฏo1wฑ1‡ฑ1—๑ใน†` ถจ1ณ1ฃฑ_๐k˜ฑวฑ฿๑ อ1`ิ1ต๐1๛q ๋1Wซ 1gใ 'ฒ"งฑ†i€ุ'๔คด�Z‰Qติ1๒.ยฑJ`ฒต€rAXrJp๒'›2!๛šจyr^�b๕Šฒใ2*๏1+/ฒ-หq-๓ล#oฃ.P๒`rr%๒sฒ0ฤ,๏1ป^)3]$๓ด3bๅฒ]ธ2"Cณa�โ7๒-wณำ็mฒ]๋ฟฒ!MP„\ป-ถฆยฎ$-�-SŒ๒kฐ ลKค€€ˆ+นŠY่3[tlU@CPภ(<ฏ$€ร:ฤะดล[œซ\ณEลH-Š:ฌHƒBWฎJ˜ดํ&ด5s3Eทด_3,ซ1L๋qMcฒM;3=[๓๎ีLw2O๛ด+๗ด({๐3ณ4#๓Pห๔M7uNืด7G๕ y< (,SืณD(o" ชคˆiฌW ฉWw*Q �‚-hชภ‹cdฒฤษœจขx€ควŠฺ‡*ใ‰žpGŸ`ฐ‡>‚1ด5ช"=”XขnFF`dHF”€5˜‚ ฐฬ#< | � zจ{ธidJ„์Fo�†`Ÿ.•vฐTก*๕);๕KณSƒ๒lCตP�๔m๓ดmqK+ต๖55๏to๓vPืถq๗PKตr[ห€‚?ะBˆ€@`ตภขdH�อฯ˜iX7vs…\้@30B ˆB4์ภaŠ‘๗ก ฑP๑ˆ”ž%Œ/@#(0ƒ่‹5[Jฯคj’Pฐ - L@�Cจ ป<ฬภ€i น KMย ั๘%�Lฮ์ฬษ;peบเฤคfุ ]�ทjR#๔ๆฒ8wkทธqว8Œ�๖kwหxk฿83 wl๓M็ถ6#7Žyp/�ท‘๛…Eำภ#ไรrVว๖ไaี]Qืuw5I91LFA>œ‚>`A.๕ธiฌท^Yแ{3๓คั#ถRภผ†Iก”Jฑ]แ๎ีัฬรlA:,Aฬ8™น >€i(‘Vม>–`A•,›ฦนa’&)TƒE1•ํูีๅีลœczฝrฃ#ฒoguฏxl‹9‘y575qŸบuฉ 95ซ๚Q๋6Žร:‘9ฎ32ญ์C9€oŠ฿0VชIZŸu๗– žB�K)๘@2 TAนฦา�?ษ  žภ8�•ษ•ื่[q€cลภ—ม `รŒมฐigy�hE“ุeT dฺฆํƒดนภœ[wQ’Qvฦ‚X0Fณ๕tเ…๛ธซš x0+ใ๔pC๕ร72ซ›บ,฿Er๙q 7* ฒ&S|Ž7sL‡�ฌ#ตษฯ8ษป6ว‡|R็บหใ…iP@เA3 ุ๕Vwร๛รํ”•;รx-`ƒอ…z{_ฅตfkL™ต—1ํ‰ัŸ=แ|;žฑlฑว@ะCผย!ะ"๔ญ)€ญแ๖�ภษฺHiจโ1ž�ฺฃ;[%eม=(Yฺ›BZฑ|�aƒธปร;ผK๑งบ%Ÿ|จ[<ซป4ฬ>‹ผฌ+>จฏ:จผงงผสท�ผŽ#/ฤ?๕Ž'�ส็ธQฟ<้ใล\1ิ๔œ ฟฟF3ตk%*R๖ํแบ1@%†ภ.,ร"ุ€ag~ด7รฺ,=�ิ@แำkปk์ฦ H!š๙œOโภ �(ิ(R่ž ศ�อ_ถGฝ€{hƒa๘waœP’คS:๚€$R"1Œ€ฆฟP?ฉ8@h8€4˜aC‡ >”8‘"Bˆ ิธฑโล;†d๘‘cBAR$1ไส"ฒ4 “ๆห‘.qๆิน“gOŸ?:”hQnข"‰\ฒ—จ ค~qH„เภ�ฮ5Dn�ˆ:ต๊ี!๒ฃ’Bหต?60€Œˆ3B๘ี๗D๐`Dกƒx็ญ๗S’8ฆW`SO}ฏาV�‹จdU!๒่;œ,%ISMGu๕ๅ•%ญyี๚Zพ4gœูƒoฟi-ฺ่ฃ‘N๚ั๚ขš†‡ธH‚ZฃฆŸ�่ค�น Vbิ'LS่‚ซ?ย@j—ธ^งnะ‚–_TH'ณ Šซน]2๛'ฝ‡ry้—ๆ๓ูf‰–Yp�็ฉ๐ฏมnuงฦ/ต?ศ็(=˜ƒZ� กWฺ๓ฯA�?ชฯฦ‰tลMžี๏Xฃ`ฆM†ศกt™Ssอ‹Zi๏l๏�๗฿_ี๘แ‰/�xž•�”rXอพภlพ��;�๏ๅ'ว”๚�1’^๛ํฟ‰๛๏ม_|๑g฿�๓ัO_�๕ูo฿�๗แ_�๙้ฏ฿�๛๑ฯ_��๙๏฿��<ญ€4เ˜@ข์z tเ!A NP(ตƒ™ฃฌG3ฺญ”ย™เHตม‡edUกโQ˜BฎPVแy4๘B๓x ?,sก fA๎‡=๔ก ;—CๅฅLg๙)ข BD "1„?tโก(ม-RขขbฬศD็ˆZœbมF1๚๏‹ _˜ล&�N^๔�๕“ฒN‘Žuดc�ส(ฤ ฮ๑‚&‹กฯฺ่ฦ ~Pfิแ™HE/†d�Yฆ�จฤฐ ’doL#%-นHMn’“ปž#UIT�”‚ผ มƒษCv’•ญte๎pืลIF๊�SI&g‰ฤร�L”ฅ4!_La3zŒ{#งP™ห_ฦฒ=๔ฉb,ษฃว-๒‘˜ีดๆ5yฒ ๐๋W�„ผูอˆ€›แg8EN 3Z\[4ฒ8x6ฐซd‰ๅๆIO›`“Ÿ�๔งF60‚\�ƒ ๒๘‡<�qЁ”ก๕P(Azะ„.4ข๒p่@ *QS  ธ�็GAาW-O0้IŸฐƒ’ขิค*eiKWสR—พT ๅS฿'EšS๎ิ'c�๓โb7’�ํงฯ€P�?BT็จ �๔ษSฉN•ชUต๊UฑšUญn•ซ]๕๊WมVฑŽ•ฌe5ซŠ ผmไYลzภขฅV‚A ฎใ@วVนึuwูฑrB ˜เฉg5์aญz<ws nQmhข๔˜ƒฦpp๘cnธ[ ชQeฬม^ศ์f;๛Y%…vดฅu‰เ๐ุฑตตm5ซ€ิ�๘… ๛แtม๛เร!–ฑƒไผƒ9ฮŽt0ณฮŒ@9อ}NtดSณธไ ๐-ส ‚cั๖ถๅ5ฏ")ะƒGXฃ‚ ‚L๐ก•งY ‡€ภ %(กฝN=(ฒแ�ƒฃ/(W/ˆq‚lC�ฐ€ lเ ˜๒-�ณh๐ˆ||ฉไ=๏‡A F4ƒa€A :@^๚fม๘mฦฒฐ55lฐวjะ =๐`+m` bผ†‚ิ๘ฦ9�q™Q–@ร>สQ(…˜สUŽข.SŽLa'+พฏ šQ฿{Dฦ16Ž|YฦCถ๑™ำ์c%ถxhF9มอส}๖ณ Cเ !ํ๘EB`>oD8ฤแšp๖�^เฟา€pฐ\c ฤค+}้dzำvษฎ`Œz@†N๙๓ซa=A ฌK.ะ…0Zํ’F�วIฮršs]่R‡3#˜@uƒ��์rวยAŠR˜โ๊XO›ฺ ์ภOsง9ฤNxาŸ�(%YcPO’๋ž8๎D•{ถh3Pตแ๏FฬGฅ˜ล~…1…ฌ^zฝXฦ6&ฏฟ" 'Q’๗มพ@ะํAธ�D�‘ˆ2แท8U้จ‹oœใฯ8ษ:r‘฿ฯ>›ป]MHxŽœๅ-w๙หas™ฯœๆ5ท๙อqžsฟœ„ข$แ2งiœฺs็EฯนGq(I”้:๙Goซ`ง ข>u T�๊Y๏&ฑ๔ๅsข7์๚F\ŽŒโ;X‚ฺ3J!ค}�ํm;D *wบG”ํEฎ2W^vม'�„IeมKƒเรณ4&โxว#~ป๕tแเ1pnDฐ๐ผ๎ๆ |�๓Y๑H๚ั“ธF�็๏ฆ1TŽ=๓ต‡๕5rธๅˆม�!๚น๋>๘ฝ��๎ :|ไ๗žหอผํก_๑c…*้๓>S>ตฉœผ้œยi+•?อ3•(n# ฃ*jข*๊ ๊มK@!t!Rฎ'H๊ฅR*ฆPjR,B9T(|jฉ๔ฉ6bา:ดDyขจ�ุฒ Š๊AMดEรา R4,9พ Y\ิEฅcกฉ๎าก #.ขqซ1G็Sชเแd(๚๊aภ@l.`ปIแs์!tD ๚ ฏหด.Fิ๊D(@ Gม5 BษQKแ“๎เคภMภtตD‹ดฎๆด8หณ�ค( 2ซQ€แt@5@bS1bุคkจ ุœ‹QOdva5็ฑต4ยR1๕M4`ภ ์ภฬำ,ภ8` ข‚ฏm๛๑;•:ยvr@TŒว8`อ‚ฬอn์  œFlไ2!gต7?๕Vy@W#’�ฦฦW�ฌฬ�์ ภ€ม โ!#๒H—ี4u[1‚TGMำ8อำ@ ,ํCฦjกrต . ห1\K3ศ ล,lุบ :Œ ู&UE/ วฬ/4ย#A_MS5ทก5ฑr�”แ RเC2 �t‰*ฏ  $O’a#ณ&` ฺบ‘฿2`L๗ เr๖ HS#Z๒%cRd#ะRมแD%"IแV„$ฦ'‰ƒ?ปษ?ฝiœ”๖W˜Vœิ ข๖Wœv�3np–€ šŠ&ภe ดภฐr#ฎ&(฿AJAŠA*ฃ๊ก�ึขโ6A้ถข. ๏�4j(ท6—Š=<@ญ๒ด‚0๔คfJฆฆ,tq3ดq/ิBk๊oAฑไ(GU”|*,รRD5Bฉ€๊จ7ฉฎ Dƒชt7Bt™สฉ,wkหl“๒uต”ะเ *–vgu4!ส L€ktหglV !†ทu—: Dกธ �„]โhี"ชท›wŸ ๅ๊ว^แธ๕4” Ÿ”f' `’p ›ฐ{Q%6๑} จด ค ำท & $ใ 8`ซP~O”�gN มMิส ม๐#j ˜ t!๘^ ธฑ(~&`Xำ#1Dx#�J8ƒ9† T}’ Cพโpฅฦ[�  b˜QุUXsฯ'IXA*�P#ฎ&Yy-a๚p t‡™nƒแ7๑์I VE!พ6ขŠั‰้�ง ๔ |๔4F@)ฦxย€EัX9ี‹~ม๒‡7ฌl%ศŠo็–ขสท'’7]@Y$   ไ`'ฐั8เjWูั•G๒�)จB_ r7@5€—{Y>yn>—^ubF4€ศน’yHฑP4uD ๅภWJq�ภŽข*œ;‰0!ŸŸšศ}ัži›}่Ÿ‹ศŸa 3ฏ~g@5`ื6ข’เ*ธ{z8l€ก฿้์ฺๆ0บž๛$:a#zข‹G vกล๎ 7:ไzฎoฆฐ8zค‹F+อ๔4* Dภร`Zฆ'ๅไ์sฅY.จg%B:išVภŒก>$ ้ก xฺขIๅ|Xจ7Nฅำh!Žบฬ’š`ทoฦ&ฌมZญฤšl๎ฆฦxph`zเาภyrโค1Z„& ซ9Zซ�บงมข%mY ฐ[j[นJ๙{ฐ5ขฐY๔ เ nq าa ข`ˆ@;็:ฆ/z2ืU๔ฏ5๏ณร#-ๅ�šค`ฉ ชไ€วฺก๐ก†ฏFต�ต]๛`[ถkXM่ม Hcฦ`ปฬข‡h”lIดAปฺ๚q~ฎด7โ~�Z#`ฆ` ึ`ˆดVฆ †+ซ๛บณ{ปป๛ปร›d€โเ๒€Aชfฎ›ณมฆฎ…fนŸS:"๘zฆง[EJj  DIื›๊j๘^„ j"Y&๙๚ธ“(gˆZฟํฟH#บ๚ด‹ฆ๊�@ใZ€4๎ข>่๚”xฉล9|ไ๎ธ Aผพ‡์ซeๅj๖`JBฎD‹3ฅซั#“š๛ลC{ศeˆั4๛SDœVฎๆ๚e_xห“ซ5�ฎYehˆ�ๅผˆ$V‡m|Vf%Mšช:•;ห�lหญพsCบQ{xฦ�งUฎfะ\หhžฃ&žฟ๊ๆ0—vฝะ �ะ—<ัฅาอฝ'฿ร$�ัUฝ<ภ)]‡จ$๎IB=ฝำ;ยั3}=bZN&&}ิCฑe์่4.ีU�Y}ึฎ=D=ึW5Uๆำๅโพ๏I%`ื’d‚]ุฒฃ โึ!‘]?�Š}ู๏1s‘ำก}&•ฝฺฑ=�ต}�นฝ�ฝ�{=�รHžฤ��Ÿ8�ฏV,่ฬอ�ๆR‡�Oณณำฝ�๎�ห]2�้}ๆ์}�ฟ2฿๏}฿qฎ฿=:?ๅฝเพ่พwึ=เพๆ�v�฿>ฏ‰F฿a‰�)พโi.โEgโžใ��C~�^ไmฯใCไQพ๖TtXพๅ1๏ๅ?'ๆeพŽK�w6�ๆyพ็}�็>่…~่‰พ่�่‘>้•~้™พ้�้ก>๊ฅ~๊ฉพ๊ญ�๊ฑ>๋ต~๋นพ๋ˆtภ8ฆ\(& ฌแ๐fvllใMyโŒ@'ฝพใเ �๚Aศ>@:ธ๛pฒ‚`ซศ•'\ภธœ๋>ซbwŽI‚`�eeM ๊๘ Z t!_$ฟ๊Š%+ขฎpB– ฃ๒ฒbGjๆJเหข./�`Yโฆ‰_ซ`Kถ๊ฐ�`�AดžD5ำ„ก”q_๗y๒c PกยJเlเ\"ITแ… ขา�„มo?ภธค”aNbป๊~มg�ฌบ๋ปยห;]"[ฯA ฒ ธรzเ \A๚uร�?�9ใOBG3F)DE�a€ย… +ŒPๅ*D…‚€๐4‡ย ๒0๛รๅ $*iFL$&2|� 3ฆฬ™4kฺผ‰3งฮ<{๚� 4จะกD‹=Š4ฉาฅL›:} 3A๒หTรL—ฆฝ`๐ก1!6<Œ่’ซWฐb=„@&฿)}X: iขฤTสธัรกZTR`ภ@‚‡ .š้ษAขƒdจ”+[พŒ9ณๆอœ;{� :tR  ูWฎˆ 1Xสศฑ ณ"6๒ขU่๖ูด'L˜„ญ”ญxฯ๎]่๐ฦท�i ำ‚DB๕h=ป๖ํ�ป{�>ผ๘˜zเiVฎ Zwๅ{vmใi฿ว๏�ฃถsGlฺ>Ž�_ื)”ภZhา1JL‚cŽ�a„NHa…^ˆ!M\aL=ฦ�AR[]’อ 0€หXŠy0 ,0˜W%ž˜"Y€b ห,bC .ลฤ‹ดฐ# Z‰9/$ฦฆฐฐมศ๘ใ=™%b],ำญ)งย oผ๒ฮKoฝ›5ฉWยˆ )`ซP @A *่หฏฟ)Lะ€"pะT*จ`“ภงฉ( €รl @;,‘ˆ4๐$ษ?ฺหrห.ฟ sฬตพGkฏ/ฉลCอาPsQ‡ษ 4vTภQะF4S8๗์^FL' ๕—=๔ภยฮQ_uึZo�ชd<๘p%”B %i`!IฐมFŠr w�rฯMwvฐF1ิใ:฿ CL(~ศ :Pku/ฮ๘ผ6e4๐๏ำAM�๏ึ˜ผิๆ–&คฐ;ฅป3�ร8ฒ 'l�ฒ6N{ํฌ^เv๘Tp bXRภˆ Ÿ=ยs฿”๏ภ฿ู�คSz<ฅSรฦ%ษ C9DยŠ�N~๙`9(ไ+hi _5์Rสƒ:ม?�ธฝฦ�8ๅ_ซื๘เ2ะ=8ม•๑}๊ณI ฌ!8 ˆ5�\t@4มภŽฑ ๛�„#๓ซCภ†! *7"ไI˜@ค๐ คณ�้โA jุะ๎ ล(fพ Q<.ฐ†€๖ฝ/~เHฐŒ๖$ฮ�w*ึdi–!ภ๚ก‹�คˆG<�d€P�LCŠพกp‹–่ไƒ!ไ‰ก(E…0@Ya`ฮ„โE0Šั(L(† ้Ž๎!าzี†ษH&ลb่Wๆ`1L ฎ๑‡ฉภ,ฃล4Iฑˆ%ภ“Cศ4ภ‘(เb*ะ@ล1Zr’ŠLD˜%ฑตJ…ˆ@ูrฉI`&พTXภ*2H$– ๘%5u‰ช— cXh'6ฐJ:“ƒ€D`$ Ÿtƒ(%#•‘3‡แ"EFเพใP๓—เ$ุ˜ยl.Db@ZDฐNเ •Zฉภเ}„–อผคL() คภ�T@7ฟINTR�ฃญ@ฟZฒฮ‰ t!่:Lวร˜�๐tฆs„8(ษœ๊ิ' ๐ƒ6าQnผ[ ธ8�17Hfศh‡S๑Q_`ฃธฦ;:‡tเ�๘‡0rpXzภทจVOต�a\๕Hชdx‰›K่CAชˆJ‹t€pหN๐kXkYa"3/AqไjWCC.สลท e"lเฑ–๕ฌ+eŸ1(Œwaาง@*ถ>`ˆj๘ฃสXSฺŽzเใุj^หีืด�%Hซ^\‚;สๆ๊ฒZAํ6�q‹k4& 0„1|”ฤะฃ \๕*X“8‚Zด๘�๘‡<~q0นาีด ˜ุUeธฃŸลGhทิS๔ช—ฝ�@YJp [p๎% ฤ7โกRธใคPG้ �Cu˜ฎ�ูฉƒผืœCHH…>ภะ€ขˆร ฎม˜ไ)Y‰YXแ‹7Œ8`ศAE.’๘ตฯc(˜a†๛ุDฎฐF์R‹Vผ<๘€„N์ร งAพ<`#ฤ;ถ!ž ,๓f—pœไไ;ผ\ รงx0)7-ม๚h„C]Acใ8ล]žเŠ�Xัฎ๐…1ฐจ„d$% ยIJฬ’ฉ>9สSๆมD’๓ ‡�V!iNฤ�š�< lธรR!N'u,€่ม=า@‹`ไ:ฌY„ (ก\9r’—,Zf๒Y Y่D?ย�9ฯว‘0ฏ}† „ AศL: `Ÿแิ609ฆ=ํoธR0„ฟ ๎ฆฝ€+ธG"*ƒmย.7ส†S„€…+< 0`ํ‹€d<WŒยะ7จ"pŒRผ`ธ‡;ื=—ˆ�ๆ– ?์ข:ฤ<คP4#–ห3š@ŠNn;dภ„9|เ€c‚'…1F๐|฿-l"(@Ÿธz…โ‰า` s&1ฦ1‘L.�ฎs\Dfศ\๓™ื�'่ม6่ &€-ภๆม„-คc Q˜šX๕&^€7ุว ืLผ�‰ˆ�aื–ฑ”%่E|[�—นิ%R1!Aดใ1ŒUฌbึใก"๑ r๘L(oธ?ฏS2<Š ์Ni6ุใ๐ซู5 Dญ T‚คžOฬžJ5hr0”C€8‘q6zE| ‡ะฦ; *U ทH*ดะ‚๋สg๑AA๚r‘ Z5ธF=ฬ๊|”cศฝจจTœI๏9#ษ๏˜ใ่HG!ฎWˆข˜๏|ะV$ VGxๅw�~ไข~(pziภ3tDv1az๔เ‹€ {0๐ะ`qง0จ‡๏Q85๐ฑ๒qu๛ฃผaพย๑9ะฦC‡Dƒ5ศmฬz9่`ศwคgz๛ ฉทz๐ำ ึqฑปื?p{ฐ‡Ohก„ๅ๗ม7|CVEศ๗ค‡n๊–7" 7e%พ—@oO(J?Hข$‰„Jอฐ ๗้๑Vผฤ„"ุŒŠ0'ฐ ‚ฐrณ0 ๖ $Xx_ฑฤ@ #n‡&@้„pŸ๐IG'…€Hธำ"/#์Hz๑$Q2%Ur%Y๒Ž๑˜JHi• YยIA2 CR$ ‰๔–n‚)ะ‰0ั#pฐ มะต ถ@g’’$&pEp$ฬx‘ฒH"&‚"*‚’.R$2•57’#;า�#$ุRลภaษ/uyŽ@y๊ภhษ˜๐ส่–‘ฤ”ŠP wโa ถ„ขbrp)8—OIˆ�H)–๒แˆ ๔๐&†ะf๊ธŽ๔ืŽ8ษ ห๐\0?ญD{_pwP)#w3|ถ]€l#0t€ง0c`Nภrl ถPcB D$!’3q`'xข'q)šย)ž* ก—ฅ๒๏X™—™™ืฑc๛s๐œค(Œโ(2)�` 5v›0tะ™๐U@Žs †b„ƒy)๖G…ษPcมi‘ฑธ}]ึ'rB'พy'yฒ'๚๙e2�(…‚D1G๐ ์เ J ค๐ิ๖jกโ *€Œoษกtณฉ -P'ะ ™ †=uTใ0้จ.์โ-pผ € ™@L -�โ-เโ ผ !@ฃ6๚W‡้ �ะrคŽข#Zขa๕+๏๏  €,w  ๒เWwQ~ Z้ต^œิŽๅAล&\อT\เ, ผP|AJLฅยL*ข$jข .ไb.่‚I๋า.งฅ@Uฅุข-ฉ/:ิb-ฆ™EYไ2\.{ฆ�0๛ต‹กฉEะ@i:9ฺ.,ะaใ",- �งNjขๅU,‹0พ,xZซว’,หา,ฯ-2ั)เโภ � ิะ/ ิfฌฦ๚ คฐ�ุกี*7)5J&%’‘I* Mฦw2ัด๐˜ข`…ฃำ1"0ฎ�ขLฐฃ P๗0'[ยKุบฺสญ  ฟฎๆค0[ฦตrุ2—"eM€L3ฎๆD ฎ™d0�ชญม2##MZ•D(๓*“I�”-วิฎวัญ)ฐ D MโdQ/ั ้$K&`รฎ*ำ› –ค�/~วe๛‚ฒ�T0แฅด ร01 ฐI311” Y๛ฺ ฯเœะตY‹ฌฯpซaญeK;[๒tŸCd/!šk †•WPS ฃ`M@ญ •ถ ฑe?ใ~ซ–ƒฑ?ม4h ธ€[ƒห3ะc3DaธŸcO�ลeฤ;รธŽ้ฐตY๛ฌœ[ ฒ ใcถฃIn;ฆ+ `๏ะ้0 f€Šค�‘ห&4#ฎb@ œหปโะ>#ฒ+ผรห_9Jผ๔‚้Dfภ|Pf )ภ;ษkฝื‹ฝ/#๓</ผู+พใKพส{นๅ‹พญ!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,z q5Hมƒ "จpaB‡.@‚ x€๓ฤ†ƒ>€๒GซŒˆ)>Hะ€ฦฃ|เ2Q)ฐโ4๛ส1มกf<อส5baใย+ฦ๊๛ร‚D‡„nข"…ำˆ!8|ฬศก็Dˆ!!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,z *� 8มƒTธแฟ„ !*”ˆะแC‹ flฃG‡†|!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,z 5Hฐ มƒ*\ศฐกร‡#JL!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,z *� 8มƒTธแฟ„ !*”ˆะแC‹ flฃG‡†|!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,z 5Hฐ มƒ*\ศฐกร‡#JL!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,z *� 8มƒTธแฟ„ !*”ˆะแC‹ flฃG‡†|!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,#‹!๙ ,z 5Hฐ มƒ*\ศฐกร‡#JL!๙ ,#‹!๙ ,#‹!๙ ,x L1@Hฐ „ไษ๛ทฐ!ร �  ขลˆ%^ผจQใฦŠ!~ฤHrdศ‘$=~„่ฐaEŠ!^ (hcร‡/Fฬ(๒ขFŽ%rœ8า"K“6†LY1ไK’7ฦD้r%ร–ย”ษ&H•{&�IดcO›BqFีูิจฯฅHžœ๙4ซษ’wr +Xช<ษz•ŠUlัฎgƒถญ 7ง\ญnชปํX•eต๒อk5๐Wป[฿๎E<˜๎โฉw;pipx-1.18.16/pylock.toml000066400000000000000000003752161527355705200150770ustar00rootroot00000000000000lock-version = "1.0" environments = [ "python_version == \"3.15\" and sys_platform == \"linux\" and platform_machine == \"x86_64\" and platform_python_implementation == \"CPython\"", "python_version == \"3.15\" and sys_platform == \"linux\" and platform_machine == \"aarch64\" and platform_python_implementation == \"CPython\"", "python_version == \"3.15\" and sys_platform == \"darwin\" and platform_machine == \"arm64\" and platform_python_implementation == \"CPython\"", "python_version == \"3.15\" and sys_platform == \"win32\" and platform_machine == \"AMD64\" and platform_python_implementation == \"CPython\"", ] requires-python = ">=3.11" extras = [ "uv", ] dependency-groups = [ "build", "dev", "docs", "docs-live", "lint", "man", "project-runtime", "release", "release-news", "release-notes", "release-prepare", "test", "type", "zipapp", ] default-groups = [ "project-runtime", ] created-by = "nab" [[packages]] name = "accessible-pygments" version = "0.0.5" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "pygments" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "accessible_pygments-0.0.5.tar.gz" upload-time = 2024-05-10 11:23:10.216775+00:00 url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz" size = 1377899 [packages.sdist.hashes] sha256 = "40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872" [[packages.wheels]] name = "accessible_pygments-0.0.5-py3-none-any.whl" upload-time = 2024-05-10 11:23:08.421378+00:00 url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl" size = 1395903 [packages.wheels.hashes] sha256 = "88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7" [[packages]] name = "alabaster" version = "1.0.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "alabaster-1.0.0.tar.gz" upload-time = 2024-07-26 18:15:03.762569+00:00 url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz" size = 24210 [packages.sdist.hashes] sha256 = "c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e" [[packages.wheels]] name = "alabaster-1.0.0-py3-none-any.whl" upload-time = 2024-07-26 18:15:02.050654+00:00 url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl" size = 13929 [packages.wheels.hashes] sha256 = "fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b" [[packages]] name = "anyio" version = "4.15.1" marker = "\"dev\" in dependency_groups or \"docs-live\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "idna" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "anyio-4.15.1.tar.gz" upload-time = 2026-09-05 10:42:39.440376+00:00 url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz" size = 276966 [packages.sdist.hashes] sha256 = "9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94" [[packages.wheels]] name = "anyio-4.15.1-py3-none-any.whl" upload-time = 2026-09-05 10:42:37.923487+00:00 url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl" size = 132079 [packages.wheels.hashes] sha256 = "6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101" [[packages]] name = "argcomplete" version = "3.7.2" marker = "\"project-runtime\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "argcomplete-3.7.2.tar.gz" upload-time = 2026-08-06 04:53:21.662042+00:00 url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz" size = 74473 [packages.sdist.hashes] sha256 = "aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9" [[packages.wheels]] name = "argcomplete-3.7.2-py3-none-any.whl" upload-time = 2026-08-06 04:53:20.246664+00:00 url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl" size = 43294 [packages.wheels.hashes] sha256 = "6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19" [[packages]] name = "babel" version = "2.18.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "babel-2.18.0.tar.gz" upload-time = 2026-02-01 12:30:56.078194+00:00 url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz" size = 9959554 [packages.sdist.hashes] sha256 = "b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d" [[packages.wheels]] name = "babel-2.18.0-py3-none-any.whl" upload-time = 2026-02-01 12:30:53.445172+00:00 url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl" size = 10196845 [packages.wheels.hashes] sha256 = "e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35" [[packages]] name = "beautifulsoup4" version = "4.15.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.7.0" dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "beautifulsoup4-4.15.0.tar.gz" upload-time = 2026-06-07 16:44:20.453113+00:00 url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz" size = 632571 [packages.sdist.hashes] sha256 = "288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7" [[packages.wheels]] name = "beautifulsoup4-4.15.0-py3-none-any.whl" upload-time = 2026-06-07 16:44:21.566528+00:00 url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl" size = 109924 [packages.wheels.hashes] sha256 = "d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9" [[packages]] name = "certifi" version = "2026.10.23" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.7" index = "https://pypi.org/simple/" [packages.sdist] name = "certifi-2026.10.23.tar.gz" upload-time = 2026-10-23 11:36:16.478386+00:00 url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz" size = 136225 [packages.sdist.hashes] sha256 = "a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd" [[packages.wheels]] name = "certifi-2026.10.23-py3-none-any.whl" upload-time = 2026-10-23 11:36:14.763067+00:00 url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl" size = 135082 [packages.wheels.hashes] sha256 = "e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1" [[packages]] name = "cfgv" version = "3.5.0" marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "cfgv-3.5.0.tar.gz" upload-time = 2025-11-19 20:55:51.612035+00:00 url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz" size = 7334 [packages.sdist.hashes] sha256 = "d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132" [[packages.wheels]] name = "cfgv-3.5.0-py2.py3-none-any.whl" upload-time = 2025-11-19 20:55:50.744331+00:00 url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl" size = 7445 [packages.wheels.hashes] sha256 = "a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0" [[packages]] name = "charset-normalizer" version = "3.5.2" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.7" index = "https://pypi.org/simple/" [packages.sdist] name = "charset_normalizer-3.5.2.tar.gz" upload-time = 2026-09-30 04:39:23.398219+00:00 url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz" size = 172659 [packages.sdist.hashes] sha256 = "39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef" [[packages.wheels]] name = "charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl" upload-time = 2026-09-30 04:37:28.329851+00:00 url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl" size = 367676 [packages.wheels.hashes] sha256 = "87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26" [[packages.wheels]] name = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" upload-time = 2026-09-30 04:37:30.167187+00:00 url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" size = 248414 [packages.wheels.hashes] sha256 = "9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364" [[packages.wheels]] name = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" upload-time = 2026-09-30 04:37:37.397915+00:00 url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" size = 257336 [packages.wheels.hashes] sha256 = "3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229" [[packages.wheels]] name = "charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl" upload-time = 2026-09-30 04:37:53.427053+00:00 url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl" size = 210858 [packages.wheels.hashes] sha256 = "89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8" [[packages.wheels]] name = "charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl" upload-time = 2026-09-30 04:38:26.216534+00:00 url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl" size = 331815 [packages.wheels.hashes] sha256 = "4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd" [[packages.wheels]] name = "charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" upload-time = 2026-09-30 04:38:28.032254+00:00 url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" size = 253276 [packages.wheels.hashes] sha256 = "1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7" [[packages.wheels]] name = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" upload-time = 2026-09-30 04:38:29.732000+00:00 url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" size = 241239 [packages.wheels.hashes] sha256 = "d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f" [[packages.wheels]] name = "charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl" upload-time = 2026-09-30 04:38:49.707190+00:00 url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl" size = 200092 [packages.wheels.hashes] sha256 = "9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036" [[packages.wheels]] name = "charset_normalizer-3.5.2-py3-none-any.whl" upload-time = 2026-09-30 04:39:21.828049+00:00 url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl" size = 68872 [packages.wheels.hashes] sha256 = "b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685" [[packages]] name = "click" version = "8.5.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"project-runtime\" in dependency_groups or \"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"type\" in dependency_groups or \"zipapp\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "click-8.5.0.tar.gz" upload-time = 2026-08-26 13:33:14.560531+00:00 url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz" size = 382235 [packages.sdist.hashes] sha256 = "ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34" [[packages.wheels]] name = "click-8.5.0-py3-none-any.whl" upload-time = 2026-08-26 13:33:12.928043+00:00 url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl" size = 125251 [packages.wheels.hashes] sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" [[packages]] name = "colorama" version = "0.4.6" marker = "\"dev\" in dependency_groups or (\"docs\" in dependency_groups and sys_platform == \"win32\") or \"docs-live\" in dependency_groups or (\"project-runtime\" in dependency_groups and sys_platform == \"win32\") or (\"test\" in dependency_groups and sys_platform == \"win32\") or (\"type\" in dependency_groups and sys_platform == \"win32\")" requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" index = "https://pypi.org/simple/" [packages.sdist] name = "colorama-0.4.6.tar.gz" upload-time = 2022-10-25 02:36:22.414799+00:00 url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz" size = 27697 [packages.sdist.hashes] sha256 = "08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" [[packages.wheels]] name = "colorama-0.4.6-py2.py3-none-any.whl" upload-time = 2022-10-25 02:36:20.889702+00:00 url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl" size = 25335 [packages.wheels.hashes] sha256 = "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" [[packages]] name = "coverage" version = "7.17.0" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.11" index = "https://pypi.org/simple/" [packages.sdist] name = "coverage-7.17.0.tar.gz" upload-time = 2026-10-22 16:06:50.009670+00:00 url = "https://files.pythonhosted.org/packages/d2/79/e78bd8f75a9b8e32233ce92d4607f0a21ce88a9156cace2884e7175a82f5/coverage-7.17.0.tar.gz" size = 991021 [packages.sdist.hashes] sha256 = "cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712" [[packages.wheels]] name = "coverage-7.17.0-cp315-cp315-macosx_11_0_arm64.whl" upload-time = 2026-10-22 16:06:06.167385+00:00 url = "https://files.pythonhosted.org/packages/87/db/94697b9ccf4ec94cdad07bf94e2a21b9702247cb212d2d5f18d2b9c3d0de/coverage-7.17.0-cp315-cp315-macosx_11_0_arm64.whl" size = 226193 [packages.wheels.hashes] sha256 = "66efc0fb99cee34626fc17d3adac9c3cd816bfdad563bb1e983c3ef432f566d4" [[packages.wheels]] name = "coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" upload-time = 2026-10-22 16:06:08.861264+00:00 url = "https://files.pythonhosted.org/packages/95/ee/84ed138990ed752abd73db4adffc1842d6152017d8e21809cc109a413fda/coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" size = 261735 [packages.wheels.hashes] sha256 = "aed06129a9f530a948928260d7d2d81c47261355b0127e3c782251b38958463c" [[packages.wheels]] name = "coverage-7.17.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" upload-time = 2026-10-22 16:06:10.490964+00:00 url = "https://files.pythonhosted.org/packages/64/ad/c1e4367bbd1e7d562d6bc979c82b542f2040544e95a8cd995b694caf0e1a/coverage-7.17.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" size = 262819 [packages.wheels.hashes] sha256 = "f6cdb47a9be2d3313829bed1d296086287682a3ee901ff987b3432b45705b771" [[packages.wheels]] name = "coverage-7.17.0-cp315-cp315-win_amd64.whl" upload-time = 2026-10-22 16:06:23.756207+00:00 url = "https://files.pythonhosted.org/packages/43/14/283120142ac281834808584249b89957de997c28573ddd65ae106b622121/coverage-7.17.0-cp315-cp315-win_amd64.whl" size = 228532 [packages.wheels.hashes] sha256 = "79cf71e5f9a48877ccfed5baa2c72f45af4feeaa596249e23eb15b473134830e" [[packages.wheels]] name = "coverage-7.17.0-py3-none-any.whl" upload-time = 2026-10-22 16:06:48.471847+00:00 url = "https://files.pythonhosted.org/packages/c9/17/48032919343afa2906112d458aceda2043afdb24a2ee8eb4c60904d8ddf1/coverage-7.17.0-py3-none-any.whl" size = 217383 [packages.wheels.hashes] sha256 = "bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d" [[packages]] name = "cython" version = "3.3.0" marker = "\"build\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "cython-3.3.0.tar.gz" upload-time = 2026-08-22 05:16:39.493040+00:00 url = "https://files.pythonhosted.org/packages/a9/d8/4981ef716ad0e3ff0d3ef383aefc6b03c4a88dee33b272bf8e0d833001ca/cython-3.3.0.tar.gz" size = 3727515 [packages.sdist.hashes] sha256 = "eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8" [[packages.wheels]] name = "cython-3.3.0-cp315-cp315-macosx_11_0_arm64.whl" upload-time = 2026-08-22 05:17:32.523944+00:00 url = "https://files.pythonhosted.org/packages/ea/45/7f6988070013e16918e39b1b3dab9c5f2c8e404253a7fd10ee685bbd6902/cython-3.3.0-cp315-cp315-macosx_11_0_arm64.whl" size = 3177681 [packages.wheels.hashes] sha256 = "596e8df019372a2cd417805015022d42cb8ee4e1803ccdc11ed00e451625fb66" [[packages.wheels]] name = "cython-3.3.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" upload-time = 2026-08-22 05:17:34.511844+00:00 url = "https://files.pythonhosted.org/packages/6e/5d/afb6866ab10236bb208dff0f172ea4b397c9693c5250280c4d9d26057218/cython-3.3.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" size = 3328312 [packages.wheels.hashes] sha256 = "4a36c34d1950845b8ac148653b07cdc62421a4b0d9abfcc849e69f1c4ff9919d" [[packages.wheels]] name = "cython-3.3.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" upload-time = 2026-08-22 05:17:36.538454+00:00 url = "https://files.pythonhosted.org/packages/c9/aa/4c0b6773ecf6bc1ec6cda7db8312a566611b330fb6dae87d740e44a47822/cython-3.3.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" size = 3454285 [packages.wheels.hashes] sha256 = "b447f6906e0555f05dc4742ef1f99091b1e5d9aa9f16616e772fbf9ff6271616" [[packages.wheels]] name = "cython-3.3.0-cp315-cp315-win_amd64.whl" upload-time = 2026-08-22 05:17:38.650619+00:00 url = "https://files.pythonhosted.org/packages/44/bb/3e2631122f96300723d6fc42b9cf65550bdcda570a6ad5c4e0226e2e787c/cython-3.3.0-cp315-cp315-win_amd64.whl" size = 2924709 [packages.wheels.hashes] sha256 = "b55c72e8eccdd508c8de3cf3bbc543aafbb3bf6a518e1ee20358d3241cd780ef" [[packages.wheels]] name = "cython-3.3.0-py3-none-any.whl" upload-time = 2026-08-22 05:16:36.741632+00:00 url = "https://files.pythonhosted.org/packages/bf/77/67b0b24e45073a699610e50f00c18474ff9b09ea29ecc95083bdf5e60acd/cython-3.3.0-py3-none-any.whl" size = 1349151 [packages.wheels.hashes] sha256 = "9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1" [[packages]] name = "defusedxml" version = "0.7.1" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" index = "https://pypi.org/simple/" [packages.sdist] name = "defusedxml-0.7.1.tar.gz" upload-time = 2021-03-08 10:59:26.269532+00:00 url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz" size = 75520 [packages.sdist.hashes] sha256 = "1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69" [[packages.wheels]] name = "defusedxml-0.7.1-py2.py3-none-any.whl" upload-time = 2021-03-08 10:59:24.450195+00:00 url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl" size = 25604 [packages.wheels.hashes] sha256 = "a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" [[packages]] name = "distlib" version = "0.4.4" marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] name = "distlib-0.4.4.tar.gz" upload-time = 2026-10-16 13:56:34.203008+00:00 url = "https://files.pythonhosted.org/packages/68/bc/d36966de7304047a1001a7559fbf266c464bb5d8cc85464536ef8c9bcc32/distlib-0.4.4.tar.gz" size = 621304 [packages.sdist.hashes] sha256 = "ca4175b4ac7f71223cc30ed7248c45d2b38c2439d8ee6e498ab67ed63cf3508d" [[packages.wheels]] name = "distlib-0.4.4-py2.py3-none-any.whl" upload-time = 2026-10-16 13:56:32.053304+00:00 url = "https://files.pythonhosted.org/packages/c1/2b/238f02a4927fa2e27aa995371d240a468b52e42eae44a678434aff736b38/distlib-0.4.4-py2.py3-none-any.whl" size = 472099 [packages.wheels.hashes] sha256 = "cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638" [[packages]] name = "docutils" version = "0.23" marker = "\"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"man\" in dependency_groups or \"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "docutils-0.23.tar.gz" upload-time = 2026-05-27 17:41:06.934109+00:00 url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz" size = 2303823 [packages.sdist.hashes] sha256 = "746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e" [[packages.wheels]] name = "docutils-0.23-py3-none-any.whl" upload-time = 2026-05-27 17:40:58.442528+00:00 url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl" size = 634701 [packages.wheels.hashes] sha256 = "25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea" [[packages]] name = "execnet" version = "2.1.2" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "execnet-2.1.2.tar.gz" upload-time = 2025-11-12 09:56:37.750774+00:00 url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz" size = 166622 [packages.sdist.hashes] sha256 = "63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd" [[packages.wheels]] name = "execnet-2.1.2-py3-none-any.whl" upload-time = 2025-11-12 09:56:36.333212+00:00 url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl" size = 40708 [packages.wheels.hashes] sha256 = "67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec" [[packages]] name = "filelock" version = "4.2.4" marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups or \"project-runtime\" in dependency_groups" requires-python = ">=3.11" index = "https://pypi.org/simple/" [packages.sdist] name = "filelock-4.2.4.tar.gz" upload-time = 2026-10-19 01:57:51.001795+00:00 url = "https://files.pythonhosted.org/packages/d8/28/3893324bcadf8f89b1616637468d766af3f625008c0735bf704fdfa86617/filelock-4.2.4.tar.gz" size = 652444 [packages.sdist.hashes] sha256 = "c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d" [[packages.wheels]] name = "filelock-4.2.4-py3-none-any.whl" upload-time = 2026-10-19 01:57:49.419460+00:00 url = "https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl" size = 162475 [packages.wheels.hashes] sha256 = "301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e" [[packages]] name = "flit-core" version = "4.2.0" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "flit_core-4.2.0.tar.gz" upload-time = 2026-10-27 21:07:48.907048+00:00 url = "https://files.pythonhosted.org/packages/d3/8b/0370578a456986d6995344967a479dd5c9d4e064681550c2a9594bb58b4a/flit_core-4.2.0.tar.gz" size = 59707 [packages.sdist.hashes] sha256 = "84e4e4d8fdd45ba2613800a3ebc6834de26d47ec9746efc3719f94181137b460" [[packages.wheels]] name = "flit_core-4.2.0-py3-none-any.whl" upload-time = 2026-10-27 21:07:46.392085+00:00 url = "https://files.pythonhosted.org/packages/d9/f0/e546c28b937f094b354ca29b9060c5f1dd5043a332b2905049a51420b7a2/flit_core-4.2.0-py3-none-any.whl" size = 48025 [packages.wheels.hashes] sha256 = "2bee22dc8ba89c2e35b4e1ad575e596aac945c85672dd8f3cad21b629036e044" [[packages]] name = "furo" version = "2025.12.19" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.8" dependencies = [ { name = "accessible-pygments" }, { name = "beautifulsoup4" }, { name = "pygments" }, { name = "sphinx" }, { name = "sphinx-basic-ng" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "furo-2025.12.19.tar.gz" upload-time = 2025-12-19 17:34:40.889109+00:00 url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz" size = 1661473 [packages.sdist.hashes] sha256 = "188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7" [[packages.wheels]] name = "furo-2025.12.19-py3-none-any.whl" upload-time = 2025-12-19 17:34:38.905692+00:00 url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl" size = 339262 [packages.wheels.hashes] sha256 = "bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f" [[packages]] name = "gitdb" version = "4.0.12" marker = "\"dev\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "smmap" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "gitdb-4.0.12.tar.gz" upload-time = 2025-01-02 07:20:46.413248+00:00 url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz" size = 394684 [packages.sdist.hashes] sha256 = "5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571" [[packages.wheels]] name = "gitdb-4.0.12-py3-none-any.whl" upload-time = 2025-01-02 07:20:43.624964+00:00 url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl" size = 62794 [packages.wheels.hashes] sha256 = "67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf" [[packages]] name = "gitpython" version = "3.2.1" marker = "\"dev\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.8" dependencies = [ { name = "gitdb" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "gitpython-3.2.1.tar.gz" upload-time = 2026-10-14 12:20:20.589439+00:00 url = "https://files.pythonhosted.org/packages/c0/ff/fee293f1ec3e1b64db6eb270e00964f29c086d769201474cedb8d1608930/gitpython-3.2.1.tar.gz" size = 241688 [packages.sdist.hashes] sha256 = "0f1229ec5c057bdabdeeda8bda4f17bda679ab33dbccc00f24e6a480553c8613" [[packages.wheels]] name = "gitpython-3.2.1-py3-none-any.whl" upload-time = 2026-10-14 12:20:18.844050+00:00 url = "https://files.pythonhosted.org/packages/6b/50/2a923c4dc02522ff9567f07c67ed1deca6d5fa9474f4708168b8088dfa37/gitpython-3.2.1-py3-none-any.whl" size = 231548 [packages.wheels.hashes] sha256 = "d66b0832aa8755c1a84892937282df0befb16c97e5a64aeebd68be9c5e323f53" [[packages]] name = "h11" version = "0.16.0" marker = "\"dev\" in dependency_groups or \"docs-live\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "h11-0.16.0.tar.gz" upload-time = 2025-04-24 03:35:25.427659+00:00 url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz" size = 101250 [packages.sdist.hashes] sha256 = "4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" [[packages.wheels]] name = "h11-0.16.0-py3-none-any.whl" upload-time = 2025-04-24 03:35:24.344199+00:00 url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl" size = 37515 [packages.wheels.hashes] sha256 = "63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" [[packages]] name = "hatch-fancy-pypi-readme" version = "25.1.0" marker = "\"build\" in dependency_groups" requires-python = ">=3.8" dependencies = [ { name = "hatchling" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "hatch_fancy_pypi_readme-25.1.0.tar.gz" upload-time = 2025-05-01 10:34:42.989334+00:00 url = "https://files.pythonhosted.org/packages/f3/0f/aed57c301f339936eb91cb4d8c1e5088a101081854bd3ec18a889df32365/hatch_fancy_pypi_readme-25.1.0.tar.gz" size = 29701 [packages.sdist.hashes] sha256 = "9c58ed3dff90d51f43414ce37009ad1d5b0f08ffc9fc216998a06380f01c0045" [[packages.wheels]] name = "hatch_fancy_pypi_readme-25.1.0-py3-none-any.whl" upload-time = 2025-05-01 10:34:41.204793+00:00 url = "https://files.pythonhosted.org/packages/58/b4/ad71e051b34f1935783de37beffee16e343ff2514b204dc515f9e939d4af/hatch_fancy_pypi_readme-25.1.0-py3-none-any.whl" size = 10046 [packages.wheels.hashes] sha256 = "ce0134c40d63d874ac48f48ccc678b8f3b62b8e50e9318520d2bffc752eedaf3" [[packages]] name = "hatch-vcs" version = "0.5.0" marker = "\"build\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "hatchling" }, { name = "setuptools-scm" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "hatch_vcs-0.5.0.tar.gz" upload-time = 2025-05-27 05:16:04.490926+00:00 url = "https://files.pythonhosted.org/packages/6b/b0/4cc743d38adbee9d57d786fa496ed1daadb17e48589b6da8fa55717a0746/hatch_vcs-0.5.0.tar.gz" size = 11424 [packages.sdist.hashes] sha256 = "0395fa126940340215090c344a2bf4e2a77bcbe7daab16f41b37b98c95809ff9" [[packages.wheels]] name = "hatch_vcs-0.5.0-py3-none-any.whl" upload-time = 2025-05-27 05:16:03.184909+00:00 url = "https://files.pythonhosted.org/packages/5f/48/1f85cee4b7b4f40b9b814b1febbc661bda6ced9649e410a0b74f6e415dd0/hatch_vcs-0.5.0-py3-none-any.whl" size = 8507 [packages.wheels.hashes] sha256 = "b49677dbdc597460cc22d01b27ab3696f5e16a21ecf2700fb01bc28e1f2a04a7" [[packages]] name = "hatchling" version = "1.32.4" marker = "\"build\" in dependency_groups or \"dev\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "packaging" }, { name = "pathspec" }, { name = "pluggy" }, { name = "tomlkit" }, { name = "trove-classifiers" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "hatchling-1.32.4.tar.gz" upload-time = 2026-09-20 22:48:45.398505+00:00 url = "https://files.pythonhosted.org/packages/f6/97/b5312f01a8c6daf729a9d272dd442e0c546dbcc630495788786c4b567ed0/hatchling-1.32.4.tar.gz" size = 58065 [packages.sdist.hashes] sha256 = "c4468f73144c054d2aab4ef0f0378c43b9878bf07f8ffd6b79690e970d375f07" [[packages.wheels]] name = "hatchling-1.32.4-py3-none-any.whl" upload-time = 2026-09-20 22:48:44.190681+00:00 url = "https://files.pythonhosted.org/packages/5f/80/91f51f439c05d4ec4623c22928ce16a938d6d793bf709477830823497859/hatchling-1.32.4-py3-none-any.whl" size = 78748 [packages.wheels.hashes] sha256 = "08ecf7548fb48205e7f213d70c71e67b8271b7242093dc3f1da578b42c734a2c" [[packages]] name = "identify" version = "2.6.20" marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "identify-2.6.20.tar.gz" upload-time = 2026-09-26 20:29:07.187608+00:00 url = "https://files.pythonhosted.org/packages/53/35/d70c0006c7cee65999ea94a6273e60b2094f600a3d8b71b04318253fc643/identify-2.6.20.tar.gz" size = 99681 [packages.sdist.hashes] sha256 = "ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285" [[packages.wheels]] name = "identify-2.6.20-py2.py3-none-any.whl" upload-time = 2026-09-26 20:29:06.054092+00:00 url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl" size = 99506 [packages.wheels.hashes] sha256 = "6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa" [[packages]] name = "idna" version = "3.20" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "idna-3.20.tar.gz" upload-time = 2026-09-17 14:11:04.752193+00:00 url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz" size = 216463 [packages.sdist.hashes] sha256 = "a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44" [[packages.wheels]] name = "idna-3.20-py3-none-any.whl" upload-time = 2026-09-17 14:11:03.168153+00:00 url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl" size = 69583 [packages.wheels.hashes] sha256 = "ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c" [[packages]] name = "imagesize" version = "2.0.1" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "imagesize-2.0.1.tar.gz" upload-time = 2026-08-24 12:35:19.130667+00:00 url = "https://files.pythonhosted.org/packages/fb/5e/513ff06670c84e7b9887c1fdf61b2d42b4f574a831f2f1d2222023049d8a/imagesize-2.0.1.tar.gz" size = 1883774 [packages.sdist.hashes] sha256 = "b2ba6a4dea487a7ebcd53248d3476aca449d30db12a2dde5e0c5ca9624fd77e5" [[packages.wheels]] name = "imagesize-2.0.1-py3-none-any.whl" upload-time = 2026-08-24 12:35:12.548956+00:00 url = "https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl" size = 14794 [packages.wheels.hashes] sha256 = "ea0c9a0384df69ed86a943a15cde37d0360b82491b3910dc2215e202e62b5b02" [[packages]] name = "iniconfig" version = "2.3.1" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "iniconfig-2.3.1.tar.gz" upload-time = 2026-10-06 22:48:38.076390+00:00 url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz" size = 21209 [packages.sdist.hashes] sha256 = "67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960" [[packages.wheels]] name = "iniconfig-2.3.1-py3-none-any.whl" upload-time = 2026-10-06 22:48:36.959685+00:00 url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl" size = 7552 [packages.wheels.hashes] sha256 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" [[packages]] name = "jinja2" version = "3.1.6" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "markupsafe" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "jinja2-3.1.6.tar.gz" upload-time = 2025-03-05 20:05:02.478302+00:00 url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz" size = 245115 [packages.sdist.hashes] sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" [[packages.wheels]] name = "jinja2-3.1.6-py3-none-any.whl" upload-time = 2025-03-05 20:05:00.369721+00:00 url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl" size = 134899 [packages.wheels.hashes] sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" [[packages]] name = "legacy-cgi" version = "2.6.4" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "legacy_cgi-2.6.4.tar.gz" upload-time = 2025-10-27 05:20:05.395452+00:00 url = "https://files.pythonhosted.org/packages/f4/9c/91c7d2c5ebbdf0a1a510bfa0ddeaa2fbb5b78677df5ac0a0aa51cf7125b0/legacy_cgi-2.6.4.tar.gz" size = 24603 [packages.sdist.hashes] sha256 = "abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577" [[packages.wheels]] name = "legacy_cgi-2.6.4-py3-none-any.whl" upload-time = 2025-10-27 05:20:04.289906+00:00 url = "https://files.pythonhosted.org/packages/8c/7e/e7394eeb49a41cc514b3eb49020223666cbf40d86f5721c2f07871e6d84a/legacy_cgi-2.6.4-py3-none-any.whl" size = 20035 [packages.wheels.hashes] sha256 = "7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd" [[packages]] name = "markdown-it-py" version = "4.2.0" marker = "\"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "mdurl" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "markdown_it_py-4.2.0.tar.gz" upload-time = 2026-05-07 12:08:28.360612+00:00 url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz" size = 82454 [packages.sdist.hashes] sha256 = "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" [[packages.wheels]] name = "markdown_it_py-4.2.0-py3-none-any.whl" upload-time = 2026-05-07 12:08:27.182789+00:00 url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl" size = 91687 [packages.wheels.hashes] sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" [[packages]] name = "markupsafe" version = "3.0.4" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "markupsafe-3.0.4.tar.gz" upload-time = 2026-10-02 23:07:22.290270+00:00 url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz" size = 153777 [packages.sdist.hashes] sha256 = "2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6" [[packages.wheels]] name = "markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl" upload-time = 2026-10-02 23:06:17.855823+00:00 url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl" size = 12075 [packages.wheels.hashes] sha256 = "ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97" [[packages.wheels]] name = "markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" upload-time = 2026-10-02 23:06:18.963942+00:00 url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" size = 24656 [packages.wheels.hashes] sha256 = "4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b" [[packages.wheels]] name = "markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" upload-time = 2026-10-02 23:06:22.524416+00:00 url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" size = 23111 [packages.wheels.hashes] sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" [[packages.wheels]] name = "markupsafe-3.0.4-cp315-cp315-win_amd64.whl" upload-time = 2026-10-02 23:06:33.258900+00:00 url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl" size = 14496 [packages.wheels.hashes] sha256 = "d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214" [[packages]] name = "mdformat" version = "1.0.0" marker = "\"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "markdown-it-py" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "mdformat-1.0.0.tar.gz" upload-time = 2025-10-16 12:05:03.695033+00:00 url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz" size = 56953 [packages.sdist.hashes] sha256 = "4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928" [[packages.wheels]] name = "mdformat-1.0.0-py3-none-any.whl" upload-time = 2025-10-16 12:05:02.607432+00:00 url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl" size = 53288 [packages.wheels.hashes] sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" [[packages]] name = "mdformat-gfm" version = "1.0.0" marker = "\"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "markdown-it-py" }, { name = "mdformat" }, { name = "mdit-py-plugins" }, { name = "wcwidth" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "mdformat_gfm-1.0.0.tar.gz" upload-time = 2025-10-16 09:12:22.402429+00:00 url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz" size = 10112 [packages.sdist.hashes] sha256 = "d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623" [[packages.wheels]] name = "mdformat_gfm-1.0.0-py3-none-any.whl" upload-time = 2025-10-16 09:12:21.276157+00:00 url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl" size = 10970 [packages.wheels.hashes] sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" [[packages]] name = "mdit-py-plugins" version = "0.6.1" marker = "\"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "markdown-it-py" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "mdit_py_plugins-0.6.1.tar.gz" upload-time = 2026-05-13 09:03:38.910230+00:00 url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz" size = 56114 [packages.sdist.hashes] sha256 = "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0" [[packages.wheels]] name = "mdit_py_plugins-0.6.1-py3-none-any.whl" upload-time = 2026-05-13 09:03:37.760000+00:00 url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl" size = 66663 [packages.wheels.hashes] sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" [[packages]] name = "mdurl" version = "0.1.2" marker = "\"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.7" index = "https://pypi.org/simple/" [packages.sdist] name = "mdurl-0.1.2.tar.gz" upload-time = 2022-08-14 12:40:10.846281+00:00 url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz" size = 8729 [packages.sdist.hashes] sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" [[packages.wheels]] name = "mdurl-0.1.2-py3-none-any.whl" upload-time = 2022-08-14 12:40:09.779676+00:00 url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl" size = 9979 [packages.wheels.hashes] sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" [[packages]] name = "nodeenv" version = "1.11.0" marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" index = "https://pypi.org/simple/" [packages.sdist] name = "nodeenv-1.11.0.tar.gz" upload-time = 2026-09-26 11:29:21.367929+00:00 url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz" size = 99301 [packages.sdist.hashes] sha256 = "3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146" [[packages.wheels]] name = "nodeenv-1.11.0-py2.py3-none-any.whl" upload-time = 2026-09-26 11:29:19.933601+00:00 url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl" size = 34277 [packages.wheels.hashes] sha256 = "edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25" [[packages]] name = "packaging" version = "26.3" marker = "\"build\" in dependency_groups or \"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"lint\" in dependency_groups or \"project-runtime\" in dependency_groups or \"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "packaging-26.3.tar.gz" upload-time = 2026-08-04 18:15:28.737071+00:00 url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz" size = 313412 [packages.sdist.hashes] sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" [[packages.wheels]] name = "packaging-26.3-py3-none-any.whl" upload-time = 2026-08-04 18:15:27.159957+00:00 url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl" size = 129956 [packages.wheels.hashes] sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" [[packages]] name = "passlib" version = "1.7.4" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] name = "passlib-1.7.4.tar.gz" upload-time = 2020-10-08 19:00:52.121177+00:00 url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz" size = 689844 [packages.sdist.hashes] sha256 = "defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04" [[packages.wheels]] name = "passlib-1.7.4-py2.py3-none-any.whl" upload-time = 2020-10-08 19:00:49.856832+00:00 url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl" size = 525554 [packages.wheels.hashes] sha256 = "aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1" [[packages]] name = "pathspec" version = "1.1.1" marker = "\"build\" in dependency_groups or \"dev\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "pathspec-1.1.1.tar.gz" upload-time = 2026-04-27 01:46:08.907631+00:00 url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz" size = 135180 [packages.sdist.hashes] sha256 = "17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a" [[packages.wheels]] name = "pathspec-1.1.1-py3-none-any.whl" upload-time = 2026-04-27 01:46:07.060850+00:00 url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl" size = 57328 [packages.wheels.hashes] sha256 = "a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189" [[packages]] name = "pip" version = "26.2.2" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"zipapp\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "pip-26.2.2.tar.gz" upload-time = 2026-10-20 16:36:29.008408+00:00 url = "https://files.pythonhosted.org/packages/fa/e8/0842d757e483e895e670f655084cfe8b8c72580c6769a76b30b2789a0608/pip-26.2.2.tar.gz" size = 1863336 [packages.sdist.hashes] sha256 = "3a8e5c800492787f33a4114ef54444546c340df428974ebba17e0fd47e11af2f" [[packages.wheels]] name = "pip-26.2.2-py3-none-any.whl" upload-time = 2026-10-20 16:36:27.046054+00:00 url = "https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl" size = 1819850 [packages.wheels.hashes] sha256 = "fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70" [[packages]] name = "platformdirs" version = "4.13.6" marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups or \"project-runtime\" in dependency_groups" requires-python = ">=3.11" index = "https://pypi.org/simple/" [packages.sdist] name = "platformdirs-4.13.6.tar.gz" upload-time = 2026-10-27 08:19:11.538584+00:00 url = "https://files.pythonhosted.org/packages/1a/43/8c4b5fe23f0e119cf4e1b16c2d16240ab9057cdbb138568c91d5a48abf81/platformdirs-4.13.6.tar.gz" size = 62750 [packages.sdist.hashes] sha256 = "211d176e24674b5895dedf9021c262bcf9fad008a70610bfb147d4a8d6d8fe1b" [[packages.wheels]] name = "platformdirs-4.13.6-py3-none-any.whl" upload-time = 2026-10-27 08:19:10.405474+00:00 url = "https://files.pythonhosted.org/packages/a8/24/b312a9466839b1f708eed42790b47e927dc7f774a528bb49b1cdf8b734b9/platformdirs-4.13.6-py3-none-any.whl" size = 32767 [packages.wheels.hashes] sha256 = "21fc198fb09f3e78cadcdfd8a6ae4fd65cadc64a3cba78b694321c2996114a3e" [[packages]] name = "pluggy" version = "1.7.0" marker = "\"build\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "pluggy-1.7.0.tar.gz" upload-time = 2026-10-15 09:50:58.343484+00:00 url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz" size = 123304 [packages.sdist.hashes] sha256 = "d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8" [[packages.wheels]] name = "pluggy-1.7.0-py3-none-any.whl" upload-time = 2026-10-15 09:50:56.808462+00:00 url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl" size = 27082 [packages.wheels.hashes] sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" [[packages]] name = "pre-commit" version = "4.7.0" marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" requires-python = ">=3.11" dependencies = [ { name = "cfgv" }, { name = "identify" }, { name = "nodeenv" }, { name = "pyyaml" }, { name = "virtualenv" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "pre_commit-4.7.0.tar.gz" upload-time = 2026-10-12 20:45:40.320969+00:00 url = "https://files.pythonhosted.org/packages/3a/c3/6965c2690ddce885ec75121572d065a5b94c5a5d8d3e657927ecda9fd715/pre_commit-4.7.0.tar.gz" size = 198120 [packages.sdist.hashes] sha256 = "1f3deee12b914c8fa184bd2b5953391c2e4f36763e403d4a23dc9983fa7c6e86" [[packages.wheels]] name = "pre_commit-4.7.0-py2.py3-none-any.whl" upload-time = 2026-10-12 20:45:38.858255+00:00 url = "https://files.pythonhosted.org/packages/eb/83/ce0a196e4f7b20b11ce3aa1fc72fb973f64b7628d51c1b8aebad37fb7f81/pre_commit-4.7.0-py2.py3-none-any.whl" size = 226261 [packages.wheels.hashes] sha256 = "2e229038ad3656081b70c27913157e4b636ef306f643f024a8a8e3f0f7b2f877" [[packages]] name = "prek" version = "0.5.7" marker = "\"release\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "prek-0.5.7.tar.gz" upload-time = 2026-10-21 00:46:48.231166+00:00 url = "https://files.pythonhosted.org/packages/e0/0c/985c6f0fe1671e639fb97bf5a60f3945369f26d4ccf5a8a1b063d357e6da/prek-0.5.7.tar.gz" size = 580753 [packages.sdist.hashes] sha256 = "91049700f0f13b4575deb44045dd6dd51f7c4a70c53ec5d452b09925962e3b35" [[packages.wheels]] name = "prek-0.5.7-py3-none-macosx_11_0_arm64.whl" upload-time = 2026-10-21 00:46:34.006275+00:00 url = "https://files.pythonhosted.org/packages/51/59/1868e2d6b3581eb9f327cb7db4270c4f38e80c5b175517e439e106eac704/prek-0.5.7-py3-none-macosx_11_0_arm64.whl" size = 5686422 [packages.wheels.hashes] sha256 = "ef10cf46c0660b011fc067b7ce20dc78f2c44b6f905bf8dc49402353f67283b1" [[packages.wheels]] name = "prek-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" upload-time = 2026-10-21 00:46:35.869439+00:00 url = "https://files.pythonhosted.org/packages/7f/e3/90901432ee7ca288b46872552ca8f658b2262cf56d9af3c1eb0416f206da/prek-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" size = 5817816 [packages.wheels.hashes] sha256 = "b38075f9363066d72d1cd887b5a260ec938516fea9ea44def53e592ee75090d5" [[packages.wheels]] name = "prek-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" upload-time = 2026-10-21 00:46:39.290591+00:00 url = "https://files.pythonhosted.org/packages/4b/15/c83456872211706839ff326474aa9f2d870eef7ee5ec2c436446519779d2/prek-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" size = 6140221 [packages.wheels.hashes] sha256 = "c139c494fb1d9826688f8107d1514fa2a58f8f4a0f0527b68e7970fa50a8b723" [[packages.wheels]] name = "prek-0.5.7-py3-none-manylinux_2_28_aarch64.whl" upload-time = 2026-10-21 00:46:41.006381+00:00 url = "https://files.pythonhosted.org/packages/cb/b5/996b05447d4c2a4399947a7c5c910b2cd2c544b3074c11153ae3e21b4112/prek-0.5.7-py3-none-manylinux_2_28_aarch64.whl" size = 5812638 [packages.wheels.hashes] sha256 = "e86139ab5662124818dc1957854d8281f635414016e545ab72e63365c995824b" [[packages.wheels]] name = "prek-0.5.7-py3-none-win_amd64.whl" upload-time = 2026-10-21 00:46:44.385080+00:00 url = "https://files.pythonhosted.org/packages/54/97/e14b12e7d8fbdb468581d778fb892c855fbca325d4344cc535c12e2851a5/prek-0.5.7-py3-none-win_amd64.whl" size = 5692294 [packages.wheels.hashes] sha256 = "fa69837b6d1d2b6be5fe7fe4cd27fd5606f2155e784fec55ee7df7ed9f9bf211" [[packages]] name = "pygments" version = "2.21.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "pygments-2.21.0.tar.gz" upload-time = 2026-08-17 08:02:48.824391+00:00 url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz" size = 5005329 [packages.sdist.hashes] sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" [[packages.wheels]] name = "pygments-2.21.0-py3-none-any.whl" upload-time = 2026-08-17 08:02:44.912148+00:00 url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl" size = 1250147 [packages.wheels.hashes] sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" [[packages]] name = "pypiserver" version = "2.4.2" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "defusedxml" }, { name = "legacy-cgi" }, { name = "packaging" }, { name = "passlib" }, { name = "pip" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "pypiserver-2.4.2.tar.gz" upload-time = 2026-09-20 11:09:33.069726+00:00 url = "https://files.pythonhosted.org/packages/df/88/bd50f0baa457597df4d475f7db2cd9c8cbc7aa9938adaf874f49b896007e/pypiserver-2.4.2.tar.gz" size = 105738 [packages.sdist.hashes] sha256 = "131bc2a31ea1eab0e0104ace799dff139789b215465e3d326c65f56df5c160cb" [[packages.wheels]] name = "pypiserver-2.4.2-py3-none-any.whl" upload-time = 2026-09-20 11:09:32.008250+00:00 url = "https://files.pythonhosted.org/packages/44/56/faa75226948ec5074610f4c4b224a363132a385f304e64501e585e44be4b/pypiserver-2.4.2-py3-none-any.whl" size = 96258 [packages.wheels.hashes] sha256 = "c8441d60bbd5f77f5647db8afa3a953e7c155ecd909eb1b6faf42bcc9477ee6a" [[packages]] name = "pytest" version = "9.1.1" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "colorama" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "pytest-9.1.1.tar.gz" upload-time = 2026-06-19 10:58:32.857420+00:00 url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz" size = 1636369 [packages.sdist.hashes] sha256 = "1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" [[packages.wheels]] name = "pytest-9.1.1-py3-none-any.whl" upload-time = 2026-06-19 10:58:31.347074+00:00 url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl" size = 386536 [packages.wheels.hashes] sha256 = "37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" [[packages]] name = "pytest-cov" version = "7.1.0" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "pytest_cov-7.1.0.tar.gz" upload-time = 2026-03-21 20:11:16.284275+00:00 url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz" size = 55592 [packages.sdist.hashes] sha256 = "30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" [[packages.wheels]] name = "pytest_cov-7.1.0-py3-none-any.whl" upload-time = 2026-03-21 20:11:14.438324+00:00 url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl" size = 22876 [packages.wheels.hashes] sha256 = "a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" [[packages]] name = "pytest-mock" version = "3.17.0" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "pytest" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "pytest_mock-3.17.0.tar.gz" upload-time = 2026-10-17 16:21:42.435701+00:00 url = "https://files.pythonhosted.org/packages/97/c2/28b9f5b955ff2bcd4727a6f768e2ca9b586cd7c37b95ea5f7b9b873ac7b3/pytest_mock-3.17.0.tar.gz" size = 43064 [packages.sdist.hashes] sha256 = "e36576e0985e01027e999891e7a966c67822d40c89ef1b4c8b6e22737e3590b4" [[packages.wheels]] name = "pytest_mock-3.17.0-py3-none-any.whl" upload-time = 2026-10-17 16:21:41.265482+00:00 url = "https://files.pythonhosted.org/packages/e8/ea/47fbdc8f37410956fd445720e927589b1e2a47772c5f28109f0ce2fa26d9/pytest_mock-3.17.0-py3-none-any.whl" size = 11248 [packages.wheels.hashes] sha256 = "1b82aa4b37137248bd687055e109723debeb07b489de1565297dc9589c5f2d6b" [[packages]] name = "pytest-subprocess" version = "1.6.0" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.6" dependencies = [ { name = "pytest" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "pytest_subprocess-1.6.0.tar.gz" upload-time = 2026-05-10 08:22:54.207532+00:00 url = "https://files.pythonhosted.org/packages/76/7a/0d5855132e11de2a96da26e596560757ebbbf8190cfe36cbf85d7423f384/pytest_subprocess-1.6.0.tar.gz" size = 47910 [packages.sdist.hashes] sha256 = "b2d746eb1b768a6f9087e5c7c91f87fb9d40c7fdc777550dc00397af428a0654" [[packages.wheels]] name = "pytest_subprocess-1.6.0-py3-none-any.whl" upload-time = 2026-05-10 08:22:52.520524+00:00 url = "https://files.pythonhosted.org/packages/c5/4f/ebe38bf128380f6a8a9b0fbbbe24cbf83915bb2f934717be65cadf55b6fa/pytest_subprocess-1.6.0-py3-none-any.whl" size = 23803 [packages.wheels.hashes] sha256 = "00037100f30429c8546adc81f357fddb5213eb036fe3bfb47b7b6befc965e5b2" [[packages]] name = "pytest-timeout" version = "2.4.0" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "pytest" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "pytest_timeout-2.4.0.tar.gz" upload-time = 2025-05-05 19:44:34.990551+00:00 url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz" size = 17973 [packages.sdist.hashes] sha256 = "7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a" [[packages.wheels]] name = "pytest_timeout-2.4.0-py3-none-any.whl" upload-time = 2025-05-05 19:44:33.502941+00:00 url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl" size = 14382 [packages.wheels.hashes] sha256 = "c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2" [[packages]] name = "pytest-xdist" version = "3.8.0" marker = "\"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "execnet" }, { name = "pytest" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "pytest_xdist-3.8.0.tar.gz" upload-time = 2025-07-01 13:30:59.346894+00:00 url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz" size = 88069 [packages.sdist.hashes] sha256 = "7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1" [[packages.wheels]] name = "pytest_xdist-3.8.0-py3-none-any.whl" upload-time = 2025-07-01 13:30:56.632054+00:00 url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl" size = 46396 [packages.wheels.hashes] sha256 = "202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88" [[packages]] name = "python-discovery" version = "1.6.6" marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "filelock" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "python_discovery-1.6.6.tar.gz" upload-time = 2026-10-27 19:43:40.605766+00:00 url = "https://files.pythonhosted.org/packages/8e/3e/0c8cbdb52feeac4a8667ccff3c58fd2568d6d2969db2717ff00a9fdae73a/python_discovery-1.6.6.tar.gz" size = 116920 [packages.sdist.hashes] sha256 = "d687024fec55c489e7a465c7f5644300a4d9dbf7727a556a85660536ee922933" [[packages.wheels]] name = "python_discovery-1.6.6-py3-none-any.whl" upload-time = 2026-10-27 19:43:39.226654+00:00 url = "https://files.pythonhosted.org/packages/23/33/d594dde9e7f510216ca87707b575492a610100497db735ab0b54345c60e2/python_discovery-1.6.6-py3-none-any.whl" size = 39043 [packages.wheels.hashes] sha256 = "95e644ce8625c62dc195a603da9f8138ca5a35048224911b2cf7b3028025527a" [[packages]] name = "pyyaml" version = "6.0.3" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"lint\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "pyyaml-6.0.3.tar.gz" upload-time = 2025-09-25 21:33:16.546886+00:00 url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz" size = 130960 [packages.sdist.hashes] sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "release-creator" version = "4.4.0" marker = "\"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.15" dependencies = [ { name = "docutils" }, { name = "markdown-it-py" }, { name = "mdformat" }, { name = "mdformat-gfm" }, { name = "packaging" }, { name = "towncrier" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "release_creator-4.4.0.tar.gz" upload-time = 2026-11-02 21:44:48.475063+00:00 url = "https://files.pythonhosted.org/packages/d8/cf/8f376cc925c26fa5434d327ba03f4ed709b80d2077edda865fc3b2fe1940/release_creator-4.4.0.tar.gz" size = 171450 [packages.sdist.hashes] sha256 = "263d518ddee6371fa13d25bd07f7b5af0fa7a6e421b16f1e8143b0a9443bc84d" [[packages.wheels]] name = "release_creator-4.4.0-py3-none-any.whl" upload-time = 2026-11-02 21:44:47.195366+00:00 url = "https://files.pythonhosted.org/packages/7b/29/6f2a3158b082b3bf8a94eb93662d80b4db4212abea0fc1fae07ac9a043da/release_creator-4.4.0-py3-none-any.whl" size = 54784 [packages.wheels.hashes] sha256 = "560ed208cca0dc53bc227878e5596e3e26e44fa277658c5d0dc662901d95f8cb" [[packages]] name = "requests" version = "2.34.2" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "requests-2.34.2.tar.gz" upload-time = 2026-05-14 19:25:27.735762+00:00 url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz" size = 142856 [packages.sdist.hashes] sha256 = "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" [[packages.wheels]] name = "requests-2.34.2-py3-none-any.whl" upload-time = 2026-05-14 19:25:26.443000+00:00 url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl" size = 73075 [packages.wheels.hashes] sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" [[packages]] name = "roman-numerals" version = "4.1.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "roman_numerals-4.1.0.tar.gz" upload-time = 2025-12-17 18:25:34.381818+00:00 url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz" size = 9077 [packages.sdist.hashes] sha256 = "1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2" [[packages.wheels]] name = "roman_numerals-4.1.0-py3-none-any.whl" upload-time = 2025-12-17 18:25:33.098993+00:00 url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl" size = 7676 [packages.wheels.hashes] sha256 = "647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7" [[packages]] name = "setuptools" version = "84.2.1" marker = "\"build\" in dependency_groups or \"dev\" in dependency_groups or \"test\" in dependency_groups or \"type\" in dependency_groups or \"zipapp\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "setuptools-84.2.1.tar.gz" upload-time = 2026-10-20 19:01:37.742699+00:00 url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz" size = 1230289 [packages.sdist.hashes] sha256 = "3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc" [[packages.wheels]] name = "setuptools-84.2.1-py3-none-any.whl" upload-time = 2026-10-20 19:01:35.678013+00:00 url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl" size = 876263 [packages.wheels.hashes] sha256 = "ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2" [[packages]] name = "setuptools-scm" version = "10.3.4" marker = "\"build\" in dependency_groups" requires-python = ">=3.8" dependencies = [ { name = "packaging" }, { name = "setuptools" }, { name = "vcs-versioning" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "setuptools_scm-10.3.4.tar.gz" upload-time = 2026-09-23 19:21:42.950372+00:00 url = "https://files.pythonhosted.org/packages/85/d8/fc143f88819ccf10ba2388ba86732ee2de193e578234e25a783f6cc14bf7/setuptools_scm-10.3.4.tar.gz" size = 78410 [packages.sdist.hashes] sha256 = "a69f28bfc245608781205e912faae437c2b2165773afa4e7b979d77447a69dd2" [[packages.wheels]] name = "setuptools_scm-10.3.4-py3-none-any.whl" upload-time = 2026-09-23 19:21:41.607429+00:00 url = "https://files.pythonhosted.org/packages/da/f5/54538a1f17ea753c42b0928dc2e62986d463f8c393340b9a1e602f2d4dd9/setuptools_scm-10.3.4-py3-none-any.whl" size = 33366 [packages.wheels.hashes] sha256 = "82f34c3e3084fc2b57d397200637cc13f2338004052d2fee47baa5f0e902464e" [[packages]] name = "shiv" version = "1.0.8" marker = "\"zipapp\" in dependency_groups" requires-python = ">=3.6" dependencies = [ { name = "click" }, { name = "pip" }, { name = "setuptools" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "shiv-1.0.8.tar.gz" upload-time = 2024-11-01 19:47:46.189867+00:00 url = "https://files.pythonhosted.org/packages/06/85/004e7123b4821c64be6d9bfed27f63147b4dde929a1cd848f137befeada4/shiv-1.0.8.tar.gz" size = 32806 [packages.sdist.hashes] sha256 = "2a68d69e98ce81cb5b8fdafbfc1e27efa93e6d89ca14bfae33482e4176f561d6" [[packages.wheels]] name = "shiv-1.0.8-py2.py3-none-any.whl" upload-time = 2024-11-01 19:47:45.061273+00:00 url = "https://files.pythonhosted.org/packages/e7/ec/afbb46f7c1ab071a50d92424daf149420ca1f0e02dc51239485747151d6c/shiv-1.0.8-py2.py3-none-any.whl" size = 20516 [packages.wheels.hashes] sha256 = "a60e4b05a2d2f8b820d567b1d89ee59af731759771c32c282d03c4ceae6aba24" [[packages]] name = "smmap" version = "5.0.3" marker = "\"dev\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.7" index = "https://pypi.org/simple/" [packages.sdist] name = "smmap-5.0.3.tar.gz" upload-time = 2026-03-09 03:43:26.100884+00:00 url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz" size = 22506 [packages.sdist.hashes] sha256 = "4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c" [[packages.wheels]] name = "smmap-5.0.3-py3-none-any.whl" upload-time = 2026-03-09 03:43:24.361292+00:00 url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl" size = 24390 [packages.wheels.hashes] sha256 = "c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f" [[packages]] name = "snowballstemmer" version = "3.1.1" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.3" index = "https://pypi.org/simple/" [packages.sdist] name = "snowballstemmer-3.1.1.tar.gz" upload-time = 2026-06-03 00:56:40.194915+00:00 url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz" size = 123314 [packages.sdist.hashes] sha256 = "e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260" [[packages.wheels]] name = "snowballstemmer-3.1.1-py3-none-any.whl" upload-time = 2026-06-03 00:56:38.614300+00:00 url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl" size = 104164 [packages.wheels.hashes] sha256 = "7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752" [[packages]] name = "soupsieve" version = "3.0.4" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.11.5" index = "https://pypi.org/simple/" [packages.sdist] name = "soupsieve-3.0.4.tar.gz" upload-time = 2026-10-22 00:45:26.362373+00:00 url = "https://files.pythonhosted.org/packages/19/63/0b6bae54546aed98d72f12d09df57b19a287f01c84d22047fa2e23772ab9/soupsieve-3.0.4.tar.gz" size = 119017 [packages.sdist.hashes] sha256 = "924fab84ae21604c19e175e1f39815eb6fa83ba23836e09e9de180925e5bc4c0" [[packages.wheels]] name = "soupsieve-3.0.4-py3-none-any.whl" upload-time = 2026-10-22 00:45:24.843035+00:00 url = "https://files.pythonhosted.org/packages/07/66/473f7ca0be0b06e38ee8c85ee2db184e3981bd769fac2e7c19c8a8865211/soupsieve-3.0.4-py3-none-any.whl" size = 42458 [packages.wheels.hashes] sha256 = "8412826614f9c632227067d0985aeb9f584bd15efc852955430e097a9fe7afbf" [[packages]] name = "sphinx" version = "9.2.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.13" dependencies = [ { name = "alabaster" }, { name = "babel" }, { name = "colorama" }, { name = "docutils" }, { name = "imagesize" }, { name = "jinja2" }, { name = "packaging" }, { name = "pygments" }, { name = "requests" }, { name = "roman-numerals" }, { name = "snowballstemmer" }, { name = "sphinxcontrib-applehelp" }, { name = "sphinxcontrib-devhelp" }, { name = "sphinxcontrib-htmlhelp" }, { name = "sphinxcontrib-jsmath" }, { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx-9.2.0.tar.gz" upload-time = 2026-10-21 00:05:15.019079+00:00 url = "https://files.pythonhosted.org/packages/64/d4/413466f0be8b1cb0e3e43893a148b96d7462d0923d82b4166b21afc5f30c/sphinx-9.2.0.tar.gz" size = 8782438 [packages.sdist.hashes] sha256 = "90470459f3cab15d9dfed146c71cf77694b0445df17b2e0e69059cb9f7fc9ee4" [[packages.wheels]] name = "sphinx-9.2.0-py3-none-any.whl" upload-time = 2026-10-21 00:05:12.241734+00:00 url = "https://files.pythonhosted.org/packages/c4/28/a0c50761cd215b7042f4bc617a6bc5c0cd7be62f75bc908e169e28a4d21e/sphinx-9.2.0-py3-none-any.whl" size = 3925388 [packages.wheels.hashes] sha256 = "4c3ec13b612712c0d3ce4cbdd9e3eb91e68577e07a8e31f32f21cf178453578c" [[packages]] name = "sphinx-argparse-cli" version = "1.24.1" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.11" dependencies = [ { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_argparse_cli-1.24.1.tar.gz" upload-time = 2026-10-27 07:02:09.017213+00:00 url = "https://files.pythonhosted.org/packages/ec/83/5502c61d82db593198adcb3a376505b767f0f464ebf2db50253cf3c7f319/sphinx_argparse_cli-1.24.1.tar.gz" size = 17808 [packages.sdist.hashes] sha256 = "9adc72739eb3b099f5de94d8f60af031aaef8355f57398d33ff3e6ea848abd00" [[packages.wheels]] name = "sphinx_argparse_cli-1.24.1-py3-none-any.whl" upload-time = 2026-10-27 07:02:07.884391+00:00 url = "https://files.pythonhosted.org/packages/51/c6/16119ac3a8b54b3e92ad668bc6e787cbc79353e7f177f247d4e3f74b3de2/sphinx_argparse_cli-1.24.1-py3-none-any.whl" size = 12299 [packages.wheels.hashes] sha256 = "6bc2e83f4ecf7c110a91a526ca239465fef6d457c0665d89d5e0a7dc3eb71c73" [[packages]] name = "sphinx-autobuild" version = "2025.8.25" marker = "\"dev\" in dependency_groups or \"docs-live\" in dependency_groups" requires-python = ">=3.11" dependencies = [ { name = "colorama" }, { name = "sphinx" }, { name = "starlette" }, { name = "uvicorn" }, { name = "watchfiles" }, { name = "websockets" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_autobuild-2025.8.25.tar.gz" upload-time = 2025-08-25 18:44:55.436866+00:00 url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz" size = 15200 [packages.sdist.hashes] sha256 = "9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213" [[packages.wheels]] name = "sphinx_autobuild-2025.8.25-py3-none-any.whl" upload-time = 2025-08-25 18:44:54.164183+00:00 url = "https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl" size = 12535 [packages.wheels.hashes] sha256 = "b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a" [[packages]] name = "sphinx-autodoc-typehints" version = "3.17.4" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.12" dependencies = [ { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_autodoc_typehints-3.17.4.tar.gz" upload-time = 2026-10-27 19:18:44.225219+00:00 url = "https://files.pythonhosted.org/packages/9e/a9/cfe13ff89678c6d310a8a9060333b7823bae1dbce9615c7032be89aaec20/sphinx_autodoc_typehints-3.17.4.tar.gz" size = 511398 [packages.sdist.hashes] sha256 = "a5ddd545192380292f13dd9fc232717a0925fbc66617bc51b3db1e0480db0fec" [[packages.wheels]] name = "sphinx_autodoc_typehints-3.17.4-py3-none-any.whl" upload-time = 2026-10-27 19:18:42.674074+00:00 url = "https://files.pythonhosted.org/packages/fa/66/40004fe2aea60edc4800328599dd115b3058f79bf148be1746349281f308/sphinx_autodoc_typehints-3.17.4-py3-none-any.whl" size = 140603 [packages.wheels.hashes] sha256 = "251271a49e46573c2e752bcc92dae232def822a2c9983272cb04d282eadc2bfa" [[packages]] name = "sphinx-basic-ng" version = "1.0.0b2" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_basic_ng-1.0.0b2.tar.gz" upload-time = 2023-07-08 18:40:54.166146+00:00 url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz" size = 20736 [packages.sdist.hashes] sha256 = "9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9" [[packages.wheels]] name = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl" upload-time = 2023-07-08 18:40:52.659879+00:00 url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl" size = 22496 [packages.wheels.hashes] sha256 = "eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b" [[packages]] name = "sphinx-codeautolink" version = "0.19.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "beautifulsoup4" }, { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_codeautolink-0.19.0.tar.gz" upload-time = 2026-07-29 17:40:15.222822+00:00 url = "https://files.pythonhosted.org/packages/26/4e/92425c60359fa7b5b9ac0e0f1f788b86a89c4e65cf29139e1174e21e3adf/sphinx_codeautolink-0.19.0.tar.gz" size = 65422 [packages.sdist.hashes] sha256 = "abd8367831bb0b084ecad7b0a9b77f8458294e4468cadd5f37861da9b38e3e7e" [[packages.wheels]] name = "sphinx_codeautolink-0.19.0-py3-none-any.whl" upload-time = 2026-07-29 17:40:13.739772+00:00 url = "https://files.pythonhosted.org/packages/fd/86/260d30033dbc48f8a0c37334159d56a1a731a7ce752d2803a6b5174e8731/sphinx_codeautolink-0.19.0-py3-none-any.whl" size = 37671 [packages.wheels.hashes] sha256 = "ebbaf815efee63cfdc5320a97ece7eb01af38f22ed404439bee520e9c8175605" [[packages]] name = "sphinx-copybutton" version = "0.5.2" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx-copybutton-0.5.2.tar.gz" upload-time = 2023-04-14 08:10:22.998763+00:00 url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz" size = 23039 [packages.sdist.hashes] sha256 = "4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd" [[packages.wheels]] name = "sphinx_copybutton-0.5.2-py3-none-any.whl" upload-time = 2023-04-14 08:10:20.844249+00:00 url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl" size = 13343 [packages.wheels.hashes] sha256 = "fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e" [[packages]] name = "sphinx-design" version = "0.7.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.11" dependencies = [ { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_design-0.7.0.tar.gz" upload-time = 2026-01-19 13:12:53.297780+00:00 url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz" size = 2203582 [packages.sdist.hashes] sha256 = "d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a" [[packages.wheels]] name = "sphinx_design-0.7.0-py3-none-any.whl" upload-time = 2026-01-19 13:12:51.077508+00:00 url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl" size = 2220350 [packages.wheels.hashes] sha256 = "f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282" [[packages]] name = "sphinx-issues" version = "6.0.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_issues-6.0.0.tar.gz" upload-time = 2026-03-13 17:23:32.501498+00:00 url = "https://files.pythonhosted.org/packages/95/4e/c9a46b19c90d4d3cdcbcaf0e4d392b09e5b41ec75b134bc5a1aa192be94f/sphinx_issues-6.0.0.tar.gz" size = 15230 [packages.sdist.hashes] sha256 = "f40f2c71cecb2068c7f06a53825778b674d58d8395b4ea60551a36164d2988e3" [[packages.wheels]] name = "sphinx_issues-6.0.0-py3-none-any.whl" upload-time = 2026-03-13 17:23:31.031035+00:00 url = "https://files.pythonhosted.org/packages/29/8a/28333d222ac31539aa0d818db0790077e5a6a408f933b4bf64f7d3440ffc/sphinx_issues-6.0.0-py3-none-any.whl" size = 8362 [packages.wheels.hashes] sha256 = "c7ed2915059526d02022733985a7712d4b2b64a707e16b98294ed9758e64df4f" [[packages]] name = "sphinx-last-updated-by-git" version = "0.3.8" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_last_updated_by_git-0.3.8.tar.gz" upload-time = 2024-08-11 07:15:54.601652+00:00 url = "https://files.pythonhosted.org/packages/03/fd/de1685b6dab173dff31da24e0d3b29f02873fc24a1cdbb7678721ddc8581/sphinx_last_updated_by_git-0.3.8.tar.gz" size = 10785 [packages.sdist.hashes] sha256 = "c145011f4609d841805b69a9300099fc02fed8f5bb9e5bcef77d97aea97b7761" [[packages.wheels]] name = "sphinx_last_updated_by_git-0.3.8-py3-none-any.whl" upload-time = 2024-08-11 07:15:53.244461+00:00 url = "https://files.pythonhosted.org/packages/e1/fb/e496f16fa11fbe2dbdd0b5e306ede153dfed050aae4766fc89d500720dc7/sphinx_last_updated_by_git-0.3.8-py3-none-any.whl" size = 8580 [packages.wheels.hashes] sha256 = "6382c8285ac1f222483a58569b78c0371af5e55f7fbf9c01e5e8a72d6fdfa499" [[packages]] name = "sphinx-llm" version = "1.0.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "sphinx" }, { name = "sphinx-markdown-builder" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_llm-1.0.0.tar.gz" upload-time = 2026-08-14 07:48:29.578550+00:00 url = "https://files.pythonhosted.org/packages/33/58/6b1802820a17e7a128b45046d735f934af00f4290704b647b126213bdeb4/sphinx_llm-1.0.0.tar.gz" size = 381976 [packages.sdist.hashes] sha256 = "1cc8284343a0100caa3110010da5933cdad8da37d2b9dda0e2a7d503b8da6b1c" [[packages.wheels]] name = "sphinx_llm-1.0.0-py3-none-any.whl" upload-time = 2026-08-14 07:48:28.161181+00:00 url = "https://files.pythonhosted.org/packages/af/5b/2a338974998bc0fe563635625abdb16a000ee06a63101b361c834cd56ccb/sphinx_llm-1.0.0-py3-none-any.whl" size = 60282 [packages.wheels.hashes] sha256 = "8d45dbe5f9caffb4d78589c35d15fc8399dd41fc5b445ee4292ff83969754e75" [[packages]] name = "sphinx-markdown-builder" version = "0.6.11" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "docutils" }, { name = "sphinx" }, { name = "tabulate" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_markdown_builder-0.6.11.tar.gz" upload-time = 2026-08-16 06:51:38.158799+00:00 url = "https://files.pythonhosted.org/packages/ad/13/102d7134743120db25e636ffb57a30818e95a416583dca83aadac9c5dd87/sphinx_markdown_builder-0.6.11.tar.gz" size = 24020 [packages.sdist.hashes] sha256 = "ba3bf33e0e42ff30ae686ac73957a95e6d045790dcfbaca012dd8ae01794e577" [[packages.wheels]] name = "sphinx_markdown_builder-0.6.11-py3-none-any.whl" upload-time = 2026-08-16 06:51:36.810420+00:00 url = "https://files.pythonhosted.org/packages/c9/71/06b415631c2e494c80546073b46013d1715dceb3abe30bd63b34b4e7d9ca/sphinx_markdown_builder-0.6.11-py3-none-any.whl" size = 18088 [packages.wheels.hashes] sha256 = "acfb94d6ee66c7a47da0ca0420578fa16f115812af25b8fd21755d5909aacf51" [[packages]] name = "sphinx-notfound-page" version = "1.1.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.8" dependencies = [ { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_notfound_page-1.1.0.tar.gz" upload-time = 2025-01-28 18:45:02.871072+00:00 url = "https://files.pythonhosted.org/packages/6a/b2/67603444a8ee97b4a8ea71b0a9d6bab1727ed65e362c87e02f818ee57b8a/sphinx_notfound_page-1.1.0.tar.gz" size = 7392 [packages.sdist.hashes] sha256 = "913e1754370bb3db201d9300d458a8b8b5fb22e9246a816643a819a9ea2b8067" [[packages.wheels]] name = "sphinx_notfound_page-1.1.0-py3-none-any.whl" upload-time = 2025-01-28 18:45:00.465862+00:00 url = "https://files.pythonhosted.org/packages/cd/d4/019fe439c840a7966012bbb95ccbdd81c5c10271749706793b43beb05145/sphinx_notfound_page-1.1.0-py3-none-any.whl" size = 8167 [packages.wheels.hashes] sha256 = "835dc76ff7914577a1f58d80a2c8418fb6138c0932c8da8adce4d9096fbcd389" [[packages]] name = "sphinx-reredirects" version = "1.1.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.11" dependencies = [ { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_reredirects-1.1.0.tar.gz" upload-time = 2025-12-22 08:28:02.792248+00:00 url = "https://files.pythonhosted.org/packages/1b/8d/0e39fe2740d7d71417edf9a6424aa80ca2c27c17fc21282cdc39f90d5a40/sphinx_reredirects-1.1.0.tar.gz" size = 614624 [packages.sdist.hashes] sha256 = "fb9b195335ab14b43f8273287d0c7eeb637ba6c56c66581c11b47202f6718b29" [[packages.wheels]] name = "sphinx_reredirects-1.1.0-py3-none-any.whl" upload-time = 2025-12-22 08:27:59.724893+00:00 url = "https://files.pythonhosted.org/packages/51/81/b5dd07067f3daac6d23687ec737b2d593740671ebcd145830c8f92d381c5/sphinx_reredirects-1.1.0-py3-none-any.whl" size = 6351 [packages.wheels.hashes] sha256 = "4b5692273c72cd2d4d917f4c6f87d5919e4d6114a752d4be033f7f5f6310efd9" [[packages]] name = "sphinx-sitemap" version = "2.9.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" dependencies = [ { name = "sphinx-last-updated-by-git" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_sitemap-2.9.0.tar.gz" upload-time = 2025-10-06 00:24:00.036537+00:00 url = "https://files.pythonhosted.org/packages/61/17/56fe0f65e3567f829b2b4153a622be1d4b222b781e0d90d7db5a7738f30f/sphinx_sitemap-2.9.0.tar.gz" size = 6978 [packages.sdist.hashes] sha256 = "70f97bcdf444e3d68e118355cf82a1f54c4d3c03d651cd17fe87398b26e25e21" [[packages.wheels]] name = "sphinx_sitemap-2.9.0-py3-none-any.whl" upload-time = 2025-10-06 00:23:58.778831+00:00 url = "https://files.pythonhosted.org/packages/e4/94/3c57e8b1985e755c48972e2ecd59526d4bf0b52a1fe805bc52a8e98cb92d/sphinx_sitemap-2.9.0-py3-none-any.whl" size = 6218 [packages.wheels.hashes] sha256 = "f1f1d3a9ad012ba17a7ef0b560d303bff2d0db26647567d6e810bcc754466664" [[packages]] name = "sphinxcontrib-applehelp" version = "2.0.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_applehelp-2.0.0.tar.gz" upload-time = 2024-07-29 01:09:00.465812+00:00 url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz" size = 20053 [packages.sdist.hashes] sha256 = "2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1" [[packages.wheels]] name = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl" upload-time = 2024-07-29 01:08:58.990722+00:00 url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl" size = 119300 [packages.wheels.hashes] sha256 = "4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5" [[packages]] name = "sphinxcontrib-devhelp" version = "2.0.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_devhelp-2.0.0.tar.gz" upload-time = 2024-07-29 01:09:23.417467+00:00 url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz" size = 12967 [packages.sdist.hashes] sha256 = "411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad" [[packages.wheels]] name = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl" upload-time = 2024-07-29 01:09:21.945229+00:00 url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl" size = 82530 [packages.wheels.hashes] sha256 = "aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2" [[packages]] name = "sphinxcontrib-htmlhelp" version = "2.1.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_htmlhelp-2.1.0.tar.gz" upload-time = 2024-07-29 01:09:37.889318+00:00 url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz" size = 22617 [packages.sdist.hashes] sha256 = "c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9" [[packages.wheels]] name = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl" upload-time = 2024-07-29 01:09:36.407072+00:00 url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl" size = 98705 [packages.wheels.hashes] sha256 = "166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8" [[packages]] name = "sphinxcontrib-jsmath" version = "2.0.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_jsmath-2.0.0.tar.gz" upload-time = 2026-10-20 18:22:05.514380+00:00 url = "https://files.pythonhosted.org/packages/59/02/4f334c7fc3e54c51d2364575a41cd2fae6847e7f53001949f0be5f32e18b/sphinxcontrib_jsmath-2.0.0.tar.gz" size = 14511 [packages.sdist.hashes] sha256 = "551f780cf9687f9c3c74f0cf9f18aca06e0d6958853453f3803fe4d357c5b924" [[packages.wheels]] name = "sphinxcontrib_jsmath-2.0.0-py3-none-any.whl" upload-time = 2026-10-20 18:22:04.420584+00:00 url = "https://files.pythonhosted.org/packages/da/c2/2965c35b39587d4b7a397f65a9c7d4b942b031061e7ac1ce91782f394a0d/sphinxcontrib_jsmath-2.0.0-py3-none-any.whl" size = 78095 [packages.wheels.hashes] sha256 = "15ca92fe0eec82f0baf91a7efcc51ffacdb605924be6327683a52ebdacb6744c" [[packages]] name = "sphinxcontrib-mermaid" version = "2.1.1" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "jinja2" }, { name = "pyyaml" }, { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_mermaid-2.1.1.tar.gz" upload-time = 2026-09-01 16:10:23.671939+00:00 url = "https://files.pythonhosted.org/packages/8c/87/06537dff20aa57df2d0f2afa87edff4afa85dec6f57f03f7acc24f83ff88/sphinxcontrib_mermaid-2.1.1.tar.gz" size = 22353 [packages.sdist.hashes] sha256 = "f8d7f1b2f0ad69b7ebdd5cbf532fb4ee107893b5373bc2d0e9634089c0b2cfee" [[packages.wheels]] name = "sphinxcontrib_mermaid-2.1.1-py3-none-any.whl" upload-time = 2026-09-01 16:10:22.377054+00:00 url = "https://files.pythonhosted.org/packages/50/6a/5bf2222b8084c7a7bc3ec23d470c91e3e03e82e4ce0bbacaa61f456ce7c1/sphinxcontrib_mermaid-2.1.1-py3-none-any.whl" size = 16368 [packages.wheels.hashes] sha256 = "3b9ac1a8e0b5154dec3c0acf9b5c54dd9ba58540e416f20858c300814c25648f" [[packages]] name = "sphinxcontrib-qthelp" version = "2.0.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_qthelp-2.0.0.tar.gz" upload-time = 2024-07-29 01:09:56.435212+00:00 url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz" size = 17165 [packages.sdist.hashes] sha256 = "4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab" [[packages.wheels]] name = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl" upload-time = 2024-07-29 01:09:54.885300+00:00 url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl" size = 88743 [packages.wheels.hashes] sha256 = "b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb" [[packages]] name = "sphinxcontrib-serializinghtml" version = "2.0.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_serializinghtml-2.0.0.tar.gz" upload-time = 2024-07-29 01:10:09.332393+00:00 url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz" size = 16080 [packages.sdist.hashes] sha256 = "e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d" [[packages.wheels]] name = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl" upload-time = 2024-07-29 01:10:08.203690+00:00 url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl" size = 92072 [packages.wheels.hashes] sha256 = "6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331" [[packages]] name = "sphinxcontrib-towncrier" version = "0.5.0a0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "sphinx" }, { name = "towncrier" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_towncrier-0.5.0a0.tar.gz" upload-time = 2025-02-28 01:59:16.894775+00:00 url = "https://files.pythonhosted.org/packages/21/fe/72ed57093e28af10595c50839b183c5fdf0952482e9ef0ca6eb90eb85c5d/sphinxcontrib_towncrier-0.5.0a0.tar.gz" size = 62453 [packages.sdist.hashes] sha256 = "294e69df6e275e7a86df7ea6a927cc7c28c2c370a884cd5c45de6ec989858f27" [[packages.wheels]] name = "sphinxcontrib_towncrier-0.5.0a0-py3-none-any.whl" upload-time = 2025-02-28 01:59:15.178594+00:00 url = "https://files.pythonhosted.org/packages/ac/5c/f7e39f243636a5e1894f2f5a72579977bf3968922afdb75175ee45062066/sphinxcontrib_towncrier-0.5.0a0-py3-none-any.whl" size = 12609 [packages.wheels.hashes] sha256 = "11d130c3ad5e4649821d543c4ea7ab64bbe78df4d859ef94f4298e7845dc0f59" [[packages]] name = "sphinxext-opengraph" version = "0.13.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxext_opengraph-0.13.0.tar.gz" upload-time = 2025-08-29 12:20:31.066405+00:00 url = "https://files.pythonhosted.org/packages/f6/c0/eb6838e3bae624ce6c8b90b245d17e84252863150e95efdb88f92c8aa3fb/sphinxext_opengraph-0.13.0.tar.gz" size = 1026875 [packages.sdist.hashes] sha256 = "103335d08567ad8468faf1425f575e3b698e9621f9323949a6c8b96d9793e80b" [[packages.wheels]] name = "sphinxext_opengraph-0.13.0-py3-none-any.whl" upload-time = 2025-08-29 12:20:29.072834+00:00 url = "https://files.pythonhosted.org/packages/bf/a4/66c1fd4f8fab88faf71cee04a945f9806ba0fef753f2cfc8be6353f64508/sphinxext_opengraph-0.13.0-py3-none-any.whl" size = 1004152 [packages.wheels.hashes] sha256 = "936c07828edc9ad9a7b07908b29596dc84ed0b3ceaa77acdf51282d232d4d80e" [[packages]] name = "starlette" version = "1.10.0" marker = "\"dev\" in dependency_groups or \"docs-live\" in dependency_groups" requires-python = ">=3.11" dependencies = [ { name = "anyio" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "starlette-1.10.0.tar.gz" upload-time = 2026-10-24 15:04:12.342990+00:00 url = "https://files.pythonhosted.org/packages/48/bb/3d8757f21e9a53d9035c9eb039a777047139a16bfdbedfc7a58241c3d914/starlette-1.10.0.tar.gz" size = 2734356 [packages.sdist.hashes] sha256 = "b8399098d4c1e3522734f7235dc79b1fc967f96cf139c81e0fa30479e0685f9b" [[packages.wheels]] name = "starlette-1.10.0-py3-none-any.whl" upload-time = 2026-10-24 15:04:10.371825+00:00 url = "https://files.pythonhosted.org/packages/88/2e/947cfe02def9371c07cd5bf5b6b61c829e9aecb9904db02a66cfafbc1eec/starlette-1.10.0-py3-none-any.whl" size = 80571 [packages.wheels.hashes] sha256 = "478c0ab3b4ced6e82719849c118fa406f0278a3499c1a898cd15cd31bdc38ce4" [[packages]] name = "tabulate" version = "0.10.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "tabulate-0.10.0.tar.gz" upload-time = 2026-03-04 18:55:34.402969+00:00 url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz" size = 91754 [packages.sdist.hashes] sha256 = "e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d" [[packages.wheels]] name = "tabulate-0.10.0-py3-none-any.whl" upload-time = 2026-03-04 18:55:31.284751+00:00 url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl" size = 39814 [packages.wheels.hashes] sha256 = "f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3" [[packages]] name = "tomlkit" version = "0.15.2" marker = "\"build\" in dependency_groups or \"dev\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "tomlkit-0.15.2.tar.gz" upload-time = 2026-10-16 04:07:08.166827+00:00 url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz" size = 190522 [packages.sdist.hashes] sha256 = "83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6" [[packages.wheels]] name = "tomlkit-0.15.2-py3-none-any.whl" upload-time = 2026-10-16 04:07:09.216014+00:00 url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl" size = 52225 [packages.wheels.hashes] sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" [[packages]] name = "towncrier" version = "26.9.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "click" }, { name = "jinja2" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "towncrier-26.9.0.tar.gz" upload-time = 2026-09-04 12:57:25.341701+00:00 url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz" size = 79338 [packages.sdist.hashes] sha256 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" [[packages.wheels]] name = "towncrier-26.9.0-py3-none-any.whl" upload-time = 2026-09-04 12:57:23.986160+00:00 url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl" size = 67030 [packages.wheels.hashes] sha256 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" [[packages]] name = "trove-classifiers" version = "2026.9.21.13" marker = "\"build\" in dependency_groups or \"dev\" in dependency_groups or \"type\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] name = "trove_classifiers-2026.9.21.13.tar.gz" upload-time = 2026-09-21 13:29:07.301512+00:00 url = "https://files.pythonhosted.org/packages/bf/93/af436dfaa845cab5d96f0adbc1e4f3730532d37fa249e4eb796fb1d7fc82/trove_classifiers-2026.9.21.13.tar.gz" size = 17085 [packages.sdist.hashes] sha256 = "0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71" [[packages.wheels]] name = "trove_classifiers-2026.9.21.13-py3-none-any.whl" upload-time = 2026-09-21 13:29:06.123578+00:00 url = "https://files.pythonhosted.org/packages/30/81/0da8afb52a71d0a4f2bd3152357b1a441e393b286374802b9d3addab4ab5/trove_classifiers-2026.9.21.13-py3-none-any.whl" size = 14232 [packages.wheels.hashes] sha256 = "8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17" [[packages]] name = "ty" version = "0.0.92" marker = "\"dev\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "ty-0.0.92.tar.gz" upload-time = 2026-10-26 03:13:58.999066+00:00 url = "https://files.pythonhosted.org/packages/45/f8/2629d3533019eec56b0008d93198b2cb1575795e017c52759d6ddbdad353/ty-0.0.92.tar.gz" size = 8071788 [packages.sdist.hashes] sha256 = "d08ff5d89bb63780e30837446ea08ddae3db73ae0b872a441a5b514d37726459" [[packages.wheels]] name = "ty-0.0.92-py3-none-macosx_11_0_arm64.whl" upload-time = 2026-10-26 03:13:14.029471+00:00 url = "https://files.pythonhosted.org/packages/18/6c/01c6680ea6d02e3b0a6a444dafbf549419fe4971cf7e139ea9c3f087a3c1/ty-0.0.92-py3-none-macosx_11_0_arm64.whl" size = 13064190 [packages.wheels.hashes] sha256 = "b1753fdfa241af570c1dbd3fbfae9ff600d2c479ba60efe528bbb47ca15860b0" [[packages.wheels]] name = "ty-0.0.92-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" upload-time = 2026-10-26 03:13:17.019412+00:00 url = "https://files.pythonhosted.org/packages/4e/f2/79faf9b5ee7defd6df4fc97ff693502d21d34762e16b9031e1cbb4b3f51c/ty-0.0.92-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" size = 13852740 [packages.wheels.hashes] sha256 = "cfaa6f4825144d0e873654d7223c72dda2ec8f9db6b1049e6897efbfb0a78680" [[packages.wheels]] name = "ty-0.0.92-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" upload-time = 2026-10-26 03:13:31.851841+00:00 url = "https://files.pythonhosted.org/packages/26/77/6fd2ec1fd78eb783e93b89f1e1aee7fce9095b32dc5f6cc0fc831bd903f4/ty-0.0.92-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" size = 14700747 [packages.wheels.hashes] sha256 = "a9b6b40225ddd0c049b299e0a655ce04d4de594c6cac3fb9c1e020b113494924" [[packages.wheels]] name = "ty-0.0.92-py3-none-win_amd64.whl" upload-time = 2026-10-26 03:13:53.170991+00:00 url = "https://files.pythonhosted.org/packages/77/b1/f455ba90564e00fbef1e58ea8b29bfebaad6c67ea050a3386fba32db55ef/ty-0.0.92-py3-none-win_amd64.whl" size = 13942099 [packages.wheels.hashes] sha256 = "646bed75c51c7ca3ae4cba015c699f384241bc873d6ad775e0c0d66629532312" [[packages]] name = "types-docutils" version = "0.23.0.20260923" marker = "\"dev\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "types_docutils-0.23.0.20260923.tar.gz" upload-time = 2026-09-23 06:51:56.366268+00:00 url = "https://files.pythonhosted.org/packages/00/ff/e5c4e5383a226703fc81e3e53bb2072526ec4b7e834eeb7146ea674192ed/types_docutils-0.23.0.20260923.tar.gz" size = 58327 [packages.sdist.hashes] sha256 = "486d9054ea84410cc201a30c15759077a5f32234247cfc08aa7290ff2509f770" [[packages.wheels]] name = "types_docutils-0.23.0.20260923-py3-none-any.whl" upload-time = 2026-09-23 06:51:54.908318+00:00 url = "https://files.pythonhosted.org/packages/3b/43/b39c0af29de3b02bcf6194a3d6675685d2d7b8b336a31af4525bc0022c4b/types_docutils-0.23.0.20260923-py3-none-any.whl" size = 92174 [packages.wheels.hashes] sha256 = "62851b429db623e36853a4a189ce15a846e0636fe090c135d6dc5dd4f9108ef5" [[packages]] name = "typing-extensions" version = "4.16.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "typing_extensions-4.16.0.tar.gz" upload-time = 2026-07-02 08:40:05.920538+00:00 url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz" size = 113555 [packages.sdist.hashes] sha256 = "dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" [[packages.wheels]] name = "typing_extensions-4.16.0-py3-none-any.whl" upload-time = 2026-07-02 08:40:04.659120+00:00 url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl" size = 45571 [packages.wheels.hashes] sha256 = "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" [[packages]] name = "urllib3" version = "2.8.0" marker = "\"dev\" in dependency_groups or \"docs\" in dependency_groups or \"docs-live\" in dependency_groups or \"type\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "urllib3-2.8.0.tar.gz" upload-time = 2026-09-15 19:29:36.253420+00:00 url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz" size = 458972 [packages.sdist.hashes] sha256 = "63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63" [[packages.wheels]] name = "urllib3-2.8.0-py3-none-any.whl" upload-time = 2026-09-15 19:29:34.577402+00:00 url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl" size = 135717 [packages.wheels.hashes] sha256 = "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" [[packages]] name = "userpath" version = "1.9.2" marker = "\"project-runtime\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "click" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "userpath-1.9.2.tar.gz" upload-time = 2024-02-29 21:39:08.742371+00:00 url = "https://files.pythonhosted.org/packages/d5/b7/30753098208505d7ff9be5b3a32112fb8a4cb3ddfccbbb7ba9973f2e29ff/userpath-1.9.2.tar.gz" size = 11140 [packages.sdist.hashes] sha256 = "6c52288dab069257cc831846d15d48133522455d4677ee69a9781f11dbefd815" [[packages.wheels]] name = "userpath-1.9.2-py3-none-any.whl" upload-time = 2024-02-29 21:39:07.551460+00:00 url = "https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl" size = 9065 [packages.wheels.hashes] sha256 = "2cbf01a23d655a1ff8fc166dfb78da1b641d1ceabf0fe5f970767d380b14e89d" [[packages]] name = "uv" version = "0.13.6" marker = "\"release\" in dependency_groups or \"uv\" in extras" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "uv-0.13.6.tar.gz" upload-time = 2026-10-27 00:45:57.605614+00:00 url = "https://files.pythonhosted.org/packages/08/d5/88d43310d425f6e3a43997ad771658ded3237d0ed78c16a89b2353eddf56/uv-0.13.6.tar.gz" size = 7545242 [packages.sdist.hashes] sha256 = "d762215478854f6cf20c3faed8f1eb30c8101d568e7bc32a3abafc063929d5c5" [[packages.wheels]] name = "uv-0.13.6-py3-none-macosx_11_0_arm64.whl" upload-time = 2026-10-27 00:45:09.125609+00:00 url = "https://files.pythonhosted.org/packages/f4/14/77230cc4f6a73a31578cb8070a5f67f58a76946ead643ae8a4155620d6da/uv-0.13.6-py3-none-macosx_11_0_arm64.whl" size = 14344205 [packages.wheels.hashes] sha256 = "51361029b7cf91185d0092c4b356d110b4ad629e9cb11593f1e006ac72a72ca9" [[packages.wheels]] name = "uv-0.13.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" upload-time = 2026-10-27 00:45:12.133681+00:00 url = "https://files.pythonhosted.org/packages/05/3b/f78583af6504f1270da95fd0d0935e381f58330c007260f450e81a1e5e1b/uv-0.13.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" size = 17764510 [packages.wheels.hashes] sha256 = "7ec5486476d67a4214c460b5492393ee2300d84e1de17b293100a019b413568b" [[packages.wheels]] name = "uv-0.13.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" upload-time = 2026-10-27 00:45:31.291019+00:00 url = "https://files.pythonhosted.org/packages/5c/43/0688ff5142934238f2812e348f4a24e09b57d48bfb38dd8f850325a0d9de/uv-0.13.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" size = 17333810 [packages.wheels.hashes] sha256 = "22759b8895e1103578de6fb4cd27e94b4399d021023378d29f01333717751fba" [[packages.wheels]] name = "uv-0.13.6-py3-none-manylinux_2_28_aarch64.whl" upload-time = 2026-10-27 00:45:34.282274+00:00 url = "https://files.pythonhosted.org/packages/99/3a/451b544e105b3595b27f74535c0d6b1105b1b2dcb9b49ca5282dd27bcbc3/uv-0.13.6-py3-none-manylinux_2_28_aarch64.whl" size = 16527707 [packages.wheels.hashes] sha256 = "2e9a99100abe4f99605366974c4f9fe27f236615e05de8e78c0f357e38f09f97" [[packages.wheels]] name = "uv-0.13.6-py3-none-win_amd64.whl" upload-time = 2026-10-27 00:45:52.086199+00:00 url = "https://files.pythonhosted.org/packages/7c/9d/714523188aea3054644d8e69ae8089aac70325a3cee2d3d05509d4f23398/uv-0.13.6-py3-none-win_amd64.whl" size = 15334118 [packages.wheels.hashes] sha256 = "7c9bc35ccb65932d8dc10a0669f9c805eaa3c9827ce18ecbecb1bd67944b2cdf" [[packages]] name = "uvicorn" version = "0.54.0" marker = "\"dev\" in dependency_groups or \"docs-live\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "click" }, { name = "h11" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "uvicorn-0.54.0.tar.gz" upload-time = 2026-09-25 06:52:37.601479+00:00 url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz" size = 112283 [packages.sdist.hashes] sha256 = "a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620" [[packages.wheels]] name = "uvicorn-0.54.0-py3-none-any.whl" upload-time = 2026-09-25 06:52:35.829563+00:00 url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl" size = 87427 [packages.wheels.hashes] sha256 = "505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf" [[packages]] name = "vcs-versioning" version = "2.6.0" marker = "\"build\" in dependency_groups" requires-python = ">=3.8" dependencies = [ { name = "packaging" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "vcs_versioning-2.6.0.tar.gz" upload-time = 2026-10-06 22:21:55.024903+00:00 url = "https://files.pythonhosted.org/packages/ad/e7/2a691db7d076b75d099fdba1a14cd966931f2aeb591e77f6d22f15097326/vcs_versioning-2.6.0.tar.gz" size = 166800 [packages.sdist.hashes] sha256 = "22e9159288e2d8bca2fa6f6c31c34c9c66aec157f60e9e526f5d160d2516d60f" [[packages.wheels]] name = "vcs_versioning-2.6.0-py3-none-any.whl" upload-time = 2026-10-06 22:21:53.267565+00:00 url = "https://files.pythonhosted.org/packages/73/1d/ad4064bf12f4503bf61e65d74c93c523672776d25ee0a06aaf42ae09ef3f/vcs_versioning-2.6.0-py3-none-any.whl" size = 123154 [packages.wheels.hashes] sha256 = "f8bebd1302ebc12e67ceb30fc7dd698ec969414b6fa43b30c3f14ac9b1e72c41" [[packages]] name = "virtualenv" version = "21.15.0" marker = "\"dev\" in dependency_groups or \"lint\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "packaging" }, { name = "platformdirs" }, { name = "python-discovery" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "virtualenv-21.15.0.tar.gz" upload-time = 2026-10-27 06:02:22.096683+00:00 url = "https://files.pythonhosted.org/packages/ee/00/c27937345311d2aaf6197203231a59010073d7abffae3971503ac0e65477/virtualenv-21.15.0.tar.gz" size = 5515331 [packages.sdist.hashes] sha256 = "41a0cfec413c886a991bf7657d02f8996e69e192485aa127b410f29f1e35cfd3" [[packages.wheels]] name = "virtualenv-21.15.0-py3-none-any.whl" upload-time = 2026-10-27 06:02:19.717969+00:00 url = "https://files.pythonhosted.org/packages/0f/25/fe570da03bdfda1bd489f3286ff9ca820e15ad8a1480d0d2c12871d58c84/virtualenv-21.15.0-py3-none-any.whl" size = 5558480 [packages.wheels.hashes] sha256 = "013eb9c340466186ac8923ce920ae05becfa1390fdf3565b7eae0d191a2f4a2e" [[packages]] name = "watchfiles" version = "1.3.0" marker = "\"dev\" in dependency_groups or \"docs-live\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "anyio" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "watchfiles-1.3.0.tar.gz" upload-time = 2026-09-21 09:08:55.664410+00:00 url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz" size = 108119 [packages.sdist.hashes] sha256 = "99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207" [[packages.wheels]] name = "watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl" upload-time = 2026-09-21 09:08:15.160503+00:00 url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl" size = 397647 [packages.wheels.hashes] sha256 = "bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c" [[packages.wheels]] name = "watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" upload-time = 2026-09-21 09:08:16.587664+00:00 url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" size = 454994 [packages.wheels.hashes] sha256 = "4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5" [[packages.wheels]] name = "watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" upload-time = 2026-09-21 09:08:23.700623+00:00 url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" size = 458353 [packages.wheels.hashes] sha256 = "b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1" [[packages.wheels]] name = "watchfiles-1.3.0-cp310-abi3-win_amd64.whl" upload-time = 2026-09-21 09:08:30.450417+00:00 url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl" size = 290643 [packages.wheels.hashes] sha256 = "1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61" [[packages]] name = "wcwidth" version = "0.9.4" marker = "\"release\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "wcwidth-0.9.4.tar.gz" upload-time = 2026-10-24 18:11:44.954051+00:00 url = "https://files.pythonhosted.org/packages/ea/01/e5ac23733628a6bbb91913a765fcf55ee8070d8d991bc88fdaaf805052c7/wcwidth-0.9.4.tar.gz" size = 970051 [packages.sdist.hashes] sha256 = "fd8d57d3cdfe570e3265c8fab1885c24e34ef40889c0759c0c304a19e9835954" [[packages.wheels]] name = "wcwidth-0.9.4-cp310-abi3-macosx_11_0_arm64.whl" upload-time = 2026-10-24 18:11:02.008720+00:00 url = "https://files.pythonhosted.org/packages/04/e9/dfdca30ca36c472de3e260406dee29fc108499ec590c63b3fe279887ee9d/wcwidth-0.9.4-cp310-abi3-macosx_11_0_arm64.whl" size = 618904 [packages.wheels.hashes] sha256 = "f279f32c8e206a9092fa350e53ca0c2bd8072e45b1c9fd31bb6d476b59b591c4" [[packages.wheels]] name = "wcwidth-0.9.4-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" upload-time = 2026-10-24 18:11:07.829696+00:00 url = "https://files.pythonhosted.org/packages/7d/5b/42bdf9a4b0e4f9126584af2ec73642de495f3bd8add907430f13c3bb98d2/wcwidth-0.9.4-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl" size = 789012 [packages.wheels.hashes] sha256 = "093469e01d881bff3db93ff3f56c364dc87c441ea01f9f41c22de0c0bdf5c6ef" [[packages.wheels]] name = "wcwidth-0.9.4-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" upload-time = 2026-10-24 18:11:18.748328+00:00 url = "https://files.pythonhosted.org/packages/fa/e8/0485850748dfdba9443f4151c7b2442cfa4987ba6cd871038eb5d6b8eca9/wcwidth-0.9.4-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" size = 785946 [packages.wheels.hashes] sha256 = "b185c8b927b23af2721be1783499d1cfa5c028b098b0858951b4ae224477f1c2" [[packages.wheels]] name = "wcwidth-0.9.4-cp310-abi3-win_amd64.whl" upload-time = 2026-10-24 18:11:36.952282+00:00 url = "https://files.pythonhosted.org/packages/d6/f1/c123f902bc2ff9e9c2ab66241b8872204df87cbb18ecdf035e120860fedc/wcwidth-0.9.4-cp310-abi3-win_amd64.whl" size = 597780 [packages.wheels.hashes] sha256 = "f28d77153303450ee13ebbe888297e75cf532c754555b6cabafc9bdda16c421c" [[packages.wheels]] name = "wcwidth-0.9.4-py3-none-any.whl" upload-time = 2026-10-24 18:10:52.651030+00:00 url = "https://files.pythonhosted.org/packages/6f/0c/309b1813b230d1dbbd6f2c2ab2bb21ef68992358977605d9ef289bbfedcc/wcwidth-0.9.4-py3-none-any.whl" size = 301806 [packages.wheels.hashes] sha256 = "901eace6d2dd25ebb917d0ce5db18e5c537e1504c905cf32f0d006c12098b05d" [[packages]] name = "websockets" version = "17.3" marker = "\"dev\" in dependency_groups or \"docs-live\" in dependency_groups" requires-python = ">=3.11" index = "https://pypi.org/simple/" [packages.sdist] name = "websockets-17.3.tar.gz" upload-time = 2026-10-19 16:58:05.813602+00:00 url = "https://files.pythonhosted.org/packages/30/4b/4692be6d5bf739ed049b3813901c42091f8c054df89d5c089343823dea0d/websockets-17.3.tar.gz" size = 188878 [packages.sdist.hashes] sha256 = "1c7dd93a1c9f89297471493674b1fc1d9467cafd45374f2172213046bdb3a329" [[packages.wheels]] name = "websockets-17.3-cp315-cp315-macosx_10_15_universal2.whl" upload-time = 2026-10-19 16:56:50.423293+00:00 url = "https://files.pythonhosted.org/packages/6e/41/1efe5ecae1f32495dc75641704f959bd2d8e56cdc825f3acc5e010a51960/websockets-17.3-cp315-cp315-macosx_10_15_universal2.whl" size = 218211 [packages.wheels.hashes] sha256 = "f300f28dff74ff4148c21e6971e4de6ca0c34e0086a3d95c017c5174ebf85ca5" [[packages.wheels]] name = "websockets-17.3-cp315-cp315-macosx_11_0_arm64.whl" upload-time = 2026-10-19 16:56:53.125319+00:00 url = "https://files.pythonhosted.org/packages/d5/b9/ec450b9650925dd5cf27c1f36d0464786fe3ac702ed656879c4c0dd5c4df/websockets-17.3-cp315-cp315-macosx_11_0_arm64.whl" size = 216164 [packages.wheels.hashes] sha256 = "09f007b2150eff015b2109f006e2ac2012d376f41486defa88ae78a776b00cff" [[packages.wheels]] name = "websockets-17.3-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" upload-time = 2026-10-19 16:56:55.727014+00:00 url = "https://files.pythonhosted.org/packages/0c/1b/f6679ad61cb8c110033a1b30d561d5c1052983e202157251ff1778f27932/websockets-17.3-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" size = 226042 [packages.wheels.hashes] sha256 = "15decc0fe293d9e3f924bd483f3d76f3d0ff26845112a470e3f8842fec2c82a1" [[packages.wheels]] name = "websockets-17.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" upload-time = 2026-10-19 16:56:57.076984+00:00 url = "https://files.pythonhosted.org/packages/28/c6/ed993aaef8046cbbb939ae367ee3dd5383dbd055f0500a5b8aed101168b6/websockets-17.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" size = 227203 [packages.wheels.hashes] sha256 = "997617dd7856c5dad742bf6893b872319156dd43f47587541a59e4a852d52bbc" [[packages.wheels]] name = "websockets-17.3-cp315-cp315-win_amd64.whl" upload-time = 2026-10-19 16:57:16.234613+00:00 url = "https://files.pythonhosted.org/packages/a0/7c/c2df129d9e344b99bb388bcac54fbbdfeec29e6c716c12dc9a3398757940/websockets-17.3-cp315-cp315-win_amd64.whl" size = 218490 [packages.wheels.hashes] sha256 = "4d5f2492cb2192aab2d42e57f2caa2159b4fd20fde06f859ec13354f524b176c" [[packages.wheels]] name = "websockets-17.3-py3-none-any.whl" upload-time = 2026-10-19 16:58:04.483050+00:00 url = "https://files.pythonhosted.org/packages/d7/b0/3f01fcb3406662f6deb1e3e72b34dd0979e993991c0b7435a9e12bc14f02/websockets-17.3-py3-none-any.whl" size = 212375 [packages.wheels.hashes] sha256 = "dc2b495a92b3971966bf102e73589f950db3ec1ca6713265355ce7771d97b199" [tool.nab] nab-version = "0.0.23" created-at = 2026-11-03 21:20:30.579166+00:00 command-line = [ "nab", "lock", "--all-groups", "--all-extras", "--no-cache", ] input-path = "pyproject.toml" mode = "universal" python-specifier = ">=3.15,<3.16" platforms = [ "linux_x86_64", "linux_aarch64", "macos_arm64", "windows_amd64", ] pipx-1.18.16/pylock.zipapp.toml000066400000000000000000000166411527355705200163730ustar00rootroot00000000000000lock-version = "1.0" environments = [ "python_version == \"3.11\" and sys_platform == \"linux\" and platform_machine == \"x86_64\"", "python_version == \"3.11\" and sys_platform == \"win32\" and platform_machine == \"AMD64\"", "python_version == \"3.11\" and sys_platform == \"darwin\" and platform_machine == \"arm64\"", ] requires-python = ">=3.11" dependency-groups = [ "project-runtime", ] default-groups = [ "project-runtime", ] created-by = "nab" [[packages]] name = "argcomplete" version = "3.7.2" marker = "\"project-runtime\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "argcomplete-3.7.2.tar.gz" upload-time = 2026-08-06 04:53:21.662042+00:00 url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz" size = 74473 [packages.sdist.hashes] sha256 = "aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9" [[packages.wheels]] name = "argcomplete-3.7.2-py3-none-any.whl" upload-time = 2026-08-06 04:53:20.246664+00:00 url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl" size = 43294 [packages.wheels.hashes] sha256 = "6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19" [[packages]] name = "click" version = "8.5.0" marker = "\"project-runtime\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "click-8.5.0.tar.gz" upload-time = 2026-08-26 13:33:14.560531+00:00 url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz" size = 382235 [packages.sdist.hashes] sha256 = "ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34" [[packages.wheels]] name = "click-8.5.0-py3-none-any.whl" upload-time = 2026-08-26 13:33:12.928043+00:00 url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl" size = 125251 [packages.wheels.hashes] sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" [[packages]] name = "colorama" version = "0.4.6" marker = "\"project-runtime\" in dependency_groups and sys_platform == \"win32\"" requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" index = "https://pypi.org/simple/" [packages.sdist] name = "colorama-0.4.6.tar.gz" upload-time = 2022-10-25 02:36:22.414799+00:00 url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz" size = 27697 [packages.sdist.hashes] sha256 = "08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" [[packages.wheels]] name = "colorama-0.4.6-py2.py3-none-any.whl" upload-time = 2022-10-25 02:36:20.889702+00:00 url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl" size = 25335 [packages.wheels.hashes] sha256 = "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" [[packages]] name = "filelock" version = "4.2.4" marker = "\"project-runtime\" in dependency_groups" requires-python = ">=3.11" index = "https://pypi.org/simple/" [packages.sdist] name = "filelock-4.2.4.tar.gz" upload-time = 2026-10-19 01:57:51.001795+00:00 url = "https://files.pythonhosted.org/packages/d8/28/3893324bcadf8f89b1616637468d766af3f625008c0735bf704fdfa86617/filelock-4.2.4.tar.gz" size = 652444 [packages.sdist.hashes] sha256 = "c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d" [[packages.wheels]] name = "filelock-4.2.4-py3-none-any.whl" upload-time = 2026-10-19 01:57:49.419460+00:00 url = "https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl" size = 162475 [packages.wheels.hashes] sha256 = "301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e" [[packages]] name = "packaging" version = "26.3" marker = "\"project-runtime\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "packaging-26.3.tar.gz" upload-time = 2026-08-04 18:15:28.737071+00:00 url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz" size = 313412 [packages.sdist.hashes] sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" [[packages.wheels]] name = "packaging-26.3-py3-none-any.whl" upload-time = 2026-08-04 18:15:27.159957+00:00 url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl" size = 129956 [packages.wheels.hashes] sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" [[packages]] name = "platformdirs" version = "4.13.6" marker = "\"project-runtime\" in dependency_groups" requires-python = ">=3.11" index = "https://pypi.org/simple/" [packages.sdist] name = "platformdirs-4.13.6.tar.gz" upload-time = 2026-10-27 08:19:11.538584+00:00 url = "https://files.pythonhosted.org/packages/1a/43/8c4b5fe23f0e119cf4e1b16c2d16240ab9057cdbb138568c91d5a48abf81/platformdirs-4.13.6.tar.gz" size = 62750 [packages.sdist.hashes] sha256 = "211d176e24674b5895dedf9021c262bcf9fad008a70610bfb147d4a8d6d8fe1b" [[packages.wheels]] name = "platformdirs-4.13.6-py3-none-any.whl" upload-time = 2026-10-27 08:19:10.405474+00:00 url = "https://files.pythonhosted.org/packages/a8/24/b312a9466839b1f708eed42790b47e927dc7f774a528bb49b1cdf8b734b9/platformdirs-4.13.6-py3-none-any.whl" size = 32767 [packages.wheels.hashes] sha256 = "21fc198fb09f3e78cadcdfd8a6ae4fd65cadc64a3cba78b694321c2996114a3e" [[packages]] name = "userpath" version = "1.9.2" marker = "\"project-runtime\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "click" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "userpath-1.9.2.tar.gz" upload-time = 2024-02-29 21:39:08.742371+00:00 url = "https://files.pythonhosted.org/packages/d5/b7/30753098208505d7ff9be5b3a32112fb8a4cb3ddfccbbb7ba9973f2e29ff/userpath-1.9.2.tar.gz" size = 11140 [packages.sdist.hashes] sha256 = "6c52288dab069257cc831846d15d48133522455d4677ee69a9781f11dbefd815" [[packages.wheels]] name = "userpath-1.9.2-py3-none-any.whl" upload-time = 2024-02-29 21:39:07.551460+00:00 url = "https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl" size = 9065 [packages.wheels.hashes] sha256 = "2cbf01a23d655a1ff8fc166dfb78da1b641d1ceabf0fe5f970767d380b14e89d" [tool.nab] nab-version = "0.0.23" created-at = 2026-11-03 21:20:33.376182+00:00 command-line = [ "nab", "lock", "--no-cache", "pyproject.toml", "--project-matrix-python", ">=3.11,<3.12", "--project-matrix-platforms", "linux_x86_64", "windows_amd64", "macos_arm64", "--project-matrix-implementations", "cpython", "--output", "pylock.zipapp.toml", "--upgrade", ] input-path = "pyproject.toml" mode = "universal" python-specifier = ">=3.11,<3.12" platforms = [ "linux_x86_64", "windows_amd64", "macos_arm64", ] cli-project-overrides = [ "--project-matrix-python=>=3.11,<3.12", "--project-matrix-platforms=linux_x86_64 windows_amd64 macos_arm64", "--project-matrix-implementations=cpython", ] pipx-1.18.16/pyproject.toml000066400000000000000000000240771527355705200156110ustar00rootroot00000000000000[build-system] build-backend = "hatchling.build" requires = [ "docutils>=0.21.2", "hatch-fancy-pypi-readme>=25.1", "hatch-vcs>=0.5", "hatchling>=1.32.4", ] [project] name = "pipx" import-names = [ "pipx" ] description = "Install and Run Python Applications in Isolated Environments" keywords = [ "cli", "install", "pip", "Virtual Environment", "workflow", ] license = "MIT" license-files = [ "LICENSE" ] authors = [ { name = "Chad Smith", email = "chadsmith.software@gmail.com" }, ] requires-python = ">=3.11" classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", ] dynamic = [ "readme", "version", ] dependencies = [ "argcomplete>=3.7.2", "colorama>=0.4.6; sys_platform=='win32'", "filelock>=4.2", "packaging>=26.3", "platformdirs>=4.12.2", "userpath!=1.9,>=1.9.2", ] optional-dependencies.uv = [ "uv>=0.12.22", ] urls."Issue Tracker" = "https://github.com/pypa/pipx/issues" urls."Source Code" = "https://github.com/pypa/pipx" urls.Changelog = "https://pipx.pypa.io/latest/changelog.html" urls.Documentation = "https://pipx.pypa.io" urls.Homepage = "https://pipx.pypa.io" scripts.pipx = "pipx.__main__:cli" [dependency-groups] dev = [ { include-group = "docs-live" }, { include-group = "lint" }, { include-group = "man" }, { include-group = "test" }, { include-group = "type" }, ] test = [ "docutils>=0.21.2", "flit-core>=4.1", "pypiserver[passlib]>=2.4.2", "pytest>=9.1.1", "pytest-cov>=7.1", "pytest-mock>=3.16", "pytest-subprocess>=1.6", "pytest-timeout>=2.4", "pytest-xdist>=3.8", "setuptools>=84; python_version>='3.12'", ] type = [ "gitpython>=3.2", "hatchling>=1.32.4", "ty>=0.0.84", "types-docutils>=0.22.3.20260724", { include-group = "docs" }, { include-group = "test" }, ] docs = [ "furo>=2025.12.19", "sphinx>=8.1.3", "sphinx-argparse-cli>=1.19", # generate the CLI reference from pipx's argparse parser so it cannot drift "sphinx-autodoc-typehints>=3.0.1", "sphinx-codeautolink>=0.19", # link the names in code blocks to the reference "sphinx-copybutton>=0.5.2", "sphinx-design>=0.6.1", # the card grid on the landing page "sphinx-issues>=6", # the :issue:/:pr: roles used in the changelog and notes "sphinx-last-updated-by-git>=0.3.8", "sphinx-llm!=1.1,>=1", "sphinx-notfound-page>=1.1", # a versioned 404 page "sphinx-reredirects>=0.1.6", # keep old MkDocs URLs alive after the move to Sphinx "sphinx-sitemap>=2.9", # emit sitemap.xml for crawlers "sphinxcontrib-mermaid>=2.1.1", # the .. mermaid:: directive used across the explanation diagrams "sphinxcontrib-towncrier>=0.5.0a0", # render unreleased news fragments as a draft changelog section "sphinxext-opengraph>=0.13", # OpenGraph tags for link previews "towncrier>=26.9", ] build = [ "cython>=3", "docutils>=0.21.2", "hatch-fancy-pypi-readme>=25.1", "hatch-vcs>=0.5", "hatchling>=1.32.4", "setuptools>=80", ] docs-live = [ "sphinx-autobuild>=2025.8.25", { include-group = "docs" }, ] lint = [ "pre-commit>=4.6.2", ] man = [ "docutils>=0.21.2", ] release = [ "prek>=0.5", "release-creator>=4.4", "towncrier>=26.9", "uv>=0.12.22", ] release-news = [ "release-creator>=4.4", "towncrier>=26.9", ] release-notes = [ "release-creator>=4.4", ] release-prepare = [ "prek>=0.5", "release-creator>=4.4", "towncrier>=26.9", ] zipapp = [ "shiv>=1.0.8", ] [tool.hatch] version.source = "vcs" metadata.hooks.fancy-pypi-readme.content-type = "text/markdown" metadata.hooks.fancy-pypi-readme.fragments = [ { path = "docs/README.md" } ] build.hooks.custom = {} build.hooks.vcs.version-file = "src/pipx/version.py" build.targets.wheel.shared-data = { "build/man" = "share/man/man1" } build.targets.sdist.include = [ "/docs/man", "/hatch_build.py", "/logo.png", "/pipx_demo.gif", "/src", ] [tool.uv] dependency-groups.release = { requires-python = ">=3.15" } dependency-groups.release-news = { requires-python = ">=3.15" } dependency-groups.release-notes = { requires-python = ">=3.15" } dependency-groups.release-prepare = { requires-python = ">=3.15" } [tool.ruff] line-length = 120 extend-exclude = [ "testdata", ] format.preview = true format.docstring-code-line-length = 100 format.docstring-code-format = true lint.select = [ "ALL", ] lint.ignore = [ "CPY", # no copyright header "D", # ignore documentation for now "docstring-missing-exception", # broken with sphinx docs "docstring-missing-returns", # no restructuredtext support yet "docstring-missing-yields", # no restructuredtext support yet "hardcoded-bind-all-interfaces", # possible binding to all interfaces "implicit-namespace-package", # no implicit namespaces here "incorrect-blank-line-before-class", # `one-blank-line-before-class` and `no-blank-line-before-class` are incompatible "missing-trailing-comma", # conflicts with formatter "multi-line-summary-first-line", # `multi-line-summary-first-line` and `multi-line-summary-second-line` are incompatible "non-empty-init-module", # `__init__` module should only contain docstrings and re-exports "single-line-implicit-string-concatenation", # conflicts with formatter "subprocess-without-shell-equals-true", # using subprocess is alright "suspicious-subprocess-import", # using subprocess is alright ] lint.per-file-ignores."src/pipx/main.py" = [ "import-outside-top-level", # each command loads its module on dispatch to keep CLI startup fast ] lint.per-file-ignores."src/pipx/venv.py" = [ "stdlib-module-shadowing", # module shadows the standard library ] lint.per-file-ignores."tests/**/*.py" = [ "assert", # asserts allowed in tests "D", # don't care about documentation in tests "FBT", # don't care about booleans as positional arguments in tests "implicit-namespace-package", # tests/ holds plain modules imported by name, not a package "magic-value-comparison", # tests compare results against literal expected values "pytest-fixture-autouse", # shared setup fixtures are meant to apply to every test "subprocess-without-shell-equals-true", # tests run only the commands they build themselves "too-many-arguments", # pytest injects each fixture as an argument ] lint.isort = { known-first-party = [ "helpers", "package_info", "pipx", ], required-imports = [ "from __future__ import annotations", ] } lint.mccabe.max-complexity = 15 lint.preview = true [tool.codespell] # the pattern matches case-sensitively ignore-regex = "\\b(UE|path/doesnt/exist)\\b" # Ref: https://github.com/codespell-project/codespell#using-a-config-file skip = ".git,*.pdf,*.svg,.nox,testdata,.mypy_cache" check-hidden = true [tool.ty] src.include = [ "hatch_build.py", "scripts", "src", "tests" ] environment.python-version = "3.15" rules.unused-ignore-comment = "error" [tool.pytest] ini_options.minversion = "8" ini_options.testpaths = [ "tests" ] ini_options.addopts = [ "-ra", "--strict-config", "--strict-markers" ] ini_options.filterwarnings = [ "error", # importlib.metadata warns before returning None for a dist-info carrying no Name; venv_inspect handles that case # and Python 3.15 turns it into the KeyError the code already catches "ignore:Implicit None on return values is deprecated:DeprecationWarning", ] ini_options.log_level = "INFO" # the limit itself comes from PYTEST_TIMEOUT in tox.toml; session fixtures that build shared libs stay outside it ini_options.timeout_func_only = true [tool.coverage] run.relative_files = true # tox installs pipx as a wheel, so map its site-packages copy back onto src/ in reports paths.source = [ "src", "*/site-packages", "*\\site-packages" ] [tool.towncrier] package = "pipx" directory = "changelog.d" filename = "docs/changelog.rst" start_string = ".. towncrier release notes start\n" template = ".github/towncrier-template.rst" title_format = "" issue_format = ":issue:`{issue}`" underlines = [ "=", "-", "~", ] type = [ { directory = "feature", name = "Added", showcontent = true }, { directory = "change", name = "Changes", showcontent = true }, { directory = "bugfix", name = "Fixed", showcontent = true }, { directory = "breaking", name = "Breaking changes", showcontent = true }, { directory = "performance", name = "Performance", showcontent = true }, { directory = "doc", name = "Documentation", showcontent = true }, { directory = "security", name = "Security", showcontent = true }, { directory = "deprecation", name = "Deprecated", showcontent = true }, { directory = "removal", name = "Removed", showcontent = true }, { directory = "packaging", name = "Packaging", showcontent = true }, ] [tool.flake8-lazy] standalone.line-length = 120 standalone.lazy-exclude-modules = [ # records whether stderr is a terminal at startup, which decides if spinners animate "pipx.animate", # records whether stdout is a terminal at startup and runs colorama.init() on Windows before anything prints "pipx.colors", # reads PIPX_FETCH_PYTHON and PIPX_FETCH_MISSING_PYTHON at startup "pipx.constants", # reads PIPX_USE_EMOJI and USE_EMOJI at startup "pipx.emojis", ] [tool.nab] mode = "universal" base-group = "project-runtime" default-groups = [] uploaded-prior-to = "P7D" decision-order = "stable" constraints = [ "pytest-timeout!=2.5.0" ] matrix.python = ">=3.15,<3.16" matrix.platforms = [ "linux_x86_64", "linux_aarch64", "macos_arm64", "windows_amd64" ] # release-creator 4.4.0 needs a seven-day cutoff exception. # expires 2026-11-09 # expires 2026-11-09 packages.release-creator.uploaded-prior-to = 2026-11-02T21:45:00Z pipx-1.18.16/scripts/000077500000000000000000000000001527355705200143525ustar00rootroot00000000000000pipx-1.18.16/scripts/check_distribution_manpage.py000066400000000000000000000050621527355705200222730ustar00rootroot00000000000000from __future__ import annotations import sys import tarfile import zipfile from pathlib import Path from typing import Final from docutils.core import publish_string from docutils.writers.manpage import Writer def main() -> None: distribution_dir: Final[Path] = Path(sys.argv[1]) rst: Final[str] = Path(sys.argv[2]).read_text(encoding="utf-8") _check_source_archive(distribution_dir, rst.encode()) generated_manpage: Final[bytes] = publish_string( "\n".join(line for line in rst.splitlines() if line.strip() != ":orphan:"), writer=Writer(), settings_overrides={"report_level": 5}, ) _check_wheel(distribution_dir, generated_manpage) def _check_source_archive(distribution_dir: Path, rst: bytes) -> None: source_archive_path: Final[Path] = _require_one( list(distribution_dir.glob("pipx-*.tar.gz")), "source archive", distribution_dir ) with tarfile.open(source_archive_path) as source_archive: sources: Final = [ member for member in source_archive.getmembers() if member.name.endswith("/docs/man/pipx.1.rst") ] if len(sources) != 1: msg = f"Missing docs/man/pipx.1.rst from {source_archive_path.name}" raise SystemExit(msg) if (extracted_source := source_archive.extractfile(sources[0])) is None or extracted_source.read() != rst: msg = f"Manpage source differs in {source_archive_path.name}" raise SystemExit(msg) def _require_one(paths: list[Path], description: str, distribution_dir: Path) -> Path: if len(paths) != 1: msg = f"Expected one pipx {description} in {distribution_dir}; found {len(paths)}" raise SystemExit(msg) return paths[0] def _check_wheel(distribution_dir: Path, generated_manpage: bytes) -> None: wheel_path: Final[Path] = _require_one(list(distribution_dir.glob("pipx-*.whl")), "wheel", distribution_dir) with zipfile.ZipFile(wheel_path) as wheel: manpages: Final[list[str]] = [ name for name in wheel.namelist() if name.endswith(".data/data/share/man/man1/pipx.1") ] if len(manpages) != 1: msg = ( f"Wheel {wheel_path.name} has no manual page at share/man/man1/pipx.1, " "so installers cannot place it in the target shared-data directory" ) raise SystemExit(msg) if wheel.read(manpages[0]) != generated_manpage: msg = f"Manual page in {wheel_path.name} differs from generated output" raise SystemExit(msg) if __name__ == "__main__": main() pipx-1.18.16/scripts/generate_man.py000077500000000000000000000117741527355705200173660ustar00rootroot00000000000000#!/usr/bin/env python3 from __future__ import annotations import os import sys import textwrap from argparse import ( SUPPRESS, Action, ArgumentParser, _SubParsersAction, # ruff:ignore[import-private-name] # argparse exposes no public subparser type ) from pathlib import Path from typing import Final def main() -> None: documented_python: Final = os.environ.get("PIPX__DOC_DEFAULT_PYTHON") os.environ["PIPX__DOC_DEFAULT_PYTHON"] = "python3" original_program: Final[str] = sys.argv[0] sys.argv[0] = "pipx" try: # pipx reads the documentation environment on import. from pipx.main import get_command_parser # ruff:ignore[import-outside-top-level] parser = get_command_parser() finally: sys.argv[0] = original_program if documented_python is None: os.environ.pop("PIPX__DOC_DEFAULT_PYTHON") else: os.environ["PIPX__DOC_DEFAULT_PYTHON"] = documented_python output: Final[Path] = Path(sys.argv[1] if len(sys.argv) > 1 else "docs/man/pipx.1.rst") output.parent.mkdir(parents=True, exist_ok=True) output.write_text(_generate_manpage_rst(parser), encoding="utf-8") def _generate_manpage_rst(parser: ArgumentParser) -> str: commands: Final[list[tuple[str, str]]] = _get_subcommands(parser) synopsis_commands: Final[str] = " | ".join(f"**{name}**" for name, _ in commands) lines: Final[list[str]] = [ ":orphan:", "", "====", "pipx", "====", "", "-" * 60, "install and run Python applications in isolated environments", "-" * 60, "", ":Manual section: 1", ":Manual group: User Commands", "", "SYNOPSIS", "--------", "", *textwrap.wrap(f"**pipx** [*global-options*] [{synopsis_commands}] [*command-options*]", width=120), "", "DESCRIPTION", "-----------", "", "pipx installs and runs Python applications in isolated environments while exposing their commands on your", "``PATH``.", "", ] lines.extend(_commands_section(commands)) lines.extend(_global_options_section(parser)) lines.extend(_static_sections()) return "".join(f"{line}\n" for line in lines) def _get_subcommands(parser: ArgumentParser) -> list[tuple[str, str]]: if parser._subparsers is None: # ruff:ignore[private-member-access] # argparse exposes no public subparser API return [] for action in parser._subparsers._actions: # ruff:ignore[private-member-access] # argparse exposes no public subparser API if isinstance(action, _SubParsersAction): return [(choice.dest, choice.help or "") for choice in action._choices_actions] # ruff:ignore[private-member-access] # argparse exposes no public subparser API return [] def _commands_section(commands: list[tuple[str, str]]) -> list[str]: lines: Final[list[str]] = ["COMMANDS", "--------", ""] for name, help_text in commands: lines.append(f"**{name}**") lines.extend(f" {line}" for line in textwrap.wrap(help_text.replace("`", "``"), width=116)) lines.append("") lines.extend(["Run **pipx** *command* **--help** for command-specific options.", ""]) return lines def _global_options_section(parser: ArgumentParser) -> list[str]: lines: Final[list[str]] = ["GLOBAL OPTIONS", "--------------", ""] seen: Final[set[int]] = set() for action in parser._actions: # ruff:ignore[private-member-access] # argparse exposes no public action list API if id(action) in seen or action.help == SUPPRESS or isinstance(action, _SubParsersAction): continue seen.add(id(action)) if option := _format_option(action): lines.extend([option, f" {action.help}", ""]) return lines def _format_option(action: Action) -> str: if not action.option_strings or not action.help: return "" option = ", ".join(f"**{value}**" for value in action.option_strings) if action.metavar: option += f" *{action.metavar if isinstance(action.metavar, str) else action.metavar[0]}*" return option def _static_sections() -> list[str]: return [ "ENVIRONMENT VARIABLES", "---------------------", "", "**PIPX_HOME**", " Directory for pipx-managed environments and state.", "", "**PIPX_BIN_DIR**", " Directory where pipx exposes application commands.", "", "**PIPX_DEFAULT_PYTHON**", " Default interpreter used to create environments.", "", "**PIPX_USE_EMOJI**", " Set to ``0`` to disable emoji output.", "", "SEE ALSO", "--------", "", "Full documentation: https://pipx.pypa.io/", "", r"**pip**\(1), **virtualenv**\(1)", "", "AUTHORS", "-------", "", "Chad Smith and contributors", "", "https://github.com/pypa/pipx", ] if __name__ == "__main__": main() pipx-1.18.16/scripts/list_test_packages.py000077500000000000000000000102731527355705200206020ustar00rootroot00000000000000#!/usr/bin/env python3 from __future__ import annotations import argparse import re import shlex import subprocess import sys import tempfile from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from pathlib import Path from typing import Final from test_packages_support import get_platform_list_path class _Options(argparse.Namespace): primary_package_list: Path package_list_dir: Path verbose: bool @dataclass(frozen=True, slots=True, kw_only=True) class _TestPackage: spec: str no_deps: bool def main(argv: list[str]) -> int: parser: Final = argparse.ArgumentParser( description="Create list of needed test packages for pipx tests and local pypiserver." ) parser.add_argument( "primary_package_list", type=Path, help="Main packages to examine, getting list of matching distribution files and dependencies.", ) parser.add_argument("package_list_dir", type=Path, help="Directory to output package distribution lists.") parser.add_argument( "-v", "--verbose", action="store_true", help="Maximum verbosity, especially for pip operations." ) options: Final = parser.parse_args(argv[1:], namespace=_Options()) return create_test_packages_list(options.package_list_dir, options.primary_package_list) def create_test_packages_list(package_list_dir_path: Path, primary_package_list_path: Path) -> int: package_list_dir_path.mkdir(exist_ok=True) if not (primary_test_packages := _parse_package_list(primary_package_list_path)): return 1 exit_code = 0 downloaded: Final[set[str]] = set() with ThreadPoolExecutor(max_workers=12) as pool: futures = {pool.submit(_download, package) for package in primary_test_packages} for future in as_completed(futures): try: downloaded.update(future.result()) except subprocess.CalledProcessError as error: sys.stderr.write(f"{shlex.join(error.cmd)} failed:\n{error.stderr}") exit_code = 1 # a partial list would stick around and fail tests with "no matching distribution", so write none if exit_code: return exit_code package_specs: Final[list[str]] = sorted( f"{match.group(1)}=={match.group(2)}" for downloaded_path in downloaded if ( match := re.search(r"([^-]+)-([^-]+)-([^-]+)\-([^-]+)-([^-]+)(-[^-]+)?\.whl$", downloaded_path) or re.search(r"(.+)-([^-]+)\.(?:tar.gz|zip)$", downloaded_path) ) ) with get_platform_list_path(package_list_dir_path).open("w") as package_list_fh: package_list_fh.writelines(f"{package_spec}\n" for package_spec in package_specs) return exit_code def _parse_package_list(package_list_file: Path) -> list[_TestPackage]: try: with package_list_file.open("r", encoding="utf-8") as package_list_fh: lines: Final[list[str]] = list(package_list_fh) except OSError: return [] test_packages: Final[list[_TestPackage]] = [] for line in lines: match re.sub(r"#.+$", "", line).split(): case []: continue case [spec]: test_packages.append(_TestPackage(spec=spec, no_deps=False)) case [spec, no_deps]: test_packages.append(_TestPackage(spec=spec, no_deps=no_deps.lower() == "true")) case _: return [] return test_packages def _download(test_package: _TestPackage) -> set[str]: with tempfile.TemporaryDirectory() as download_dir: subprocess.run( [ # ruff:ignore[start-process-with-partial-path] # whichever pip is on PATH "pip", "download", *(["--no-deps"] if test_package.no_deps else []), test_package.spec, "-d", download_dir, ], capture_output=True, text=True, check=True, ) return {path.name for path in Path(download_dir).iterdir()} if __name__ == "__main__": try: sys.exit(main(sys.argv)) except KeyboardInterrupt: sys.exit(130) __all__ = [ "create_test_packages_list", ] pipx-1.18.16/scripts/migrate_pipsi_to_pipx.py000077500000000000000000000021651527355705200213310ustar00rootroot00000000000000#!/usr/bin/env python3 """Migrate the packages pipsi manages to pipx.""" from __future__ import annotations import os import subprocess import sys from pathlib import Path from shutil import which from typing import Final def main() -> None: if not which("pipx"): sys.exit("pipx must be installed to migrate from pipsi to pipx") if not sys.stdout.isatty(): sys.exit("Must be run from a terminal, not a script") pipsi_home: Final = os.environ.get("PIPSI_HOME", Path("~/.local/venvs/").expanduser()) if not (packages := [path.name for path in Path(pipsi_home).iterdir()]): sys.exit(0) while (answer := input("Continue? [y/n] ")) not in {"y", "n"}: pass if answer == "n": sys.exit(0) for package in packages: if not subprocess.run(["pipsi", "uninstall", "--yes", package], check=False).returncode: # ruff:ignore[start-process-with-partial-path] # pipsi resolved from PATH subprocess.run(["pipx", "install", package], check=False) # ruff:ignore[start-process-with-partial-path] # pipx resolved from PATH if __name__ == "__main__": main() pipx-1.18.16/scripts/release.py000066400000000000000000000123041527355705200163440ustar00rootroot00000000000000"""Preview a release locally, or dispatch its protected workflow.""" from __future__ import annotations import argparse import os import subprocess import sys from pathlib import Path from tempfile import TemporaryDirectory from typing import Final _ROOT: Final[Path] = Path(__file__).resolve().parent.parent class _Options(argparse.Namespace): bump: str dispatch: bool def main() -> None: parser: Final = argparse.ArgumentParser(description=__doc__) parser.add_argument("--bump", choices=("auto", "major", "minor", "patch"), default="auto") parser.add_argument("--dispatch", action="store_true", help="start the protected release workflow on GitHub") args: Final = parser.parse_args(namespace=_Options()) if args.dispatch: _run( "gh", "workflow", "run", "pre-release.yml", "--repo", "pypa/pipx", "--ref", "main", "-f", f"bump={args.bump}", ) return with TemporaryDirectory(prefix="pipx-release-") as temporary_directory: source: Final[Path] = Path(temporary_directory) / "source" _run("git", "clone", "--quiet", "--shared", str(_ROOT), str(source)) tags: Final[str] = _run("git", "tag", "--list", cwd=source, capture_output=True).stdout version: Final[str] = _run( "release-creator", "tags", "--next", args.bump, "--directory", "changelog.d", cwd=source, stdin_text=tags, capture_output=True, ).stdout.strip() previous: Final[str] = _run( "release-creator", "tags", version, "--previous", cwd=source, stdin_text=tags, capture_output=True, ).stdout.strip() _run( "release-creator", "fragments", "--directory", "changelog.d", "--repository", "pypa/pipx", cwd=source, ) _run("towncrier", "build", "--yes", "--version", version, cwd=source) _run( "release-creator", "contributors", "--changelog", "docs/changelog.rst", "--version", version, "--repository", "pypa/pipx", cwd=source, ) try: _run("prek", "run", "docstrfmt", "--files", "docs/changelog.rst", cwd=source) except subprocess.CalledProcessError: # the hook fails when it rewrites the file, so a rerun checks the result _run("prek", "run", "docstrfmt", "--files", "docs/changelog.rst", cwd=source) _run("prek", "run", "--all-files", cwd=source) _run("git", "config", "user.name", "pipx release preview", cwd=source) _run("git", "config", "user.email", "preview@localhost", cwd=source) _run("git", "add", "docs/changelog.rst", "changelog.d", cwd=source) _run("git", "commit", "-m", f"Release {version}", cwd=source) _run("git", "tag", "-a", version, "-m", f"release {version}", cwd=source) notes: Final[str] = _run( "release-creator", "notes", "--changelog", "docs/changelog.rst", "--tag", version, "--repository", "pypa/pipx", "--artifact-url", f"https://pypi.org/project/pipx/{version}/", *(("--previous", previous) if previous else ()), cwd=source, capture_output=True, ).stdout build_environment: Final[Path] = Path(temporary_directory) / "build" _run("uv", "venv", "--python", "3.15", str(build_environment), cwd=source) _run( "uv", "pip", "sync", "--preview-features", "pylock", "--python", str(build_environment), "--group", "build", "--require-hashes", "pylock.toml", cwd=source, ) epoch: Final[str] = _run("git", "log", "-1", "--format=%ct", cwd=source, capture_output=True).stdout.strip() _run( "uv", "build", "--python", str(build_environment), "--sdist", "--wheel", "--no-build-isolation", "--out-dir", "dist", cwd=source, env=dict(os.environ, SOURCE_DATE_EPOCH=epoch), ) sys.stdout.write(notes) def _run( *command: str, cwd: Path = _ROOT, stdin_text: str | None = None, capture_output: bool = False, env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: return subprocess.run( command, cwd=cwd, input=stdin_text, capture_output=capture_output, stdout=None if capture_output else sys.stderr, text=True, check=True, shell=False, env=env, ) if __name__ == "__main__": try: main() except subprocess.CalledProcessError as error: if error.stderr: sys.stderr.write(error.stderr) raise SystemExit(error.returncode) from None pipx-1.18.16/scripts/test_packages_support.py000066400000000000000000000021541527355705200213370ustar00rootroot00000000000000from __future__ import annotations import platform import sys import sysconfig from typing import TYPE_CHECKING, Final if TYPE_CHECKING: from pathlib import Path # free-threaded builds need cp3XXt wheels, so they resolve and seed a cache of their own instead of sharing the # same-version GIL build's directory; the "t" suffix also matches the "3.XXt" naming CI keys its cache on _PYTHON_VERSION_STR: Final[str] = ( f"{sys.version_info[0]}.{sys.version_info[1]}{'t' if sysconfig.get_config_var('Py_GIL_DISABLED') else ''}" ) def get_platform_list_path(package_list_dir_path: Path) -> Path: match sys.platform: case "darwin": full_platform = f"macos{platform.release().split('.')[0]}" case "win32": full_platform = "win" case _: full_platform = "unix" return package_list_dir_path / f"{full_platform}-python{_PYTHON_VERSION_STR}.txt" def get_platform_packages_dir_path(pipx_package_cache_path: Path) -> Path: return pipx_package_cache_path / _PYTHON_VERSION_STR __all__ = [ "get_platform_list_path", "get_platform_packages_dir_path", ] pipx-1.18.16/scripts/update_package_cache.py000077500000000000000000000074511527355705200210160ustar00rootroot00000000000000#!/usr/bin/env python3 from __future__ import annotations import argparse import re import subprocess import sys from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import Final from list_test_packages import create_test_packages_list from test_packages_support import get_platform_list_path, get_platform_packages_dir_path class _Options(argparse.Namespace): package_list_dir: Path pipx_package_cache_dir: Path check_only: bool def main(argv: list[str]) -> int: parser: Final = argparse.ArgumentParser( description="Check and update as needed the pipx tests package cache " "for use with the pipx tests local pypiserver." ) parser.add_argument( "package_list_dir", type=Path, help="Directory where platform- and python-specific package lists are found for pipx tests.", ) parser.add_argument( "pipx_package_cache_dir", type=Path, help="Directory to store the packages distribution files.", ) parser.add_argument( "-c", "--check-only", action="store_true", help="Only check to see if needed packages are in PACKAGES_DIR, do not download or delete files.", ) options: Final = parser.parse_args(argv[1:], namespace=_Options()) return _update_test_packages_cache( options.package_list_dir, options.pipx_package_cache_dir, check_only=options.check_only ) def _update_test_packages_cache(package_list_dir_path: Path, pipx_package_cache_path: Path, *, check_only: bool) -> int: packages_dir_path: Final[Path] = get_platform_packages_dir_path(pipx_package_cache_path) packages_dir_path.mkdir(exist_ok=True, parents=True) packages_dir_files: Final[list[Path]] = list(packages_dir_path.iterdir()) platform_package_list_path: Final[Path] = get_platform_list_path(package_list_dir_path) if not platform_package_list_path.exists() and create_test_packages_list( package_list_dir_path, package_list_dir_path / "primary_packages.txt" ): return 1 try: platform_package_list_fh = platform_package_list_path.open() except OSError: return 1 exit_code = 0 packages_dir_missing: Final[list[str]] = [] with platform_package_list_fh: for line in platform_package_list_fh: if not (package_spec_match := re.search(r"^(.+)==(.+)$", package_spec := line.strip())): exit_code = 1 continue package_name, package_version = package_spec_match.groups() package_dist_pattern = rf"{re.escape(package_name)}-{re.escape(package_version)}(.tar.gz|.zip|-)" if matches := [path for path in packages_dir_files if re.search(package_dist_pattern, path.name)]: for matched_file in matches: packages_dir_files.remove(matched_file) else: packages_dir_missing.append(package_spec) if check_only: return 1 if packages_dir_missing else 0 with ThreadPoolExecutor(max_workers=4) as pool: futures = {pool.submit(_download, package_spec, packages_dir_path) for package_spec in packages_dir_missing} for future in as_completed(futures): exit_code = future.result() or exit_code for unused_file in packages_dir_files: unused_file.unlink() return exit_code def _download(package_spec: str, packages_dir_path: Path) -> int: pip_download: Final = subprocess.run( [sys.executable, "-m", "pip", "download", "--no-deps", package_spec, "-d", str(packages_dir_path)], capture_output=True, text=True, check=False, ) return 0 if pip_download.returncode == 0 else 1 if __name__ == "__main__": try: sys.exit(main(sys.argv)) except KeyboardInterrupt: sys.exit(130) pipx-1.18.16/src/000077500000000000000000000000001527355705200134525ustar00rootroot00000000000000pipx-1.18.16/src/pipx/000077500000000000000000000000001527355705200144325ustar00rootroot00000000000000pipx-1.18.16/src/pipx/__init__.py000066400000000000000000000000431527355705200165400ustar00rootroot00000000000000from __future__ import annotations pipx-1.18.16/src/pipx/__main__.py000066400000000000000000000046141527355705200165310ustar00rootroot00000000000000# PYTHON_ARGCOMPLETE_OK from __future__ import annotations __lazy_modules__ = ["typing"] import os import sys if not sys.flags.safe_path and __spec__ and __spec__.parent: # Python searches the directory of the script it started (the working directory under -m) ahead of the standard # library. pip writes the pipx script into the directory pipx links apps into, so an app named shlex.py would be # imported in place of that module; drop the entry before the next import, unless pipx itself was found through it # (cd src && python -m pipx). try: _SCRIPT_DIR: Final[str] = ( os.getcwd() # ruff:ignore[os-getcwd] # no pathlib yet if __name__ == "__main__" else os.path.dirname(os.path.realpath(sys.argv[0])) # ruff:ignore[os-path-dirname] # no pathlib yet ) except OSError: pass # Python adds no entry for a working directory that is gone else: _PACKAGE_PARENT: Final[str] = os.path.dirname(os.path.dirname(__file__)) # ruff:ignore[os-path-dirname] # no pathlib yet if sys.path[:1] == [_SCRIPT_DIR] != [_PACKAGE_PARENT]: del sys.path[0] import pathlib from typing import Final if sys.version_info >= (3, 15): # defers every module-level import in the process, third-party ones included; these modules snapshot terminal state # and environment variables at import (and run colorama.init() on Windows), so they keep loading at startup _EAGER_MODULES: Final[frozenset[str]] = frozenset({"pipx.animate", "pipx.colors", "pipx.constants", "pipx.emojis"}) sys.set_lazy_imports_filter( lambda _importer, name, fromlist: ( name not in _EAGER_MODULES and _EAGER_MODULES.isdisjoint(f"{name}.{item}" for item in fromlist or ()) ) ) # respect a mode the user picked; sys.flags.lazy_imports reads -1 for both unset and an explicit "normal" if not ( "lazy_imports" in sys._xoptions # ruff:ignore[private-member-access] # documented home of -X options or (not sys.flags.ignore_environment and os.environ.get("PYTHON_LAZY_IMPORTS")) ): sys.set_lazy_imports("all") if not __spec__ or not __spec__.parent: # python src/pipx runs this file outside its package, so put src on the path for the import below sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) from pipx.main import cli if __name__ == "__main__": sys.exit(cli()) pipx-1.18.16/src/pipx/animate.py000066400000000000000000000105001527355705200164160ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["logging", "pipx.fanout", "shutil", "threading"] import logging import shutil import sys from contextlib import contextmanager from dataclasses import dataclass from threading import Event, Thread from typing import TYPE_CHECKING, Final from pipx.constants import WINDOWS from pipx.emojis import EMOJI_SUPPORT from pipx.fanout import output_is_held if TYPE_CHECKING: from collections.abc import Generator STDERR_IS_TTY: Final[bool] = bool(sys.stderr and sys.stderr.isatty()) CLEAR_LINE: Final[str] = "\033[K" EMOJI_ANIMATION_FRAMES: Final[tuple[str, ...]] = ("โฃท", "โฃฏ", "โฃŸ", "โกฟ", "โขฟ", "โฃป", "โฃฝ", "โฃพ") NONEMOJI_ANIMATION_FRAMES: Final[tuple[str, ...]] = ("", ".", "..", "...") _MINIMUM_COLS_ALLOW_ANIMATION: Final[int] = 16 if WINDOWS: import ctypes class _CursorInfo(ctypes.Structure): _fields_ = (("size", ctypes.c_int), ("visible", ctypes.c_byte)) @contextmanager def animate(message: str, *, do_animation: bool, delay: float = 0) -> Generator[None, None, None]: if (handlers := logging.getLogger("pipx").handlers) and handlers[0].level > logging.WARNING: yield return if not do_animation or not _env_supports_animation(): sys.stderr.write(f"{message}...\n") yield return # a spinner leaves nothing on screen once cleared, and replaying its frames later would only flicker if output_is_held(): yield return event: Final[Event] = Event() thread: Final[Thread] = Thread( target=_print_animation, args=( message, event, delay, _Spinner( frames=EMOJI_ANIMATION_FRAMES if EMOJI_SUPPORT else NONEMOJI_ANIMATION_FRAMES, period=0.1 if EMOJI_SUPPORT else 1, at_line_start=EMOJI_SUPPORT, ), ), ) thread.start() try: yield finally: event.set() thread.join() _clear_line() def _env_supports_animation() -> bool: return STDERR_IS_TTY and shutil.get_terminal_size(fallback=(0, 0)).columns > _MINIMUM_COLS_ALLOW_ANIMATION def _print_animation(message: str, event: Event, delay: float, spinner: _Spinner) -> None: term_cols: Final[int] = shutil.get_terminal_size(fallback=(9999, 24)).columns event.wait(delay) while not event.wait(0): for symbol in spinner.frames: if spinner.at_line_start: max_message_len = term_cols - len(f"{symbol} ... ") current_line = f"{symbol} {message:.{max_message_len}}" if len(message) > max_message_len: current_line += "..." else: max_message_len = term_cols - len("... ") current_line = f"{message:.{max_message_len}}{symbol}" _clear_line() sys.stderr.write(current_line) sys.stderr.flush() if event.wait(spinner.period): break def _clear_line() -> None: sys.stderr.write(f"\r{CLEAR_LINE}") sys.stdout.write(f"\r{CLEAR_LINE}") @dataclass(frozen=True, slots=True, kw_only=True) class _Spinner: frames: tuple[str, ...] period: float at_line_start: bool def hide_cursor() -> None: if STDERR_IS_TTY: if WINDOWS: _win_cursor(visible=False) else: sys.stderr.write("\033[?25l") sys.stderr.flush() # Windows consoles before Windows 10 TH2 (v1511) ignore ANSI escapes: https://stackoverflow.com/a/10455937 def _win_cursor(*, visible: bool) -> None: if sys.platform != "win32": # narrows the platform so the type checker accepts ctypes.windll return cursor_info: Final[_CursorInfo] = _CursorInfo() handle: Final = ctypes.windll.kernel32.GetStdHandle(-11) ctypes.windll.kernel32.GetConsoleCursorInfo(handle, ctypes.byref(cursor_info)) cursor_info.visible = visible ctypes.windll.kernel32.SetConsoleCursorInfo(handle, ctypes.byref(cursor_info)) def show_cursor() -> None: if STDERR_IS_TTY: if WINDOWS: _win_cursor(visible=True) else: sys.stderr.write("\033[?25h") sys.stderr.flush() __all__ = [ "CLEAR_LINE", "EMOJI_ANIMATION_FRAMES", "NONEMOJI_ANIMATION_FRAMES", "STDERR_IS_TTY", "animate", "hide_cursor", "show_cursor", ] pipx-1.18.16/src/pipx/backends/000077500000000000000000000000001527355705200162045ustar00rootroot00000000000000pipx-1.18.16/src/pipx/backends/__init__.py000066400000000000000000000001111527355705200203060ustar00rootroot00000000000000"""Installers that create and fill pipx-managed virtual environments.""" pipx-1.18.16/src/pipx/backends/_base.py000066400000000000000000000077611527355705200176420ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["json", "pipx.util"] import json from abc import ABC, abstractmethod from dataclasses import dataclass from typing import TYPE_CHECKING, Final, TypedDict, cast from pipx.util import PipxError if TYPE_CHECKING: from collections.abc import Sequence from pathlib import Path from subprocess import CompletedProcess PIP: Final[str] = "pip" UV: Final[str] = "uv" KNOWN_BACKENDS: Final[tuple[str, ...]] = (PIP, UV) class Backend(ABC): name: str @abstractmethod def create_venv( # ruff:ignore[too-many-arguments] # backend contract mirrors the venv-creation CLI inputs self, root: Path, *, python: str, venv_args: list[str], pip_args: list[str], include_pip: bool, verbose: bool, ) -> None: ... @abstractmethod def install( # ruff:ignore[too-many-arguments] # install flags map one-to-one onto backend CLI options self, *, venv_root: Path, venv_python: Path, requirements: list[str], pip_args: list[str], no_deps: bool = False, upgrade_package: str | None = None, log_pip_errors: bool = True, verbose: bool, ) -> CompletedProcess[str]: ... def install_lock( self, *, venv_root: Path, venv_python: Path, lock_file: Path, pip_args: list[str], verbose: bool, ) -> CompletedProcess[str]: return self.install( venv_root=venv_root, venv_python=venv_python, requirements=["--requirement", str(lock_file)], pip_args=pip_args, verbose=verbose, ) @staticmethod @abstractmethod def cooldown_args(cooldown_days: int | None) -> list[str]: ... @abstractmethod def uninstall( self, *, venv_root: Path, venv_python: Path, packages: Sequence[str], verbose: bool, ) -> CompletedProcess[str]: ... @abstractmethod def list_installed( self, *, venv_root: Path, venv_python: Path, not_required: bool, ) -> set[str]: ... @abstractmethod def list_outdated( self, *, venv_root: Path, venv_python: Path, index_args: list[str], exclude: list[str], ) -> tuple[OutdatedPackage, ...]: ... @abstractmethod def run_raw_pip( # ruff:ignore[too-many-arguments] # passes raw pip invocation controls through self, *, venv_root: Path, venv_python: Path, args: list[str], capture_stdout: bool = True, capture_stderr: bool = True, verbose: bool, in_working_dir: bool = False, ) -> CompletedProcess[str]: ... @abstractmethod def needs_shared_libs(self) -> bool: ... @abstractmethod def upgrade_packaging_libraries(self, venv_python: Path, pip_args: list[str]) -> None: ... def outdated_packages_from_process(process: CompletedProcess[str]) -> tuple[OutdatedPackage, ...]: if process.returncode: msg = f"Package backend exited with code {process.returncode}.\nstderr: {process.stderr}" raise PipxError(msg, wrap_message=False) try: return tuple( OutdatedPackage(name=entry["name"], version=entry["version"], latest_version=entry["latest_version"]) for entry in cast("list[_OutdatedEntry]", json.loads(process.stdout)) ) except (json.JSONDecodeError, KeyError, TypeError) as error: msg = "Package backend returned invalid JSON for an outdated query." raise PipxError(msg, wrap_message=False) from error @dataclass(frozen=True, slots=True, kw_only=True) class OutdatedPackage: name: str version: str latest_version: str class _OutdatedEntry(TypedDict): name: str version: str latest_version: str __all__ = [ "KNOWN_BACKENDS", "PIP", "UV", "Backend", "OutdatedPackage", "outdated_packages_from_process", ] pipx-1.18.16/src/pipx/backends/pip.py000066400000000000000000000371431527355705200173560ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "json", "packaging.version", "pipx", "pipx.redact", "pipx.shared_libs", "pipx.subprocess_runner", "pipx.trash", "pipx.util", "re", ] import json import logging import re import sys from threading import Lock from typing import TYPE_CHECKING, Final from packaging.version import InvalidVersion, Version from pipx import paths from pipx.animate import animate from pipx.backends._base import PIP, Backend, OutdatedPackage, outdated_packages_from_process from pipx.constants import PIPX_SHARED_PTH, WINDOWS from pipx.redact import redact_command_line from pipx.shared_libs import SHARED_LIBS from pipx.subprocess_runner import get_site_packages, run_subprocess, subprocess_post_check from pipx.trash import safe_unlink from pipx.util import PipxError, get_venv_paths, open_owner_only if TYPE_CHECKING: from collections.abc import Sequence from pathlib import Path from re import Pattern from subprocess import CompletedProcess _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) # Windows appends by seeking to the end and then writing, so fan-out tasks appending at once overwrite each other _ERROR_LOG_LOCK: Final[Lock] = Lock() class PipBackend(Backend): name = PIP def needs_shared_libs(self) -> bool: # ruff:ignore[no-self-use] # Backend interface method, must dispatch polymorphically return True def upgrade_packaging_libraries(self, venv_python: Path, pip_args: list[str]) -> None: # only venvs that ship their own pip (`pipx install pip`) reach this; shared-libs venvs upgrade through # shared_libs.upgrade self._drop_site_config(venv_python.parent.parent) process: Final = run_subprocess( [str(venv_python), "-m", "pip", "--no-input", "install", "--upgrade", *pip_args, "pip"], run_dir=str(venv_python.parent.parent), ) subprocess_post_check(process) @staticmethod def _drop_site_config(venv_root: Path) -> None: # pip reads /pip.conf over the user's own configuration, and any installed wheel can unpack a data # file there; pip has no switch that skips only this file (--isolated still reads it, PIP_CONFIG_FILE=os.devnull # drops every file), so a `target` in it would make the next upgrade install outside the venv if (site_config := venv_root / ("pip.ini" if WINDOWS else "pip.conf")).is_file() or site_config.is_symlink(): _LOGGER.warning( "Removing %s: pip would read it as configuration, and installed packages can write it. " "Use PIP_* variables, --pip-args or your user-level pip configuration file instead.", site_config, ) safe_unlink(site_config) def create_venv( # ruff:ignore[no-self-use, too-many-arguments] # Backend interface method mirroring venv-creation inputs self, root: Path, *, python: str, venv_args: list[str], pip_args: list[str], include_pip: bool, verbose: bool, ) -> None: cmd: Final = [python, "-m", "venv", *([] if include_pip else ["--without-pip"]), *venv_args, str(root)] with animate("creating virtual environment", do_animation=not verbose): venv_process: Final = run_subprocess(cmd, run_dir=str(root)) subprocess_post_check(venv_process) SHARED_LIBS.create(verbose=verbose, pip_args=pip_args) if not include_pip: _, python_path, _ = get_venv_paths(root) purelib: Final = get_site_packages(python_path)[0] purelib.mkdir(parents=True, exist_ok=True) (purelib / PIPX_SHARED_PTH).write_text("".join(f"{path}\n" for path in SHARED_LIBS.site_packages)) def install( # ruff:ignore[too-many-arguments] # Backend interface method mapping flags to pip options self, *, venv_root: Path, venv_python: Path, requirements: list[str], pip_args: list[str], no_deps: bool = False, upgrade_package: str | None = None, log_pip_errors: bool = True, verbose: bool, ) -> CompletedProcess[str]: cmd: list[str] = [str(venv_python), "-m", "pip", "--no-input", "install"] if upgrade_package is not None: cmd.append("--upgrade") if no_deps: cmd.append("--no-dependencies") if verbose: cmd.append("--progress-bar=on") cmd += [*pip_args, *requirements] self._drop_site_config(venv_root) process: Final = SHARED_LIBS.run_repairing_pip( lambda: run_subprocess( cmd, log_stdout=not log_pip_errors, log_stderr=not log_pip_errors, run_dir=str(venv_root), stream_output=verbose, ), pip_args=pip_args, verbose=verbose, ) if log_pip_errors: subprocess_post_check_handle_pip_error(process, streamed=verbose) return process def install_lock( self, *, venv_root: Path, venv_python: Path, lock_file: Path, pip_args: list[str], verbose: bool, ) -> CompletedProcess[str]: process: Final = SHARED_LIBS.run_repairing_pip( lambda: run_subprocess([str(venv_python), "-m", "pip", "--version"], run_dir=str(venv_root)), pip_args=pip_args, verbose=verbose, ) try: version: Version | None = Version(process.stdout.split()[1]) except (IndexError, InvalidVersion): version = None # older pip reads `--requirement pylock.toml` as a requirements file, applying its lines as options min_version: Final[str] = "26.1" if version is None or version < Version(min_version): msg = ( f"Installing from {lock_file.name} needs pip>={min_version}, but this environment runs " f"pip {version or 'of an unknown version'}.\n" "Upgrade it with `pipx upgrade-shared`, or run with `--backend uv`." ) raise PipxError(msg) return super().install_lock( venv_root=venv_root, venv_python=venv_python, lock_file=lock_file, pip_args=pip_args, verbose=verbose ) @staticmethod def cooldown_args(cooldown_days: int | None) -> list[str]: return [] if not cooldown_days else ["--uploaded-prior-to", f"P{cooldown_days}D"] def uninstall( self, *, venv_root: Path, venv_python: Path, packages: Sequence[str], verbose: bool, ) -> CompletedProcess[str]: cmd: Final = [str(venv_python), "-m", "pip", "uninstall", "-y", *packages, *([] if verbose else ["-q"])] self._drop_site_config(venv_root) process: Final = SHARED_LIBS.run_repairing_pip( lambda: run_subprocess(cmd, run_dir=str(venv_root)), pip_args=[], verbose=verbose ) subprocess_post_check(process) return process def list_installed( self, *, venv_root: Path, venv_python: Path, not_required: bool, ) -> set[str]: if not_required: from pipx.venv_inspect import list_not_required_packages # ruff:ignore[import-outside-top-level] # pulls in packaging.requirements, slows startup return list_not_required_packages(venv_python) cmd: Final = [str(venv_python), "-m", "pip", "list", "--format=json"] self._drop_site_config(venv_root) process: Final = SHARED_LIBS.run_repairing_pip(lambda: run_subprocess(cmd), pip_args=[], verbose=False) if process.returncode != 0: msg = ( f"Failed to execute {process.args}.\n" f"Process exited with return code {process.returncode}.\n" f"stderr: {process.stderr}" ) raise PipxError(msg) return {entry["name"] for entry in json.loads(process.stdout.strip())} def list_outdated( self, *, venv_root: Path, venv_python: Path, index_args: list[str], exclude: list[str], ) -> tuple[OutdatedPackage, ...]: self._drop_site_config(venv_root) process: Final = SHARED_LIBS.run_repairing_pip( lambda: run_subprocess( [ str(venv_python), "-m", "pip", "list", "--outdated", "--format=json", *index_args, *(f"--exclude={name}" for name in exclude), ], run_dir=str(venv_root), ), pip_args=index_args, verbose=False, ) return outdated_packages_from_process(process) def run_raw_pip( # ruff:ignore[too-many-arguments] # Backend interface method passing raw pip controls through self, *, venv_root: Path, venv_python: Path, args: list[str], capture_stdout: bool = True, capture_stderr: bool = True, verbose: bool, in_working_dir: bool = False, ) -> CompletedProcess[str]: cmd: Final = [str(venv_python), "-m", "pip", *args, *([] if verbose else ["-q"])] self._drop_site_config(venv_root) return SHARED_LIBS.run_repairing_pip( lambda: run_subprocess( cmd, capture_stdout=capture_stdout, capture_stderr=capture_stderr, run_dir=None if in_working_dir else str(venv_root), # uncaptured, it talks to the user's terminal, as under `pipx runpip` interactive=not (capture_stdout or capture_stderr), ), pip_args=[], verbose=verbose, ) def subprocess_post_check_handle_pip_error( completed_process: CompletedProcess[str], tool_name: str = "pip", *, streamed: bool, ) -> None: if not completed_process.returncode: return _LOGGER.info("%r failed", redact_command_line(completed_process.args)) if paths.CTX.log_file is None: msg = "Pipx internal error: No log_file present." raise PipxError(msg) error_file: Final[Path] = paths.CTX.log_file.parent / f"{paths.CTX.log_file.stem}_{tool_name}_errors.log" upper_label: Final[str] = tool_name.upper() # the fan-out tasks of an -all command append their failures to the same file at once with _ERROR_LOG_LOCK, open(error_file, "a", encoding="utf-8", opener=open_owner_only) as error_fh: error_fh.write( f"{upper_label} STDOUT\n----------\n{completed_process.stdout or ''}" f"\n{upper_label} STDERR\n----------\n{completed_process.stderr or ''}" ) _LOGGER.error( "Fatal error from %s prevented installation. Full %s output in file:\n %s", tool_name, tool_name, error_file ) # the pip output analyser's patterns do not match uv's error format, so pipx shows uv's stderr as is, and either # tool skips its error lines once the user saw them streamed live if tool_name == "pip": _analyze_pip_output(completed_process.stdout, completed_process.stderr, streamed=streamed) elif not streamed and ( uv_errors := [line for line in (completed_process.stderr or "").splitlines() if line.strip()] ): print("\nErrors from uv install:", file=sys.stderr) # ruff:ignore[print] # user-facing CLI output for line in uv_errors: print(f" {line}", file=sys.stderr) # ruff:ignore[print] # user-facing CLI output def _analyze_pip_output(pip_stdout: str, pip_stderr: str, *, streamed: bool) -> None: failed_build_stdout: list[str] = [] last_collecting_dep: str | None = None # stdout carries these lines only when pip runs without -q for line in pip_stdout.split("\n"): if failed_match := re.search(r"Failed to build\s+(\S.+)$", line): failed_build_stdout = failed_match.group(1).strip().split() if collecting_match := re.search(r"^\s*Collecting\s+(\S+)", line): last_collecting_dep = collecting_match.group(1) # most useful first, each beside a pip line it catches relevant_searches: Final[dict[str, Pattern[str]]] = { # Package cairo was not found in the pkg-config search path. "not_found": re.compile(r"not (?:be )?found", re.IGNORECASE), # unable to execute 'gcc': No such file or directory "no_such": re.compile(r"no such", re.IGNORECASE), # Exception: Unable to find OpenSSL >= 1.0 headers. (Looked here: ... "exception_error": re.compile(r"(Exception|Error):\s*\S+"), # LINK : fatal error LNK1104: cannot open file 'kernel32.lib' "fatal_error": re.compile(r"fatal error", re.IGNORECASE), # ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/... "conflict_": re.compile(r"conflict", re.IGNORECASE), # error: can't copy 'lib\ansible\module_utils\ansible_release.py': doesn't exist ... "error_": re.compile( r"error:" r"(?!.+Command errored out)" r"(?!.+failed building wheel for)" r"(?!.+could not build wheels? for)" r"(?!.+failed to build one or more wheels)" r".+[^:]$", re.IGNORECASE, ), } relevants_saved: Final[dict[str, str]] = {} failed_build_stderr: Final[set[str]] = set() for line in pip_stderr.split("\n"): if failed_build_match := re.search(r"Failed to build\s+(?!one or more packages)(\S+)", line): failed_build_stderr.add(failed_build_match.group(1)) for category, pattern in relevant_searches.items(): if pattern.search(line): relevants_saved.setdefault(line.strip(), category) break _log_failed_builds(failed_build_stdout, failed_build_stderr, last_collecting_dep) if relevants_saved and not streamed: print("\nErrors from pip install:", file=sys.stderr) # ruff:ignore[print] # user-facing CLI output print_categories: Final[list[str]] = list(relevant_searches) relevants_saved_filtered = list(relevants_saved) max_relevant_errors: Final[int] = 10 while len(print_categories) > 1 and len(relevants_saved_filtered) > max_relevant_errors: print_categories.pop(-1) relevants_saved_filtered = [ line for line, category in relevants_saved.items() if category in print_categories ] for line in relevants_saved_filtered: print(f" {line}", file=sys.stderr) # ruff:ignore[print] # user-facing CLI output def _log_failed_builds( failed_build_stdout: list[str], failed_build_stderr: set[str], last_collecting_dep: str | None, ) -> None: if failed_build_stdout: failed_to_build_str = "\n ".join(failed_build_stdout) plural_str = "s" if len(failed_build_stdout) > 1 else "" print(file=sys.stderr) # ruff:ignore[print] # user-facing CLI output _LOGGER.error("pip failed to build package%s:\n %s", plural_str, failed_to_build_str) elif failed_build_stderr: failed_to_build_str = "\n ".join(failed_build_stderr) plural_str = "s" if len(failed_build_stderr) > 1 else "" print(file=sys.stderr) # ruff:ignore[print] # user-facing CLI output _LOGGER.error("pip failed to build package%s:\n %s", plural_str, failed_to_build_str) elif last_collecting_dep is not None: print(file=sys.stderr) # ruff:ignore[print] # user-facing CLI output _LOGGER.error("pip failed while processing package:\n %s", last_collecting_dep) __all__ = [ "PipBackend", "subprocess_post_check_handle_pip_error", ] pipx-1.18.16/src/pipx/backends/registry.py000066400000000000000000000045641527355705200204370ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["pipx.backends._base", "pipx.backends.pip", "pipx.backends.uv", "pipx.util"] import os from functools import cache from pipx.backends._base import KNOWN_BACKENDS, PIP, UV, Backend from pipx.backends.pip import PipBackend from pipx.backends.uv import UvBackend, find_uv_binary, uv_meets_minimum_version from pipx.util import PipxError def resolve_backend_name( *, cli_value: str | None = None, env_value: str | None, metadata_value: str | None = None, auto: bool = True, ) -> tuple[str, str]: # metadata outranks env so PIPX_DEFAULT_BACKEND=uv cannot retarget pip-backed venvs already on disk for candidate, source in ((cli_value, "cli"), (metadata_value, "metadata"), (env_value, "env")): if (validated := _validate(candidate)) is not None: return validated, source if auto and (found := find_uv_binary())[0] is not None and uv_meets_minimum_version(found[0]): return UV, f"auto-{found[1]}" return PIP, "auto-pip" def _validate(candidate: str | None) -> str | None: if not candidate: return None if candidate not in KNOWN_BACKENDS: msg = f"Unknown backend {candidate!r}. Valid backends: {', '.join(KNOWN_BACKENDS)}." raise PipxError(msg) return candidate # cached so UvBackend.__init__ probes the version and logs once per process, though validation and construction both # ask for the backend @cache def get_backend(name: str) -> Backend: if name == PIP: return PipBackend() if name == UV: return UvBackend() msg = f"Unknown backend {name!r}. Valid backends: {', '.join(KNOWN_BACKENDS)}." raise PipxError(msg) def assert_not_pip_under_uv(package_name: str, backend_name: str) -> None: if package_name == "pip" and backend_name != PIP: msg = ( "The 'pip' package cannot be installed or exposed via the uv backend, since uv venvs " "don't ship pip and the resulting environment would be inconsistent.\n" "Use `--backend pip` (or set PIPX_DEFAULT_BACKEND=pip) to install 'pip' as a tool." ) raise PipxError(msg) def env_default_backend() -> str | None: return raw.strip() if (raw := os.environ.get("PIPX_DEFAULT_BACKEND")) else None __all__ = [ "assert_not_pip_under_uv", "env_default_backend", "get_backend", "resolve_backend_name", ] pipx-1.18.16/src/pipx/backends/uv.py000066400000000000000000000337511527355705200172210ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "contextlib", "importlib", "json", "pathlib", "pipx", "pipx.backends.pip", "pipx.subprocess_runner", "pipx.trash", "pipx.util", "shutil", "subprocess", ] import json import logging import re import shutil import subprocess from contextlib import nullcontext from functools import cache from importlib import import_module from pathlib import Path from typing import TYPE_CHECKING, Final from packaging.version import InvalidVersion, Version from pipx import paths from pipx.animate import animate from pipx.backends._base import UV, Backend, OutdatedPackage, outdated_packages_from_process from pipx.backends.pip import subprocess_post_check_handle_pip_error from pipx.constants import WINDOWS from pipx.subprocess_runner import run_subprocess, subprocess_post_check from pipx.trash import running_launcher_moved_aside from pipx.util import PipxError, reject_empty_index if TYPE_CHECKING: from collections.abc import Callable, Sequence from subprocess import CompletedProcess _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) # uv lets UV_DEFAULT_INDEX override a command-line --index-url, but not --default-index PIP_TO_UV_INDEX_FLAGS: Final[dict[str, str]] = dict.fromkeys(("--index-url", "-i"), "--default-index") def _load_uv_bin_finder() -> Callable[[], str] | None: try: return import_module("uv").find_uv_bin except (AttributeError, ImportError): return None _FIND_UV_BIN_FROM_EXTRA: Final[Callable[[], str] | None] = _load_uv_bin_finder() _MIN_UV_VERSION: Final[Version] = Version("0.9.17") # Version() validates the captured token _VERSION_RE: Final[re.Pattern[str]] = re.compile(r"uv\s+(\S+)") class UvBackend(Backend): name = UV def __init__(self) -> None: self._binary = resolve_uv_binary() _, source = find_uv_binary() _LOGGER.info("using %s uv %s from %s", source, _check_uv_version(self._binary), self._binary) def needs_shared_libs(self) -> bool: # ruff:ignore[no-self-use] # Backend interface method, must dispatch polymorphically return False def upgrade_packaging_libraries( # ruff:ignore[no-self-use] # Backend interface method, must dispatch polymorphically self, venv_python: Path, pip_args: list[str] ) -> None: del venv_python, pip_args # uv venvs ship no pip to upgrade def create_venv( # ruff:ignore[too-many-arguments] # Backend interface method mirroring venv-creation inputs self, root: Path, *, python: str, venv_args: list[str], pip_args: list[str], include_pip: bool, verbose: bool, ) -> None: del pip_args # uv venv has no pip to seed if include_pip: msg = ( "The uv backend cannot create a virtual environment with pip preinstalled.\n" "Reinstall the package with `--backend pip` (or unset PIPX_DEFAULT_BACKEND)." ) raise PipxError(msg) cmd: Final[list[str | Path]] = [ self._binary, "venv", "--python", python, "--allow-existing", *venv_args, "--verbose" if verbose else "--quiet", str(root), ] with animate("creating virtual environment", do_animation=not verbose): process: Final = run_subprocess(cmd, run_dir=str(root), env_overrides=self._env_overrides()) subprocess_post_check(process) @staticmethod def _env_overrides() -> dict[str, str | None]: # Stripping VIRTUAL_ENV stops uv from auto-targeting an active venv when no `--python` flag is passed. pipx # shows its own spinner in place of uv's bar, and uv hides the bar under --verbose anyway. UV_PROJECT moves # uv's project-config search from its working directory, which can sit in a shared /tmp, to pipx's home; user # and system config still load. return {"VIRTUAL_ENV": None, "UV_NO_PROGRESS": "1", "UV_PROJECT": str(paths.CTX.home)} def install( # ruff:ignore[too-many-arguments] # Backend interface method mapping flags to uv options self, *, venv_root: Path, venv_python: Path, requirements: list[str], pip_args: list[str], no_deps: bool = False, upgrade_package: str | None = None, log_pip_errors: bool = True, verbose: bool, ) -> CompletedProcess[str]: reject_empty_index(pip_args) cmd = self._uv_pip_command("install", venv_python, verbose=verbose) # uv's --upgrade re-resolves every dependency, pip's only-if-needed default upgrades only the named package if upgrade_package is not None: cmd += ["--upgrade-package", upgrade_package] if no_deps: cmd.append("--no-deps") cmd += [*self._default_index_args(self._strip_pip_quiet_flags(pip_args)), *requirements] # unlike pip, which moves the files it replaces aside, uv deletes them in place, and Windows refuses that for # the running launcher of a pipx upgrading itself with running_launcher_moved_aside(venv_python.parent) if WINDOWS else nullcontext(): process: Final = run_subprocess( cmd, run_dir=str(venv_root), log_stdout=not log_pip_errors, log_stderr=not log_pip_errors, env_overrides=self._env_overrides(), stream_output=verbose, ) if log_pip_errors: subprocess_post_check_handle_pip_error(process, tool_name="uv", streamed=verbose) return process def _uv_pip_command(self, subcommand: str, venv_python: Path, *, verbose: bool) -> list[str | Path]: return [self._binary, "pip", subcommand, "--python", str(venv_python), "--verbose" if verbose else "--quiet"] @staticmethod def _default_index_args(pip_args: list[str]) -> list[str]: return [ f"{PIP_TO_UV_INDEX_FLAGS[flag]}{separator}{value}" if flag in PIP_TO_UV_INDEX_FLAGS else f"{PIP_TO_UV_INDEX_FLAGS['-i']}={arg[2:]}" if arg.startswith("-i") else arg for arg in pip_args for flag, separator, value in [arg.partition("=")] ] @staticmethod def _strip_pip_quiet_flags(pip_args: list[str]) -> list[str]: return [arg for arg in pip_args if arg not in {"-q", "-qq", "--quiet"}] @staticmethod def cooldown_args(cooldown_days: int | None) -> list[str]: return [] if not cooldown_days else ["--exclude-newer", f"P{cooldown_days}D"] def uninstall( self, *, venv_root: Path, venv_python: Path, packages: Sequence[str], verbose: bool, ) -> CompletedProcess[str]: cmd: Final = [*self._uv_pip_command("uninstall", venv_python, verbose=verbose), *packages] process: Final = run_subprocess(cmd, run_dir=str(venv_root), env_overrides=self._env_overrides()) subprocess_post_check(process) return process def list_installed( self, *, venv_root: Path, venv_python: Path, not_required: bool, ) -> set[str]: if not_required: from pipx.venv_inspect import list_not_required_packages # ruff:ignore[import-outside-top-level] # pulls in packaging.requirements, slows startup return list_not_required_packages(venv_python) cmd: Final = [*self._uv_pip_command("list", venv_python, verbose=False), "--format", "json"] process: Final = run_subprocess(cmd, run_dir=str(venv_root), env_overrides=self._env_overrides()) if process.returncode != 0: msg = ( f"Failed to execute {process.args}.\n" f"Process exited with return code {process.returncode}.\n" f"stderr: {process.stderr}" ) raise PipxError(msg) return {entry["name"] for entry in json.loads(process.stdout.strip())} def list_outdated( self, *, venv_root: Path, venv_python: Path, index_args: list[str], exclude: list[str], ) -> tuple[OutdatedPackage, ...]: reject_empty_index(index_args) cmd: Final = [ *self._uv_pip_command("list", venv_python, verbose=False), "--outdated", "--format=json", *self._default_index_args(index_args), *(f"--exclude={name}" for name in exclude), ] process: Final = run_subprocess(cmd, run_dir=str(venv_root), env_overrides=self._env_overrides()) return outdated_packages_from_process(process) def run_raw_pip( # ruff:ignore[too-many-arguments] # Backend interface method passing raw pip controls through self, *, venv_root: Path, venv_python: Path, args: list[str], capture_stdout: bool = True, capture_stderr: bool = True, verbose: bool, in_working_dir: bool = False, ) -> CompletedProcess[str]: # with no args, show pip's help as `pipx runpip TOOL` does under the pip backend cmd: list[str | Path] = [self._binary, "pip", args[0] if args else "--help", "--python", str(venv_python)] # As with pip, verbose only means "do not silence": uv's --verbose prints DEBUG logs, and runpip always sets it if verbose: cmd += args[1:] else: if args: cmd.append("--quiet") cmd += self._strip_pip_quiet_flags(args[1:]) return run_subprocess( cmd, run_dir=None if in_working_dir else str(venv_root), capture_stdout=capture_stdout, capture_stderr=capture_stderr, env_overrides=self._env_overrides(), # uncaptured, it talks to the user's terminal, as under `pipx runpip` interactive=not (capture_stdout or capture_stderr), ) def resolve_uv_binary() -> Path: # checks the version here so callers outside UvBackend, such as `pipx run`'s uv tool run shortcut, get pipx's # version error instead of an opaque one from uv binary, _ = find_uv_binary() if binary is None: msg = ( "The uv backend was requested but the 'uv' executable could not be found.\n" "Install pipx with the uv extra (`pipx install pipx[uv]`) or place 'uv' on your PATH.\n" "Alternatively, run with `--backend pip` (or set PIPX_DEFAULT_BACKEND=pip)." ) raise PipxError(msg) _check_uv_version(binary) return binary # cached so `upgrade-all` and similar loops over many venvs walk PATH once @cache def find_uv_binary() -> tuple[Path | None, str]: if _FIND_UV_BIN_FROM_EXTRA is not None: try: bundled: Final = Path(_FIND_UV_BIN_FROM_EXTRA()) except FileNotFoundError: pass else: # is_file rejects a stale path from a half-installed extra; _binary_runs catches a binary that fails to # start (missing dylib, ENOEXEC), so the search falls through to PATH instead of erroring later if bundled.is_file() and _binary_runs(bundled): return bundled, "bundled" # probe the PATH candidate too, so a broken or hanging uv on PATH is skipped here instead of stalling the later # version check if (path := shutil.which("uv")) and _binary_runs(candidate := Path(path)): return candidate, "path" return None, "missing" def _binary_runs(binary: Path) -> bool: try: process: Final = _run_uv_version(binary) except (OSError, subprocess.TimeoutExpired) as exc: _LOGGER.debug("uv launch probe failed for %s: %s", binary, exc) return False if process.returncode != 0 or not _VERSION_RE.search(process.stdout): _LOGGER.debug( "uv launch probe rejected %s: rc=%s, stdout=%r, stderr=%r", binary, process.returncode, process.stdout, process.stderr, ) return False return True # cached so the launch probe, the version floor check and `upgrade-all` over many venvs share one run of uv @cache def _run_uv_version(binary: Path) -> subprocess.CompletedProcess[str]: return subprocess.run([str(binary), "--version"], check=False, text=True, capture_output=True, timeout=10) def _check_uv_version(binary: Path) -> Version: if (version := _uv_version(binary)) < _MIN_UV_VERSION: msg = ( f"pipx needs uv>={_MIN_UV_VERSION}, but {binary} reports {version}.\n" "Upgrade uv (`uv self update` or reinstall pipx[uv]), or run with `--backend pip` to bypass." ) raise PipxError(msg) return version def _uv_version(binary: Path) -> Version: try: process: Final = _run_uv_version(binary) except (OSError, subprocess.TimeoutExpired) as exc: msg = ( f"Could not run {binary} --version ({exc}).\n" "Repair or reinstall uv, or run with `--backend pip` (or set PIPX_DEFAULT_BACKEND=pip)." ) raise PipxError(msg) from exc if (match := _VERSION_RE.search(process.stdout)) is None: msg = ( f"Could not parse uv version from {binary} " f"(rc={process.returncode}, stdout={process.stdout!r}, stderr={process.stderr!r})." ) raise PipxError(msg) try: return Version(match.group(1)) except InvalidVersion as exc: msg = f"Unrecognized uv version {match.group(1)!r}." raise PipxError(msg) from exc # cached so auto-detection, which runs once per venv, warns once per process @cache def uv_meets_minimum_version(binary: Path) -> bool: if (version := _uv_version(binary)) >= _MIN_UV_VERSION: return True _LOGGER.warning( "Skipping uv %s at %s since pipx needs uv>=%s; using pip. " "Upgrade uv, or set PIPX_DEFAULT_BACKEND=pip to silence this.", version, binary, _MIN_UV_VERSION, ) return False __all__ = [ "PIP_TO_UV_INDEX_FLAGS", "UvBackend", "find_uv_binary", "resolve_uv_binary", "uv_meets_minimum_version", ] pipx-1.18.16/src/pipx/colors.py000066400000000000000000000014701527355705200163070ustar00rootroot00000000000000from __future__ import annotations import sys from typing import TYPE_CHECKING, Final if TYPE_CHECKING: from collections.abc import Callable _PRINT_COLOR: Final[bool] = bool(sys.stdout and sys.stdout.isatty()) if sys.platform == "win32" and _PRINT_COLOR: # colorama, a Windows-only dependency, wraps the console so the ANSI escapes below render on legacy terminals import colorama colorama.init() def _colorizer(start: str) -> Callable[[str], str]: def colorize(text: str) -> str: return f"{start}{text}\033[0m" if _PRINT_COLOR else text return colorize BOLD: Final[Callable[[str], str]] = _colorizer("\033[1m") RED: Final[Callable[[str], str]] = _colorizer("\033[91m") GREEN: Final[Callable[[str], str]] = _colorizer("\033[92m") __all__ = [ "BOLD", "GREEN", "RED", ] pipx-1.18.16/src/pipx/commands/000077500000000000000000000000001527355705200162335ustar00rootroot00000000000000pipx-1.18.16/src/pipx/commands/__init__.py000066400000000000000000000000641527355705200203440ustar00rootroot00000000000000"""The pipx subcommands, one module per command.""" pipx-1.18.16/src/pipx/commands/cache.py000066400000000000000000000025731527355705200176570ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["pipx.trash"] from dataclasses import dataclass from typing import TYPE_CHECKING, Final from pipx.result import OperationData, OperationResult, OutputMessage from pipx.trash import rmdir if TYPE_CHECKING: from pipx.venv import VenvContainer def print_cache_dir(venv_container: VenvContainer) -> OperationResult[CacheData]: directory: Final[str] = str(venv_container) return OperationResult( command=("cache", "dir"), data=CacheData(directory=directory, removed=()), messages=(OutputMessage(text=directory),), ) @dataclass(frozen=True, slots=True, kw_only=True) class CacheData(OperationData): directory: str removed: tuple[str, ...] def purge_cache(venv_container: VenvContainer) -> OperationResult[CacheData]: removed: Final[list[str]] = [] for venv_dir in venv_container.iter_locked_venv_dirs(tuple(venv_container.iter_venv_dirs())): rmdir(venv_dir) removed.append(venv_dir.name) noun: Final[str] = "environment" if len(removed) == 1 else "environments" return OperationResult( command=("cache", "purge"), data=CacheData(directory=str(venv_container), removed=tuple(removed)), messages=(OutputMessage(text=f"Removed {len(removed)} cached {noun}."),), ) __all__ = [ "CacheData", "print_cache_dir", "purge_cache", ] pipx-1.18.16/src/pipx/commands/common.py000066400000000000000000001367271527355705200201150ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "collections", "filecmp", "importlib.machinery", "packaging.utils", "pathlib", "pipx", "pipx.backends._base", "pipx.backends.registry", "pipx.fanout", "pipx.package_specifier", "pipx.pipx_metadata_file", "pipx.result", "pipx.trash", "pipx.util", "pipx.venv", "pipx.wrap", "re", "shlex", "shutil", "tempfile", "userpath", ] import filecmp import logging import os import re import shlex import shutil import sys import tempfile import time from collections import Counter from contextlib import AbstractContextManager, contextmanager, suppress from contextvars import ContextVar from dataclasses import dataclass, fields from importlib.machinery import all_suffixes from pathlib import Path from shutil import which from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Final, Literal import userpath from packaging.utils import canonicalize_name from pipx import paths from pipx.backends._base import PIP from pipx.backends.registry import env_default_backend, resolve_backend_name from pipx.colors import BOLD, RED from pipx.constants import COMPLETION_SECTIONS, MACOS, MAN_SECTIONS, WINDOWS from pipx.emojis import HAZARD, STARS from pipx.fanout import in_turn from pipx.package_specifier import local_project_name, parse_specifier_for_install, valid_pypi_name from pipx.pipx_metadata_file import PIPX_INFO_FILENAME, PackageInfo, PipxMetadata from pipx.result import OutputMessage, OutputStream from pipx.trash import safe_unlink from pipx.util import PipxError, is_file_name, mkdir from pipx.venv import Venv, VenvContainer from pipx.wrap import pipx_wrap if TYPE_CHECKING: from collections.abc import Callable, Collection, Generator, Iterable, Mapping, Sequence from pipx.options import BackendOptions from pipx.venv_inspect import VenvMetadata _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) _SHARED_RESOURCES: Final[ContextVar[frozenset[Path]]] = ContextVar("shared_resources", default=frozenset()) # shared links a fan-out task took over because they dangled, with the target they had and the one written _DANGLING_TAKEN: Final[ContextVar[dict[Path, tuple[Path, Path]]]] = ContextVar("dangling_taken") # None is a user's --force, which replaces whatever holds a name _REPLACEABLE_RESOURCES: Final[ContextVar[frozenset[Path] | None]] = ContextVar("replaceable_resources", default=None) _RESOURCE_DIR_SCANS: Final[ContextVar[dict[Path, _ResourceDirScan] | None]] = ContextVar( "resource_dir_scans", default=None ) _VENV_CLAIMS: Final[ContextVar[dict[Path, set[Path]] | None]] = ContextVar("venv_claims", default=None) _CAN_SYMLINK_CACHE: Final[dict[Path, bool]] = {} @contextmanager def force_replaces_only(resource_paths: Iterable[Path]) -> Generator[None]: # a rebuild forces only to refresh its own exposed files, not to take names install left with their owner token: Final = _REPLACEABLE_RESOURCES.set(frozenset(resource_paths)) try: yield finally: _REPLACEABLE_RESOURCES.reset(token) @contextmanager def expose_shared_resources_in_order( venvs_metadata: Iterable[PipxMetadata], local_bin_dir: Path, local_man_dir: Path ) -> Generator[None]: # a path several environments of a fan-out expose must end up where a one-at-a-time run leaves it, so they expose # it in task order; only shared paths wait, as a task waiting for its turn holds one of the fan-out's workers counts: Final[Counter[Path]] = Counter( path for metadata in venvs_metadata for path in _shareable_paths(metadata, local_bin_dir, local_man_dir) ) token: Final = _SHARED_RESOURCES.set(frozenset(path for path, count in counts.items() if count > 1)) taken_token: Final = _DANGLING_TAKEN.set({}) try: yield finally: _DANGLING_TAKEN.reset(taken_token) _SHARED_RESOURCES.reset(token) def _shareable_paths(metadata: PipxMetadata, local_bin_dir: Path, local_man_dir: Path) -> set[Path]: try: return _expected_resource_paths( metadata, (metadata.main_package, *metadata.injected_packages.values()), local_bin_dir, local_man_dir ) except (AttributeError, TypeError): # corrupt metadata, such as a null path list, is for the task of its own environment to report return set() def _expected_resource_paths( metadata: PipxMetadata, packages: Collection[PackageInfo], local_bin_dir: Path, local_man_dir: Path ) -> set[Path]: if not metadata.exposure_enabled: return set() return ( { local_bin_dir / add_suffix(path.name, package.suffix) for package in packages for path in package.app_paths_to_expose } | {local_man_dir / path.parent.name / path.name for package in packages for path in package.man_paths_to_expose} | { paths.CTX.completion_dir / path.parent.parent.name / path.parent.name / path.name for package in packages for path in package.completion_paths_to_expose } ) def add_suffix(name: str, suffix: str) -> str: # Windows opens a file by its extension, so hello.py becomes hello@1.py there; the 14 of pip3.14 is a version, not # an extension, and POSIX ignores extensions, so elsewhere the suffix goes last app: Final[Path] = Path(name) if WINDOWS and app.suffix and not app.suffix[1:].isdigit(): return f"{app.stem}{suffix}{app.suffix}" return f"{name}{suffix}" @contextmanager def reuse_resource_dir_scans() -> Generator[None]: # list and uninstall-all ask every venv which exposed resources it owns, so scanning each shared directory once per # command instead of once per venv keeps them linear in the number of venvs token: Final = _RESOURCE_DIR_SCANS.set({}) claims_token: Final = _VENV_CLAIMS.set({}) try: yield finally: _VENV_CLAIMS.reset(claims_token) _RESOURCE_DIR_SCANS.reset(token) def drop_links_taken_over(resource_paths: set[Path], *venv_dirs: Path) -> set[Path]: # a link dangles while its venv is rebuilt or its package uninstalled, and the next package claiming a dangling name # takes it, so a path gathered before then is still the venv's only if it links into the venv venv_roots: Final[list[Path]] = [venv_dir.resolve() for venv_dir in venv_dirs] return { path for path in resource_paths if ( any(_link_target(path).is_relative_to(venv_root) for venv_root in venv_roots) if path.is_symlink() else not can_symlink(path.parent) ) } def _link_target(link: Path) -> Path: # with UV_LINK_MODE=symlink the venv's own file links on into uv's cache, so only the first hop names the venv target: Final[Path] = link.parent / _without_extended_prefix(str(link.readlink())) return target.parent.resolve() / target.name def _without_extended_prefix(target: str) -> str: # Windows reads a link target back as \\?\C:\... or \\?\UNC\server\share\..., a spelling resolve() keeps while the # venv paths compared against never use it if not WINDOWS: return target if target.startswith(unc := "\\\\?\\UNC\\"): return target.replace(unc, "\\\\", 1) return target.removeprefix("\\\\?\\") def can_symlink(local_resource_dir: Path) -> bool: if not WINDOWS: # even on Unix this depends on the filesystem return True if local_resource_dir not in _CAN_SYMLINK_CACHE: with TemporaryDirectory(dir=local_resource_dir) as directory: (target := Path(directory) / "a").touch() try: (Path(directory) / "b").symlink_to(target) _CAN_SYMLINK_CACHE[local_resource_dir] = True except (OSError, NotImplementedError): _CAN_SYMLINK_CACHE[local_resource_dir] = False return _CAN_SYMLINK_CACHE[local_resource_dir] def give_back_shared_links(resource_paths: set[Path], venv_dir: Path) -> None: # a link dangles while its venv sits in the trash for a rebuild, and an earlier fan-out task exposing the same name # meanwhile takes it; a failed rebuild gives the name back, as a one-at-a-time run would have left it for path in resource_paths & _SHARED_RESOURCES.get(): with _in_turn_if_shared(path): if (taken := _DANGLING_TAKEN.get().get(path)) is None: continue dangling, written = taken # a link pointing elsewhere than the takeover left it was replaced since, such as by another run's --force if ( path.is_symlink() and path.resolve() == written.resolve() and (restored := (path.parent / dangling).resolve()).is_relative_to(venv_dir.resolve()) and restored.exists() ): path.unlink() path.symlink_to(dangling) def package_name_from_spec( # ruff:ignore[too-many-arguments] # resolver inputs are independent scalars, not a cohesive struct package_spec: str, python: str, *, pip_args: list[str], verbose: bool, backend: BackendOptions, cooldown_days: int | None = None, ) -> str: start_time: Final[float] = time.time() if (pypi_name := valid_pypi_name(package_spec)) is not None: # pipx keeps the PyPI name even when the installed distribution calls itself something else _LOGGER.info("Determined package name: %s", pypi_name) _LOGGER.info("Package name determined in %.1fs", time.time() - start_time) return pypi_name package_spec, pip_args = parse_specifier_for_install(package_spec, pip_args) if (project_name := local_project_name(package_spec)) is not None: backend_name, _ = resolve_backend_name( cli_value=backend.cli, env_value=env_default_backend() if backend.env is None else backend.env ) # match what the installer would report: pip lists the declared name, uv the normalized one package_name = project_name if backend_name == PIP else canonicalize_name(project_name) _LOGGER.info("Determined package name: %s", package_name) _LOGGER.info("Package name determined in %.1fs", time.time() - start_time) return package_name with tempfile.TemporaryDirectory() as temp_venv_dir: venv = Venv(Path(temp_venv_dir), python=python, verbose=verbose, backend=backend) # under pip this can be the call that builds the shared libraries, so their pip must come from the user's index venv.create_venv(venv_args=[], pip_args=pip_args) package_name = venv.install_package_no_deps( package_or_url=package_spec, pip_args=pip_args, cooldown_days=cooldown_days, ) _LOGGER.info("Package name determined in %.1fs", time.time() - start_time) return package_name def run_post_install_actions( # ruff:ignore[too-many-arguments] # post-install needs venv, both resource dirs, and the venv dir venv: Venv, package_name: str, local_bin_dir: Path, local_man_dir: Path, venv_dir: Path, *, force: bool, previous_resource_paths: set[Path], ) -> tuple[OutputMessage, ...]: # package_name is the metadata key, so it stays the bare name; the record's suffix field renders the suffix in the # summary below package_metadata: Final[PackageInfo] = venv.package_metadata[package_name] display_name: Final[str] = f"{package_name}{package_metadata.suffix}" if not package_metadata.apps: library_name: Final[str] = package_metadata.package or package_name library_guidance: Final[str] = ( "`pipx install` requires an application entry point. " f"Add this package to an existing environment with `pipx inject {library_name}`. " f"For a new application environment, place `--preinstall {library_name}` before the application spec." ) if not package_metadata.apps_of_dependencies: if venv.safe_to_remove(): venv.remove_venv() msg = f""" No apps associated with package {display_name} or its dependencies. {library_guidance} """ raise PipxError(msg) if not package_metadata.included_dependency_apps: if venv.safe_to_remove(): venv.remove_venv() dependency_apps: Final[str] = "\n".join( f"{dependency}: {', '.join(app.name for app in apps)}" for dependency, apps in package_metadata.app_paths_of_dependencies.items() ) msg = f""" No apps associated with package {display_name}. Use '--include-resources-from PACKAGE' to select one of the dependencies listed below, or '--include-deps' to include all of them. {dependency_apps} {library_guidance} """ raise PipxError(msg) if venv.pipx_metadata.exposure_enabled: expose_package_resources(package_metadata, local_bin_dir, local_man_dir, force=force) remove_stale_venv_resources(previous_resource_paths, venv, local_bin_dir, local_man_dir) package_summary, _ = get_venv_summary(venv_dir, package_name=package_name, new_install=True) pipx_logger: Final[logging.Logger] = logging.getLogger("pipx") if pipx_logger.handlers and pipx_logger.handlers[0].level > logging.WARNING: return () messages: Final[list[OutputMessage]] = [OutputMessage(text=package_summary)] if path_warning := _warn_if_not_on_path(local_bin_dir): messages.append(path_warning) messages.append(OutputMessage(text=f"done! {STARS}", stream=OutputStream.STDERR)) return tuple(messages) def expose_package_resources( package_metadata: PackageInfo, local_bin_dir: Path, local_man_dir: Path, *, force: bool, ) -> list[Path]: collisions: Final[list[Path]] = [] if app_paths := package_metadata.app_paths_to_expose: collisions.extend( expose_resources_globally("app", local_bin_dir, app_paths, force=force, suffix=package_metadata.suffix) ) if man_paths := package_metadata.man_paths_to_expose: collisions.extend(expose_resources_globally("man", local_man_dir, man_paths, force=force)) if completion_paths := package_metadata.completion_paths_to_expose: # the script names the command it completes, so a suffix here would point the shell at a command that is gone collisions.extend( expose_resources_globally("completion", paths.CTX.completion_dir, completion_paths, force=force) ) return collisions def expose_resources_globally( resource_type: str, local_resource_dir: Path, resource_paths: list[Path], *, force: bool, suffix: str = "", ) -> list[Path]: collisions: Final[list[Path]] = [] for path in resource_paths: # names come from package metadata and pipx_metadata.json; ".." or a path would escape the exposure directory if not is_file_name(name := add_suffix(path.name, suffix)): msg = f"Refusing to expose {path} as {name!r}, which is not a file name." raise PipxError(msg) if resource_type == "app": if module := _shadowed_module(name, path): _LOGGER.warning( "%s Not exposing %s: a Python script started from %s, such as pip's pipx launcher, would import " "it in place of the %s module.", HAZARD, name, local_resource_dir, module, ) continue _add_ignore_environment_to_python_shebang(path) src = path.resolve() if resource_type == "man": dest_dir = local_resource_dir / src.parent.name elif resource_type == "completion": # a completion section is two levels deep, such as bash-completion/completions dest_dir = local_resource_dir / src.parent.parent.name / src.parent.name else: dest_dir = local_resource_dir if not dest_dir.is_dir(): mkdir(dest_dir) with _in_turn_if_shared(dest_dir / name): if ( collision := _symlink_package_resource( dest_dir, path, force=force, suffix=suffix, executable=(resource_type == "app"), ) if can_symlink(dest_dir) else _copy_package_resource(dest_dir, path, force=force, suffix=suffix) ) is None: _remove_resource_named_before_last_dot(dest_dir, path, suffix) else: collisions.append(collision) return collisions def _shadowed_module(name: str, path: Path) -> str | None: # a script started from the bin dir, such as pip's pipx launcher, searches that dir first, and the launcher imports # re (and through it enum, functools and more) and then pipx itself before pipx can drop the entry from sys.path shadowed: Final[frozenset[str]] = frozenset({*sys.stdlib_module_names, "pipx"}) if path.is_dir(): return name if name in shadowed else None return next( (module for suffix in all_suffixes() if (module := name.removesuffix(suffix)) != name and module in shadowed), None, ) def _add_ignore_environment_to_python_shebang(path: Path) -> None: if WINDOWS or not path.is_file(): return try: first_line, separator, rest = path.read_bytes().partition(b"\n") except OSError: return if not first_line.startswith(b"#!"): return flag: Final[bytes] = b" -E" interpreter: Final[bytes] = first_line[2:] if interpreter.endswith(flag): return if b"python" not in interpreter.lower() or b" " in interpreter or b"\t" in interpreter: return # macOS refuses a shebang line longer than 512 bytes (newline included) and Linux before 5.1 truncates it at 127, # which can cut ` -E` to ` -` and make python run stdin; distlib caps the shebangs it writes at the same bounds if len(first_line) + len(separator) + len(flag) > (512 if MACOS else 127): return # uv hard-links or symlinks installed files to its cache, which other environments share, so write a new inode with TemporaryDirectory(dir=path.parent) as directory: (rewritten := Path(directory) / path.name).write_bytes(first_line + flag + separator + rest) shutil.copymode(path, rewritten) rewritten.replace(path) def _in_turn_if_shared(path: Path) -> AbstractContextManager[None]: return in_turn(lambda: path not in _SHARED_RESOURCES.get()) def _symlink_package_resource( dest_dir: Path, path: Path, *, force: bool, suffix: str, executable: bool, ) -> Path | None: name_suffixed: Final[str] = add_suffix(path.name, suffix) symlink_path: Final[Path] = dest_dir / name_suffixed if not symlink_path.parent.is_dir(): mkdir(symlink_path.parent) if force and _force_replaces(symlink_path) and (symlink_path.is_symlink() or not symlink_path.is_dir()): _LOGGER.info("Force is true. Removing %s.", symlink_path) with suppress(FileNotFoundError, RuntimeError): symlink_path.unlink() exists: Final[bool] = symlink_path.exists() is_symlink: Final[bool] = symlink_path.is_symlink() if exists or (is_symlink and _links_into_another_venv(symlink_path, path)): if exists and symlink_path.samefile(path): _LOGGER.info("Same path %s and %s", symlink_path, path) return None _LOGGER.warning( pipx_wrap( f""" {HAZARD} File exists at {symlink_path!s} and points to {symlink_path.resolve()}{"" if exists else ", which is missing"}. pipx leaves it in place instead of linking it to {path!s}. """, subsequent_indent=" " * 4, ) ) return symlink_path if is_symlink and not exists: _LOGGER.info("Removing existing symlink %s because it points to a missing location", symlink_path) if symlink_path in _SHARED_RESOURCES.get(): _DANGLING_TAKEN.get()[symlink_path] = (symlink_path.readlink(), path) symlink_path.unlink() existing_executable_on_path: Final[str | None] = which(name_suffixed) if executable else None symlink_path.symlink_to(path) if existing_executable_on_path: _LOGGER.warning( pipx_wrap( f""" {HAZARD} Note: {name_suffixed} was already on your PATH at {existing_executable_on_path} """, subsequent_indent=" " * 4, ) ) return None def _force_replaces(path: Path) -> bool: return (replaceable := _REPLACEABLE_RESOURCES.get()) is None or path in replaceable def _links_into_another_venv(symlink_path: Path, path: Path) -> bool: # an installer removes a package's old files before it writes the new ones, and another task of a fan-out or another # pipx run can expose meanwhile, so a dangling link into a venv that still exists is that venv's, not a left-over venvs: Final[Path] = paths.CTX.venvs.resolve() try: target: Final[Path] = symlink_path.resolve() except RuntimeError: # a symlink loop, which Python before 3.13 reports this way return False venv_dir: Final[Path | None] = next((parent for parent in target.parents if parent.parent == venvs), None) return ( venv_dir is not None and venv_dir.is_dir() and not path.resolve().is_relative_to(venv_dir) and _still_exposed_by(venv_dir, target) ) def _still_exposed_by(venv_dir: Path, target: Path) -> bool: # pipx before 1.18.0 kept the link when a new release dropped the app, so a resource the venv's metadata no longer # lists is a left-over, not a file an installer is rewriting metadata: Final[PipxMetadata] = PipxMetadata(venv_dir) if metadata.read_metadata_version is None: # unreadable metadata can't prove the link stale return True return any( resource.resolve() == target for package in (metadata.main_package, *metadata.injected_packages.values()) for resource in ( *package.app_paths_to_expose, *package.man_paths_to_expose, *package.completion_paths_to_expose, ) ) def _copy_package_resource(dest_dir: Path, path: Path, *, force: bool, suffix: str) -> Path | None: src: Final[Path] = path.resolve() dest: Final[Path] = dest_dir / add_suffix(src.name, suffix) if not dest.parent.is_dir(): mkdir(dest.parent) if dest.exists(): if filecmp.cmp(dest, src, shallow=False): return None if not force or not _force_replaces(dest): _LOGGER.warning("%s File exists at %s and does not match %s. Not modifying.", HAZARD, dest, src) return dest # a rebuilt launcher no longer matches its old copy (Windows .exe stubs embed their build time), but a copy # whose shebang names this venv is the one pipx exposed, so refreshing it needs no warning if _directory_id_or_none(src.parent) in _launcher_owner_ids(dest): _LOGGER.info("Refreshing %s from %s", dest, src) else: _LOGGER.warning("%s Overwriting file %s with %s", HAZARD, dest, src) safe_unlink(dest) if src.exists(): shutil.copy(src, dest) return None def _remove_resource_named_before_last_dot(dest_dir: Path, path: Path, suffix: str) -> None: new_name: Final[str] = add_suffix(path.name, suffix) for legacy in (dest_dir / name for name in exposed_app_names(path.name, suffix) - {new_name}): if legacy.is_symlink(): owned = legacy.resolve() == path.resolve() else: # a launcher rebuilt since then no longer matches byte for byte, but its shebang still names this venv owned = legacy.is_file() and ( filecmp.cmp(legacy, path, shallow=False) or _directory_id(path.parent) in _launcher_owner_ids(legacy) ) if owned and drop_copies_claimed_elsewhere({legacy}, path.parent): legacy.unlink() _LOGGER.warning( "%s Renamed %s to %s, as pipx now puts --suffix at the end of app names", HAZARD, legacy.name, new_name ) def exposed_app_names(name: str, suffix: str) -> set[str]: # pipx 1.17.14 and earlier put the suffix before the last dot (pip3@1.12 for pip3.12), and an app exposed back then # keeps that name until pipx exposes it again app: Final[Path] = Path(name) return {add_suffix(name, suffix), f"{app.stem}{suffix}{app.suffix}"} def _directory_id(path: Path) -> tuple[int, int]: # the identity Path.samefile compares, so a venv reached through a different spelling still matches return (stat := path.stat()).st_dev, stat.st_ino def _launcher_owner_ids(resource_path: Path) -> list[tuple[int, int]]: # a copied launcher keeps a shebang to the venv interpreter it was built for, so a force reinstall that rewrites its # source (and Windows .exe stubs, which embed install-time timestamps) still leaves a stale copy recognisable, while # another venv's colliding copy is not try: data: Final[bytes] = resource_path.read_bytes() except OSError: return [] owner_ids: Final[list[tuple[int, int]]] = [] # the "#!" line is the first line on POSIX, and on Windows sits between the .exe launcher stub and the # appended zip start = 0 while (marker := data.find(b"#!", start)) != -1: start = marker + 2 end = len(data) if (newline := data.find(b"\n", start)) == -1 else newline next_end = len(data) if (newline := data.find(b"\n", end + 1)) == -1 else newline # the scan reads every file in the bin directory, so a stray "#!" in a foreign binary hands over arbitrary # bytes: os.fsdecode rejects undecodable ones under the surrogatepass codec Windows uses, shlex unbalanced # quotes, and stat an embedded NUL; none makes the copy ours with suppress(OSError, ValueError): if interpreter := _shebang_interpreter(data[start:end].strip(), data[end + 1 : next_end]): owner_ids.append(_directory_id(Path(interpreter).parent)) return owner_ids def _shebang_interpreter(line: bytes, next_line: bytes) -> str: if line == b"/bin/sh" and next_line.startswith(b"'''exec' "): # pip and uv write a /bin/sh trampoline when the interpreter path is too long for a shebang or holds a space, # and name the interpreter, shell-quoted, on the exec line that follows words: Final[list[str]] = shlex.split(os.fsdecode(next_line)) return words[1] if len(words) > 1 else "" if line.startswith(b'"'): return os.fsdecode(line[1:close]) if (close := line.find(b'"', 1)) != -1 else "" return os.fsdecode(parts[0]) if (parts := line.split()) else "" def drop_copies_claimed_elsewhere(resource_paths: set[Path], venv_bin_path: Path) -> set[Path]: # a static script copies byte for byte, so the copy another venv exposes under a name this venv also answers to # (tool's run.cmd for tool@2) matches by content; unless its shebang names this venv, it belongs to the other one own_id: Final[tuple[int, int] | None] = _directory_id_or_none(venv_bin_path) copies: Final[set[Path]] = { path for path in resource_paths if not path.is_symlink() and own_id not in _launcher_owner_ids(path) } return resource_paths - (copies & _claimed_by_other_venvs(venv_bin_path)) if copies else resource_paths def _directory_id_or_none(path: Path) -> tuple[int, int] | None: try: return _directory_id(path) except OSError: return None def _claimed_by_other_venvs(venv_bin_path: Path) -> set[Path]: # a venv removed since the claims were read, as uninstall-all does one by one, no longer holds its paths return { path for venv_dir, claimed in _venv_claims_by_dir().items() if not venv_bin_path.is_relative_to(venv_dir) and venv_dir.is_dir() for path in claimed } def _venv_claims_by_dir() -> dict[Path, set[Path]]: if (cache := _VENV_CLAIMS.get()) is None: return _read_venv_claims() if not cache: cache.update(_read_venv_claims()) return cache def _read_venv_claims() -> dict[Path, set[Path]]: return {venv_dir: _venv_claims(venv_dir) for venv_dir in VenvContainer(paths.CTX.venvs).iter_venv_dirs()} def _venv_claims(venv_dir: Path) -> set[Path]: metadata: Final[PipxMetadata] = PipxMetadata(venv_dir) if not metadata.exposure_enabled or metadata.main_package.package is None: return set() packages: Final[list[PackageInfo]] = [metadata.main_package, *metadata.injected_packages.values()] return _expected_resource_paths(metadata, packages, paths.CTX.bin_dir, paths.CTX.man_dir) | { paths.CTX.bin_dir / name for package in packages for path in package.app_paths_to_expose for name in exposed_app_names(path.name, package.suffix) } def remove_stale_venv_resources( previous_resource_paths: set[Path], venv: Venv, local_bin_dir: Path, local_man_dir: Path, ) -> None: current_resource_paths: Final[set[Path]] = get_expected_venv_resource_paths(venv, local_bin_dir, local_man_dir) # only remove a dropped destination pipx still owns, so a replacement the user or another package put there stays owned: Final[set[Path]] = _venv_owned_resource_paths(venv, local_bin_dir, local_man_dir) for path in sorted( drop_copies_claimed_elsewhere((previous_resource_paths - current_resource_paths) & owned, venv.bin_path) ): safe_unlink(path) def get_expected_venv_resource_paths(venv: Venv, local_bin_dir: Path, local_man_dir: Path) -> set[Path]: return _expected_resource_paths(venv.pipx_metadata, venv.package_metadata.values(), local_bin_dir, local_man_dir) def _venv_owned_resource_paths(venv: Venv, local_bin_dir: Path, local_man_dir: Path) -> set[Path]: owned: set[Path] = get_exposed_paths_for_package( venv.bin_path, local_bin_dir, group_app_paths(venv.package_metadata.values()) ) man_paths: Final[list[Path]] = [ path for package in venv.package_metadata.values() for path in package.man_paths_to_expose ] for man_section in MAN_SECTIONS: owned |= get_exposed_man_paths_for_package(venv.man_path / man_section, local_man_dir / man_section, man_paths) completion_paths: Final[dict[str, list[Path]]] = group_resource_paths( (path.name, path) for package in venv.package_metadata.values() for path in package.completion_paths_to_expose ) for completion_section in COMPLETION_SECTIONS: owned |= get_exposed_paths_for_package( venv.man_path.parent / completion_section, paths.CTX.completion_dir / completion_section, completion_paths, ) return owned def get_exposed_paths_for_package( venv_resource_path: Path, local_resource_dir: Path, package_resource_paths: Mapping[str, Sequence[Path]] | None = None, ) -> set[Path]: if not local_resource_dir.exists(): return set() scan: Final[_ResourceDirScan] = _scan_resource_dir(local_resource_dir) try: exposed = set(scan.by_owner.get(_directory_id(venv_resource_path), ())) except OSError: exposed = set() if can_symlink(local_resource_dir): return exposed # Man pages and completions carry no launcher shebang, so a copy can only match the venv's own file of the same # name or a recorded source; looking up those names avoids comparing every copy with every venv. try: venv_names = [path.name for path in venv_resource_path.iterdir()] except OSError: venv_names = [] return exposed | { resource_path for name in {os.path.normcase(name) for name in (*venv_names, *(package_resource_paths or {}))} for resource_path in scan.by_name.get(name, ()) if _copy_matches_source(resource_path, venv_resource_path, package_resource_paths) } def _scan_resource_dir(local_resource_dir: Path) -> _ResourceDirScan: if (scans := _RESOURCE_DIR_SCANS.get()) is None: return _build_resource_dir_scan(local_resource_dir) if local_resource_dir not in scans: scans[local_resource_dir] = _build_resource_dir_scan(local_resource_dir) return scans[local_resource_dir] def _build_resource_dir_scan(local_resource_dir: Path) -> _ResourceDirScan: owner_ids: Final = _symlink_owner_ids if can_symlink(local_resource_dir) else _launcher_owner_ids by_owner: Final[dict[tuple[int, int], set[Path]]] = {} by_name: Final[dict[str, list[Path]]] = {} for resource_path in local_resource_dir.iterdir(): by_name.setdefault(os.path.normcase(resource_path.name), []).append(resource_path) for owner_id in owner_ids(resource_path): by_owner.setdefault(owner_id, set()).add(resource_path) return _ResourceDirScan(by_owner=by_owner, by_name=by_name) def _symlink_owner_ids(resource_path: Path) -> list[tuple[int, int]]: # Some apps expose symlinks whose names differ from their targets, so resolved paths cannot identify them. try: return [_directory_id(_link_target(resource_path).parent)] if resource_path.is_symlink() else [] except (FileNotFoundError, RuntimeError): return [] @dataclass(frozen=True, slots=True, kw_only=True) class _ResourceDirScan: by_owner: dict[tuple[int, int], set[Path]] by_name: dict[str, list[Path]] def _copy_matches_source( resource_path: Path, venv_resource_path: Path, package_resource_paths: Mapping[str, Sequence[Path]] | None, ) -> bool: # a static resource copies byte-for-byte, while a colliding copy from another package differs try: same_named: Final[Path] = venv_resource_path / resource_path.name if same_named.is_file() and filecmp.cmp(resource_path, same_named, shallow=False): return True return any( source.is_file() and filecmp.cmp(resource_path, source, shallow=False) for source in (package_resource_paths or {}).get(resource_path.name, ()) ) except FileNotFoundError: return False def group_app_paths(packages: Iterable[PackageInfo]) -> dict[str, list[Path]]: return group_resource_paths( (name, path) for package in packages for path in package.app_paths_to_expose for name in exposed_app_names(path.name, package.suffix) ) def group_resource_paths(resource_paths: Iterable[tuple[str, Path]]) -> dict[str, list[Path]]: grouped: Final[dict[str, list[Path]]] = {} for name, path in resource_paths: grouped.setdefault(name, []).append(path) return grouped def get_exposed_man_paths_for_package( venv_man_path: Path, local_man_dir: Path, package_man_paths: Sequence[Path], ) -> set[Path]: return get_exposed_paths_for_package( venv_man_path, local_man_dir, group_resource_paths((path.name, path) for path in package_man_paths if path.parent.name == venv_man_path.name), ) def get_venv_summary( venv_dir: Path, *, package_name: str | None = None, new_install: bool = False, include_injected: bool = False, ) -> tuple[str, VenvProblems]: venv: Final[Venv] = Venv(venv_dir) if package_name is None: package_name = venv.main_package_name venv_problems, warning_message = venv_health_check(venv, package_name) if venv_problems.any_(): return (warning_message, venv_problems) return ( _get_list_output(venv, package_name, new_install=new_install, include_injected=include_injected), venv_problems, ) def venv_health_check(venv: Venv, package_name: str | None = None) -> tuple[VenvProblems, str]: venv_dir: Final[Path] = venv.root python_path: Final[Path] = venv.python_path.resolve() if package_name is None: package_name = venv.main_package_name if not python_path.is_file(): return ( VenvProblems(invalid_interpreter=True), f" package {RED(BOLD(venv_dir.name))} has invalid interpreter {python_path!s}\r{HAZARD}", ) # the metadata is owner-only as it can hold index credentials, so another user sees a --global environment this way metadata_file: Final[Path] = venv_dir / PIPX_INFO_FILENAME if not venv.package_metadata and metadata_file.exists() and not os.access(metadata_file, os.R_OK): return ( VenvProblems(unreadable_metadata=True), f" package {RED(BOLD(venv_dir.name))} has pipx metadata this user cannot read.\r{HAZARD}", ) if not venv.package_metadata: return ( VenvProblems(missing_metadata=True), f" package {RED(BOLD(venv_dir.name))} has no internal pipx metadata.\r{HAZARD}", ) if venv_dir.name != canonicalize_name(venv_dir.name): return ( VenvProblems(bad_venv_name=True), f" package {RED(BOLD(venv_dir.name))} needs its internal data updated.\r{HAZARD}", ) if not venv.package_metadata[package_name].package_version: return ( VenvProblems(not_installed=True), f" package {RED(BOLD(package_name))} {RED('is not installed')} in the venv {venv_dir.name}\r{HAZARD}", ) return (VenvProblems(), "") @dataclass(frozen=True, slots=True, kw_only=True) class VenvProblems: bad_venv_name: bool = False invalid_interpreter: bool = False missing_metadata: bool = False unreadable_metadata: bool = False not_installed: bool = False def any_(self) -> bool: return any(getattr(self, field.name) for field in fields(self)) def or_(self, venv_problems: VenvProblems) -> VenvProblems: return VenvProblems(**{ field.name: getattr(self, field.name) or getattr(venv_problems, field.name) for field in fields(self) }) def _get_list_output(venv: Venv, package_name: str, *, new_install: bool, include_injected: bool) -> str: package_metadata: Final[PackageInfo] = venv.package_metadata[package_name] python_version: Final[str | None] = venv.pipx_metadata.python_version source_interpreter: Final[Path | None] = venv.pipx_metadata.source_interpreter standalone: Final[str] = ( " (standalone)" if source_interpreter and str(source_interpreter).startswith(str(paths.CTX.standalone_python_cachedir.resolve())) else "" ) suffix: Final[str] = ( f" ({BOLD(shlex.quote(f'{package_name}{package_metadata.suffix}'))})" if package_metadata.suffix else "" ) output: Final[list[str]] = [ ( f" {'installed' if new_install else ''} package {BOLD(shlex.quote(package_name))}" f" {BOLD(package_metadata.package_version)}{suffix}, installed using " f"{python_version if python_version is not None else ''}{standalone}" ) ] if venv.pipx_metadata.exposure_enabled: exposed_binary_names, unavailable_binary_names, exposed_man_pages, unavailable_man_pages = ( _get_exposed_resource_names(venv, package_metadata, new_install=new_install) ) if new_install and (exposed_binary_names or unavailable_binary_names): output.append(" These apps are now available") output.extend(f" - {name}" for name in exposed_binary_names) output.extend( f" - {RED(name)} (symlink missing or pointing to unexpected location)" for name in unavailable_binary_names ) if new_install and (exposed_man_pages or unavailable_man_pages): output.append(" These manual pages are now available") output.extend(f" - {name}" for name in exposed_man_pages) output.extend( f" - {RED(name)} (symlink missing or pointing to unexpected location)" for name in unavailable_man_pages ) else: output.append(" apps and manual pages are not exposed") if include_injected and (injected_packages := venv.pipx_metadata.injected_packages): output.append(" Injected Packages:") output.extend(f" - {name} {injected_packages[name].package_version}" for name in injected_packages) return "\n".join(output) def _get_exposed_resource_names( venv: Venv, package_metadata: PackageInfo, *, new_install: bool, ) -> tuple[list[str], list[str], list[str], list[str]]: resource_packages: Final[tuple[PackageInfo, ...]] = ( (package_metadata,) if new_install else tuple(metadata for metadata in venv.package_metadata.values() if metadata.include_apps) ) exposed_binary_names: Final[list[str]] = sorted( path.name for path in get_exposed_paths_for_package(venv.bin_path, paths.CTX.bin_dir, group_app_paths(resource_packages)) ) unavailable_binary_names: Final[list[str]] = sorted({ add_suffix(name, package_metadata.suffix) for name in package_metadata.apps if not exposed_app_names(name, package_metadata.suffix) & set(exposed_binary_names) }) man_paths: Final[list[Path]] = [path for metadata in resource_packages for path in metadata.man_paths_to_expose] exposed_man_pages: Final[list[str]] = sorted( str(Path(path.parent.name) / path.name) for man_section in MAN_SECTIONS for path in get_exposed_man_paths_for_package( venv.man_path / man_section, paths.CTX.man_dir / man_section, man_paths ) ) unavailable_man_pages: Final[list[str]] = sorted(set(package_metadata.man_pages) - set(exposed_man_pages)) return exposed_binary_names, unavailable_binary_names, exposed_man_pages, unavailable_man_pages def _warn_if_not_on_path(local_bin_dir: Path) -> OutputMessage | None: if not userpath.in_current_path(str(local_bin_dir)): return OutputMessage( text=pipx_wrap( f""" {HAZARD} Note: '{local_bin_dir}' is not on your PATH environment variable. These apps will not be globally accessible until your PATH is updated. Run `pipx ensurepath` to automatically add it, or manually modify your PATH in your shell's config file (e.g. ~/.bashrc). """, subsequent_indent=" " * 4, ), stream=OutputStream.LOG, ) return None def validate_expected_apps(venv: Venv, package_name: str, expected_apps: Sequence[str]) -> None: if not expected_apps: return package: Final[PackageInfo] = venv.package_metadata[package_name] available_apps: Final[list[str]] = sorted({ app[:-4] if WINDOWS and app.lower().endswith(".exe") else app for app in package.apps_to_expose }) if missing_apps := sorted(set(expected_apps) - set(available_apps)): msg: Final[str] = ( f"Package {package_name} does not provide expected {'app' if len(missing_apps) == 1 else 'apps'} " f"{', '.join(missing_apps)}. " f"Available apps: {', '.join(available_apps) or 'none'}." ) raise PipxError(msg) def validate_suffix(suffix: str) -> str: # pipx splices a suffix straight into an exposed file name, so anything but a portable character set could steer the # destination out of PIPX_BIN_DIR or PIPX_MAN_DIR if re.fullmatch(r"[A-Za-z0-9._@+-]*", suffix) is None: msg: Final[str] = ( f"Invalid suffix {suffix!r}. Use only letters, digits, and the characters . _ - + @ so exposed file " f"names stay inside pipx's directories." ) raise PipxError(msg) return suffix def locked_package_message(package_name: str) -> str: return f"Not upgrading locked package {package_name}. Update its lock file and run `pipx reinstall {package_name}`." def get_venv_package_infos(venv: Venv) -> tuple[PackageInfo, ...] | None: if venv.pipx_metadata.main_package.package is not None: return tuple(venv.package_metadata.values()) if not venv.python_path.is_file(): return None try: venv_metadata: Final[VenvMetadata] = venv.get_venv_metadata_for_package(venv.root.name, set()) except PipxError as error: # an environment that cannot report its packages is still removable, from the links that point into it _LOGGER.warning("%s Unable to inspect %s, removing it anyway: %s", HAZARD, venv.name, error) return None return ( PackageInfo( package=venv.root.name, package_or_url="", pip_args=[], include_apps=True, include_dependencies=False, apps=venv_metadata.apps, app_paths=venv_metadata.app_paths, apps_of_dependencies=venv_metadata.apps_of_dependencies, app_paths_of_dependencies=venv_metadata.app_paths_of_dependencies, man_pages=venv_metadata.man_pages, man_paths=venv_metadata.man_paths, man_pages_of_dependencies=venv_metadata.man_pages_of_dependencies, man_paths_of_dependencies=venv_metadata.man_paths_of_dependencies, package_version=venv_metadata.package_version, suffix="", ), ) def get_venv_resource_paths( resource_type: Literal["app", "man", "completion"], venv_resource_path: Path, local_resource_dir: Path, package_infos: tuple[PackageInfo, ...] | None, ) -> set[Path]: if package_infos is not None: get_package_resource_paths: Final[Callable[[PackageInfo, Path, Path], set[Path]]] = { "app": get_package_bin_dir_app_paths, "man": get_package_man_paths, "completion": get_package_completion_paths, }[resource_type] return set().union( *( get_package_resource_paths(package_info, venv_resource_path, local_resource_dir) for package_info in package_infos ) ) # without metadata, pipx infers ownership from link targets if not local_resource_dir.is_dir() or not can_symlink(local_resource_dir): return set() return get_exposed_paths_for_package(venv_resource_path, local_resource_dir) def get_package_bin_dir_app_paths(package_info: PackageInfo, venv_bin_path: Path, local_bin_dir: Path) -> set[Path]: return get_exposed_paths_for_package(venv_bin_path, local_bin_dir, group_app_paths((package_info,))) def get_package_man_paths(package_info: PackageInfo, venv_man_path: Path, local_man_dir: Path) -> set[Path]: return get_exposed_man_paths_for_package(venv_man_path, local_man_dir, package_info.man_paths_to_expose) def get_package_completion_paths( package_info: PackageInfo, venv_completion_path: Path, local_completion_dir: Path, ) -> set[Path]: return get_exposed_paths_for_package( venv_completion_path, local_completion_dir, group_resource_paths((path.name, path) for path in package_info.completion_paths_to_expose), ) __all__ = [ "VenvProblems", "add_suffix", "can_symlink", "drop_copies_claimed_elsewhere", "drop_links_taken_over", "expose_package_resources", "expose_resources_globally", "expose_shared_resources_in_order", "exposed_app_names", "force_replaces_only", "get_expected_venv_resource_paths", "get_exposed_man_paths_for_package", "get_exposed_paths_for_package", "get_package_bin_dir_app_paths", "get_package_completion_paths", "get_package_man_paths", "get_venv_package_infos", "get_venv_resource_paths", "get_venv_summary", "give_back_shared_links", "group_app_paths", "group_resource_paths", "locked_package_message", "package_name_from_spec", "remove_stale_venv_resources", "reuse_resource_dir_scans", "run_post_install_actions", "validate_expected_apps", "validate_suffix", "venv_health_check", ] pipx-1.18.16/src/pipx/commands/ensure_path.py000066400000000000000000000217571527355705200211360ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["pipx", "pipx.util", "pipx.wrap", "re", "shlex", "typing", "userpath"] import logging import re import shlex import site import sys from pathlib import Path from typing import Final import userpath from pipx import paths from pipx.constants import EXIT_CODE_OK, MACOS, ExitCode from pipx.emojis import HAZARD, STARS from pipx.util import PipxError from pipx.wrap import pipx_wrap _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) _GLOBAL_PATH_FILE: Final[Path] = Path("/etc/paths.d/pipx" if MACOS else "/etc/profile.d/pipx.sh") def ensure_pipx_paths(*, force: bool, prepend: bool, all_shells: bool, dry_run: bool, is_global: bool) -> ExitCode: if is_global: written: Final[bool] = _ensure_global_path(paths.CTX.bin_dir, force=force, prepend=prepend, dry_run=dry_run) print() # ruff:ignore[print] # user-facing CLI output if dry_run: print( # ruff:ignore[print] # user-facing CLI output pipx_wrap("This was a dry run; no changes were made to the system PATH configuration.") ) elif written: print( # ruff:ignore[print] # user-facing CLI output pipx_wrap("Users must open a new terminal or re-login for the PATH change to take effect.") + "\n" ) print(f"Otherwise pipx is ready to go! {STARS}") # ruff:ignore[print] # user-facing CLI output return EXIT_CODE_OK bin_paths: Final[set[Path]] = {paths.CTX.bin_dir} if (pipx_user_bin_path := _get_pipx_user_bin_path()) is not None: bin_paths.add(pipx_user_bin_path) outcomes: Final[list[tuple[bool, bool]]] = [ ensure_path(bin_path, prepend=prepend, force=force, all_shells=all_shells, dry_run=dry_run) for bin_path in bin_paths ] path_added: Final[bool] = any(added for added, _ in outcomes) need_shell_restart: Final[bool] = any(restart for _, restart in outcomes) print() # ruff:ignore[print] # user-facing CLI output if dry_run: print( # ruff:ignore[print] # user-facing CLI output pipx_wrap("This was a dry run; no changes were made to your PATH or shell configuration files.") ) return EXIT_CODE_OK if path_added: print( # ruff:ignore[print] # user-facing CLI output pipx_wrap( """ Consider adding shell completions for pipx. Run 'pipx completions' for instructions. """ ) + "\n" ) elif not need_shell_restart and all(userpath.in_current_path(str(bin_path)) for bin_path in bin_paths): sys.stdout.flush() _LOGGER.warning( "%s\n", pipx_wrap( f""" {HAZARD} All pipx binary directories are already in PATH. To add them again anyway, try again with the '--force' flag. """ ), ) if need_shell_restart: print( # ruff:ignore[print] # user-facing CLI output pipx_wrap( """ You will need to open a new terminal or re-login for the PATH changes to take effect. Alternatively, you can source your shell's config file with e.g. 'source ~/.bashrc'. """ ) + "\n" ) print(f"Otherwise pipx is ready to go! {STARS}") # ruff:ignore[print] # user-facing CLI output return EXIT_CODE_OK def _ensure_global_path(location: Path, *, force: bool, prepend: bool, dry_run: bool) -> bool: if MACOS: contents = f"{location}\n" else: quoted_location = shlex.quote(str(location)) assignment = f'export PATH={quoted_location}:"$PATH"' if prepend else f'export PATH="$PATH":{quoted_location}' contents = f'case ":$PATH:" in *:{quoted_location}:*) ;; *) {assignment} ;; esac\n' if not force: try: existing = _GLOBAL_PATH_FILE.read_text(encoding="utf-8") if _GLOBAL_PATH_FILE.exists() else "" except UnicodeDecodeError: # pipx writes UTF-8, so undecodable bytes came from someone else existing = None if existing == contents: print( # ruff:ignore[print] # user-facing CLI output pipx_wrap(f"{location} is already in the system PATH configuration.", subsequent_indent=" " * 4) ) return False # the name alone does not make the file pipx's: an administrator may keep other settings in it if existing is None or (existing and not _written_by_pipx(existing)): msg = ( f"{_GLOBAL_PATH_FILE} holds content pipx did not write, so pipx left it alone. Add {location} to PATH " "there yourself, or pass --force to replace the file." ) raise PipxError(msg) if dry_run: print( # ruff:ignore[print] # user-facing CLI output pipx_wrap( f"Would write {location} to the system PATH configuration at {_GLOBAL_PATH_FILE}.", subsequent_indent=" " * 4, ) ) return False _GLOBAL_PATH_FILE.write_text(contents, encoding="utf-8") _GLOBAL_PATH_FILE.chmod(0o644) print( # ruff:ignore[print] # user-facing CLI output pipx_wrap( f"Success! Added {location} to the system PATH configuration at {_GLOBAL_PATH_FILE}.", subsequent_indent=" " * 4, ) ) return True def _written_by_pipx(contents: str) -> bool: if MACOS: # /etc/paths.d holds one directory per line, so only a single line can be pipx's own entry return len(contents.splitlines()) == 1 # the entry an earlier run wrote for any location, appended or prepended entry: Final[str] = ( r'case ":\$PATH:" in \*:(?P.+?):\*\) ;; \*\) ' r'export PATH=(?:(?P=location):"\$PATH"|"\$PATH":(?P=location)) ;; esac\n' ) return re.fullmatch(entry, contents) is not None def _get_pipx_user_bin_path() -> Path | None: # misses a pipx installed as editable with `pip install --user -e` userbase_path: Final[Path] = Path(site.getuserbase()).resolve() if not Path(__file__).resolve().is_relative_to(userbase_path): return None # the user scheme puts scripts in /bin on Linux and macOS, and in /Python/Scripts on # Windows: https://docs.python.org/3/library/sysconfig.html#user-scheme return next( ( script.parent for script in ( userbase_path / "bin" / "pipx", Path(site.getusersitepackages()).resolve().parent / "Scripts" / "pipx.exe", ) if script.exists() ), None, ) def ensure_path(location: Path, *, force: bool, prepend: bool, all_shells: bool, dry_run: bool) -> tuple[bool, bool]: location_str: Final[str] = str(location) need_shell_restart: Final[bool] = userpath.need_shell_restart(location_str) in_current_path: Final[bool] = userpath.in_current_path(location_str) if force or (not in_current_path and not need_shell_restart): if dry_run: print( # ruff:ignore[print] # user-facing CLI output pipx_wrap( f"Would {'prepend' if prepend else 'append'} {location_str} to the PATH environment variable.", subsequent_indent=" " * 4, ) ) return False, False path_added: Final[bool] = (userpath.prepend if prepend else userpath.append)( location_str, "pipx", all_shells=all_shells ) if path_added: print( # ruff:ignore[print] # user-facing CLI output pipx_wrap( f"Success! Added {location_str} to the PATH environment variable.", subsequent_indent=" " * 4, ) ) else: print( # ruff:ignore[print] # user-facing CLI output pipx_wrap( f"{HAZARD} {location_str} is not added to the PATH environment variable successfully. " f"You may need to add it to PATH manually.", subsequent_indent=" " * 4, ) ) return path_added, userpath.need_shell_restart(location_str) if not in_current_path and need_shell_restart: print( # ruff:ignore[print] # user-facing CLI output pipx_wrap( f""" {location_str} has been added to PATH, but you need to open a new terminal or re-login for this PATH change to take effect. Alternatively, you can source your shell's config file with e.g. 'source ~/.bashrc'. """, subsequent_indent=" " * 4, ) ) else: print( # ruff:ignore[print] # user-facing CLI output pipx_wrap(f"{location_str} is already in PATH.", subsequent_indent=" " * 4) ) return False, need_shell_restart __all__ = [ "ensure_path", "ensure_pipx_paths", ] pipx-1.18.16/src/pipx/commands/environment.py000066400000000000000000000103431527355705200211520ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["functools", "pipx", "pipx.backends.registry", "pipx.backends.uv", "pipx.interpreter", "pipx.util"] import os from functools import cache from typing import TYPE_CHECKING, Final from pipx import paths from pipx.backends.registry import env_default_backend, resolve_backend_name from pipx.backends.uv import find_uv_binary from pipx.constants import EXIT_CODE_OK, ExitCode from pipx.emojis import EMOJI_SUPPORT from pipx.interpreter import get_default_python from pipx.shared_libs import ( DISABLE_SHARED_LIBS_AUTO_UPGRADE, shared_libs_auto_upgrade_disabled, ) from pipx.util import PipxError if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path ENVIRONMENT_VARIABLES: Final[list[str]] = [ "PIPX_HOME", "PIPX_GLOBAL_HOME", "PIPX_BIN_DIR", "PIPX_GLOBAL_BIN_DIR", "PIPX_MAN_DIR", "PIPX_COMPLETION_DIR", "PIPX_GLOBAL_MAN_DIR", "PIPX_GLOBAL_COMPLETION_DIR", "PIPX_SHARED_LIBS", "PIPX_DEFAULT_PYTHON", "PIPX_DEFAULT_BACKEND", "PIPX_COOLDOWN", "PIPX_FETCH_MISSING_PYTHON", "PIPX_FETCH_PYTHON", DISABLE_SHARED_LIBS_AUTO_UPGRADE, "PIPX_USE_EMOJI", "PIPX_MAX_LOGS", ] DERIVED_ENVIRONMENT_VARIABLES: Final[list[str]] = [ "PIPX_LOCAL_VENVS", "PIPX_LOG_DIR", "PIPX_TRASH_DIR", "PIPX_VENV_CACHEDIR", "PIPX_STANDALONE_PYTHON_CACHEDIR", "PIPX_RESOLVED_BACKEND", "PIPX_BACKEND_SOURCE", "PIPX_UV_BINARY", "UV_CACHE_DIR", ] ENVIRONMENT_VALUE_CHOICES: Final[list[str]] = ENVIRONMENT_VARIABLES + DERIVED_ENVIRONMENT_VARIABLES def environment(value: str | None) -> ExitCode: derived_values: Final[dict[str, Callable[[], Path | str]]] = _get_derived_values() if value is None: print("Environment variables (set by user):") # ruff:ignore[print] # user-facing CLI output print() # ruff:ignore[print] # user-facing CLI output for env_variable in ENVIRONMENT_VARIABLES: print(f"{env_variable}={os.getenv(env_variable, '')}") # ruff:ignore[print] # user-facing CLI output print() # ruff:ignore[print] # user-facing CLI output print("Derived values (computed by pipx):") # ruff:ignore[print] # user-facing CLI output print() # ruff:ignore[print] # user-facing CLI output for env_variable, resolve_derived_value in derived_values.items(): print(f"{env_variable}={resolve_derived_value()}") # ruff:ignore[print] # user-facing CLI output elif (get_derived_value := derived_values.get(value)) is not None: print(get_derived_value()) # ruff:ignore[print] # user-facing CLI output elif value in ENVIRONMENT_VARIABLES: print(os.getenv(value, "")) # ruff:ignore[print] # user-facing CLI output else: msg: Final[str] = "Variable not found." raise PipxError(msg) return EXIT_CODE_OK def _get_derived_values() -> dict[str, Callable[[], Path | str]]: @cache def resolve_backend() -> tuple[str, str]: return resolve_backend_name(env_value=env_default_backend()) return { "PIPX_HOME": lambda: paths.CTX.home, "PIPX_BIN_DIR": lambda: paths.CTX.bin_dir, "PIPX_MAN_DIR": lambda: paths.CTX.man_dir, "PIPX_COMPLETION_DIR": lambda: paths.CTX.completion_dir, "PIPX_SHARED_LIBS": lambda: paths.CTX.shared_libs, "PIPX_LOCAL_VENVS": lambda: paths.CTX.venvs, "PIPX_LOG_DIR": lambda: paths.CTX.logs, "PIPX_TRASH_DIR": lambda: paths.CTX.trash, "PIPX_VENV_CACHEDIR": lambda: paths.CTX.venv_cache, "PIPX_STANDALONE_PYTHON_CACHEDIR": lambda: paths.CTX.standalone_python_cachedir, "PIPX_DEFAULT_PYTHON": get_default_python, "PIPX_RESOLVED_BACKEND": lambda: resolve_backend()[0], "PIPX_BACKEND_SOURCE": lambda: resolve_backend()[1], "PIPX_UV_BINARY": lambda: str(binary) if (binary := find_uv_binary()[0]) else "", "UV_CACHE_DIR": lambda: os.environ.get("UV_CACHE_DIR", ""), DISABLE_SHARED_LIBS_AUTO_UPGRADE: lambda: str(shared_libs_auto_upgrade_disabled()).lower(), "PIPX_USE_EMOJI": lambda: str(EMOJI_SUPPORT).lower(), } __all__ = [ "DERIVED_ENVIRONMENT_VARIABLES", "ENVIRONMENT_VALUE_CHOICES", "ENVIRONMENT_VARIABLES", "environment", ] pipx-1.18.16/src/pipx/commands/execute.py000066400000000000000000000036341527355705200202550ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["itertools", "pipx.subprocess_runner", "pipx.util", "pipx.venv"] import os from itertools import chain from typing import TYPE_CHECKING, Final, NoReturn from pipx.constants import WINDOWS from pipx.subprocess_runner import exec_app from pipx.util import PipxError from pipx.venv import Venv if TYPE_CHECKING: from pathlib import Path def execute(package: str, venv_dir: Path, app: str, app_args: list[str]) -> NoReturn: if not venv_dir.is_dir(): msg = f"pipx does not manage environment {package!r}." raise PipxError(msg) venv: Final[Venv] = Venv(venv_dir) app_paths: Final[dict[str, Path]] = _application_paths(venv) if (app_path := app_paths.get(app)) is None: available: Final[str] = ", ".join(sorted(app_paths)) or "none" msg = f"Application {app!r} was not found in environment {package!r}.\nAvailable applications: {available}" raise PipxError(msg, wrap_message=False) if not app_path.is_file(): msg = ( f"Application {app!r} is missing from environment {package!r} at {app_path}.\n" f"Reinstall it with `pipx reinstall {package}`." ) raise PipxError(msg, wrap_message=False) environment: Final[dict[str, str]] = dict(os.environ) environment["VIRTUAL_ENV"] = str(venv_dir) environment["PATH"] = ( os.pathsep.join((str(venv.bin_path), path)) if (path := environment.get("PATH")) else str(venv.bin_path) ) exec_app([app_path, *app_args], env=environment) def _application_paths(venv: Venv) -> dict[str, Path]: return { path.stem if WINDOWS and path.suffix.lower() == ".exe" else path.name: path for package in (venv.pipx_metadata.main_package, *venv.pipx_metadata.injected_packages.values()) for path in chain(package.app_paths, *package.app_paths_of_dependencies.values()) } __all__ = [ "execute", ] pipx-1.18.16/src/pipx/commands/expose.py000066400000000000000000000144511527355705200201150ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["pipx", "pipx.commands.common", "pipx.trash", "pipx.venv"] from dataclasses import dataclass from enum import StrEnum from typing import TYPE_CHECKING, Final from pipx import paths from pipx.commands.common import drop_copies_claimed_elsewhere, expose_package_resources, get_venv_resource_paths from pipx.constants import COMPLETION_SECTIONS, MAN_SECTIONS, ExitCode from pipx.result import OperationData, OperationError, OperationResult, OutputLevel, OutputMessage, OutputStream from pipx.trash import safe_unlink from pipx.venv import Venv if TYPE_CHECKING: from pathlib import Path from pipx.pipx_metadata_file import PackageInfo def expose(venv_dir: Path, local_bin_dir: Path, local_man_dir: Path, *, verbose: bool) -> OperationResult[ExposureData]: return _set_exposure(venv_dir, local_bin_dir, local_man_dir, verbose=verbose, enabled=True) def _set_exposure( venv_dir: Path, local_bin_dir: Path, local_man_dir: Path, *, verbose: bool, enabled: bool, ) -> OperationResult[ExposureData]: command: Final[tuple[str, ...]] = ("expose",) if enabled else ("unexpose",) if not venv_dir.is_dir(): return _failure(command, venv_dir.name, f"pipx does not manage package {venv_dir.name}") venv: Final[Venv] = Venv(venv_dir, verbose=verbose) if venv.pipx_metadata.main_package.package is None: return _failure(command, venv.name, f"pipx cannot read metadata for package {venv.name}") if not enabled and not venv.pipx_metadata.exposure_enabled: return _success(command, venv.name, _ExposureStatus.UNEXPOSED, f"{venv.name}: already unexposed") if enabled: linked_before: Final[set[Path] | None] = ( _exposed_resources(venv, local_bin_dir, local_man_dir) if venv.pipx_metadata.exposure_enabled else None ) attempted: Final[int] = sum( len(package_metadata.app_paths_to_expose) + len(package_metadata.man_paths_to_expose) + len(package_metadata.completion_paths_to_expose) for package_metadata in venv.package_metadata.values() ) collisions: Final[list[Path]] = [ collision for package_metadata in venv.package_metadata.values() for collision in expose_package_resources(package_metadata, local_bin_dir, local_man_dir, force=False) ] venv.pipx_metadata.exposure_enabled = True venv.pipx_metadata.write() if collisions: return _collision_outcome(command, venv.name, collisions, exposed_any=len(collisions) < attempted) prefix: Final[str] = ( "already " if _exposed_resources(venv, local_bin_dir, local_man_dir) == linked_before else "" ) return _success(command, venv.name, _ExposureStatus.EXPOSED, f"{venv.name}: {prefix}exposed") for resource_path in drop_copies_claimed_elsewhere( _exposed_resources(venv, local_bin_dir, local_man_dir), venv.bin_path ): safe_unlink(resource_path) venv.pipx_metadata.exposure_enabled = False venv.pipx_metadata.write() return _success(command, venv.name, _ExposureStatus.UNEXPOSED, f"{venv.name}: unexposed") def _failure(command: tuple[str, ...], environment: str, error: str) -> OperationResult[ExposureData]: return OperationResult( command=command, data=ExposureData(environments=()), messages=(OutputMessage(text=error, stream=OutputStream.STDERR, level=OutputLevel.ERROR),), exit_code=ExitCode(1), errors=(OperationError(code="environment_expose_failed", message=error, environment=environment),), ) @dataclass(frozen=True, slots=True, kw_only=True) class ExposureData(OperationData): environments: tuple[_EnvironmentExposure, ...] def _success( command: tuple[str, ...], environment: str, status: _ExposureStatus, message: str ) -> OperationResult[ExposureData]: return OperationResult( command=command, data=ExposureData(environments=(_EnvironmentExposure(environment=environment, status=status),)), messages=(OutputMessage(text=message),), ) @dataclass(frozen=True, slots=True, kw_only=True) class _EnvironmentExposure: environment: str status: _ExposureStatus class _ExposureStatus(StrEnum): EXPOSED = "exposed" UNEXPOSED = "unexposed" def _exposed_resources(venv: Venv, local_bin_dir: Path, local_man_dir: Path) -> set[Path]: package_infos: Final[tuple[PackageInfo, ...]] = tuple(venv.package_metadata.values()) resource_paths = get_venv_resource_paths("app", venv.bin_path, local_bin_dir, package_infos) for man_section in MAN_SECTIONS: resource_paths |= get_venv_resource_paths( "man", venv.man_path / man_section, local_man_dir / man_section, package_infos, ) for completion_section in COMPLETION_SECTIONS: resource_paths |= get_venv_resource_paths( "completion", venv.man_path.parent / completion_section, paths.CTX.completion_dir / completion_section, package_infos, ) return resource_paths def _collision_outcome( command: tuple[str, ...], environment: str, collisions: list[Path], *, exposed_any: bool ) -> OperationResult[ExposureData]: summary: Final[str] = ( f"{environment}: skipped {len(collisions)} resource(s) already present in the target directory" ) return OperationResult( command=command, data=ExposureData( environments=(_EnvironmentExposure(environment=environment, status=_ExposureStatus.EXPOSED),) ), messages=(OutputMessage(text=summary, stream=OutputStream.STDERR, level=OutputLevel.ERROR),), exit_code=ExitCode(1), errors=tuple( OperationError( code="environment_expose_conflict", message=f"{path} already exists and does not belong to {environment}", environment=environment, ) for path in collisions ), succeeded=exposed_any, ) def unexpose( venv_dir: Path, local_bin_dir: Path, local_man_dir: Path, *, verbose: bool ) -> OperationResult[ExposureData]: return _set_exposure(venv_dir, local_bin_dir, local_man_dir, verbose=verbose, enabled=False) __all__ = [ "ExposureData", "expose", "unexpose", ] pipx-1.18.16/src/pipx/commands/health.py000066400000000000000000000166051527355705200200620ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "pipx.commands.reinstall", "pipx.commands.transaction", "pipx.pipx_metadata_file", "pipx.subprocess_runner", "pipx.util", ] from dataclasses import dataclass from enum import StrEnum from typing import TYPE_CHECKING, Final from pipx.commands.reinstall import reinstall from pipx.commands.transaction import is_unfinished from pipx.constants import ExitCode from pipx.pipx_metadata_file import PIPX_INFO_FILENAME, PipxMetadata from pipx.result import OperationData, OperationError, OperationResult, OutputLevel, OutputMessage, OutputStream from pipx.subprocess_runner import run_subprocess from pipx.util import PipxError, get_venv_paths if TYPE_CHECKING: from collections.abc import Iterable from pathlib import Path from pipx.options import ReinstallOptions from pipx.venv import VenvContainer def health(venv_container: VenvContainer, venv_dirs: Iterable[Path]) -> OperationResult[HealthData]: environments: Final[list[_EnvironmentHealth]] = [] messages: Final[list[OutputMessage]] = [] for venv_dir in venv_dirs: with venv_container.venv_lock(venv_dir): environment: Final[_EnvironmentHealth] = _check_health(venv_dir) environments.append(environment) if environment.status is _HealthStatus.HEALTHY: messages.append(OutputMessage(text=f"{environment.environment}: healthy")) continue location: Final[str] = f" at {environment.interpreter}" if environment.interpreter else "" messages.append(OutputMessage(text=f"{environment.environment}: {environment.error}{location}")) if not messages: messages.append(OutputMessage(text="pipx manages no packages.")) errors: Final[tuple[OperationError, ...]] = tuple( OperationError(code="environment_unhealthy", message=environment.error, environment=environment.environment) for environment in environments if environment.error is not None ) return OperationResult( command=("health",), data=HealthData(environments=tuple(environments)), messages=tuple(messages), exit_code=ExitCode(1 if errors else 0), errors=errors, succeeded=any(environment.status is _HealthStatus.HEALTHY for environment in environments), ) def _check_health(venv_dir: Path) -> _EnvironmentHealth: if not venv_dir.is_dir(): return _EnvironmentHealth( environment=venv_dir.name, interpreter="", status=_HealthStatus.MISSING, error="pipx does not manage this environment", ) _, interpreter, _ = get_venv_paths(venv_dir) if not interpreter.is_file(): return _EnvironmentHealth( environment=venv_dir.name, interpreter=str(interpreter), status=_HealthStatus.BROKEN, error="interpreter is missing", ) try: process: Final = run_subprocess([interpreter, "--version"], log_stdout=False, log_stderr=False) except OSError as error: return _EnvironmentHealth( environment=venv_dir.name, interpreter=str(interpreter), status=_HealthStatus.BROKEN, error=f"interpreter could not start: {error.strerror or error}", ) if process.returncode: return _EnvironmentHealth( environment=venv_dir.name, interpreter=str(interpreter), status=_HealthStatus.BROKEN, error=f"interpreter exited with status {process.returncode}", ) return _EnvironmentHealth( environment=venv_dir.name, interpreter=str(interpreter), status=_HealthStatus.HEALTHY, error=None ) class _HealthStatus(StrEnum): HEALTHY = "healthy" BROKEN = "broken" MISSING = "missing" @dataclass(frozen=True, slots=True, kw_only=True) class _EnvironmentHealth: environment: str interpreter: str status: _HealthStatus error: str | None @dataclass(frozen=True, slots=True, kw_only=True) class HealthData(OperationData): environments: tuple[_EnvironmentHealth, ...] def repair( venv_container: VenvContainer, venv_dirs: Iterable[Path], options: ReinstallOptions ) -> OperationResult[RepairData]: failures: Final[list[_FailedRepair]] = [] messages: Final[list[OutputMessage]] = [] repaired: Final[list[_RepairedEnvironment]] = [] skipped: Final[list[_SkippedRepair]] = [] for venv_dir in venv_dirs: with venv_container.venv_lock(venv_dir) as venv_lock: environment = _check_health(venv_dir) if environment.status is _HealthStatus.HEALTHY and not _lost_record(venv_dir): skipped.append(_SkippedRepair(environment=venv_dir.name, reason="healthy")) messages.append(OutputMessage(text=f"{venv_dir.name}: healthy")) continue if environment.status is _HealthStatus.MISSING: error = environment.error or "environment is missing" else: try: reinstall(venv_dir, options, venv_lock=venv_lock) except PipxError as reinstall_error: error = str(reinstall_error) else: if (environment := _check_health(venv_dir)).status is _HealthStatus.HEALTHY: repaired.append( _RepairedEnvironment(environment=venv_dir.name, interpreter=environment.interpreter) ) messages.append(OutputMessage(text=f"{venv_dir.name}: repaired")) continue error = environment.error or "interpreter remains broken" failures.append(_FailedRepair(environment=venv_dir.name, error=error)) messages.append( OutputMessage(text=f"{venv_dir.name}: {error}", stream=OutputStream.STDERR, level=OutputLevel.ERROR) ) if not messages: messages.append(OutputMessage(text="pipx found no environments to repair.")) return OperationResult( command=("repair",), data=RepairData(repaired=tuple(repaired), skipped=tuple(skipped)), messages=tuple(messages), exit_code=ExitCode(1 if failures else 0), errors=tuple( OperationError(code="environment_repair_failed", message=failure.error, environment=failure.environment) for failure in failures ), succeeded=bool(repaired), ) def _lost_record(venv_dir: Path) -> bool: # a killed install or a newer pipx leaves a running interpreter without a usable record, which reinstall refuses # rather than guess the source; an environment from pipx before 0.15.0.0 never had one and stays healthy if (venv_dir / PIPX_INFO_FILENAME).exists(): return PipxMetadata(venv_dir=venv_dir).read_metadata_version is None return is_unfinished(venv_dir) @dataclass(frozen=True, slots=True, kw_only=True) class _SkippedRepair: environment: str reason: str @dataclass(frozen=True, slots=True, kw_only=True) class _RepairedEnvironment: environment: str interpreter: str @dataclass(frozen=True, slots=True, kw_only=True) class _FailedRepair: environment: str error: str @dataclass(frozen=True, slots=True, kw_only=True) class RepairData(OperationData): repaired: tuple[_RepairedEnvironment, ...] skipped: tuple[_SkippedRepair, ...] __all__ = [ "HealthData", "RepairData", "health", "repair", ] pipx-1.18.16/src/pipx/commands/inject.py000066400000000000000000000351701527355705200200670ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "functools", "packaging.utils", "pathlib", "pipx", "pipx.backends._base", "pipx.backends.registry", "pipx.commands.common", "pipx.commands.transaction", "pipx.options", "pipx.package_specifier", "pipx.redact", "pipx.util", "pipx.venv", "pipx.wrap", "re", ] import logging import os import re from dataclasses import dataclass, replace from enum import StrEnum from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Final from packaging.utils import canonicalize_name from pipx import paths from pipx.backends._base import UV from pipx.backends.registry import assert_not_pip_under_uv from pipx.colors import BOLD from pipx.commands.common import ( get_expected_venv_resource_paths, package_name_from_spec, run_post_install_actions, ) from pipx.commands.transaction import marked_unfinished, preserve_venv, runs_from_venv from pipx.constants import EXIT_CODE_INJECT_ERROR, EXIT_CODE_OK from pipx.emojis import HAZARD, STARS from pipx.options import ExposureOptions from pipx.package_specifier import add_extras, get_extras, source_without_extras, trusted_recorded_pip_args from pipx.redact import redact_url_credentials from pipx.result import ( OperationData, OperationError, OperationResult, OutputFormat, OutputLevel, OutputMessage, OutputStream, render_messages, render_result, ) from pipx.util import InstalledNameMismatchError, PipxError from pipx.venv import Venv from pipx.wrap import pipx_wrap if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator from pipx.options import InjectOptions from pipx.pipx_metadata_file import PackageInfo from pipx.venv_inspect import VenvMetadata _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) def inject_dep( venv_dir: Path, package_name: str | None, package_spec: str, options: InjectOptions, ) -> OperationResult[InjectionData]: _LOGGER.debug("Injecting package %s", redact_url_credentials(package_spec)) if not venv_dir.exists() or next(venv_dir.iterdir(), None) is None: msg = f""" Can't inject {redact_url_credentials(package_spec)!r} into nonexistent Virtual Environment {venv_dir.name!r}. Be sure to install the package first with 'pipx install {venv_dir.name}' before injecting into it. """ raise PipxError(msg) venv: Final[Venv] = Venv(venv_dir, verbose=options.verbose, backend=options.backend) if not venv.package_metadata: msg = f""" Can't inject {redact_url_credentials(package_spec)!r} into Virtual Environment {venv.name!r}. {venv.name!r} has no internal pipx metadata, which pipx versions before 0.15.0.0 did not record. Uninstall and install {venv.name!r}, or run 'pipx reinstall-all', to fix it. """ raise PipxError(msg) if (lock_file := venv.pipx_metadata.main_package.lock_file) is not None: msg = ( f"Cannot inject into locked environment {venv.name}. " f"Update {lock_file} and run `pipx reinstall {venv.name}`." ) raise PipxError(msg) cooldown_days: Final[int | None] = ( venv.pipx_metadata.main_package.cooldown_days if options.cooldown_days is None else options.cooldown_days ) venv.check_upgrade_shared_libs(pip_args=options.pip_args, verbose=options.verbose) # the spec can be anything pip installs, such as a VCS URL or an archive, so only resolving it gives the name if package_name is None: package_name = package_name_from_spec( package_spec, os.fspath(venv.python_path), pip_args=options.pip_args, verbose=options.verbose, backend=replace(options.backend, cli=venv.backend_name), cooldown_days=cooldown_days, ) # as at install: pip in a uv venv works for `pipx run` but breaks anyone reaching for the venv's missing pip assert_not_pip_under_uv(canonicalize_name(package_name), venv.backend_name) is_main_package: Final[bool] = canonicalize_name(package_name) == canonicalize_name(venv.main_package_name) already_installed: Final[bool] = not options.force and venv.has_package(package_name) main_spec: Final[str | None] = ( _main_spec_with_extras(venv, package_spec) if already_installed and is_main_package else None ) if already_installed and main_spec is None: _LOGGER.info("Package %s is already installed", package_name) return _finish_inject( OperationResult( command=("inject",), data=InjectionData( packages=(), skipped=(_InjectionSkip(environment=venv.name, package=package_name, reason="already-installed"),), ), messages=( OutputMessage( text=pipx_wrap( f""" {HAZARD} {package_name} is already installed in {venv.name!r}. Not modifying existing installation in '{venv_dir}'. Pass '--force' to force installation. """ ) ), ), ), emit_output=options.emit_output, ) package_spec = main_spec or package_spec main_package: Final[PackageInfo] = venv.pipx_metadata.main_package pip_args: Final[list[str]] = ( options.pip_args or trusted_recorded_pip_args(main_package.pip_args, venv_dir) if is_main_package else options.pip_args ) exposure: Final[ExposureOptions] = ( ExposureOptions.recorded(main_package) if is_main_package else ExposureOptions( include_apps=options.include_apps, include_dependencies=options.include_dependencies, include_resources_from=options.include_resources_from, suffix=venv.package_metadata[venv.main_package_name].suffix if options.with_suffix else "", ) ) # pip's --force-reinstall covers the whole resolution, so it also refetches the main package by name from this # package's index when this package depends on it; uv can limit the reinstall to this package reinstall_args: Final[list[str] | None] = ( (["--reinstall-package", package_name] if venv.backend_name == UV else ["--force-reinstall"]) if options.force and options.force_reinstall and venv.has_package(package_name) else None ) previous_resource_paths: Final[set[Path]] = get_expected_venv_resource_paths( venv, paths.CTX.bin_dir, paths.CTX.man_dir ) messages: Final[list[OutputMessage]] = [] # like install, a package without the apps --include-apps asked for leaves the venv as it was with ( preserve_venv( venv_dir, enabled=exposure.include_apps or bool(exposure.include_resources_from) or runs_from_venv(venv_dir), ), marked_unfinished(venv_dir), ): package_name = _install_under_installed_name( partial( venv.install_package, package_or_url=package_spec, pip_args=pip_args, install_only_pip_args=reinstall_args, exposure=exposure, is_main_package=is_main_package, pinned=main_package.pinned if is_main_package else options.pinned, cooldown_days=cooldown_days, ), package_name, ) if exposure.include_apps: messages.extend( run_post_install_actions( venv, package_name, paths.CTX.bin_dir, paths.CTX.man_dir, venv_dir, force=options.force, previous_resource_paths=previous_resource_paths, ), ) if is_main_package: messages.append(OutputMessage(text=f" updated package {BOLD(package_name)} in venv {BOLD(venv.name)}")) else: messages.append(OutputMessage(text=f" injected package {BOLD(package_name)} into venv {BOLD(venv.name)}")) messages.append(OutputMessage(text=f"done! {STARS}", stream=OutputStream.STDERR)) package: Final[PackageInfo] = venv.package_metadata[package_name] return _finish_inject( OperationResult( command=("inject",), data=InjectionData( packages=( InjectionPackage( environment=venv.name, package=f"{package.package}{package.suffix}", version=package.package_version, status=InjectionStatus.UPDATED if is_main_package else InjectionStatus.INJECTED, location=str(venv.root), ), ), skipped=(), ), messages=tuple(messages), ), emit_output=options.emit_output, ) def _main_spec_with_extras(venv: Venv, package_spec: str) -> str | None: # without --force only extras may change, so naming the package cannot swap the recorded path or URL for the index's if not (extras := get_extras(package_spec)) or (recorded := venv.pipx_metadata.main_package.package_or_url) is None: return None try: recorded_source: Final[str] = source_without_extras(recorded) except PipxError as error: msg = ( f"The recorded source {redact_url_credentials(recorded)} of {venv.name!r} no longer exists. " f"Pass '--force' to install {redact_url_credentials(package_spec)!r} in its place." ) raise PipxError(msg) from error name: Final[str] = canonicalize_name(venv.main_package_name) # NAME[extra] @ URL names the same source as a bare recorded URL if source_without_extras(package_spec) not in {name, recorded_source, f"{name} @ {recorded_source}"}: return None return add_extras(recorded, venv.main_package_name, extras) def _finish_inject( result: OperationResult[InjectionData], *, emit_output: bool, ) -> OperationResult[InjectionData]: if not emit_output: return result if result.errors: render_messages( tuple(message for message in result.messages if message.level is OutputLevel.NORMAL), quiet=0, ) raise PipxError(result.errors[0].message) render_result(result, output=OutputFormat.HUMAN, quiet=0) return result def _install_under_installed_name(install: Callable[[str], VenvMetadata], package_name: str) -> str: try: install(package_name) except InstalledNameMismatchError as error: # the spec built under another name than the one pipx resolved, so record the package the venv now holds install(error.installed_name) return error.installed_name return package_name def inject( venv_dir: Path, package_specs: Iterable[str], requirement_files: Iterable[str], options: InjectOptions, ) -> OperationResult[InjectionData]: package_set: Final[set[str]] = set(package_specs) for filename in requirement_files: package_set.update(_parse_requirements(filename)) packages: Final[list[str]] = sorted(package_set) if not packages: error: Final[str] = "No packages to inject; pass a package spec or --requirement." return _finish_inject( OperationResult( command=("inject",), data=InjectionData(packages=(), skipped=()), messages=(OutputMessage(text=error, stream=OutputStream.STDERR, level=OutputLevel.ERROR),), exit_code=EXIT_CODE_INJECT_ERROR, errors=(OperationError(code="package_inject_failed", message=error, environment=venv_dir.name),), ), emit_output=options.emit_output, ) _LOGGER.info("Injecting packages: %r", [redact_url_credentials(package) for package in packages]) dependency_options: Final[InjectOptions] = replace( options, include_apps=options.include_apps or options.include_dependencies or bool(options.include_resources_from), emit_output=False, ) changed: Final[list[InjectionPackage]] = [] errors: Final[list[OperationError]] = [] messages: Final[list[OutputMessage]] = [] skipped: Final[list[_InjectionSkip]] = [] for dependency in packages: try: result: OperationResult[InjectionData] = inject_dep(venv_dir, None, dependency, dependency_options) except PipxError as error: errors.append( OperationError( code="package_inject_failed", message=error.raw_message, environment=venv_dir.name, package=redact_url_credentials(dependency), ) ) messages.append(OutputMessage(text=str(error), stream=OutputStream.STDERR, level=OutputLevel.ERROR)) break changed.extend(result.data.packages) skipped.extend(result.data.skipped) messages.extend(result.messages) return _finish_inject( OperationResult( command=("inject",), data=InjectionData(packages=tuple(changed), skipped=tuple(skipped)), messages=tuple(messages), exit_code=EXIT_CODE_INJECT_ERROR if errors else EXIT_CODE_OK, errors=tuple(errors), succeeded=bool(changed or skipped), ), emit_output=options.emit_output, ) def _parse_requirements(filename: str) -> Iterator[str]: # strips comments the way pip does: https://github.com/pypa/pip/blob/main/src/pip/_internal/req/req_file.py comment: Final[re.Pattern[str]] = re.compile(r"(^|\s+)#.*$") with Path(filename).open(encoding="utf-8") as requirements: for line in requirements: if spec := comment.sub("", line).strip(): yield spec class InjectionStatus(StrEnum): INJECTED = "injected" UNINJECTED = "uninjected" UPDATED = "updated" @dataclass(frozen=True, slots=True, kw_only=True) class InjectionPackage: environment: str package: str version: str status: InjectionStatus location: str @dataclass(frozen=True, slots=True, kw_only=True) class _InjectionSkip: environment: str package: str reason: str @dataclass(frozen=True, slots=True, kw_only=True) class InjectionData(OperationData): packages: tuple[InjectionPackage, ...] skipped: tuple[_InjectionSkip, ...] __all__ = [ "InjectionData", "InjectionPackage", "InjectionStatus", "inject", "inject_dep", ] pipx-1.18.16/src/pipx/commands/install.py000066400000000000000000001103621527355705200202560ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "functools", "json", "packaging.utils", "pipx", "pipx.backends._base", "pipx.commands.common", "pipx.commands.inject", "pipx.commands.transaction", "pipx.fanout", "pipx.interpreter", "pipx.options", "pipx.package_specifier", "pipx.pipx_metadata_file", "pipx.redact", "pipx.requires_python", "pipx.script", "pipx.trash", "pipx.util", "pipx.venv", "pipx.wrap", "re", ] import json import logging import re import sys from dataclasses import dataclass, replace from functools import partial from typing import TYPE_CHECKING, Final from packaging.utils import canonicalize_name from pipx import paths from pipx.backends._base import PIP from pipx.commands.common import ( expose_package_resources, expose_shared_resources_in_order, get_expected_venv_resource_paths, locked_package_message, package_name_from_spec, remove_stale_venv_resources, run_post_install_actions, validate_expected_apps, validate_suffix, ) from pipx.commands.inject import inject from pipx.commands.transaction import VenvBackup, marked_unfinished, preserve_venv from pipx.constants import EXIT_CODE_OK, ExitCode from pipx.emojis import HAZARD, SLEEP from pipx.fanout import fan_out, locked_in_order from pipx.interpreter import get_default_python from pipx.options import BackendOptions, CooldownOptions, ExposureOptions, InjectOptions, InstallOptions from pipx.package_specifier import ( changeable_by_others, package_spec_satisfied, translate_pip_args, trusted_recorded_pip_args, ) from pipx.pipx_metadata_file import PackageInfo, PipxMetadata, load_spec_file from pipx.redact import redact_url_credentials from pipx.requires_python import IncompatiblePythonError, interpreter_for from pipx.result import ( OperationData, OperationError, OperationResult, OutputFormat, OutputLevel, OutputMessage, OutputStream, render_messages, render_result, ) from pipx.script import script_name_from_spec from pipx.trash import rmdir from pipx.util import PipxError from pipx.venv import Venv, VenvContainer from pipx.wrap import pipx_wrap if TYPE_CHECKING: from collections.abc import Iterator, Sequence from pathlib import Path from filelock import BaseFileLock from packaging.specifiers import SpecifierSet from pipx.constants import FetchPythonOptions from pipx.venv_inspect import VenvMetadata _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) def install( # ruff:ignore[too-many-locals, too-many-statements] # one pass resolves, builds and exposes each venv under its lock venv_dir: Path | None, package_names: list[str] | None, package_specs: list[str], options: InstallOptions, *, venv_lock: BaseFileLock | None = None, ) -> OperationResult[InstallData]: validate_suffix(options.exposure.suffix) messages: Final[list[OutputMessage]] = [] packages: Final[list[_InstalledPackage]] = [] skipped: Final[list[_SkippedInstall]] = [] failures: Final[list[_FailedInstall]] = [] try: lock_file: Final[Path | None] = _validate_install_options(package_specs, options) python: Final[str] = options.python or get_default_python() except PipxError as error: return _finish_install(_failed_install_result(package_specs[0], error), emit_output=options.emit_output) package_names, resolution_failure, interpreters = _resolve_package_names( package_names, package_specs, python, options, messages=messages ) if resolution_failure is not None: package_spec, resolution_error = resolution_failure return _finish_install(_failed_install_result(package_spec, resolution_error), emit_output=options.emit_output) venv_container: Final[VenvContainer] = VenvContainer(venv_dir.parent if venv_dir is not None else paths.CTX.venvs) for package_name, package_spec, package_python in zip(package_names, package_specs, interpreters, strict=False): if venv_dir is None: venv_dir = venv_container.get_venv_dir(f"{package_name}{options.exposure.suffix}") with venv_lock or venv_container.venv_lock(venv_dir): exists: Final[bool] = venv_dir.exists() and any(venv_dir.iterdir()) installs_pip: Final[bool] = canonicalize_name(package_name) == "pip" try: # ruff:ignore[too-many-statements-in-try-clause] # one PipxError handler must cover the whole existing-venv resolution venv = Venv( venv_dir, python=package_python, verbose=options.verbose, # `pipx install pip` needs pip, which uv venvs lack; an explicit --backend uv still reaches # assert_not_pip_under_uv and fails backend=BackendOptions(cli=options.backend.cli or PIP) if installs_pip else options.backend, ) recorded_lock: Final[Path | None] = venv.pipx_metadata.main_package.lock_file required_lock: Final[Path | None] = lock_file if options.replace_lock else lock_file or recorded_lock build: Final[_EnvironmentBuild] = _EnvironmentBuild( venv_args=options.venv_args, pip_args=options.pip_args, override_shared=installs_pip, exposure_enabled=venv.pipx_metadata.exposure_enabled if options.exposure_enabled is None else options.exposure_enabled, preinstall_packages=options.preinstall_packages, package_name=package_name, package_spec=package_spec, force_reinstall=options.force and exists, exposure=options.exposure, expected_apps=tuple( dict.fromkeys( options.expected_apps if options.replace_expected_apps else options.expected_apps or venv.pipx_metadata.main_package.expected_apps ) ), lock_file=required_lock, cooldown_days=_resolve_cooldown( required_lock, options.cooldown, venv.pipx_metadata.main_package.cooldown_days, modifies_existing=exists and options.force, ), pinned=venv.pipx_metadata.main_package.pinned if options.pinned is None else options.pinned, ) if exists: existing: Final[_ExistingInstall] = _handle_existing_install( venv, build, options, python=package_python ) messages.extend(existing.messages) packages.extend(existing.packages) skipped.extend(existing.skipped) if not existing.continue_install: venv_dir = None continue # --force reuses the environment, so --python does not switch it; `pipx reinstall --python` does if ( options.python_flag_passed and (recorded := venv.pipx_metadata.source_interpreter) is not None and recorded.is_file() ): venv.python = str(recorded) except PipxError as error: _record_install_failure(failures, messages, venv_dir.name, error) break previous_resource_paths: Final[set[Path]] = get_expected_venv_resource_paths( venv, options.local_bin_dir, options.local_man_dir ) preserve_existing_venv: Final[bool] = exists and ( options.preserve_existing or bool(build.expected_apps or options.exposure.include_resources_from) or required_lock is not None ) try: # ruff:ignore[too-many-statements-in-try-clause] # one handler must roll back the whole build-and-expose sequence on failure with preserve_venv(venv_dir, enabled=exists, copy_upfront=preserve_existing_venv) as venv_backup: venv.check_upgrade_shared_libs(pip_args=options.pip_args, verbose=options.verbose) if exists and (required_lock is not None or (options.replace_lock and recorded_lock is not None)): rmdir(venv_dir) venv = Venv( venv_dir, python=venv.python, verbose=options.verbose, backend=BackendOptions(cli=venv.backend_name), ) venv = _install_on_supported_python( venv, build, options, venv_backup=venv_backup, messages=messages ) validate_expected_apps(venv, package_name, build.expected_apps) messages.extend( run_post_install_actions( venv, package_name, options.local_bin_dir, options.local_man_dir, venv_dir, force=options.force, previous_resource_paths=previous_resource_paths, ) ) except (Exception, KeyboardInterrupt) as error: # any failure, an interrupt included, rolls back a new environment; pipx reports only a PipxError if not exists: venv.remove_venv() if not isinstance(error, PipxError): raise _record_install_failure(failures, messages, venv.name, error) break packages.append(_installed_package(venv, package_name)) venv_dir = None return _finish_install( OperationResult( command=("install",), data=InstallData(packages=tuple(packages), skipped=tuple(skipped)), messages=tuple(messages), exit_code=ExitCode(1 if failures else 0), errors=tuple( OperationError(code="package_install_failed", message=f.error, environment=f.environment) for f in failures ), succeeded=bool(packages or skipped), ), emit_output=options.emit_output, ) def _validate_install_options(package_specs: Sequence[str], options: InstallOptions) -> Path | None: if options.upgrade_strategy is not None and not options.upgrade: msg = "--upgrade-strategy requires --upgrade" raise PipxError(msg) if options.expected_apps and len(package_specs) != 1: msg = "--app accepts one package spec" raise PipxError(msg) if options.lock_file is None: return None if len(package_specs) != 1: msg = "--lock accepts one package spec" raise PipxError(msg) if options.preinstall_packages: msg = "--lock cannot be combined with --preinstall" raise PipxError(msg) if options.upgrade: msg = "--lock cannot be combined with --upgrade; use --force to apply a new lock" raise PipxError(msg) if options.cooldown.cli_days is not None: msg = "--lock cannot be combined with --cooldown" raise PipxError(msg) lock_file: Final[Path] = options.lock_file.expanduser().resolve() if not re.fullmatch(r"pylock(?:\.[^.]+)?\.toml", lock_file.name): msg = f"Rename the lock file {lock_file.name!r} to pylock.toml or pylock..toml" raise PipxError(msg) if not lock_file.is_file(): msg = f"Lock file does not exist: {lock_file}" raise PipxError(msg) return lock_file def _finish_install(result: OperationResult[InstallData], *, emit_output: bool) -> OperationResult[InstallData]: if not emit_output: return result if result.errors: render_messages( tuple(message for message in result.messages if message.level is OutputLevel.NORMAL), quiet=0, ) raise PipxError(next(message.text for message in result.messages if message.level is OutputLevel.ERROR)) render_result(result, output=OutputFormat.HUMAN, quiet=0) return result def _failed_install_result(environment: str, error: PipxError) -> OperationResult[InstallData]: return OperationResult( command=("install",), data=InstallData(packages=(), skipped=()), messages=(OutputMessage(text=str(error), stream=OutputStream.STDERR, level=OutputLevel.ERROR),), exit_code=ExitCode(1), errors=( OperationError( code="package_install_failed", message=_error_report(error), environment=redact_url_credentials(environment), ), ), ) @dataclass(frozen=True, slots=True, kw_only=True) class InstallData(OperationData): packages: tuple[_InstalledPackage, ...] skipped: tuple[_SkippedInstall, ...] def _error_report(error: PipxError) -> str: # a JSON consumer reads only the envelope, which needs the installer's reason the terminal showed beside it return f"{error}\n{error.backend_output}" if error.backend_output else str(error) def _first_cooldown(*candidates: int | None) -> int | None: return next((candidate for candidate in candidates if candidate is not None), None) def _resolve_package_names( package_names: list[str] | None, package_specs: list[str], python: str, options: InstallOptions, *, messages: list[OutputMessage], ) -> tuple[list[str], tuple[str, PipxError] | None, list[str]]: if package_names is not None and len(package_names) == len(package_specs): return package_names, None, [python] * len(package_specs) # a lock file already pins every version, leaving PIPX_COOLDOWN nothing to constrain cooldown_days: Final[int | None] = None if options.lock_file is not None else options.cooldown.days expected_apps: Final[tuple[str, ...]] = tuple(options.expected_apps) resolved: Final[list[str]] = [] interpreters: Final[list[str]] = [] for package_spec in package_specs: interpreter = python try: # ruff:ignore[too-many-statements-in-try-clause] # one PipxError handler must cover the whole per-spec name resolution if (script_name := script_name_from_spec(package_spec, expected_apps)) is not None: resolved.append(script_name) interpreters.append(interpreter) continue try: name = package_name_from_spec( package_spec, interpreter, pip_args=options.pip_args, verbose=options.verbose, backend=options.backend, cooldown_days=cooldown_days, ) except IncompatiblePythonError as error: # reading the name off a local project already runs an install, so the constraint surfaces here first interpreter = _supported_python( error.constraint, fetch_python=options.fetch_python, python_flag_passed=options.python_flag_passed, messages=messages, ) name = package_name_from_spec( package_spec, interpreter, pip_args=options.pip_args, verbose=options.verbose, backend=options.backend, cooldown_days=cooldown_days, ) resolved.append(name) interpreters.append(interpreter) except PipxError as error: return resolved, (package_spec, error), interpreters return resolved, None, interpreters def _supported_python( constraint: SpecifierSet, *, fetch_python: FetchPythonOptions, python_flag_passed: bool, messages: list[OutputMessage], ) -> str: if python_flag_passed: msg = ( f"The Python you named does not satisfy {str(constraint)!r}, which this package requires. " f"Name one that does, or leave --python out and let pipx choose." ) raise PipxError(msg) interpreter: Final[str] = interpreter_for(constraint, fetch_python) messages.append( OutputMessage(text=f"This package needs Python {constraint}, so pipx used {interpreter}.", notice=True) ) return interpreter def _resolve_cooldown( lock_file: Path | None, requested: CooldownOptions, stored: int | None, *, modifies_existing: bool, ) -> int | None: if modifies_existing and lock_file is not None and requested.cli_days is not None: msg = "--cooldown cannot modify a locked environment" raise PipxError(msg) if lock_file is not None: return None # PIPX_COOLDOWN states a current machine-wide policy, so it outranks the cooldown the venv was last installed with return _first_cooldown(requested.cli_days, requested.env_days, stored) def _handle_existing_install( venv: Venv, build: _EnvironmentBuild, options: InstallOptions, *, python: str ) -> _ExistingInstall: messages: Final[list[OutputMessage]] = [] if not options.reinstall and options.force and options.python_flag_passed: messages.append( OutputMessage( text=pipx_wrap( f""" pipx ignores --python when you pass --force. To reinstall {build.package_name} with {python}, run `pipx reinstall {venv.name} --python {python}` instead. """ ) ) ) if options.force: messages.append(OutputMessage(text=f"Installing to existing venv {venv.name!r}")) return _ExistingInstall( continue_install=True, packages=(), skipped=(), messages=tuple(messages), ) if options.upgrade: reason, upgrade_messages = _upgrade_existing_venv(venv, build, options) messages.extend(upgrade_messages) return _ExistingInstall( continue_install=False, packages=(_installed_package(venv, build.package_name),) if reason is None else (), skipped=(_SkippedInstall(environment=venv.name, package=build.package_name, reason=reason),) if reason is not None else (), messages=tuple(messages), ) installed_version: Final[str] = venv.pipx_metadata.main_package.package_version messages.append( OutputMessage( text=pipx_wrap( f""" {venv.name!r}{f" ({installed_version})" if installed_version else ""} is already installed. Not modifying existing installation in '{venv.root}'. Pass '--force' to force installation, or use 'pipx upgrade {venv.name}' to upgrade. """, keep_newlines=True, ) ) ) return _ExistingInstall( continue_install=False, packages=(), skipped=(_SkippedInstall(environment=venv.name, package=build.package_name, reason="already-installed"),), messages=tuple(messages), ) @dataclass(frozen=True, slots=True, kw_only=True) class _ExistingInstall: continue_install: bool packages: tuple[_InstalledPackage, ...] skipped: tuple[_SkippedInstall, ...] messages: tuple[OutputMessage, ...] def _upgrade_existing_venv( venv: Venv, build: _EnvironmentBuild, options: InstallOptions ) -> tuple[str | None, tuple[OutputMessage, ...]]: package_metadata = venv.pipx_metadata.main_package installed_version: Final[str] = package_metadata.package_version if package_metadata.lock_file is not None: skip = ("locked", locked_package_message(venv.name)) elif package_spec_satisfied( build.package_spec, build.package_name, installed_version, package_metadata.package_or_url or build.package_name, ): skip = ( "already-satisfied", f"{build.package_name} {installed_version} already satisfies {redact_url_credentials(build.package_spec)}", ) elif package_metadata.pinned: skip = ("pinned", f"Not upgrading pinned package {venv.name}. Run `pipx unpin {venv.name}` to unpin it.") else: skip = None if skip is not None: validate_expected_apps(venv, build.package_name, build.expected_apps) _record_expected_apps(venv, build.expected_apps) reason, message = skip return reason, (OutputMessage(text=message),) with preserve_venv(venv.root, enabled=bool(build.expected_apps)): main_pip_args: Final[list[str]] = options.pip_args or trusted_recorded_pip_args( package_metadata.pip_args, venv.root ) venv.check_upgrade_shared_libs(pip_args=main_pip_args, verbose=options.verbose) venv.upgrade_packaging_libraries(main_pip_args) previous_resource_paths: Final[set[Path]] = get_expected_venv_resource_paths( venv, options.local_bin_dir, options.local_man_dir ) venv.upgrade_package( build.package_name, build.package_spec, main_pip_args, exposure=ExposureOptions.recorded(package_metadata), is_main_package=True, upgrade_only_pip_args=[f"--upgrade-strategy={options.upgrade_strategy}"] if options.upgrade_strategy is not None else None, expected_apps=build.expected_apps, cooldown_days=build.cooldown_days, ) validate_expected_apps(venv, build.package_name, build.expected_apps) package_metadata = venv.pipx_metadata.main_package if venv.pipx_metadata.exposure_enabled: expose_package_resources(package_metadata, options.local_bin_dir, options.local_man_dir, force=False) remove_stale_venv_resources(previous_resource_paths, venv, options.local_bin_dir, options.local_man_dir) return None, ( OutputMessage( text=pipx_wrap( f""" upgraded package {venv.name} from {installed_version} to {package_metadata.package_version} (location: {venv.root!s}) """ ) ), ) def _record_expected_apps(venv: Venv, expected_apps: Sequence[str]) -> None: expected: Final[list[str]] = list(dict.fromkeys(expected_apps)) if venv.pipx_metadata.main_package.expected_apps == expected: return venv.pipx_metadata.main_package = replace(venv.pipx_metadata.main_package, expected_apps=expected) venv.pipx_metadata.write() def _installed_package(venv: Venv, package_name: str) -> _InstalledPackage: package: Final[PackageInfo] = venv.package_metadata[package_name] return _InstalledPackage( environment=venv.name, package=str(package.package), version=package.package_version, location=str(venv.root), interpreter=venv.pipx_metadata.python_version, backend=venv.pipx_metadata.backend, ) @dataclass(frozen=True, slots=True, kw_only=True) class _InstalledPackage: environment: str package: str version: str location: str interpreter: str | None backend: str @dataclass(frozen=True, slots=True, kw_only=True) class _SkippedInstall: environment: str package: str reason: str def _record_install_failure( failures: list[_FailedInstall], messages: list[OutputMessage], environment: str, error: PipxError, ) -> None: failures.append(_FailedInstall(environment=environment, error=_error_report(error))) messages.append(OutputMessage(text=str(error), stream=OutputStream.STDERR, level=OutputLevel.ERROR)) @dataclass(frozen=True, slots=True, kw_only=True) class _FailedInstall: environment: str error: str # holds everything needed to fill a venv, letting pipx repeat it when the first interpreter turns out unsupported @dataclass(frozen=True, slots=True, kw_only=True) class _EnvironmentBuild: venv_args: list[str] pip_args: list[str] override_shared: bool exposure_enabled: bool preinstall_packages: list[str] | None package_name: str package_spec: str force_reinstall: bool exposure: ExposureOptions expected_apps: tuple[str, ...] lock_file: Path | None cooldown_days: int | None pinned: bool def run(self, venv: Venv) -> VenvMetadata: venv.create_venv(self.venv_args, self.pip_args, override_shared=self.override_shared) venv.pipx_metadata.exposure_enabled = self.exposure_enabled venv.pipx_metadata.environment = venv.root.name # pipx writes the record last; a kill before it must not leave what looks like a pre-0.15 environment with marked_unfinished(venv.root): for dependency in self.preinstall_packages or []: venv.upgrade_package_no_metadata(dependency, self.pip_args, cooldown_days=self.cooldown_days) return venv.install_package( package_name=self.package_name, package_or_url=self.package_spec, pip_args=self.pip_args, install_only_pip_args=["--force-reinstall"] if self.force_reinstall else None, exposure=self.exposure, is_main_package=True, expected_apps=self.expected_apps, lock_file=self.lock_file, cooldown_days=self.cooldown_days, pinned=self.pinned, ) def _install_on_supported_python( venv: Venv, build: _EnvironmentBuild, options: InstallOptions, *, venv_backup: VenvBackup, messages: list[OutputMessage], ) -> Venv: try: # uv installs onto an interpreter above the package's upper bound, so ask the metadata as well constraint = build.run(venv).unsupported_python except IncompatiblePythonError as error: constraint = error.constraint if constraint is None: return venv interpreter: Final[str] = _supported_python( constraint, fetch_python=options.fetch_python, python_flag_passed=options.python_flag_passed, messages=messages, ) venv_backup.remove_venv() # the rebuild deletes the recorded backend; resolving it again could switch installer and index rebuilt: Final[Venv] = Venv( venv.root, python=interpreter, verbose=options.verbose, backend=BackendOptions(cli=venv.backend_name) ) build.run(rebuilt) return rebuilt def install_all(spec_metadata_file: Path, options: InstallOptions) -> ExitCode: venvs_metadata: Final[list[PipxMetadata]] = [] malformed: PipxError | None = None try: for venv_metadata in _extract_venv_metadata(spec_metadata_file): venvs_metadata.append(venv_metadata) except PipxError as error: # a one-by-one install reached a malformed entry only after the entries before it; install those first if not venvs_metadata: raise malformed = error venv_container: Final[VenvContainer] = VenvContainer(paths.CTX.venvs) with expose_shared_resources_in_order(venvs_metadata, options.local_bin_dir, options.local_man_dir): outcomes: Final[list[tuple[str, bool]]] = fan_out( [partial(_install_spec_venv, venv_container, venv_metadata, options) for venv_metadata in venvs_metadata], sequential=options.verbose, ) if malformed is not None: raise malformed if not any(succeeded for _, succeeded in outcomes): print( # ruff:ignore[print] # user-facing CLI output f"No packages installed after running 'pipx install-all {spec_metadata_file}' {SLEEP}" ) if failed := [environment for environment, succeeded in outcomes if not succeeded]: msg = f"The following package(s) failed to install: {', '.join(failed)}" raise PipxError(msg) return EXIT_CODE_OK def _extract_venv_metadata(spec_metadata_file: Path) -> Iterator[PipxMetadata]: try: spec: Final = load_spec_file(spec_metadata_file) except OSError as exc: msg = f"Unable to read the spec metadata file {spec_metadata_file}: {exc.strerror}" raise PipxError(msg) from exc except (json.decoder.JSONDecodeError, UnicodeDecodeError) as exc: msg = f"The spec metadata file {spec_metadata_file} is not valid JSON." raise PipxError(msg) from exc if not isinstance(spec, dict) or not isinstance(venvs_metadata_dict := spec.get("venvs", {}), dict): msg = f"The spec metadata file {spec_metadata_file} is invalid." raise PipxError(msg) if not venvs_metadata_dict: msg = f"No packages found in the spec metadata file {spec_metadata_file}." raise PipxError(msg) for package_path_name, entry in venvs_metadata_dict.items(): venv_metadata = PipxMetadata(paths.CTX.venvs.joinpath(package_path_name), read=False) try: venv_metadata.from_dict(entry["metadata"]) except KeyError as exc: msg = f"Entry {package_path_name!r} in the spec metadata file {spec_metadata_file} is missing {exc}." raise PipxError(msg) from exc except (AttributeError, TypeError) as exc: msg = f"Entry {package_path_name!r} in the spec metadata file {spec_metadata_file} is malformed: {exc}" raise PipxError(msg) from exc yield venv_metadata def _install_spec_venv( venv_container: VenvContainer, venv_metadata: PipxMetadata, options: InstallOptions ) -> tuple[str, bool]: main_package: Final[PackageInfo] = venv_metadata.main_package venv_dir: Final[Path] = venv_container.get_venv_dir(f"{main_package.package}{main_package.suffix}") interpreter: Final[str | None] = ( options.python if options.python_flag_passed else _get_python_interpreter(venv_metadata.source_interpreter) or options.python ) packages: Final[list[PackageInfo]] = [main_package, *venv_metadata.injected_packages.values()] try: package_pip_args: Final[list[list[str]]] = ( [options.pip_args or trusted_recorded_pip_args(package.pip_args, venv_dir) for package in packages] if options.pip_args or options.backend.cli in {None, venv_metadata.backend} else _translate_recorded_pip_args(venv_metadata, packages, options.backend.cli) ) with locked_in_order(venv_container.venv_lock(venv_dir)) as venv_lock: package_cooldown: Final[int | None] = _resolve_cooldown( main_package.lock_file, CooldownOptions(cli_days=options.cooldown.days), main_package.cooldown_days, modifies_existing=False, ) installed: Final[OperationResult[InstallData]] = install( venv_dir, None, [_generate_package_spec(main_package)], InstallOptions( local_bin_dir=options.local_bin_dir, local_man_dir=options.local_man_dir, python=interpreter, pip_args=package_pip_args[0], venv_args=options.venv_args or venv_metadata.venv_args, verbose=options.verbose, force=options.force, reinstall=False, exposure=ExposureOptions( include_dependencies=main_package.include_dependencies, include_resources_from=main_package.include_resources_from, suffix=main_package.suffix, ), preinstall_packages=[], expected_apps=main_package.expected_apps, lock_file=main_package.lock_file, python_flag_passed=options.python_flag_passed, backend=replace(options.backend, cli=options.backend.cli or venv_metadata.backend), exposure_enabled=venv_metadata.exposure_enabled, replace_expected_apps=True, replace_lock=True, cooldown=CooldownOptions(cli_days=package_cooldown), pinned=main_package.pinned, ), venv_lock=venv_lock, ) # install left an existing environment alone, so its injected packages must not change it either for inject_package, inject_pip_args in ( () if installed.data.skipped else zip(packages[1:], package_pip_args[1:], strict=True) ): inject( venv_dir, [_generate_package_spec(inject_package)], [], InjectOptions( pip_args=inject_pip_args, verbose=options.verbose, include_apps=inject_package.include_apps, include_dependencies=inject_package.include_dependencies, include_resources_from=inject_package.include_resources_from, force=options.force, with_suffix=inject_package.suffix == main_package.suffix, cooldown_days=_first_cooldown(options.cooldown.days, inject_package.cooldown_days), pinned=inject_package.pinned, ), ) except PipxError as error: print(error, file=sys.stderr) # ruff:ignore[print] # user-facing CLI output return venv_dir.name, False return venv_dir.name, True def _get_python_interpreter(source_interpreter: Path | None) -> str | None: if source_interpreter is None: return None if (reason := _distrusted_interpreter(source_interpreter)) is None: return str(source_interpreter) print( # ruff:ignore[print] # user-facing CLI output pipx_wrap( f""" The exported python interpreter '{source_interpreter}' is ignored as {reason}; using the default python instead. """ ) ) return None def _distrusted_interpreter(python: Path) -> str | None: if not python.is_file(): return "not found" # the snapshot can outlive the interpreter it names, and whoever creates that path again picks what runs here resolved: Final[Path] = python.resolve() if any((path.parent.parent / "pyvenv.cfg").is_file() for path in {python, resolved}): return "it is inside a virtual environment, which can be removed and created again" if (location := changeable_by_others(python) or changeable_by_others(resolved)) is not None: return f"another account can change {location}" return None def _translate_recorded_pip_args( venv_metadata: PipxMetadata, packages: list[PackageInfo], backend: str ) -> list[list[str]]: # recorded pip args can hold options only the recording backend accepts, such as pip's --progress-bar environment: Final[str] = f"{venv_metadata.main_package.package}{venv_metadata.main_package.suffix}" translations: Final[list[tuple[list[str], list[str]]]] = [ translate_pip_args(trusted_recorded_pip_args(package.pip_args, venv_metadata.venv_dir), backend, environment) for package in packages ] if dropped := list( dict.fromkeys(" ".join(map(redact_url_credentials, other)) for _, other in translations if other) ): kept: Final[list[str]] = list( dict.fromkeys( " ".join(map(redact_url_credentials, translated)) for translated, _ in translations if translated ) ) _LOGGER.warning( "%s Ignoring pip args recorded for the %s backend of %s: %s%s", HAZARD, venv_metadata.backend, environment, " ".join(dropped), f"; translated for {backend}: {' '.join(kept)}" if kept else "", ) return [translated for translated, _ in translations] def _generate_package_spec(package_info: PackageInfo) -> str: if not package_info.package_or_url: msg = f"A package spec is not available for {package_info.package}" raise PipxError(msg) if package_info.package == package_info.package_or_url: return f"{package_info.package}=={package_info.package_version}" return package_info.package_or_url __all__ = [ "InstallData", "install", "install_all", ] pipx-1.18.16/src/pipx/commands/interpreter.py000066400000000000000000000212631527355705200211540ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "packaging", "pipx", "pipx.commands.reinstall", "pipx.options", "pipx.trash", "pipx.venv", "subprocess", ] import logging import subprocess from dataclasses import dataclass from typing import TYPE_CHECKING, Final from packaging import version from pipx import constants, paths from pipx.animate import animate from pipx.commands.reinstall import reinstall from pipx.options import ReinstallOptions from pipx.result import OperationData, OperationResult, OutputMessage from pipx.trash import rmdir from pipx.venv import Venv, VenvContainer if TYPE_CHECKING: from pathlib import Path _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) def list_interpreters( venv_container: VenvContainer, ) -> OperationResult[InterpreterData]: venvs: Final[list[Venv]] = _venvs_using_standalone_interpreter(venv_container) output: Final[list[str]] = [f"Standalone interpreters are in {paths.CTX.standalone_python_cachedir}"] listed: Final[list[_Interpreter]] = [] for interpreter in _installed_standalone_interpreters(): output.append(f"Python {interpreter.name}") if used_in := [venv.pipx_metadata for venv in _interpreter_users(interpreter, venvs)]: output.append(" Used in:") output.extend(f" - {p.main_package.package} {p.main_package.package_version}" for p in used_in) else: output.append(" Unused") listed.append( _Interpreter( version=interpreter.name, used_by=tuple(str(metadata.main_package.package) for metadata in used_in), ) ) return OperationResult( command=("interpreter", "list"), data=InterpreterData(interpreters=tuple(listed), removed=(), upgraded=()), messages=(OutputMessage(text="\n".join(output)),), ) def _venvs_using_standalone_interpreter(venv_container: VenvContainer) -> list[Venv]: venvs: Final[list[Venv]] = [] for venv_dir in venv_container.iter_venv_dirs(): with venv_container.venv_lock(venv_dir): if (venv := Venv(venv_dir)).pipx_metadata.source_interpreter: venvs.append(venv) return venvs def _installed_standalone_interpreters() -> list[Path]: # a dot-prefixed directory is the staging or backup copy of a fetch that may still be running, not an interpreter return [ python_dir for python_dir in paths.CTX.standalone_python_cachedir.iterdir() if python_dir.is_dir() and not python_dir.name.startswith(".") ] def _interpreter_users(interpreter: Path, venvs: list[Venv]) -> list[Venv]: return [ venv for venv in venvs if venv.pipx_metadata.source_interpreter and venv.pipx_metadata.source_interpreter.is_relative_to(interpreter) ] @dataclass(frozen=True, slots=True, kw_only=True) class _Interpreter: version: str used_by: tuple[str, ...] @dataclass(frozen=True, slots=True, kw_only=True) class InterpreterData(OperationData): interpreters: tuple[_Interpreter, ...] removed: tuple[str, ...] upgraded: tuple[_UpgradedInterpreter, ...] def prune_interpreters( venv_container: VenvContainer, ) -> OperationResult[InterpreterData]: venvs: Final[list[Venv]] = _venvs_using_standalone_interpreter(venv_container) removed: Final[list[str]] = [] for interpreter in _installed_standalone_interpreters(): if _interpreter_users(interpreter, venvs): continue rmdir(interpreter, safe_rm=True) removed.append(interpreter.name) report: Final[str] = ( "\n".join(["Removed:", *(f" - Python {name}" for name in removed)]) if removed else "Nothing to remove" ) return OperationResult( command=("interpreter", "prune"), data=InterpreterData(interpreters=(), removed=tuple(removed), upgraded=()), messages=(OutputMessage(text=report),), ) def upgrade_interpreters(venv_container: VenvContainer, *, verbose: bool) -> OperationResult[InterpreterData]: from pipx import standalone_python # ruff:ignore[import-outside-top-level] # pulls in http.client, slows startup with animate("Getting the index of the latest standalone python builds", do_animation=not verbose): latest_pythons: Final[dict[str, tuple[str, str]]] = standalone_python.list_pythons(use_cache=False) parsed_latest_python_versions: Final[list[version.Version]] = [ parsed for raw_version in latest_pythons if (parsed := _parse_python_version(raw_version)) is not None ] upgraded: Final[list[tuple[str, str, version.Version]]] = [] venvs: list[Venv] | None = None for interpreter_dir in _installed_standalone_interpreters(): interpreter_python = ( interpreter_dir / "python.exe" if constants.WINDOWS else interpreter_dir / "bin" / "python3" ) try: interpreter_full_version = ( subprocess .run([str(interpreter_python), "--version"], stdout=subprocess.PIPE, check=True, text=True) .stdout.removeprefix("Python ") .strip() ) parsed_interpreter_full_version = version.parse(interpreter_full_version) except (OSError, subprocess.CalledProcessError, version.InvalidVersion): _LOGGER.info("Cannot read the interpreter version at %s. Skipping.", interpreter_dir) continue latest_micro_version = _latest_micro_version(parsed_interpreter_full_version, parsed_latest_python_versions) if latest_micro_version > parsed_interpreter_full_version: # the new build lands in py/X.Y, so venvs on a patch-pinned py/X.Y.Z build must move to it upgraded_python = standalone_python.download_python_build_standalone( f"{latest_micro_version.major}.{latest_micro_version.minor}", override=True, ) if venvs is None: venvs = _venvs_using_standalone_interpreter(venv_container) for venv in _interpreter_users(interpreter_dir, venvs): with venv_container.venv_lock(venv.root) as venv_lock: _LOGGER.info( "Upgrade the interpreter of %s from %s to %s", venv.name, interpreter_full_version, latest_micro_version, ) reinstall( venv.root, ReinstallOptions( local_bin_dir=paths.CTX.bin_dir, local_man_dir=paths.CTX.man_dir, python=upgraded_python, verbose=verbose, # the new build is what this rebuild is for, so a package whose Requires-Python rules it # out fails (and keeps its environment) instead of moving to another Python from PATH python_flag_passed=True, ), venv_lock=venv_lock, ) upgraded.append((venv.name, interpreter_full_version, latest_micro_version)) if upgraded: report = "\n".join([ "Successfully upgraded the interpreter(s):", *(f" - {name}: {old} -> {new}" for name, old, new in upgraded), ]) else: report = "Nothing to upgrade" return OperationResult( command=("interpreter", "upgrade"), data=InterpreterData( interpreters=(), removed=(), upgraded=tuple( _UpgradedInterpreter(environment=name, old_version=old, new_version=str(new)) for name, old, new in upgraded ), ), messages=(OutputMessage(text=report),), ) def _parse_python_version(raw_version: str) -> version.Version | None: try: return version.parse(raw_version) except version.InvalidVersion: _LOGGER.info("Invalid version found in latest pythons: %s. Skipping.", raw_version) return None def _latest_micro_version( current_version: version.Version, latest_python_versions: list[version.Version] ) -> version.Version: for latest_python_version in latest_python_versions: if ( current_version.major == latest_python_version.major and current_version.minor == latest_python_version.minor ): return latest_python_version return current_version @dataclass(frozen=True, slots=True, kw_only=True) class _UpgradedInterpreter: environment: str old_version: str new_version: str __all__ = [ "InterpreterData", "list_interpreters", "prune_interpreters", "upgrade_interpreters", ] pipx-1.18.16/src/pipx/commands/list_packages.py000066400000000000000000000214541527355705200214240ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "json", "pipx", "pipx.commands.common", "pipx.commands.transaction", "pipx.pipx_metadata_file", "pipx.self_install", "pipx.venv", ] import json import logging import sys from typing import TYPE_CHECKING, Final from pipx import paths from pipx.colors import BOLD from pipx.commands.common import VenvProblems, get_venv_summary, reuse_resource_dir_scans, venv_health_check from pipx.commands.transaction import is_unfinished from pipx.constants import EXIT_CODE_LIST_PROBLEM, EXIT_CODE_OK, WINDOWS, ExitCode from pipx.emojis import SLEEP from pipx.pipx_metadata_file import JsonEncoderHandlesPath, PipxMetadata from pipx.self_install import backs_self_managed_venv from pipx.venv import Venv, VenvContainer if TYPE_CHECKING: from collections.abc import Collection, Iterable from pathlib import Path from pipx.options import ListOptions _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) def list_packages(venv_container: VenvContainer, venv_dirs: Collection[Path], options: ListOptions) -> ExitCode: if not venv_dirs: print( # ruff:ignore[print] # user-facing CLI output f"nothing has been installed with pipx {SLEEP}", file=sys.stderr ) locked_venv_dirs: Final = venv_container.iter_locked_venv_dirs(venv_dirs, allow_permission_error=True) if options.json_format: all_venv_problems = _list_json(locked_venv_dirs) elif options.short_format: all_venv_problems = _list_short(locked_venv_dirs, include_injected=options.include_injected) elif options.pinned_only: all_venv_problems = _list_pinned(locked_venv_dirs, include_injected=options.include_injected) else: if not venv_dirs: return EXIT_CODE_OK all_venv_problems = _list_text(locked_venv_dirs, str(venv_container), include_injected=options.include_injected) _log_venv_problems(all_venv_problems) if unfinished := [venv_dir.name for venv_dir in venv_dirs if is_unfinished(venv_dir)]: _LOGGER.warning( "\nOne or more packages were left half changed by an interrupted pipx run: %s.\n" " To fix, execute: pipx upgrade-all, or pipx reinstall PACKAGE", ", ".join(unfinished), ) if all_venv_problems.any_() or unfinished: print(file=sys.stderr) # ruff:ignore[print] # user-facing CLI output return EXIT_CODE_LIST_PROBLEM return EXIT_CODE_OK def _list_json(venv_dirs: Iterable[Path]) -> VenvProblems: warning_messages: Final[list[str]] = [] listed: Final[dict[str, PipxMetadata]] = {} all_venv_problems = VenvProblems() for venv_dir in venv_dirs: venv_metadata, venv_problems, warning_message = _venv_metadata_summary(venv_dir) all_venv_problems = all_venv_problems.or_(venv_problems) if venv_problems.any_(): warning_messages.append(warning_message) continue if _records_stale_self_managed_python(venv_metadata): # install-all would build on whatever interpreter appears at the recorded path again venv_metadata.source_interpreter = None listed[venv_dir.name] = venv_metadata print( # ruff:ignore[print] # user-facing CLI output json.dumps( { "pipx_spec_version": "0.1", "venvs": {name: {"metadata": venv_metadata.to_dict()} for name, venv_metadata in listed.items()}, }, indent=4, sort_keys=True, cls=JsonEncoderHandlesPath, ) ) for warning_message in warning_messages: _LOGGER.warning(warning_message) return all_venv_problems def _records_stale_self_managed_python(venv_metadata: PipxMetadata) -> bool: # pipx is self-managed only on POSIX, where its venv links bin/python to the interpreter it is built on return ( not WINDOWS and venv_metadata.main_package.package == "pipx" and (recorded := venv_metadata.source_interpreter) is not None and not backs_self_managed_venv(recorded, venv_metadata.venv_dir) ) def _venv_metadata_summary(venv_dir: Path) -> tuple[PipxMetadata, VenvProblems, str]: venv: Final[Venv] = Venv(venv_dir) venv_problems, warning_message = venv_health_check(venv) if venv_problems.any_(): return PipxMetadata(venv_dir, read=False), venv_problems, warning_message return venv.pipx_metadata, venv_problems, "" def _list_short(venv_dirs: Iterable[Path], *, include_injected: bool) -> VenvProblems: all_venv_problems = VenvProblems() for venv_dir in venv_dirs: venv_metadata, venv_problems, warning_message = _venv_metadata_summary(venv_dir) if venv_problems.any_(): _LOGGER.warning(warning_message) else: print( # ruff:ignore[print] # user-facing CLI output venv_metadata.main_package.package, venv_metadata.main_package.package_version, ) if include_injected: for package_name, package in venv_metadata.injected_packages.items(): print( # ruff:ignore[print] # user-facing CLI output package_name, package.package_version, f"(injected in venv {venv_dir.name})" ) all_venv_problems = all_venv_problems.or_(venv_problems) return all_venv_problems def _list_pinned(venv_dirs: Iterable[Path], *, include_injected: bool) -> VenvProblems: all_venv_problems = VenvProblems() for venv_dir in venv_dirs: venv_metadata, venv_problems, warning_message = _venv_metadata_summary(venv_dir) if venv_problems.any_(): _LOGGER.warning(warning_message) else: if venv_metadata.main_package.pinned: print( # ruff:ignore[print] # user-facing CLI output venv_metadata.main_package.package, venv_metadata.main_package.package_version, ) if include_injected: for package_name, package in venv_metadata.injected_packages.items(): if package.pinned: print( # ruff:ignore[print] # user-facing CLI output package_name, package.package_version, f"(injected in venv {venv_dir.name})" ) all_venv_problems = all_venv_problems.or_(venv_problems) return all_venv_problems def _list_text(venv_dirs: Iterable[Path], venv_root_dir: str, *, include_injected: bool) -> VenvProblems: print(f"venvs are in {BOLD(venv_root_dir)}") # ruff:ignore[print] # user-facing CLI output print( # ruff:ignore[print] # user-facing CLI output f"apps are exposed on your $PATH at {BOLD(str(paths.CTX.bin_dir))}" ) print(f"manual pages are exposed at {BOLD(str(paths.CTX.man_dir))}") # ruff:ignore[print] # user-facing CLI output print( # ruff:ignore[print] # user-facing CLI output f"shell completions are exposed at {BOLD(str(paths.CTX.completion_dir))}" ) all_venv_problems = VenvProblems() with reuse_resource_dir_scans(): for venv_dir in venv_dirs: package_summary, venv_problems = get_venv_summary(venv_dir, include_injected=include_injected) if venv_problems.any_(): _LOGGER.warning(package_summary) else: print(package_summary) # ruff:ignore[print] # user-facing CLI output all_venv_problems = all_venv_problems.or_(venv_problems) return all_venv_problems def _log_venv_problems(problems: VenvProblems) -> None: if problems.bad_venv_name: _LOGGER.warning( "\nOne or more packages contain out-of-date internal data installed from a\n" "previous pipx version and need to be updated.\n" " To fix, execute: pipx reinstall-all" ) if problems.invalid_interpreter: _LOGGER.warning( "\nOne or more packages have a missing python interpreter.\n To fix, execute: pipx reinstall-all" ) if problems.missing_metadata: _LOGGER.warning( "\nOne or more packages have no internal pipx metadata.\n" " pipx versions before 0.15.0.0 did not record it.\n" " Uninstall and install these packages to fix them." ) if problems.unreadable_metadata: _LOGGER.warning( "\nOne or more packages have internal pipx metadata this user cannot read.\n" " pipx keeps it readable by its owner only, since it can hold index credentials.\n" " Run the command as that user, for example with sudo for --global, to list them." ) if problems.not_installed: _LOGGER.warning( "\nOne or more packages are not installed properly.\n Uninstall and install these packages to fix them." ) __all__ = [ "list_packages", ] pipx-1.18.16/src/pipx/commands/manifest.py000066400000000000000000000532521527355705200204220ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "collections", "contextlib", "packaging.requirements", "packaging.utils", "pathlib", "pipx.commands.common", "pipx.commands.expose", "pipx.commands.install", "pipx.commands.uninstall", "pipx.options", "pipx.package_specifier", "pipx.pipx_metadata_file", "pipx.redact", "pipx.util", "shutil", "subprocess", "tempfile", "tomllib", ] import subprocess import tomllib from collections import Counter from contextlib import suppress from dataclasses import dataclass from datetime import date, datetime, time from pathlib import Path from shutil import copy2, which from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Final, TypeAlias from packaging.requirements import InvalidRequirement, Requirement from packaging.utils import canonicalize_name from pipx.commands.common import force_replaces_only from pipx.commands.expose import expose, unexpose from pipx.commands.install import install from pipx.commands.uninstall import uninstall from pipx.constants import EXIT_CODE_OK, ExitCode from pipx.options import ExposureOptions, InstallOptions from pipx.package_specifier import trusted_recorded_pip_args from pipx.pipx_metadata_file import PipxMetadata from pipx.redact import redact_url_credentials from pipx.result import ( OperationData, OperationError, OperationResult, OutputLevel, OutputMessage, OutputStream, ) from pipx.util import PipxError if TYPE_CHECKING: from typing import TypeIs from pipx.options import SyncOptions from pipx.venv import VenvContainer def sync_manifest( manifest_file: Path, venv_container: VenvContainer, options: SyncOptions ) -> OperationResult[ManifestData]: manifest: Final[_Manifest] = _load_manifest(manifest_file, require_locks=True) failures: Final[list[_FailedTool]] = [] messages: Final[list[OutputMessage]] = [] synced: Final[list[str]] = [] for tool in manifest.tools: venv_dir: Final[Path] = venv_container.get_venv_dir(tool.environment) with venv_container.venv_lock(venv_dir) as venv_lock: existed: Final[bool] = venv_dir.is_dir() recorded: Final[PipxMetadata] = PipxMetadata(venv_dir) was_exposed: Final[bool] = existed and recorded.exposure_enabled try: if was_exposed: unexpose(venv_dir, options.local_bin_dir, options.local_man_dir, verbose=options.verbose) # unexpose already removed what the environment held, so nothing else is the rebuild's to replace with force_replaces_only(()): outcome: Final = install( venv_dir, [tool.package_name], [tool.package], InstallOptions( local_bin_dir=options.local_bin_dir, local_man_dir=options.local_man_dir, python=options.python, pip_args=trusted_recorded_pip_args(recorded.main_package.pip_args, venv_dir), venv_args=recorded.venv_args, verbose=options.verbose, force=existed, reinstall=False, exposure=ExposureOptions( include_dependencies=tool.include_dependencies, include_resources_from=tool.include_resources_from, suffix=tool.suffix, ), preinstall_packages=None, expected_apps=tool.apps, lock_file=tool.lock_file, backend=options.backend, exposure_enabled=tool.expose, preserve_existing=True, replace_expected_apps=True, replace_lock=True, emit_output=False, ), venv_lock=venv_lock, ) error_message = outcome.errors[0].message if outcome.errors else None except PipxError as error: error_message = str(error) except (Exception, KeyboardInterrupt): # install rolls the environment back on an interrupt or a crash too, to the copy taken after unexpose if was_exposed: expose(venv_dir, options.local_bin_dir, options.local_man_dir, verbose=options.verbose) raise # install returns a failed result rather than raising when it does not render, so check both if error_message is not None: failures.append(_FailedTool(environment=tool.environment, error=error_message)) messages.append(OutputMessage(text=error_message, stream=OutputStream.STDERR, level=OutputLevel.ERROR)) if was_exposed: expose(venv_dir, options.local_bin_dir, options.local_man_dir, verbose=options.verbose) else: synced.append(tool.environment) # install links the main package alone, while unexpose also dropped the links of injected packages if existed and tool.expose: expose(venv_dir, options.local_bin_dir, options.local_man_dir, verbose=options.verbose) if options.prune and not failures: messages.extend( _prune_environments( manifest, venv_container, options.local_bin_dir, options.local_man_dir, verbose=options.verbose ) ) return OperationResult( command=("manifest", "sync"), data=ManifestData(environments=tuple(synced), locks=()), messages=tuple(messages), exit_code=ExitCode(1) if failures else EXIT_CODE_OK, errors=tuple( OperationError(code="manifest_sync_failed", message=failure.error, environment=failure.environment) for failure in failures ), succeeded=bool(synced), ) def _load_manifest(manifest_file: Path, *, require_locks: bool) -> _Manifest: path: Final[Path] = manifest_file.expanduser().resolve() try: with path.open("rb") as file: data: Final[dict[str, _TomlValue]] = tomllib.load(file) except FileNotFoundError as error: msg = f"Manifest does not exist: {path}" raise PipxError(msg) from error except (OSError, tomllib.TOMLDecodeError) as error: msg = f"Cannot read manifest {path}: {error}" raise PipxError(msg) from error groups, tool_data = _parse_manifest_tables(data) tools: Final[list[_ManifestTool]] = [] for environment, group in groups.items(): if not isinstance(group, list) or len(group) != 1 or not isinstance(package := group[0], str): msg = f"Dependency group {environment} must contain one package requirement." raise PipxError(msg) if not isinstance(values := tool_data.get(environment, {}), dict): msg = f"Manifest tool {environment} must be a table." raise PipxError(msg) tools.append(_parse_tool(environment, package, values, path.parent, require_locks=require_locks)) if duplicates := _duplicate_lock_files(tools): msg = f"Lock files must be unique per tool: {', '.join(map(str, duplicates))}" raise PipxError(msg) return _Manifest(path=path, tools=tuple(tools)) def _parse_manifest_tables( data: dict[str, _TomlValue], ) -> tuple[dict[str, _TomlValue], dict[str, _TomlValue]]: if unknown := sorted(set(data) - {"project", "dependency-groups", "tool"}): msg = f"Unknown manifest {'table' if len(unknown) == 1 else 'tables'}: {', '.join(unknown)}" raise PipxError(msg) if not isinstance(project := data.get("project"), dict): msg = "Manifest project must be a table." raise PipxError(msg) _validate_project(project) if not isinstance(groups := data.get("dependency-groups"), dict) or not groups: msg = "Manifest dependency-groups must be a non-empty table." raise PipxError(msg) if not isinstance(tool_table := data.get("tool"), dict): msg = "Manifest tool must be a table." raise PipxError(msg) if unknown := sorted(set(tool_table) - {"pipx", "nab"}): msg = f"Unknown manifest tool {'table' if len(unknown) == 1 else 'tables'}: {', '.join(unknown)}" raise PipxError(msg) if (nab_data := tool_table.get("nab")) is not None and not isinstance(nab_data, dict): msg = "Manifest tool.nab must be a table." raise PipxError(msg) return groups, _parse_pipx_table(tool_table, groups) def _validate_project(data: dict[str, _TomlValue]) -> None: if unknown := sorted(set(data) - {"name", "version", "dependencies", "requires-python"}): msg = f"Unknown manifest project {'key' if len(unknown) == 1 else 'keys'}: {', '.join(unknown)}" raise PipxError(msg) if not isinstance(data.get("name"), str) or not isinstance(data.get("version"), str): msg = "Manifest project requires string name and version values." raise PipxError(msg) if data.get("dependencies") != []: msg = "Manifest project dependencies must be empty; declare tools in dependency-groups." raise PipxError(msg) if (requires_python := data.get("requires-python")) is not None and not isinstance(requires_python, str): msg = "Manifest project requires-python must be a string." raise PipxError(msg) def _parse_pipx_table( tool_table: dict[str, _TomlValue], groups: dict[str, _TomlValue], ) -> dict[str, _TomlValue]: if not isinstance(pipx_data := tool_table.get("pipx"), dict): msg = "Manifest tool.pipx must be a table." raise PipxError(msg) if unknown := sorted(set(pipx_data) - {"version", "tools"}): msg = f"Unknown tool.pipx {'key' if len(unknown) == 1 else 'keys'}: {', '.join(unknown)}" raise PipxError(msg) manifest_version: Final[str] = "1.0" if pipx_data.get("version") != manifest_version: msg = f'Manifest version must be "{manifest_version}".' raise PipxError(msg) if not isinstance(tool_data := pipx_data.get("tools", {}), dict): msg = "Manifest tool.pipx.tools must be a table." raise PipxError(msg) if unknown := sorted(set(tool_data) - set(groups)): msg = f"Missing dependency groups for manifest tools: {', '.join(unknown)}" raise PipxError(msg) return tool_data def _parse_tool( environment: str, package: str, data: dict[str, _TomlValue], manifest_dir: Path, *, require_locks: bool, ) -> _ManifestTool: if environment != canonicalize_name(environment): msg = f"Manifest tool name must be normalized: {environment}" raise PipxError(msg) if unknown := sorted( set(data) - {"suffix", "apps", "include-dependencies", "include-resources-from", "expose", "lock"} ): msg = f"Unknown {'key' if len(unknown) == 1 else 'keys'} for manifest tool {environment}: {', '.join(unknown)}" raise PipxError(msg) package_name: Final[str] = _parse_package(environment, package) if not isinstance(suffix := data.get("suffix", ""), str): msg = f"Manifest tool {environment} suffix must be a string." raise PipxError(msg) if environment != canonicalize_name(f"{package_name}{suffix}"): msg = f"Manifest tool {environment} must match package {package_name} and suffix {suffix!r}." raise PipxError(msg) if not isinstance(include_dependencies := data.get("include-dependencies", False), bool): msg = f"Manifest tool {environment} include-dependencies must be a boolean." raise PipxError(msg) include_resources_from: Final[tuple[str, ...]] = _parse_include_resources_from(environment, data) if include_dependencies and include_resources_from: msg = f"Manifest tool {environment} cannot combine include-dependencies with include-resources-from." raise PipxError(msg) if not isinstance(expose_resources := data.get("expose", True), bool): msg = f"Manifest tool {environment} expose must be a boolean." raise PipxError(msg) return _ManifestTool( environment=environment, package=package, package_name=package_name, suffix=suffix, apps=_parse_apps(environment, data), include_dependencies=include_dependencies, include_resources_from=include_resources_from, expose=expose_resources, lock_file=_parse_lock_file(environment, data, manifest_dir, require_locks=require_locks), ) def _parse_package(environment: str, package: str) -> str: try: requirement: Final[Requirement] = Requirement(package) except InvalidRequirement: msg = f"Manifest tool {environment} has an invalid package requirement: {redact_url_credentials(package)}" # packaging quotes the requirement in its message, so chaining it would log the credentials with the traceback raise PipxError(msg) from None if requirement.marker is not None: msg = f"Manifest tool {environment} cannot use an environment marker." raise PipxError(msg) return canonicalize_name(requirement.name) def _parse_include_resources_from(environment: str, data: dict[str, _TomlValue]) -> tuple[str, ...]: if not _is_non_empty_strings(packages := data.get("include-resources-from", [])): msg = f"Manifest tool {environment} include-resources-from must be non-empty strings." raise PipxError(msg) included: Final[tuple[str, ...]] = tuple(canonicalize_name(package) for package in packages) if len(included) != len(set(included)): msg = f"Manifest tool {environment} include-resources-from must be unique." raise PipxError(msg) return included def _is_non_empty_strings(value: object) -> TypeIs[list[str]]: return isinstance(value, list) and all(isinstance(item, str) and item for item in value) def _parse_apps(environment: str, data: dict[str, _TomlValue]) -> tuple[str, ...]: if not _is_non_empty_strings(apps := data.get("apps", [])): msg = f"Manifest tool {environment} apps must be non-empty strings." raise PipxError(msg) if len(apps) != len(set(apps)): msg = f"Manifest tool {environment} apps must be unique." raise PipxError(msg) return tuple(apps) def _parse_lock_file( environment: str, data: dict[str, _TomlValue], manifest_dir: Path, *, require_locks: bool, ) -> Path | None: if (lock_value := data.get("lock")) is None: return None if not isinstance(lock_value, str): msg = f"Manifest tool {environment} lock must be a path string." raise PipxError(msg) # joining onto the manifest directory keeps an absolute lock path as it is lock_file: Final[Path] = (manifest_dir / Path(lock_value).expanduser()).resolve() if not _is_pylock_name(lock_file.name): msg = f"Manifest tool {environment} lock must be named pylock.toml or pylock..toml." raise PipxError(msg) if require_locks and not lock_file.is_file(): msg = f"Lock file does not exist for manifest tool {environment}: {lock_file}" raise PipxError(msg) return lock_file def _is_pylock_name(name: str) -> bool: if name == "pylock.toml": return True return ( name.startswith("pylock.") and name.endswith(".toml") and bool(lock_name := name.removeprefix("pylock.").removesuffix(".toml")) and "." not in lock_name ) @dataclass(frozen=True, slots=True, kw_only=True) class _ManifestTool: environment: str package: str package_name: str suffix: str apps: tuple[str, ...] include_dependencies: bool include_resources_from: tuple[str, ...] expose: bool lock_file: Path | None def _duplicate_lock_files(tools: list[_ManifestTool]) -> tuple[Path, ...]: counts: Final[Counter[Path]] = Counter(tool.lock_file for tool in tools if tool.lock_file is not None) return tuple(sorted(lock_file for lock_file, count in counts.items() if count > 1)) @dataclass(frozen=True, slots=True, kw_only=True) class _Manifest: path: Path tools: tuple[_ManifestTool, ...] _TomlValue: TypeAlias = str | int | float | bool | datetime | date | time | list["_TomlValue"] | dict[str, "_TomlValue"] @dataclass(frozen=True, slots=True, kw_only=True) class _FailedTool: environment: str error: str def _prune_environments( manifest: _Manifest, venv_container: VenvContainer, local_bin_dir: Path, local_man_dir: Path, *, verbose: bool, ) -> list[OutputMessage]: declared: Final[set[str]] = {tool.environment for tool in manifest.tools} messages: Final[list[OutputMessage]] = [] for venv_dir in sorted(venv_container.iter_venv_dirs()): if venv_dir.name in declared: continue with venv_container.venv_lock(venv_dir): messages.extend(uninstall(venv_dir, local_bin_dir, local_man_dir, verbose=verbose).messages) return messages @dataclass(frozen=True, slots=True, kw_only=True) class ManifestData(OperationData): environments: tuple[str, ...] locks: tuple[str, ...] def lock_manifest(manifest_file: Path) -> OperationResult[ManifestData]: manifest: Final[_Manifest] = _load_manifest(manifest_file, require_locks=False) if not any(tool.lock_file is not None for tool in manifest.tools): msg = "The manifest does not declare any lock files." raise PipxError(msg) if outside := sorted( str(tool.lock_file) for tool in manifest.tools if tool.lock_file is not None and not tool.lock_file.is_relative_to(manifest.path.parent) ): msg = f"Manifest lock files must be inside the manifest directory {manifest.path.parent}: {', '.join(outside)}" raise PipxError(msg) if not (nab := which("nab")): msg = "The nab command is required. Install it with `pipx install nab`." raise PipxError(msg) try: locked: Final[list[str]] = _generate_locks(manifest, nab) except OSError as error: msg = f"Cannot write manifest locks: {error}" raise PipxError(msg) from error return OperationResult( command=("manifest", "lock"), data=ManifestData(environments=(), locks=tuple(locked)), messages=tuple(OutputMessage(text=f"locked {lock_file}") for lock_file in locked), ) def _generate_locks(manifest: _Manifest, nab: str) -> list[str]: with TemporaryDirectory(prefix=".pipx-lock-", dir=manifest.path.parent) as temporary_dir: generated: Final[list[tuple[Path, Path]]] = [] for tool in manifest.tools: if tool.lock_file is None: continue tool_dir: Final[Path] = Path(temporary_dir) / tool.environment tool_dir.mkdir() generated_lock = tool_dir / tool.lock_file.name if tool.lock_file.is_file(): copy2(tool.lock_file, generated_lock) if subprocess.run( [ nab, "lock", str(manifest.path), "--groups", tool.environment, # nab marks every group member with `"" in dependency_groups`, and an installer selects # no groups unless the lock names one as its default, so without this the lock installs nothing "--project-default-group", tool.environment, "--output", str(generated_lock), ], check=False, cwd=manifest.path.parent, ).returncode: msg = f"nab failed to lock {tool.environment}." raise PipxError(msg) if not generated_lock.is_file(): msg = f"nab did not create {generated_lock.name} for {tool.environment}." raise PipxError(msg) generated.append((generated_lock, tool.lock_file)) backups_dir: Final[Path] = Path(temporary_dir) / ".previous" backups_dir.mkdir() locked: Final[list[str]] = [] # replacing the whole set one file at a time is not atomic, so back each target up first and, if any replacement # fails, roll every applied one back to leave the manifest's locks all-old rather than half-new applied: Final[list[tuple[Path, Path | None]]] = [] try: # ruff:ignore[too-many-statements-in-try-clause] # one handler rolls back every applied lock for index, (generated_lock, lock_file) in enumerate(generated): lock_file.parent.mkdir(parents=True, exist_ok=True) backup: Path | None = None if lock_file.is_file(): backup = backups_dir / f"{index}-{lock_file.name}" lock_file.replace(backup) # record before the swap-in so a failure mid-replacement still rolls this target back applied.append((lock_file, backup)) generated_lock.replace(lock_file) locked.append(str(lock_file)) except OSError: for lock_file, backup in reversed(applied): # what broke a swap-in (a directory at the lock path) can break its undo too, and the backups go away # with the temporary directory, so one failed undo must not keep the earlier locks from being restored with suppress(OSError): if backup is not None: backup.replace(lock_file) else: lock_file.unlink(missing_ok=True) raise return locked __all__ = [ "ManifestData", "lock_manifest", "sync_manifest", ] pipx-1.18.16/src/pipx/commands/outdated.py000066400000000000000000000242151527355705200204220ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "concurrent.futures", "functools", "packaging.utils", "pipx.options", "pipx.package_specifier", "pipx.util", "pipx.venv", ] from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from functools import partial from typing import TYPE_CHECKING, Final from packaging.utils import canonicalize_name from pipx.constants import ExitCode from pipx.options import OutdatedOptions from pipx.package_specifier import changeable_recorded_source, extract_index_options, valid_pypi_name from pipx.result import OperationData, OperationError, OperationResult, OutputLevel, OutputMessage, OutputStream from pipx.util import PipxError from pipx.venv import Venv, VenvContainer if TYPE_CHECKING: from collections.abc import Callable, Collection, Sequence from pathlib import Path from pipx.pipx_metadata_file import PackageInfo def list_outdated( venv_container: VenvContainer, venv_dirs: Collection[Path], *, include_injected: bool, ) -> OperationResult[OutdatedData]: data: Final[OutdatedData] = inspect_outdated( venv_container, OutdatedOptions(include_injected=include_injected), venv_dirs=venv_dirs ) messages: Final[list[OutputMessage]] = [ OutputMessage( text=f"{failure.environment}: {failure.error}", stream=OutputStream.STDERR, level=OutputLevel.ERROR, ) for failure in data.failures ] messages.extend(_package_message(package) for package in data.packages) if not data.packages and not data.failures: messages.append( OutputMessage( text="pipx found no available upgrades." if data.packages_checked else "pipx found no index packages to check." ) ) return OperationResult( command=("list",), data=data, messages=tuple(messages), exit_code=ExitCode(1 if data.failures else 0), errors=tuple( OperationError( code="environment_outdated_check_failed", message=failure.error, environment=failure.environment ) for failure in data.failures ), succeeded=bool(data.packages), ) def inspect_outdated( venv_container: VenvContainer, options: OutdatedOptions, *, venv_dirs: Collection[Path] | None = None ) -> OutdatedData: selected_venv_dirs: Final[tuple[Path, ...]] = tuple( sorted( venv_dir for venv_dir in (venv_container.iter_venv_dirs() if venv_dirs is None else venv_dirs) if venv_dir.name not in options.skip ) ) checks: Final[tuple[_EnvironmentOutdated, ...]] = _list_environments_outdated( venv_container, selected_venv_dirs, options ) packages: Final[tuple[_OutdatedPackage, ...]] = tuple( sorted( (package for check in checks for package in check.packages), key=lambda package: (package.environment, package.package), ) ) failures: Final[tuple[_FailedEnvironment, ...]] = tuple( sorted( (failure for check in checks for failure in check.failures), key=lambda failure: (failure.environment, failure.error), ) ) skipped: Final[tuple[_SkippedPackage, ...]] = tuple( sorted( (package for check in checks for package in check.skipped), key=lambda package: (package.environment, package.package), ) ) return OutdatedData( packages_checked=sum(check.packages_checked for check in checks), packages=packages, skipped=skipped, failures=failures, ) def _list_environments_outdated( venv_container: VenvContainer, venv_dirs: tuple[Path, ...], options: OutdatedOptions ) -> tuple[_EnvironmentOutdated, ...]: if not venv_dirs: return () check: Final[Callable[[Path], _EnvironmentOutdated]] = partial( _list_environment_outdated, venv_container, options=options ) if len(venv_dirs) == 1: return (check(venv_dirs[0]),) with ThreadPoolExecutor(max_workers=min(8, len(venv_dirs))) as executor: return tuple(executor.map(check, venv_dirs)) def _list_environment_outdated( venv_container: VenvContainer, venv_dir: Path, options: OutdatedOptions ) -> _EnvironmentOutdated: with venv_container.venv_lock(venv_dir): if not venv_dir.is_dir(): return _EnvironmentOutdated() return _list_venv_outdated( Venv(venv_dir, backend=options.backend), include_injected=options.include_injected, upgradable_only=options.upgradable_only, pip_args=options.pip_args, ) def _list_venv_outdated( venv: Venv, *, include_injected: bool, upgradable_only: bool, pip_args: Sequence[str], ) -> _EnvironmentOutdated: if not venv.python_path.is_file(): message: Final[str] = f"Invalid python interpreter {venv.python_path}. To fix, execute: pipx reinstall-all" return _EnvironmentOutdated(failures=(_FailedEnvironment(environment=venv.name, error=message),)) if not venv.package_metadata: return _EnvironmentOutdated( failures=(_FailedEnvironment(environment=venv.name, error="Missing internal pipx metadata."),) ) if upgradable_only and venv.pipx_metadata.main_package.lock_file is not None: return _EnvironmentOutdated() failures: Final[list[_FailedEnvironment]] = [] packages: Final[list[_OutdatedPackage]] = [] packages_by_index: Final[dict[tuple[str, ...], dict[str, PackageInfo]]] = {} skipped: Final[list[_SkippedPackage]] = [] for package_name, package_info in venv.package_metadata.items(): if package_name != venv.main_package_name and not include_injected: continue display_name: Final[str] = f"{package_name}{package_info.suffix}" if package_info.package_or_url is None: failures.append( _FailedEnvironment(environment=venv.name, error=f"Package {display_name} has corrupt pipx metadata.") ) elif upgradable_only and package_info.pinned: continue elif "--editable" in package_info.pip_args: skipped.append(_SkippedPackage(environment=venv.name, package=display_name, reason="editable")) elif valid_pypi_name(package_info.package_or_url) is None: skipped.append(_SkippedPackage(environment=venv.name, package=display_name, reason="non-index")) elif not pip_args and (source := changeable_recorded_source(package_info.pip_args, venv.root)) is not None: failures.append( _FailedEnvironment( environment=venv.name, error=f"refusing to check {display_name} for updates through {source}, and with it the versions " "reported. Move the file or directory to a place only you and root can change.", ) ) else: packages_by_index.setdefault(tuple(extract_index_options(list(pip_args) or package_info.pip_args)), {})[ canonicalize_name(package_name) ] = package_info for index_args, package_infos in packages_by_index.items(): index_packages, index_failures = _collect_outdated(venv, index_args, package_infos) packages.extend(index_packages) failures.extend(index_failures) return _EnvironmentOutdated( packages_checked=sum(len(package_infos) for package_infos in packages_by_index.values()), packages=tuple(packages), skipped=tuple(skipped), failures=tuple(failures), ) def _collect_outdated( venv: Venv, index_args: tuple[str, ...], package_infos: dict[str, PackageInfo], ) -> tuple[list[_OutdatedPackage], list[_FailedEnvironment]]: try: outdated: Final = list(venv.list_outdated_packages(list(index_args), package_infos)) except PipxError as error: return [], [_FailedEnvironment(environment=venv.name, error=str(error))] return [ _OutdatedPackage( environment=venv.name, package=f"{managed_package.package}{managed_package.suffix}", version=package.version, latest_version=package.latest_version, injected=managed_package.package != venv.main_package_name, pinned=managed_package.pinned, ) for package in outdated if (managed_package := package_infos.get(canonicalize_name(package.name))) is not None ], [] def _package_message(package: _OutdatedPackage) -> OutputMessage: subject: Final[str] = ( f"{package.package} (injected in {package.environment})" if package.injected else package.package ) return OutputMessage( text=f"{subject}{' [pinned]' if package.pinned else ''}: {package.version} -> {package.latest_version}" ) @dataclass(frozen=True, slots=True, kw_only=True) class _OutdatedPackage: environment: str package: str version: str latest_version: str injected: bool pinned: bool @dataclass(frozen=True, slots=True, kw_only=True) class _SkippedPackage: environment: str package: str reason: str @dataclass(frozen=True, slots=True, kw_only=True) class _FailedEnvironment: environment: str error: str @dataclass(frozen=True, slots=True, kw_only=True) class _EnvironmentOutdated: packages_checked: int = 0 packages: tuple[_OutdatedPackage, ...] = () skipped: tuple[_SkippedPackage, ...] = () failures: tuple[_FailedEnvironment, ...] = () @dataclass(frozen=True, slots=True, kw_only=True) class OutdatedData(OperationData): packages_checked: int packages: tuple[_OutdatedPackage, ...] skipped: tuple[_SkippedPackage, ...] failures: tuple[_FailedEnvironment, ...] def to_dict(self) -> dict[str, object]: # the envelope reports failures as top-level errors, and upgrade still reads the field; the base is named # because a slots dataclass breaks zero-argument super() before Python 3.14 data: Final[dict[str, object]] = OperationData.to_dict(self) del data["failures"] return data __all__ = [ "OutdatedData", "inspect_outdated", "list_outdated", ] pipx-1.18.16/src/pipx/commands/pin.py000066400000000000000000000153571527355705200174060ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["packaging.utils", "pipx.options", "pipx.venv"] from dataclasses import dataclass from enum import StrEnum from typing import TYPE_CHECKING, Final from packaging.utils import canonicalize_name from pipx.colors import BOLD from pipx.constants import ExitCode from pipx.emojis import SLEEP from pipx.options import ExposureOptions from pipx.result import OperationData, OperationError, OperationResult, OutputLevel, OutputMessage, OutputStream from pipx.venv import Venv if TYPE_CHECKING: from collections.abc import Sequence from pathlib import Path from pipx.pipx_metadata_file import PackageInfo def pin( venv_dir: Path, skip: Sequence[str], *, verbose: bool, injected_only: bool, ) -> OperationResult[PinData]: venv: Final[Venv] = Venv(venv_dir, verbose=verbose) if (main_package := venv.package_metadata.get(venv.main_package_name)) is None: return _missing_result(("pin",), venv) messages: Final[list[OutputMessage]] = [] packages: Final[list[_ChangedPackage]] = [] skipped: Final[list[_SkippedPackage]] = [] if injected_only or skip: skip_names: Final[set[str]] = {canonicalize_name(requested) for requested in skip} for package_name, package in venv.package_metadata.items(): if package_name == venv.main_package_name: continue if ( canonicalize_name(package_name) in skip_names or canonicalize_name(f"{package_name}{package.suffix}") in skip_names ): skipped.append(_SkippedPackage(environment=venv.name, package=package_name, reason="requested")) continue if package.pinned: skipped.append(_SkippedPackage(environment=venv.name, package=package_name, reason="already-pinned")) messages.append(OutputMessage(text=f"pipx already pins {package_name}; skipping it.")) continue _update_pin_info(venv, package_name, is_main_package=False, pinned=True) packages.append(_changed_package(venv, package_name, _PinStatus.PINNED)) else: for package_name, package in venv.package_metadata.items(): if package.pinned: skipped.append(_SkippedPackage(environment=venv.name, package=package_name, reason="already-pinned")) continue _update_pin_info( venv, package_name, is_main_package=package_name == venv.main_package_name, pinned=True, ) packages.append(_changed_package(venv, package_name, _PinStatus.PINNED)) if main_package.pinned: messages.append( OutputMessage(text=f"pipx already pins package {main_package.package} {SLEEP}", stream=OutputStream.LOG) ) if packages: messages.append(_summary_message(venv, _PinStatus.PINNED, len(packages))) messages.extend(OutputMessage(text=f" - {package.package} {package.version}") for package in packages) return OperationResult( command=("pin",), data=PinData(packages=tuple(packages), skipped=tuple(skipped)), messages=tuple(messages), ) def unpin(venv_dir: Path, *, verbose: bool) -> OperationResult[PinData]: venv: Final[Venv] = Venv(venv_dir, verbose=verbose) if venv.package_metadata.get(venv.main_package_name) is None: return _missing_result(("unpin",), venv) packages: Final[list[_ChangedPackage]] = [] skipped: Final[list[_SkippedPackage]] = [] for package_name, package in venv.package_metadata.items(): if not package.pinned: skipped.append(_SkippedPackage(environment=venv.name, package=package_name, reason="not-pinned")) continue _update_pin_info( venv, package_name, is_main_package=package_name == venv.main_package_name, pinned=False, ) packages.append(_changed_package(venv, package_name, _PinStatus.UNPINNED)) return OperationResult( command=("unpin",), data=PinData(packages=tuple(packages), skipped=tuple(skipped)), messages=( ( _summary_message(venv, _PinStatus.UNPINNED, len(packages)), *(OutputMessage(text=f" - {package.package}") for package in packages), ) if packages else (OutputMessage(text=f"pipx found no pinned packages in venv {venv.name}", stream=OutputStream.LOG),) ), ) def _missing_result(command: tuple[str, ...], venv: Venv) -> OperationResult[PinData]: error: Final[str] = f"pipx does not manage package {venv.name}" return OperationResult( command=command, data=PinData(packages=(), skipped=()), messages=(OutputMessage(text=error, stream=OutputStream.STDERR, level=OutputLevel.ERROR),), exit_code=ExitCode(1), errors=(OperationError(code="package_pin_failed", message=error, environment=venv.name),), ) def _update_pin_info(venv: Venv, package_name: str, *, is_main_package: bool, pinned: bool) -> None: package: Final[PackageInfo] = venv.package_metadata[package_name] venv.update_package_metadata( package_name=str(package.package), package_or_url=str(package.package_or_url), pip_args=package.pip_args, exposure=ExposureOptions.recorded(package), is_main_package=is_main_package, pinned=pinned, ) def _changed_package(venv: Venv, package_name: str, status: _PinStatus) -> _ChangedPackage: package: Final[PackageInfo] = venv.package_metadata[package_name] return _ChangedPackage( environment=venv.name, package=f"{package.package}{package.suffix}", version=package.package_version, status=status, injected=package_name != venv.main_package_name, location=str(venv.root), ) def _summary_message(venv: Venv, status: _PinStatus, package_count: int) -> OutputMessage: package_label: Final[str] = "package" if package_count == 1 else "packages" return OutputMessage(text=BOLD(f"pipx {status.value} {package_count} {package_label} in venv {venv.name}")) class _PinStatus(StrEnum): PINNED = "pinned" UNPINNED = "unpinned" @dataclass(frozen=True, slots=True, kw_only=True) class _ChangedPackage: environment: str package: str version: str status: _PinStatus injected: bool location: str @dataclass(frozen=True, slots=True, kw_only=True) class _SkippedPackage: environment: str package: str reason: str @dataclass(frozen=True, slots=True, kw_only=True) class PinData(OperationData): packages: tuple[_ChangedPackage, ...] skipped: tuple[_SkippedPackage, ...] __all__ = [ "PinData", "pin", "unpin", ] pipx-1.18.16/src/pipx/commands/reinstall.py000066400000000000000000000416521527355705200206120ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "contextlib", "functools", "packaging.utils", "pathlib", "pipx", "pipx.commands.common", "pipx.commands.inject", "pipx.commands.install", "pipx.commands.transaction", "pipx.fanout", "pipx.options", "pipx.package_specifier", "pipx.pipx_metadata_file", "pipx.redact", "pipx.trash", "pipx.util", "pipx.venv", "tempfile", ] import contextlib import logging from dataclasses import dataclass, replace from functools import partial from pathlib import Path from tempfile import mkdtemp from typing import TYPE_CHECKING, Final from packaging.utils import canonicalize_name from pipx import paths from pipx.commands.common import ( add_suffix, drop_copies_claimed_elsewhere, drop_links_taken_over, expose_shared_resources_in_order, force_replaces_only, get_venv_package_infos, get_venv_resource_paths, give_back_shared_links, ) from pipx.commands.inject import inject_dep from pipx.commands.install import install from pipx.commands.transaction import ( REINSTALL_BACKUP_SUFFIX, import_from_copy, is_unfinished, release_copy, rename_once_released, repoint_imports, runs_from_venv, ) from pipx.constants import ( COMPLETION_SECTIONS, EXIT_CODE_OK, EXIT_CODE_REINSTALL_INVALID_PYTHON, EXIT_CODE_REINSTALL_VENV_NONEXISTENT, MAN_SECTIONS, ExitCode, ) from pipx.emojis import ERROR, SLEEP, STARS from pipx.fanout import fan_out, locked_in_order, uninterrupted from pipx.options import CooldownOptions, ExposureOptions, InjectOptions, InstallOptions from pipx.package_specifier import trusted_recorded_pip_args from pipx.pipx_metadata_file import PIPX_INFO_FILENAME, PipxMetadata from pipx.redact import redact_url_credentials from pipx.result import OperationData, OperationError, OperationResult, OutputLevel, OutputMessage, OutputStream from pipx.trash import rmdir, safe_unlink from pipx.util import PipxError from pipx.venv import Venv, VenvContainer if TYPE_CHECKING: from filelock import BaseFileLock from pipx.options import ReinstallOptions from pipx.pipx_metadata_file import PackageInfo _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) def reinstall_all(venv_container: VenvContainer, options: ReinstallOptions) -> OperationResult[ReinstallData]: venv_dirs: Final[list[Path]] = [ venv_dir for venv_dir in venv_container.iter_venv_dirs() if venv_dir.name not in options.skip ] with expose_shared_resources_in_order( (PipxMetadata(venv_dir) for venv_dir in venv_dirs), options.local_bin_dir, options.local_man_dir ): outcomes: Final[list[OperationResult[ReinstallData]]] = fan_out( [partial(_reinstall_locked, venv_container, venv_dir, options) for venv_dir in venv_dirs], sequential=options.verbose, ) errors: Final[list[OperationError]] = [] messages: Final[list[OutputMessage]] = [] reinstalled: Final[list[_ReinstalledEnvironment]] = [] for outcome in outcomes: errors.extend(outcome.errors) messages.extend(outcome.messages) if not outcome.errors: reinstalled.extend(outcome.data.environments) if not reinstalled: messages.append(OutputMessage(text=f"No packages reinstalled after running 'pipx reinstall-all' {SLEEP}")) return OperationResult( command=("reinstall-all",), data=ReinstallData(environments=tuple(reinstalled)), messages=tuple(messages), exit_code=ExitCode(1) if errors else EXIT_CODE_OK, errors=tuple(errors), succeeded=bool(reinstalled), ) def _reinstall_locked( venv_container: VenvContainer, venv_dir: Path, options: ReinstallOptions ) -> OperationResult[ReinstallData]: try: with locked_in_order(venv_container.venv_lock(venv_dir)) as venv_lock: return reinstall(venv_dir, options, venv_lock=venv_lock) except PipxError as error_raised: return OperationResult( command=("reinstall",), data=ReinstallData(environments=()), messages=(OutputMessage(text=str(error_raised), stream=OutputStream.STDERR, level=OutputLevel.ERROR),), exit_code=ExitCode(1), errors=( OperationError( code="environment_reinstall_failed", message=str(error_raised), environment=venv_dir.name ), ), ) def reinstall( venv_dir: Path, options: ReinstallOptions, *, venv_lock: BaseFileLock | None ) -> OperationResult[ReinstallData]: if not venv_dir.exists(): return _outcome( venv_dir.name, OutputMessage(text=f"Nothing to reinstall for {venv_dir.name} {SLEEP}"), exit_code=EXIT_CODE_REINSTALL_VENV_NONEXISTENT, ) try: Path(options.python).relative_to(venv_dir) except ValueError: pass else: return _outcome( venv_dir.name, OutputMessage( text=f"{ERROR} Error, the python executable would be deleted! Change it using the --python option " f"or PIPX_DEFAULT_PYTHON environment variable.", stream=OutputStream.STDERR, level=OutputLevel.ERROR, ), exit_code=EXIT_CODE_REINSTALL_INVALID_PYTHON, ) venv: Final[Venv] = Venv(venv_dir, verbose=options.verbose, backend=options.backend) # checked before the backup, so a refused source leaves the environment as it was main_pip_args: Final[list[str]] = trusted_recorded_pip_args(venv.pipx_metadata.main_package.pip_args, venv_dir) injected_pip_args: Final[dict[str, list[str]]] = { name: trusted_recorded_pip_args(package.pip_args, venv_dir) for name, package in venv.pipx_metadata.injected_packages.items() } venv.check_upgrade_shared_libs(pip_args=main_pip_args, verbose=options.verbose) package_or_url: Final[str] = ( venv.pipx_metadata.main_package.package_or_url if venv.pipx_metadata.main_package.package_or_url is not None else _unrecorded_package_spec(venv) ) if venv.pipx_metadata.main_package.pinned: msg: Final[str] = f"{ERROR} Package {venv_dir} is pinned. Run `pipx unpin {venv_dir.name}` to unpin it first." raise PipxError(msg) old_resource_paths: Final[set[Path]] = _get_reinstall_resource_paths( venv, options.local_bin_dir, options.local_man_dir ) running: Final[bool] = runs_from_venv(venv_dir) reinstall_backup_dir: Final[Path] = _create_reinstall_backup(venv_dir) if running: import_from_copy(reinstall_backup_dir) messages: Final[list[OutputMessage]] = [OutputMessage(text=f"uninstalled {venv.name}! {STARS}")] # a legacy environment directory can carry a name that is not canonical; the rebuild uses the canonical one canonical_venv_dir: Final[Path] = venv_dir.with_name(canonicalize_name(venv_dir.name)) try: # ruff:ignore[too-many-statements-in-try-clause] # the whole rebuild must share one handler so any failure restores the backup with force_replaces_only(old_resource_paths): installed: Final = install( canonical_venv_dir, [venv.main_package_name], [package_or_url], InstallOptions( local_bin_dir=options.local_bin_dir, local_man_dir=options.local_man_dir, python=options.python, pip_args=main_pip_args, venv_args=venv.pipx_metadata.venv_args, verbose=options.verbose, force=True, reinstall=True, exposure=ExposureOptions( include_dependencies=venv.pipx_metadata.main_package.include_dependencies, include_resources_from=venv.pipx_metadata.main_package.include_resources_from, suffix=venv.pipx_metadata.main_package.suffix, ), preinstall_packages=[], expected_apps=venv.pipx_metadata.main_package.expected_apps, lock_file=venv.pipx_metadata.main_package.lock_file, cooldown=CooldownOptions(cli_days=venv.pipx_metadata.main_package.cooldown_days), python_flag_passed=options.python_flag_passed, backend=( recorded_backend := replace( options.backend, cli=options.backend.cli or venv.pipx_metadata.backend ) ), exposure_enabled=venv.pipx_metadata.exposure_enabled, emit_output=False, ), venv_lock=venv_lock, ) # install does not raise when it does not render, so restore the backup on a failed result too _raise_first_error(installed.errors) messages.extend(installed.messages) for injected_name, injected_package in venv.pipx_metadata.injected_packages.items(): injected: Final = inject_dep( canonical_venv_dir, injected_name, _require_injected_url(injected_package, venv.name), InjectOptions( pip_args=injected_pip_args[injected_name], verbose=options.verbose, include_apps=injected_package.include_apps, include_dependencies=injected_package.include_dependencies, include_resources_from=injected_package.include_resources_from, force=True, # the venv was just rebuilt, and --force-reinstall would also refetch the main package by name # from the injected package's index when the injected package depends on it force_reinstall=False, with_suffix=injected_package.suffix == venv.pipx_metadata.main_package.suffix, backend=recorded_backend, cooldown_days=injected_package.cooldown_days, pinned=injected_package.pinned, emit_output=False, ), ) messages.extend(injected.messages) new_resource_paths: Final[set[Path]] = _get_expected_reinstall_resource_paths( Venv(canonical_venv_dir, verbose=options.verbose), options.local_bin_dir, options.local_man_dir ) _remove_stale_reinstall_resources( drop_links_taken_over( drop_copies_claimed_elsewhere(old_resource_paths - new_resource_paths, venv.bin_path), venv_dir, canonical_venv_dir, ) ) except PipxError: _restore_reinstall_backup(canonical_venv_dir, venv_dir, reinstall_backup_dir) give_back_shared_links(old_resource_paths, venv_dir) # an expected failure (pip could not install the package, say) that the CLI reports itself, so like the CLI # entry point keep its traceback for debug output _LOGGER.error("%s Reinstall failed; restored %s.", ERROR, venv.name) # ruff:ignore[error-instead-of-exception] _LOGGER.debug("Reinstall failure", exc_info=True) raise except KeyboardInterrupt: _restore_reinstall_backup(canonical_venv_dir, venv_dir, reinstall_backup_dir) give_back_shared_links(old_resource_paths, venv_dir) _LOGGER.error("%s Interrupted; restored %s.", ERROR, venv.name) # ruff:ignore[error-instead-of-exception] raise except Exception: _restore_reinstall_backup(canonical_venv_dir, venv_dir, reinstall_backup_dir) give_back_shared_links(old_resource_paths, venv_dir) _LOGGER.exception("%s Reinstall failed; restored %s.", ERROR, venv.name) raise else: # the running pipx keeps importing from the backup, so the trash cleanup of the next pipx run removes it if not running: rmdir(reinstall_backup_dir) return OperationResult( command=("reinstall",), data=ReinstallData(environments=(_ReinstalledEnvironment(environment=venv.name),)), messages=tuple(messages), ) def _outcome(environment: str, message: OutputMessage, *, exit_code: ExitCode) -> OperationResult[ReinstallData]: return OperationResult( command=("reinstall",), data=ReinstallData(environments=()), messages=(message,), exit_code=exit_code, errors=(OperationError(code="environment_reinstall_failed", message=message.text, environment=environment),), ) def _unrecorded_package_spec(venv: Venv) -> str: # only pipx before 0.15.0.0 wrote no record, and it named the environment after the package inside; a killed install # or an unreadable record hides a spec, index and suffix the directory name cannot recover if not (venv.root / PIPX_INFO_FILENAME).exists() and not is_unfinished(venv.root): with contextlib.suppress(OSError): if venv.has_package(venv.main_package_name): return venv.main_package_name msg: Final[str] = ( f"Not reinstalling {venv.name}. It has no internal pipx metadata, so pipx cannot tell what it was " "installed from. Uninstall and install this package to fix it." ) raise PipxError(msg) def _get_reinstall_resource_paths(venv: Venv, local_bin_dir: Path, local_man_dir: Path) -> set[Path]: package_infos: Final[tuple[PackageInfo, ...] | None] = get_venv_package_infos(venv) return get_venv_resource_paths("app", venv.bin_path, local_bin_dir, package_infos).union( *( get_venv_resource_paths("man", venv.man_path / section, local_man_dir / section, package_infos) for section in MAN_SECTIONS ), *( get_venv_resource_paths( "completion", venv.man_path.parent / section, paths.CTX.completion_dir / section, package_infos ) for section in COMPLETION_SECTIONS ), ) def _create_reinstall_backup(venv_dir: Path) -> Path: # keep the backup in the trash rather than beside the venv, so it is not enumerated as a broken environment; the # trash shares the home's filesystem, so moving the venv there and back stays an atomic rename paths.CTX.trash.mkdir(parents=True, exist_ok=True) backup_dir: Final[Path] = Path( mkdtemp(prefix=f"{venv_dir.name}-", suffix=REINSTALL_BACKUP_SUFFIX, dir=paths.CTX.trash) ) backup_dir.rmdir() rename_once_released(venv_dir, backup_dir) return backup_dir def _raise_first_error(errors: tuple[OperationError, ...]) -> None: # raising from a helper keeps the raise out of the caller's try block if errors: raise PipxError(errors[0].message) def _require_injected_url(injected_package: PackageInfo, venv_name: str) -> str: # the record types package_or_url as optional, though reinstall metadata always carries it if injected_package.package_or_url is None: msg: Final[str] = ( f"Internal Error injecting package {redact_url_credentials(str(injected_package))} into {venv_name}" ) raise PipxError(msg) return injected_package.package_or_url def _get_expected_reinstall_resource_paths(venv: Venv, local_bin_dir: Path, local_man_dir: Path) -> set[Path]: if not venv.pipx_metadata.exposure_enabled: return set() resource_paths: Final[set[Path]] = set() for package_info in venv.package_metadata.values(): resource_paths.update( local_bin_dir / add_suffix(app_path.name, package_info.suffix) for app_path in package_info.app_paths_to_expose ) resource_paths.update( local_man_dir / man_path.parent.name / man_path.name for man_path in package_info.man_paths_to_expose ) resource_paths.update( paths.CTX.completion_dir / completion_path.parent.parent.name / completion_path.parent.name / completion_path.name for completion_path in package_info.completion_paths_to_expose ) return resource_paths def _remove_stale_reinstall_resources(resource_paths: set[Path]) -> None: for path in sorted(resource_paths): with contextlib.suppress(FileNotFoundError): safe_unlink(path) def _restore_reinstall_backup(venv_dir: Path, restore_venv_dir: Path, backup_dir: Path) -> None: # a signal between the two steps would leave the venv deleted and its only copy in the trash with uninterrupted(): rmdir(venv_dir) release_copy(backup_dir) backup_dir.rename(restore_venv_dir) repoint_imports(backup_dir, restore_venv_dir) @dataclass(frozen=True, slots=True, kw_only=True) class _ReinstalledEnvironment: environment: str @dataclass(frozen=True, slots=True, kw_only=True) class ReinstallData(OperationData): environments: tuple[_ReinstalledEnvironment, ...] __all__ = [ "ReinstallData", "reinstall", "reinstall_all", ] pipx-1.18.16/src/pipx/commands/reset.py000066400000000000000000000107031527355705200177300ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["contextlib", "pipx", "pipx.commands.common", "pipx.commands.uninstall", "pipx.trash", "pipx.venv"] from contextlib import suppress from dataclasses import dataclass from typing import TYPE_CHECKING, Final from pipx import paths from pipx.commands.common import get_venv_package_infos, get_venv_resource_paths from pipx.commands.uninstall import UninstallData, uninstall_all from pipx.constants import COMPLETION_SECTIONS, MAN_SECTIONS from pipx.result import OperationData, OperationResult, OutputMessage, OutputStream from pipx.trash import rmdir from pipx.venv import Venv if TYPE_CHECKING: from pathlib import Path from pipx.venv import VenvContainer def reset( venv_container: VenvContainer, local_bin_dir: Path, local_man_dir: Path, *, verbose: bool, dry_run: bool, ) -> OperationResult[ResetData]: targets: Final[tuple[Path, ...]] = _reset_targets() if dry_run: # the exposed apps, manual pages, and completions live outside the pipx home, so list them too or the dry run # understates what a real reset unlinks would_remove: Final[tuple[Path, ...]] = ( *_exposed_resource_paths(venv_container, local_bin_dir, local_man_dir), *targets, ) return OperationResult( command=("reset",), data=ResetData( packages=tuple(sorted(venv_dir.name for venv_dir in venv_container.iter_venv_dirs())), removed=tuple(str(path) for path in would_remove), ), messages=tuple( OutputMessage(text=f"Would remove {path}", stream=OutputStream.STDOUT) for path in would_remove ), ) # uninstalling unlinks the apps and the man pages, which live outside the pipx home uninstalled: Final[OperationResult[UninstallData]] = uninstall_all( venv_container, local_bin_dir, local_man_dir, verbose=verbose ) removed: Final[tuple[str, ...]] = tuple(str(target) for target in targets if _remove(target)) return OperationResult( command=("reset",), data=ResetData( packages=tuple(package.environment for package in uninstalled.data.packages), removed=removed, ), messages=( *uninstalled.messages, OutputMessage(text=f"pipx is back to a fresh install under {paths.CTX.home}.", stream=OutputStream.STDOUT), ), exit_code=uninstalled.exit_code, ) def _reset_targets() -> tuple[Path, ...]: return tuple( target for target in ( paths.CTX.venvs, paths.CTX.shared_libs, paths.CTX.venv_cache, paths.CTX.standalone_python_cachedir, paths.CTX.logs, paths.CTX.trash, ) if target.is_dir() ) def _exposed_resource_paths( venv_container: VenvContainer, local_bin_dir: Path, local_man_dir: Path ) -> tuple[Path, ...]: exposed: set[Path] = set() for venv_dir in venv_container.iter_venv_dirs(): venv = Venv(venv_dir) package_infos = get_venv_package_infos(venv) exposed |= get_venv_resource_paths("app", venv.bin_path, local_bin_dir, package_infos) for man_section in MAN_SECTIONS: exposed |= get_venv_resource_paths( "man", venv.man_path / man_section, local_man_dir / man_section, package_infos ) for completion_section in COMPLETION_SECTIONS: exposed |= get_venv_resource_paths( "completion", venv.man_path.parent / completion_section, paths.CTX.completion_dir / completion_section, package_infos, ) return tuple(sorted(exposed)) @dataclass(frozen=True, slots=True, kw_only=True) class ResetData(OperationData): packages: tuple[str, ...] removed: tuple[str, ...] def _remove(target: Path) -> bool: if target == paths.CTX.logs: _clear_logs(target) return True rmdir(target, safe_rm=False) return not target.is_dir() def _clear_logs(logs: Path) -> None: # this command logs into one of these files, and Windows refuses to unlink a file it holds open for entry in logs.iterdir(): if entry == paths.CTX.log_file: continue if entry.is_dir(): rmdir(entry, safe_rm=False) else: with suppress(OSError): entry.unlink() __all__ = [ "ResetData", "reset", ] pipx-1.18.16/src/pipx/commands/run.py000066400000000000000000000667431527355705200174310ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "dataclasses", "datetime", "hashlib", "hmac", "json", "packaging.requirements", "packaging.utils", "pathlib", "pipx", "pipx.backends._base", "pipx.backends.registry", "pipx.commands.common", "pipx.commands.run_uv", "pipx.options", "pipx.package_specifier", "pipx.redact", "pipx.requires_python", "pipx.script", "pipx.subprocess_runner", "pipx.trash", "pipx.util", "pipx.venv", "pipx.wrap", "secrets", "shutil", "tempfile", "urllib.parse", ] import datetime as dt import hashlib import hmac import json import logging import os import secrets import sys import tempfile import urllib.parse from contextlib import contextmanager from dataclasses import replace from pathlib import Path from shutil import which from typing import TYPE_CHECKING, Final, NoReturn from packaging.requirements import InvalidRequirement, Requirement from packaging.utils import canonicalize_name from pipx import paths from pipx.backends._base import UV from pipx.backends.registry import resolve_backend_name from pipx.commands.common import package_name_from_spec from pipx.commands.run_uv import run_script_via_uv_run, run_via_uv_tool_run from pipx.constants import TEMP_VENV_EXPIRATION_THRESHOLD_DAYS, WINDOWS from pipx.emojis import HAZARD from pipx.options import ExposureOptions from pipx.package_specifier import absolute_path_pip_args, resolve_local_path from pipx.redact import redact_url_credentials from pipx.requires_python import interpreter_for, unsatisfied_by_interpreter from pipx.script import ScriptMetadata, download_script, local_script, read_script_metadata from pipx.subprocess_runner import exec_app, run_pypackage_bin from pipx.trash import rmdir from pipx.util import PipxError, get_pypackage_bin_path from pipx.venv import Venv, VenvContainer from pipx.wrap import pipx_wrap if TYPE_CHECKING: from collections.abc import Generator from pipx.constants import FetchPythonOptions from pipx.options import RunOptions _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) _VENV_EXPIRED_FILENAME: Final[str] = "pipx_expired_venv" _RUN_KEY_FILENAME: Final[str] = ".run-key" _RUN_KEY_BYTES: Final[int] = 32 def run(app: str, options: RunOptions, *, spec: str | None) -> NoReturn: requirement_name: Final[str] = _requirement_name(app) package_name: Final[str] = canonicalize_name(requirement_name) if spec is None else requirement_name # a VCS URL names no script at all, and a normalized name is no longer the one on disk (`pip_search` installs a # `pip_search` script but canonicalizes to `pip-search`), so pipx picks the app from the installed entry points infer_app_name: Final[bool] = spec is None and (_is_vcs_url(app) or package_name != requirement_name) # the resolved backend only picks the route, uv tool run or a venv; the venv still gets the CLI and environment # values apart, so it attributes the backend to the right source resolved_backend, _ = resolve_backend_name(cli_value=options.backend.cli, env_value=options.backend.env) # uv cannot discover inferred app names or pipx.run entry points before installing the package use_uvx: Final[bool] = ( resolved_backend == UV and not options.pypackages and not options.python_args and not infer_app_name and _can_use_uvx_for_spec(app, spec) ) if spec is None and (content := maybe_script_content(app, is_path=options.is_path)) is not None: _run_script( content, options, resolved_backend=resolved_backend, script_source=Path(app) if isinstance(content, Path) else None, ) elif use_uvx: run_via_uv_tool_run(package_name if spec is None else app, spec if spec is not None else app, options) else: run_package( package_name, spec if spec is not None else app, options, infer_app_name=infer_app_name, resolved_backend=resolved_backend, ) def _requirement_name(app: str) -> str: try: return Requirement(app).name except InvalidRequirement: return app def _is_vcs_url(value: str) -> bool: vcs, separator, _ = urllib.parse.urlparse(value).scheme.partition("+") return bool(separator) and vcs in {"bzr", "git", "hg", "svn"} def _can_use_uvx_for_spec(app: str, spec: str | None) -> bool: if spec is None: return True try: return app == canonicalize_name(Requirement(spec).name) except InvalidRequirement: return False def maybe_script_content(app: str, *, is_path: bool) -> str | Path | None: app_path: Final[Path] = Path(app) if not is_path: try: requirement: Final[Requirement] = Requirement(app) if requirement.extras or requirement.specifier or requirement.marker or requirement.url: return None except InvalidRequirement: pass parsed: Final = urllib.parse.urlparse(app) local_path: Final[bool] = is_path or not (parsed.scheme and "://" in app) if local_path and app_path.is_file(): return app_path # a named pipe yields its content once, so read it here for both the metadata check and the interpreter if local_path and app_path.is_fifo(): return app_path.read_text(encoding="utf-8") if is_path: msg = f"The specified path {app} does not exist" raise PipxError(msg) if parsed.scheme: if _is_vcs_url(app): return None # match on the URL path so a query string such as ``script.py?raw=1`` is still recognised if not parsed.path.lower().endswith(".py"): msg = """ pipx will only execute apps from the internet directly if they end with '.py'. To run a package from another URL, use 'pipx run --spec URL BINARY'. """ raise PipxError(msg) _LOGGER.info("Detected url. Downloading and executing as a Python file.") return download_script(app) return None def _run_script( content: str | Path, options: RunOptions, *, resolved_backend: str, script_source: Path | None, ) -> NoReturn: try: metadata: Final[ScriptMetadata | None] = read_script_metadata(content) except ValueError as error: msg = f"Invalid inline script metadata: {error}" raise PipxError(msg) from error requirements = None if metadata is None else list(metadata.dependencies) if options.dependencies: if requirements is None: # a plain script has no requirements list for ``--with`` to extend msg = ( "--with packages can only be applied to scripts with PEP 723 inline metadata " "(`# /// script` block). Add the dependencies to the script's metadata or run " "via `pipx run --spec`." ) raise PipxError(msg) requirements = [*requirements, *options.dependencies] # uv treats the --python pipx hands it as an explicit request and runs a script on it even when requires-python # rules it out, so pipx settles the interpreter for both backends python: Final[str] = ( options.python if metadata is None or metadata.requires_python is None else _interpreter_for_script( metadata.requires_python, options.python, options.fetch_python, python_flag_passed=options.python_flag_passed, ) ) cooldown_days: Final[int | None] = options.cooldown.days if resolved_backend == UV and requirements is not None and not options.python_args: if script_source is not None: # uv keys its own script environment on the path alone and reuses it while it satisfies the script, so a # changed index or cooldown, or --refresh, would keep running what an earlier request installed script_venv_dir: Final[Path] = _get_temporary_venv_path( ["--script", str(script_source.resolve())], python, options.pip_args, options.venv_args, UV, cooldown_days=cooldown_days, ) with _running_uv_script_venv( Venv(script_venv_dir, backend=options.backend), use_cache=options.use_cache, refresh=options.refresh ) as run_venv_dir: run_script_via_uv_run(script_source, run_venv_dir, replace(options, python=python)) # URL and named-pipe content has no path on disk to hand to ``uv run --script`` _LOGGER.warning( pipx_wrap( f""" {HAZARD} Script content is not a local file; building a pipx-managed venv via the uv backend instead of `uv run --script`. The uv cache and uv-managed Python features will not apply to this run. """, subsequent_indent=" " * 4, ) ) if not requirements: _exec_script(Path(python), content, options.app_args, options.python_args) # keyed on the requirements rather than the script name, so scripts that need the same packages share one # environment; read_script_metadata normalizes them so spacing differences do not split the cache pip_args: Final[list[str]] = absolute_path_pip_args(options.pip_args, Path.cwd()) venv_dir: Final[Path] = _get_temporary_venv_path( requirements, python, pip_args, options.venv_args, resolved_backend, cooldown_days=cooldown_days, ) with _locked_venv_cache(venv_dir): venv = Venv(venv_dir, backend=options.backend) _prepare_venv_cache(venv, None, use_cache=options.use_cache, refresh=options.refresh) if venv_dir.exists(): _LOGGER.info("Reusing cached venv %s", venv_dir) else: venv = Venv(venv_dir, python=python, verbose=options.verbose, backend=options.backend) venv.check_upgrade_shared_libs(pip_args=pip_args, verbose=options.verbose) try: venv.create_venv(options.venv_args, pip_args) venv.install_unmanaged_packages(requirements, pip_args, cooldown_days=cooldown_days) except (OSError, PipxError, KeyboardInterrupt): # expire the cache so the next run reinstalls the requirements instead of reusing a broken venv (venv_dir / _VENV_EXPIRED_FILENAME).touch() raise _exec_script(venv.python_path, content, options.app_args, options.python_args) def _interpreter_for_script( requires_python: str, python: str, fetch_python: FetchPythonOptions, *, python_flag_passed: bool ) -> str: if (constraint := unsatisfied_by_interpreter(requires_python, python)) is None: return python if python_flag_passed: msg = ( f"The script requires Python {requires_python}, which the interpreter chosen with --python does not " f"satisfy. Pass a matching --python, or drop --python to let pipx pick one." ) raise PipxError(msg) return interpreter_for(constraint, fetch_python) def _get_temporary_venv_path( # ruff:ignore[too-many-arguments] # all inputs feed the cache-key digest; a struct would only relocate them requirements: list[str], python: str, pip_args: list[str], venv_args: list[str], backend: str, *, cooldown_days: int | None, ) -> Path: # The installer also reads its index, find-links and similar settings from the environment, more of them than a list # could track: pip takes PIP_ followed by any option name or alias, in any case, with - or _. So every PIP_*/UV_* # variable counts except these, which tune timeouts, retries, parallelism, output and the cache, or are set by # `uv run` for its children. pipx compares names as pip sees them: upper case with _ for -. prefix, unkeyed = ( ( "UV_", { "UV_CACHE_DIR", "UV_CONCURRENT_BUILDS", "UV_CONCURRENT_CACHE_READS", "UV_CONCURRENT_DOWNLOADS", "UV_CONCURRENT_INSTALLS", "UV_HTTP_CONNECT_TIMEOUT", "UV_HTTP_RETRIES", "UV_HTTP_TIMEOUT", "UV_NO_CACHE", "UV_NO_PROGRESS", "UV_REQUEST_TIMEOUT", "UV_RUN_RECURSION_DEPTH", }, ) if backend == UV else ( "PIP_", { "PIP_CACHE_DIR", "PIP_DISABLE_PIP_VERSION_CHECK", "PIP_LOCAL_LOG", "PIP_LOG", "PIP_LOG_FILE", "PIP_NO_CACHE_DIR", "PIP_NO_COLOR", "PIP_NO_INPUT", "PIP_NO_PYTHON_VERSION_WARNING", "PIP_NO_WARN_CONFLICTS", "PIP_NO_WARN_SCRIPT_LOCATION", "PIP_PROGRESS_BAR", "PIP_QUIET", "PIP_RESUME_RETRIES", "PIP_RETRIES", "PIP_ROOT_USER_ACTION", "PIP_TIMEOUT", "PIP_VERBOSE", }, ) ) # pip and uv (or requests, keyring and platformdirs for them) also read these, and each can change which config file # loads, which server answers, which certificate they trust or which credentials they send. HOME and USERPROFILE # locate config files too, but another home means another user, whose default pipx home already differs. tool_env: Final = { "ALL_PROXY", "APPDATA", "AWS_ACCESS_KEY_ID", "AWS_CONFIG_FILE", "AWS_DEFAULT_REGION", "AWS_PROFILE", "AWS_REGION", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_SHARED_CREDENTIALS_FILE", "CURL_CA_BUNDLE", "HF_TOKEN", "HTTPS_PROXY", "HTTP_PROXY", "NETRC", "NO_PROXY", "PYTHON_KEYRING_BACKEND", "PYX_API_KEY", "PYX_AUTH_TOKEN", "REQUESTS_CA_BUNDLE", "SSL_CERT_DIR", "SSL_CERT_FILE", "SSL_CLIENT_CERT", "SYSTEMDRIVE", "WIN_PD_OVERRIDE_APPDATA", "WIN_PD_OVERRIDE_COMMON_APPDATA", "XDG_CONFIG_DIRS", "XDG_CONFIG_HOME", } installer_env: Final = sorted( (name, value) for name, value in os.environ.items() if ((normalized := name.upper().replace("-", "_")).startswith(prefix) and normalized not in unkeyed) or normalized in tool_env ) # Preserve list and field boundaries so different invocations cannot reuse the same digest input. Only the truncated # digest leaves this function, so proxy URLs and tokens in the environment never reach the cache path or the logs. key: Final = json.dumps([requirements, python, pip_args, venv_args, backend, cooldown_days, installer_env]).encode() # other accounts can list the cache, so an unkeyed digest would let them test guesses at a token against a name digest: Final = ( hashlib.sha256(key) if (secret := _cache_secret()) is None else hmac.new(secret, key, hashlib.sha256) ) return Path(paths.CTX.venv_cache) / digest.hexdigest()[:15] def _cache_secret() -> bytes | None: secret_path: Final[Path] = Path(paths.CTX.venv_cache) / _RUN_KEY_FILENAME try: return _read_cache_secret(secret_path) or _write_cache_secret(secret_path) except OSError as error: # a cache pipx cannot write to can still serve the entries already in it, so name them the way it always did _LOGGER.debug("Cannot use %s (%s), so run cache names are not keyed with a secret", secret_path, error) return None def _read_cache_secret(secret_path: Path) -> bytes | None: try: with secret_path.open("rb") as file: status: Final[os.stat_result] = os.fstat(file.fileno()) secret: Final[bytes] = file.read(_RUN_KEY_BYTES + 1) except FileNotFoundError: return None # Windows reports no owner and fakes the mode bits; the ACLs of the user profile keep the file private there if sys.platform != "win32" and (status.st_uid != os.geteuid() or status.st_mode & 0o077): _LOGGER.debug("Replacing %s, which another account owns or can read", secret_path) return None return secret if len(secret) == _RUN_KEY_BYTES else None def _write_cache_secret(secret_path: Path) -> bytes: secret_path.parent.mkdir(parents=True, exist_ok=True) secret: Final[bytes] = secrets.token_bytes(_RUN_KEY_BYTES) # mkstemp creates the file with O_EXCL and mode 0600, and linking it into place publishes it whole, so a concurrent # first run sees either no secret or the complete one descriptor, staged = tempfile.mkstemp(dir=secret_path.parent, prefix=f"{_RUN_KEY_FILENAME}-") try: with os.fdopen(descriptor, "wb") as file: file.write(secret) try: os.link(staged, secret_path) except FileExistsError: if (existing := _read_cache_secret(secret_path)) is not None: return existing Path(staged).replace(secret_path) finally: Path(staged).unlink(missing_ok=True) return secret @contextmanager def _running_uv_script_venv(venv: Venv, *, use_cache: bool, refresh: bool) -> Generator[Path]: from filelock import FileLock # ruff:ignore[import-outside-top-level] # pulls in asyncio, slows startup # on Windows exec_app waits for the script, so holding the cache lock across it would block every concurrent run of # this script, including one the script starts itself; a lock file of each run's own lets those through while # keeping a wipe or an expiry sweep away from an environment a script still runs in, and uv locks the environment # while it syncs venv_dir: Final[Path] = venv.root with _locked_venv_cache(venv_dir): if _in_use(venv_dir) and (refresh or not use_cache): if refresh and venv_dir.is_dir(): # the next run to find the environment idle rebuilds it (venv_dir / _VENV_EXPIRED_FILENAME).touch() running = None else: _prepare_venv_cache(venv, None, use_cache=use_cache, refresh=refresh) (running := FileLock(venv_dir.with_name(f".{venv_dir.name}.run-{secrets.token_hex(8)}.lock"))).acquire() if running is None: _LOGGER.info("Another run uses the cached venv %s, so this run builds one of its own", venv_dir) private_venv_dir: Final[Path] = venv_dir.with_name(f"{venv_dir.name}-{secrets.token_hex(4)}") try: yield private_venv_dir finally: rmdir(private_venv_dir) return try: yield venv_dir finally: running.release() Path(running.lock_file).unlink(missing_ok=True) @contextmanager def _locked_venv_cache(venv_dir: Path) -> Generator[None, None, None]: from filelock import Timeout # ruff:ignore[import-outside-top-level] # pulls in asyncio, slows startup venv_container: Final[VenvContainer] = VenvContainer(paths.CTX.venv_cache) for cached_venv_dir in sorted(venv_container.iter_venv_dirs()): if cached_venv_dir == venv_dir: continue # Purging expired venvs is opportunistic housekeeping, so skip any a concurrent run holds rather than block on # it; on Windows an in-use cache would keep the lock held for the whole install and stall every other run. lock = venv_container.venv_lock(cached_venv_dir) try: lock.acquire(timeout=0) except Timeout: continue try: _remove_expired_venv(cached_venv_dir) finally: lock.release() with venv_container.venv_lock(venv_dir): _remove_expired_venv(venv_dir) yield def _remove_expired_venv(venv_dir: Path) -> None: if not venv_dir.is_dir(): return age: Final[float] = dt.datetime.now(tz=dt.UTC).timestamp() - venv_dir.stat().st_ctime if ( age > 60 * 60 * 24 * TEMP_VENV_EXPIRATION_THRESHOLD_DAYS or (venv_dir / _VENV_EXPIRED_FILENAME).exists() ) and not _in_use(venv_dir): _LOGGER.info("Removing expired venv %s", venv_dir) rmdir(venv_dir) def _in_use(venv_dir: Path) -> bool: from filelock import FileLock, Timeout # ruff:ignore[import-outside-top-level] # pulls in asyncio, slows startup in_use = False for lock_file in venv_dir.parent.glob(f".{venv_dir.name}.run-*.lock"): lock = FileLock(lock_file) try: lock.acquire(timeout=0) except Timeout: in_use = True continue # the run that held it has ended or, on POSIX, handed its process over to uv lock.release() lock_file.unlink(missing_ok=True) return in_use def _prepare_venv_cache(venv: Venv, bin_path: Path | None, *, use_cache: bool, refresh: bool) -> None: venv_dir: Final[Path] = venv.root if refresh and venv_dir.exists(): _LOGGER.info("Refreshing cached venv %s", venv_dir) rmdir(venv_dir) elif not use_cache and (bin_path is None or bin_path.exists()): _LOGGER.info("Removing cached venv %s", venv_dir) rmdir(venv_dir) def _exec_script( python_path: Path, content: str | Path, app_args: list[str], python_args: list[str], ) -> NoReturn: if isinstance(content, Path): exec_app([python_path, *python_args, content, *app_args]) else: exec_app([python_path, "-P", *python_args, "-c", content, *app_args]) def run_package( app: str, package_or_url: str, options: RunOptions, *, infer_app_name: bool, resolved_backend: str, ) -> NoReturn: if not options.no_path_check and (app_path := which(app)): _LOGGER.warning( pipx_wrap( f""" {HAZARD} {app} is already on your PATH and installed at {app_path}. Downloading and running anyway. """, subsequent_indent=" " * 4, ) ) app, app_filename = _app_names(app) pypackage_bin_path: Final[Path] = get_pypackage_bin_path(app) if pypackage_bin_path.exists(): if options.python_args: msg = "--python-args cannot run applications from __pypackages__." raise PipxError(msg) _LOGGER.info("Using app in local __pypackages__ directory at '%s'", pypackage_bin_path) run_pypackage_bin(pypackage_bin_path, options.app_args) if options.pypackages: msg = f""" '--pypackages' flag was passed, but '{pypackage_bin_path}' was not found. See https://github.com/cs01/pythonloc to learn how to install here, or omit the flag. """ raise PipxError(msg) # ``--with`` packages share this venv, so they belong in its key, else ``pipx run foo`` and # ``pipx run --with bar foo`` would share one cache dir; the installer resolves local paths against the working # directory, so the key does too venv_dir: Final[Path] = _get_temporary_venv_path( [ str(script) if (script := local_script(spec)) is not None else resolve_local_path(spec) for spec in (package_or_url, *options.dependencies) ], options.python, absolute_path_pip_args(options.pip_args, Path.cwd()), options.venv_args, resolved_backend, cooldown_days=options.cooldown.days, ) with _locked_venv_cache(venv_dir): venv = Venv(venv_dir, backend=options.backend) if infer_app_name and (cached := venv.pipx_metadata.main_package).package is not None: # repeat ``_prepare_venv``'s sole-script fallback, else the cache check below misses and rebuilds the venv app, app_filename = _app_names(cached.apps[0] if len(cached.apps) == 1 else cached.package) _prepare_venv_cache(venv, venv.bin_path / app_filename, use_cache=options.use_cache, refresh=options.refresh) if venv.has_app(app, app_filename): _LOGGER.info("Reusing cached venv %s", venv_dir) else: _LOGGER.info("venv location is %s", venv_dir) venv, app, app_filename = _prepare_venv( venv_dir, package_or_url, (app, app_filename), options, infer_app_name=infer_app_name ) venv.run_app(app, app_filename, options.app_args, python_args=options.python_args) def _app_names(app: str) -> tuple[str, str]: if not WINDOWS: return app, app # apps recorded in the venv metadata carry the .exe stub suffix, so drop it before re-appending name: Final[str] = app[: -len(".exe")] if app.lower().endswith(".exe") else app _LOGGER.info("Assuming app is %r (Windows only)", app_filename := f"{name}.exe") return name, app_filename def _prepare_venv( venv_dir: Path, package_or_url: str, app_names: tuple[str, str], options: RunOptions, *, infer_app_name: bool, ) -> tuple[Venv, str, str]: app, app_filename = app_names pip_args: Final[list[str]] = options.pip_args venv: Final[Venv] = Venv(venv_dir, python=options.python, verbose=options.verbose, backend=options.backend) venv.check_upgrade_shared_libs(pip_args=pip_args, verbose=options.verbose) if venv.pipx_metadata.main_package.package is not None: package_name = venv.pipx_metadata.main_package.package else: # a spec without a distribution name costs a throwaway venv here, so resolve it on the requested backend package_name = package_name_from_spec( package_or_url, options.python, pip_args=pip_args, verbose=options.verbose, backend=options.backend, cooldown_days=options.cooldown.days, ) if infer_app_name: app, app_filename = _app_names(package_name) try: venv.create_venv(options.venv_args, pip_args, override_shared=package_name == "pip") venv.install_package( package_name=package_name, package_or_url=package_or_url, pip_args=pip_args, exposure=ExposureOptions(), is_main_package=True, cooldown_days=options.cooldown.days, # resolved with the app so a pin or cap on one of its dependencies takes effect extra_requirements=options.dependencies, ) except (OSError, PipxError, KeyboardInterrupt): # a half-built venv has no record of its backend, so the next run must start from scratch; a missing app below # leaves a complete venv the next correct command can reuse if venv_dir.is_dir(): (venv_dir / _VENV_EXPIRED_FILENAME).touch() raise if not venv.has_app(app, app_filename): apps: Final[list[str]] = venv.pipx_metadata.main_package.apps if app == package_name and len(apps) == 1: app, app_filename = _app_names(apps[0]) print(f"NOTE: running app {app!r} from {package_name!r}") # ruff:ignore[print] # user-facing CLI output else: app_lines: Final[str] = "\n ".join( f"{name} - usage: 'pipx run --spec {redact_url_credentials(package_or_url)} {name} [arguments?]'" for name in apps ) msg = ( f"'{app}' executable script not found in package '{package_name}'.\n" f"Available executable scripts:\n {app_lines}" ) raise PipxError(msg, wrap_message=False) if not options.use_cache: # the next run's cache sweep removes a venv built without the cache (venv_dir / _VENV_EXPIRED_FILENAME).touch() return venv, app, app_filename __all__ = [ "maybe_script_content", "run", "run_package", ] pipx-1.18.16/src/pipx/commands/run_pip.py000066400000000000000000000062711527355705200202670ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "packaging.utils", "pathlib", "pipx.commands.common", "pipx.commands.transaction", "pipx.options", "pipx.package_specifier", "pipx.util", "pipx.venv", ] from pathlib import Path from typing import TYPE_CHECKING, Final from packaging.utils import canonicalize_name from pipx.commands.common import package_name_from_spec from pipx.commands.transaction import preserve_venv, runs_from_venv from pipx.options import BackendOptions, ExposureOptions from pipx.package_specifier import absolute_path_pip_args, extract_index_options, install_specs, replace_index_options from pipx.util import PipxError from pipx.venv import Venv if TYPE_CHECKING: from pipx.constants import ExitCode def run_pip(package: str, venv_dir: Path, pip_args: list[str], *, verbose: bool) -> ExitCode: venv: Final[Venv] = Venv(venv_dir, verbose=verbose) if not venv.python_path.exists(): msg = f"pipx has no venv for {package!r}; run 'pipx list' to see the installed packages" raise PipxError(msg) venv.verbose = True # pip may change the pipx running this, which keeps importing its own version from the copy with preserve_venv(venv_dir, enabled=runs_from_venv(venv_dir)): exit_code: Final[ExitCode] = venv.run_pip_get_exit_code(pip_args) if exit_code == 0: _sync_main_package_metadata_after_runpip_install(venv, pip_args) return exit_code def _sync_main_package_metadata_after_runpip_install(venv: Venv, pip_args: list[str]) -> None: main_package: Final = venv.pipx_metadata.main_package if main_package.package is None: return for package_spec, editable in install_specs(pip_args[1:]) if pip_args[:1] == ["install"] else []: package_name = package_name_from_spec( package_spec, str(venv.python_path), pip_args=["--editable"] if editable else [], verbose=venv.verbose, backend=BackendOptions(cli=venv.backend_name), ) if canonicalize_name(package_name) != canonicalize_name(main_package.package): continue venv.update_package_metadata( package_name=main_package.package, package_or_url=package_spec, pip_args=_updated_main_package_pip_args(main_package.pip_args, pip_args, editable=editable), exposure=ExposureOptions.recorded(main_package), is_main_package=True, pinned=main_package.pinned, ) return def _updated_main_package_pip_args( existing_pip_args: list[str], runpip_args: list[str], *, editable: bool ) -> list[str]: pip_args = [arg for arg in existing_pip_args if arg not in {"-e", "--editable"}] # pip resolved the new spec on the indexes this command named, so upgrade and reinstall must ask the same ones; it # read relative paths from the caller's directory, while pipx reads a recorded relative path against the venv if index_args := extract_index_options(runpip_args): pip_args = replace_index_options(pip_args, absolute_path_pip_args(index_args, Path.cwd())) if editable: pip_args.append("--editable") return pip_args __all__ = [ "run_pip", ] pipx-1.18.16/src/pipx/commands/run_uv.py000066400000000000000000000141571527355705200201330ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "pipx", "pipx.backends.uv", "pipx.redact", "pipx.subprocess_runner", "pipx.util", "pipx.wrap", "shutil", ] import logging import os from shutil import which from typing import TYPE_CHECKING, Final, NoReturn from pipx import paths from pipx.backends.uv import PIP_TO_UV_INDEX_FLAGS, UvBackend, resolve_uv_binary from pipx.emojis import HAZARD from pipx.redact import redact_url_credentials from pipx.subprocess_runner import exec_app from pipx.util import PipxError from pipx.wrap import pipx_wrap if TYPE_CHECKING: from collections.abc import Iterator from pathlib import Path from pipx.options import RunOptions _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) _UV_FORMAT_CONTROL_FLAG_MAP: Final[dict[str, tuple[str, str]]] = { "--no-binary": ("--no-binary", "--no-binary-package"), "--only-binary": ("--no-build", "--no-build-package"), } def run_via_uv_tool_run(app: str, package_or_url: str, options: RunOptions) -> NoReturn: _reject_venv_args(options.venv_args) if not options.no_path_check and (existing_app_path := which(app)): _LOGGER.warning( pipx_wrap( f""" {HAZARD} {app} is already on your PATH and installed at {existing_app_path}. Downloading and running anyway. """, subsequent_indent=" " * 4, ) ) cmd: list[str] = [str(resolve_uv_binary()), "tool", "run"] if package_or_url and package_or_url != app: cmd += ["--from", package_or_url] if options.python: cmd += ["--python", options.python] for dependency in options.dependencies: cmd += ["--with", dependency] if not options.use_cache: cmd.append("--no-cache") if options.refresh: cmd.append("--refresh") if options.verbose: cmd.append("--verbose") cmd += translate_pip_args_for_uv(options.pip_args) cmd += UvBackend.cooldown_args(options.cooldown.days) # without the separator uv reads an app name that starts with a dash as one of its own options cmd += ["--", app, *options.app_args] exec_app(cmd) def _reject_venv_args(venv_args: list[str]) -> None: # uv creates the venv, so accepting these options would ignore the requested behavior if venv_args: msg = ( f"{' '.join(map(redact_url_credentials, venv_args))} is not supported by `pipx run` under the uv backend.\n" "Use `pipx run --backend pip` if you need it, or drop it." ) raise PipxError(msg) def translate_pip_args_for_uv(pip_args: list[str]) -> list[str]: translated_bool_flags: Final[dict[str, str]] = { "--pre": "--prerelease=allow", "--no-cache-dir": "--no-cache", "--upgrade": "--upgrade", "-U": "--upgrade", } value_flags: Final[dict[str, str]] = { "--extra-index-url": "--extra-index-url", "--find-links": "--find-links", "-f": "-f", "--trusted-host": "--allow-insecure-host", } translated: Final[list[str]] = [] iterator: Final[Iterator[str]] = iter(pip_args) for arg in iterator: if arg in {"-q", "-qq", "--quiet"}: continue if arg in {"--editable", "-e"}: msg = ( "`--editable` is not supported by `pipx run --backend uv`.\n" "Use `pipx run --backend pip` for editable installs." ) raise PipxError(msg) if (translated_bool := translated_bool_flags.get(arg)) is not None: translated.append(translated_bool) continue flag, separator, attached_value = arg.partition("=") if flag in _UV_FORMAT_CONTROL_FLAG_MAP: translated.extend( _translate_format_control(flag, attached_value if separator else _next_pip_arg(flag, iterator)) ) continue if (translated_flag := PIP_TO_UV_INDEX_FLAGS.get(flag) or value_flags.get(flag)) is not None: if separator: translated.append(f"{translated_flag}={attached_value}") else: translated.extend([translated_flag, _next_pip_arg(flag, iterator)]) continue msg = ( f"--pip-args contains {redact_url_credentials(arg)!r}, which has no `uv tool run` equivalent.\n" "Use `--backend pip` if you need pip-only flags." ) raise PipxError(msg) return translated def _translate_format_control(flag: str, value: str) -> list[str]: all_flag, package_flag = _UV_FORMAT_CONTROL_FLAG_MAP[flag] if value == ":all:": return [all_flag] if value == ":none:": return [] if not all(packages := value.split(",")): msg = f"Invalid value for {flag!r} in --pip-args: {value!r}." raise PipxError(msg) return [item for package in packages for item in (package_flag, package)] def _next_pip_arg(flag: str, iterator: Iterator[str]) -> str: try: return next(iterator) except StopIteration as exc: msg = f"Missing value for {flag!r} in --pip-args." raise PipxError(msg) from exc def run_script_via_uv_run(script_path: Path, venv_dir: Path, options: RunOptions) -> NoReturn: _reject_venv_args(options.venv_args) # uv searches for project config from --project, or else from the script's directory, which another account may own cmd: list[str] = [str(resolve_uv_binary()), "run", "--active", "--script", "--project", str(paths.CTX.home)] if options.python: cmd += ["--python", options.python] if not options.use_cache: cmd.append("--no-cache") if options.refresh: cmd.append("--refresh") if options.verbose: cmd.append("--verbose") for dependency in options.dependencies: cmd += ["--with", dependency] cmd += translate_pip_args_for_uv(options.pip_args) cmd += UvBackend.cooldown_args(options.cooldown.days) cmd += [str(script_path), *options.app_args] exec_app(cmd, env={**os.environ, "VIRTUAL_ENV": str(venv_dir)}) __all__ = [ "run_script_via_uv_run", "run_via_uv_tool_run", "translate_pip_args_for_uv", ] pipx-1.18.16/src/pipx/commands/transaction.py000066400000000000000000000215161527355705200211370ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["pathlib", "pipx", "pipx.fanout", "pipx.trash", "pipx.util", "pipx.venv", "shutil", "tempfile"] import sys from contextlib import contextmanager, suppress from pathlib import Path from shutil import Error, copytree from tempfile import mkdtemp from time import sleep as _sleep from typing import TYPE_CHECKING, Final from pipx import paths from pipx.fanout import finished_on_interrupt, uninterrupted from pipx.trash import rmdir from pipx.util import PipxError from pipx.venv import VenvContainer if TYPE_CHECKING: from collections.abc import Generator from filelock import BaseFileLock _UNFINISHED_MARKER: Final[str] = "pipx_unfinished" _IN_USE_LOCK: Final[str] = "pipx_in_use.lock" _BACKUP_SUFFIX: Final[str] = "-pipx-backup" REINSTALL_BACKUP_SUFFIX: Final[str] = "-pipx-reinstall" # filelock releases a lock it garbage collects, so the locks on copies the running pipx imports from live here _HELD_COPIES: Final[dict[Path, BaseFileLock]] = {} @contextmanager def preserve_venv(venv_dir: Path, *, enabled: bool, copy_upfront: bool = True) -> Generator[VenvBackup]: backup: Final[VenvBackup] = VenvBackup(venv_dir, enabled=enabled) if enabled and (copy_upfront or runs_from_venv(venv_dir)): backup.copy() try: yield backup except (Exception, KeyboardInterrupt): # any failure, an interrupt included, rolls the environment back backup.restore() raise else: backup.discard() class VenvBackup: def __init__(self, venv_dir: Path, *, enabled: bool) -> None: self._venv_dir: Final[Path] = venv_dir self._enabled: Final[bool] = enabled self._backup_dir: Path | None = None self._holds_imports: bool = False def copy(self) -> None: backup_dir: Final[Path] = self._new_backup_dir() try: copytree(self._venv_dir, backup_dir, symlinks=True) except (Error, OSError) as error: rmdir(backup_dir) msg: Final[str] = f"pipx could not back up environment {self._venv_dir.name}: {error}" raise PipxError(msg) from error self._backup_dir = backup_dir self._holds_imports = runs_from_venv(self._venv_dir) and import_from_copy(backup_dir) def _new_backup_dir(self) -> Path: # the backup lives in the trash, not beside the venv, so a concurrent list or reinstall-all does not enumerate # it as a broken environment; the trash shares the home's filesystem, so restoring it is an atomic rename paths.CTX.trash.mkdir(parents=True, exist_ok=True) backup_dir: Final[Path] = Path( mkdtemp(prefix=f"{self._venv_dir.name}-", suffix=_BACKUP_SUFFIX, dir=paths.CTX.trash) ) backup_dir.rmdir() return backup_dir def remove_venv(self) -> None: if self._enabled and self._backup_dir is None: # a rename costs the same for any environment size, so rebuilds back up only when they delete backup_dir: Final[Path] = self._new_backup_dir() # a signal between the rename and recording the backup would leave restore unaware of the moved venv with uninterrupted(): rename_once_released(self._venv_dir, backup_dir) self._backup_dir = backup_dir else: rmdir(self._venv_dir) def restore(self) -> None: if self._backup_dir is not None: # a signal between the two steps would leave the venv deleted and its only copy in the trash with uninterrupted(): rmdir(self._venv_dir) release_copy(self._backup_dir) self._backup_dir.rename(self._venv_dir) if self._holds_imports: repoint_imports(self._backup_dir, Path(sys.prefix)) def discard(self) -> None: # the running pipx keeps importing from its copy, so the trash cleanup of the next pipx run removes it if self._backup_dir is not None and not self._holds_imports: rmdir(self._backup_dir) def runs_from_venv(venv_dir: Path) -> bool: return Path(sys.prefix).resolve() == venv_dir.resolve() def import_from_copy(copy_dir: Path) -> bool: if not repoint_imports(Path(sys.prefix), copy_dir): return False from filelock import FileLock # ruff:ignore[import-outside-top-level] # pulls in asyncio, slows startup # the running pipx imports from the copy until it exits, long after it releases the venv lock, so it holds a lock # of its own that keeps a concurrent trash sweep away; the operating system drops it when the process ends (lock := FileLock(copy_dir / _IN_USE_LOCK)).acquire() _HELD_COPIES[copy_dir] = lock return True def repoint_imports(source: Path, target: Path) -> bool: # pipx changing the venv it runs from puts another version in its place; later imports (lazy ones and those inside # functions) must load the old code from its copy, or old and new modules mix; sys.path finds top-level modules and # each package's __path__ its submodules moved: Final[list[str]] = [_moved(entry, source, target) for entry in sys.path] changed: Final[bool] = moved != sys.path sys.path[:] = moved for module in tuple(sys.modules.values()): if isinstance(search_path := getattr(module, "__path__", None), list): search_path[:] = [_moved(entry, source, target) for entry in search_path] return changed def _moved(entry: str, source: Path, target: Path) -> str: return str(target / path.relative_to(source)) if (path := Path(entry)).is_relative_to(source) else entry def release_copy(copy_dir: Path) -> None: # the copy goes back in place of the venv, which must not carry the lock file if (lock := _HELD_COPIES.pop(copy_dir, None)) is not None: lock.release() (copy_dir / _IN_USE_LOCK).unlink(missing_ok=True) def rename_once_released(source: Path, target: Path) -> None: # Windows refuses to rename a directory while a handle inside it is open, and a concurrent rebuild of another venv # briefly opens this one's scripts directory while it matches the launcher copies in the bin directory to venvs for delay in (0.05, 0.1, 0.2, 0.4, 0.8): try: source.rename(target) except PermissionError: _sleep(delay) else: return source.rename(target) @contextmanager def marked_unfinished(venv_dir: Path) -> Generator[None]: # an interrupt, a crash or a kill leaves the mark behind, which tells the next upgrade or reinstall to rebuild the # environment; an error pipx reports clears it with finished_on_interrupt(): marker: Final[Path] = venv_dir / _UNFINISHED_MARKER marker.touch() try: yield except PipxError: marker.unlink() raise marker.unlink() def is_unfinished(venv_dir: Path) -> bool: return (venv_dir / _UNFINISHED_MARKER).is_file() def empty_trash() -> None: # rmdir refuses a symlinked trash, so handing one to it never empties the link's target if paths.CTX.trash.is_symlink() or not paths.CTX.trash.is_dir(): rmdir(paths.CTX.trash, safe_rm=False) return for entry in paths.CTX.trash.iterdir(): if entry.is_symlink() or not entry.is_dir(): with suppress(OSError): entry.unlink() elif not _is_backup_in_use(entry): rmdir(entry, safe_rm=False) def _is_backup_in_use(entry: Path) -> bool: # a command keeps its environment's lock for as long as it may restore that environment from its backup, and a # pipx that changed its own environment keeps the copy's lock for as long as it imports from it suffix: Final[str | None] = next( (candidate for candidate in (_BACKUP_SUFFIX, REINSTALL_BACKUP_SUFFIX) if entry.name.endswith(candidate)), None ) if suffix is None: return False from filelock import FileLock # ruff:ignore[import-outside-top-level] # pulls in asyncio, slows startup venv_name: Final[str] = entry.name.removesuffix(suffix).rpartition("-")[0] if _is_held(VenvContainer(paths.CTX.venvs).venv_lock(paths.CTX.venvs / venv_name)): return True # only a copy some pipx imported from has the lock file, and acquiring a missing one would create it return (in_use := entry / _IN_USE_LOCK).is_file() and _is_held(FileLock(in_use)) def _is_held(lock: BaseFileLock) -> bool: from filelock import Timeout # ruff:ignore[import-outside-top-level] # pulls in asyncio, slows startup try: lock.acquire(timeout=0) except (Timeout, OSError): return True lock.release() return False __all__ = [ "REINSTALL_BACKUP_SUFFIX", "VenvBackup", "empty_trash", "import_from_copy", "is_unfinished", "marked_unfinished", "preserve_venv", "release_copy", "rename_once_released", "repoint_imports", "runs_from_venv", ] pipx-1.18.16/src/pipx/commands/uninject.py000066400000000000000000000242021527355705200204240ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "packaging.utils", "pathlib", "pipx", "pipx.commands.common", "pipx.commands.inject", "pipx.redact", "pipx.trash", "pipx.venv", "pipx.venv_inspect", "pipx.wrap", ] import logging from pathlib import Path from typing import TYPE_CHECKING, Final from packaging.utils import canonicalize_name from pipx import paths from pipx.colors import BOLD from pipx.commands.common import ( drop_copies_claimed_elsewhere, drop_links_taken_over, exposed_app_names, get_expected_venv_resource_paths, get_package_bin_dir_app_paths, get_package_completion_paths, get_package_man_paths, ) from pipx.commands.inject import ( InjectionData, InjectionPackage, InjectionStatus, ) from pipx.constants import ( COMPLETION_SECTIONS, EXIT_CODE_OK, EXIT_CODE_UNINJECT_ERROR, MAN_SECTIONS, ) from pipx.emojis import STARS from pipx.redact import redact_url_credentials from pipx.result import OperationError, OperationResult, OutputLevel, OutputMessage, OutputStream from pipx.trash import safe_unlink from pipx.venv import Venv from pipx.venv_inspect import ( fetch_info_in_venv, get_distributions_by_name, get_required_dependency_names, not_required_packages, ) from pipx.wrap import pipx_wrap if TYPE_CHECKING: from importlib import metadata from pipx.options import UninjectOptions from pipx.pipx_metadata_file import PackageInfo _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) def uninject(venv_dir: Path, dependencies: list[str], options: UninjectOptions) -> OperationResult[InjectionData]: if not venv_dir.exists() or next(venv_dir.iterdir(), None) is None: return _uninject_failure( venv_dir.name, None, f"Virtual environment {venv_dir.name} does not exist.", stream=OutputStream.STDERR, ) venv: Final[Venv] = Venv(venv_dir, verbose=options.verbose) if not venv.package_metadata: return _uninject_failure( venv.name, None, pipx_wrap( f""" Can't uninject from Virtual Environment {venv_dir.name!r}. {venv_dir.name!r} has no internal pipx metadata. pipx versions before 0.15.0.0 did not record it. Uninstall and install {venv_dir.name!r} manually to fix it. """ ), stream=OutputStream.STDERR, ) errors: Final[list[OperationError]] = [] messages: Final[list[OutputMessage]] = [] packages: Final[list[InjectionPackage]] = [] for dependency in dependencies: result: OperationResult[InjectionData] = _uninject_dep( venv, dependency, local_bin_dir=options.local_bin_dir, local_man_dir=options.local_man_dir, leave_deps=options.leave_deps, ) errors.extend(result.errors) messages.extend(result.messages) packages.extend(result.data.packages) return OperationResult( command=("uninject",), data=InjectionData(packages=tuple(packages), skipped=()), messages=tuple(messages), exit_code=EXIT_CODE_UNINJECT_ERROR if errors else EXIT_CODE_OK, errors=tuple(errors), succeeded=bool(packages), ) def _uninject_failure( environment: str, package: str | None, error: str, *, stream: OutputStream = OutputStream.LOG, ) -> OperationResult[InjectionData]: return OperationResult( command=("uninject",), data=InjectionData(packages=(), skipped=()), messages=(OutputMessage(text=error, stream=stream, level=OutputLevel.ERROR),), exit_code=EXIT_CODE_UNINJECT_ERROR, errors=( OperationError(code="package_uninject_failed", message=error, environment=environment, package=package), ), ) def _uninject_dep( venv: Venv, package_name: str, *, local_bin_dir: Path, local_man_dir: Path, leave_deps: bool, ) -> OperationResult[InjectionData]: package_name = _injected_metadata_name(venv, canonicalize_name(package_name)) if canonicalize_name(package_name) == canonicalize_name(venv.main_package_name): return _uninject_failure( venv.name, package_name, pipx_wrap( f""" {package_name} is the main package of {venv.root.name} venv. Use `pipx uninstall {venv.root.name}` to uninstall instead of uninject. """, subsequent_indent=" " * 4, ), ) if package_name not in venv.pipx_metadata.injected_packages: return _uninject_failure( venv.name, masked_name := redact_url_credentials(package_name), f"{masked_name} is not in the {venv.root.name} venv. Skipping.", ) package: Final[PackageInfo] = venv.package_metadata[package_name] resource_paths: Final[set[Path]] = drop_copies_claimed_elsewhere( _get_include_resource_paths(package, venv, local_bin_dir, local_man_dir), venv.bin_path ) if not leave_deps: orig_not_required_packages: Final[set[str]] = venv.list_installed_packages(not_required=True) _LOGGER.info("Original not required packages: %s", orig_not_required_packages) venv.uninstall_packages(package_name, was_injected=True) if not leave_deps: # inspect again: the uninstall can drop search path entries, such as an editable install's .pth venv_sys_path, venv_env, _ = fetch_info_in_venv(venv.python_path) new_not_required_packages: Final[set[str]] = not_required_packages(venv_sys_path, venv_env) _LOGGER.info("New not required packages: %s", new_not_required_packages) if deps_of_uninstalled := new_not_required_packages - orig_not_required_packages: deps_of_uninstalled -= _get_remaining_dependencies(venv, package_name, venv_sys_path, venv_env) _LOGGER.info("Dependencies of uninstalled package: %s", deps_of_uninstalled) if deps_of_uninstalled: venv.uninstall_packages(*sorted(deps_of_uninstalled)) if package.include_apps: # the uninstall dropped this package's record, so what the venv still expects belongs to its other packages expected: Final[set[Path]] = get_expected_venv_resource_paths(venv, local_bin_dir, local_man_dir) for path in drop_links_taken_over(resource_paths - expected, venv.root): try: safe_unlink(path) except FileNotFoundError: _LOGGER.info("tried to remove but couldn't find %s", path) else: _LOGGER.info("removed file %s", path) deps_string: Final[str] = "" if leave_deps else " and its dependencies" return OperationResult( command=("uninject",), data=InjectionData( packages=( InjectionPackage( environment=venv.name, package=f"{package.package}{package.suffix}", version=package.package_version, status=InjectionStatus.UNINJECTED, location=str(venv.root), ), ), skipped=(), ), messages=( OutputMessage( text=f"Uninjected package {BOLD(package_name)}{deps_string} from venv {BOLD(venv.root.name)} {STARS}" ), ), ) def _injected_metadata_name(venv: Venv, package_name: str) -> str: # metadata keys an injected package by the name the installer reported, which pip leaves as declared (Mixed_Case), # and `inject --with-suffix` adds the environment suffix, so users spell it black@1 too for name, injected_package in venv.pipx_metadata.injected_packages.items(): if package_name in {canonicalize_name(name), canonicalize_name(f"{name}{injected_package.suffix}")}: return name return package_name def _get_include_resource_paths( package: PackageInfo, venv: Venv, local_bin_dir: Path, local_man_dir: Path ) -> set[Path]: apps: Final[set[str]] = {name for app in package.apps_to_expose for name in exposed_app_names(app, package.suffix)} man_pages: Final[set[str]] = set(package.man_pages_to_expose) completions: Final[set[str]] = set(package.completions_to_expose) return ( {path for path in get_package_bin_dir_app_paths(package, venv.bin_path, local_bin_dir) if path.name in apps} | { path for section in MAN_SECTIONS for path in get_package_man_paths(package, venv.man_path / section, local_man_dir / section) if str(Path(path.parent.name) / path.name) in man_pages } | { path for section in COMPLETION_SECTIONS for path in get_package_completion_paths( package, venv.man_path.parent / section, paths.CTX.completion_dir / section ) if f"{path.parent.parent.name}/{path.parent.name}/{path.name}" in completions } ) def _get_remaining_dependencies( venv: Venv, excluded_package: str, venv_sys_path: list[str], venv_env: dict[str, str] ) -> set[str]: distributions: Final[dict[str, metadata.Distribution]] = get_distributions_by_name(venv_sys_path) remaining_deps: Final[set[str]] = set() for name in (venv.pipx_metadata.main_package.package, *venv.pipx_metadata.injected_packages): if name is not None and name != excluded_package: remaining_deps.update(_collect_transitive_deps(name, distributions, venv_env)) return remaining_deps def _collect_transitive_deps( package_name: str, distributions: dict[str, metadata.Distribution], env: dict[str, str], visited: set[str] | None = None, ) -> set[str]: if visited is None: visited = set() if (canonical := canonicalize_name(package_name)) in visited: return visited visited.add(canonical) if distribution := distributions.get(canonical): for dependency_name in get_required_dependency_names(distribution, env): _collect_transitive_deps(dependency_name, distributions, env, visited) return visited __all__ = [ "uninject", ] pipx-1.18.16/src/pipx/commands/uninstall.py000066400000000000000000000125311527355705200206200ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["pipx", "pipx.commands.common", "pipx.trash", "pipx.venv", "shutil"] import logging from dataclasses import dataclass from shutil import which from typing import TYPE_CHECKING, Final if TYPE_CHECKING: from pathlib import Path from pipx.pipx_metadata_file import PackageInfo from pipx import paths from pipx.commands.common import ( drop_copies_claimed_elsewhere, drop_links_taken_over, get_venv_package_infos, get_venv_resource_paths, reuse_resource_dir_scans, ) from pipx.constants import ( COMPLETION_SECTIONS, EXIT_CODE_OK, EXIT_CODE_UNINSTALL_ERROR, EXIT_CODE_UNINSTALL_VENV_NONEXISTENT, MAN_SECTIONS, ) from pipx.emojis import HAZARD, SLEEP, STARS from pipx.result import OperationData, OperationError, OperationResult, OutputMessage from pipx.trash import rmdir_via_trash, safe_unlink from pipx.venv import Venv, VenvContainer _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) def uninstall_all( venv_container: VenvContainer, local_bin_dir: Path, local_man_dir: Path, *, verbose: bool, ) -> OperationResult[UninstallData]: errors: Final[list[OperationError]] = [] messages: Final[list[OutputMessage]] = [] packages: Final[list[_UninstalledPackage]] = [] with reuse_resource_dir_scans(): for venv_dir in venv_container.iter_venv_dirs(): with venv_container.venv_lock(venv_dir): result: Final = uninstall(venv_dir, local_bin_dir, local_man_dir, verbose=verbose) errors.extend(result.errors) messages.extend(result.messages) packages.extend(result.data.packages) return OperationResult( command=("uninstall-all",), data=UninstallData( packages=tuple(sorted(packages, key=lambda package: package.environment)), ), messages=tuple(messages), exit_code=EXIT_CODE_UNINSTALL_ERROR if errors else EXIT_CODE_OK, errors=tuple(sorted(errors, key=lambda error: error.environment or "")), succeeded=bool(packages), ) def uninstall( venv_dir: Path, local_bin_dir: Path, local_man_dir: Path, *, verbose: bool, ) -> OperationResult[UninstallData]: if not venv_dir.exists(): messages: Final[list[OutputMessage]] = [OutputMessage(text=f"Nothing to uninstall for {venv_dir.name} {SLEEP}")] if app := which(venv_dir.name): messages.append( OutputMessage(text=f"{HAZARD} Note: '{app}' still exists on your system and is on your PATH") ) return OperationResult( command=("uninstall",), data=UninstallData(packages=()), messages=tuple(messages), exit_code=EXIT_CODE_UNINSTALL_VENV_NONEXISTENT, errors=( OperationError( code="environment_uninstall_failed", message=f"Nothing to uninstall for {venv_dir.name}.", environment=venv_dir.name, ), ), ) venv: Final[Venv] = Venv(venv_dir, verbose=verbose) package_infos: Final[tuple[PackageInfo, ...] | None] = get_venv_package_infos(venv) resource_paths = get_venv_resource_paths("app", venv.bin_path, local_bin_dir, package_infos) for man_section in MAN_SECTIONS: resource_paths |= get_venv_resource_paths( "man", venv.man_path / man_section, local_man_dir / man_section, package_infos ) for completion_section in COMPLETION_SECTIONS: resource_paths |= get_venv_resource_paths( "completion", venv.man_path.parent / completion_section, paths.CTX.completion_dir / completion_section, package_infos, ) for path in drop_links_taken_over(drop_copies_claimed_elsewhere(resource_paths, venv.bin_path), venv_dir): _remove_resource(path) package_info: Final[PackageInfo | None] = next( (package_info for package_info in package_infos or () if package_info.package == venv.main_package_name), None, ) # a removal killed halfway could drop the record but keep the package, which reinstall reads as a venv from pipx # before 0.15 rmdir_via_trash(venv_dir) return OperationResult( command=("uninstall",), data=UninstallData( packages=( _UninstalledPackage( environment=venv.name, package=str(package_info.package) if package_info is not None else venv.name, version=None if package_info is None else package_info.package_version or None, location=str(venv.root), ), ) ), messages=(OutputMessage(text=f"uninstalled {venv.name}! {STARS}"),), ) @dataclass(frozen=True, slots=True, kw_only=True) class UninstallData(OperationData): packages: tuple[_UninstalledPackage, ...] def _remove_resource(path: Path) -> None: try: safe_unlink(path) except FileNotFoundError: _LOGGER.info("pipx did not find resource %s", path) else: _LOGGER.info("pipx removed resource %s", path) @dataclass(frozen=True, slots=True, kw_only=True) class _UninstalledPackage: environment: str package: str version: str | None location: str __all__ = [ "UninstallData", "uninstall", "uninstall_all", ] pipx-1.18.16/src/pipx/commands/upgrade.py000066400000000000000000000615151527355705200202440ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "functools", "pipx", "pipx.commands.common", "pipx.commands.install", "pipx.commands.outdated", "pipx.commands.reinstall", "pipx.commands.transaction", "pipx.fanout", "pipx.interpreter", "pipx.options", "pipx.package_specifier", "pipx.pipx_metadata_file", "pipx.redact", "pipx.script", "pipx.shared_libs", "pipx.subprocess_runner", "pipx.util", "pipx.venv", "pipx.wrap", ] import logging from dataclasses import dataclass, replace from enum import StrEnum from functools import partial from typing import TYPE_CHECKING, Final from pipx import paths from pipx.colors import BOLD, RED from pipx.commands.common import ( expose_package_resources, expose_shared_resources_in_order, get_expected_venv_resource_paths, locked_package_message, remove_stale_venv_resources, validate_expected_apps, ) from pipx.commands.install import install as install_package from pipx.commands.outdated import inspect_outdated from pipx.commands.reinstall import reinstall from pipx.commands.transaction import is_unfinished, marked_unfinished, preserve_venv, runs_from_venv from pipx.constants import ExitCode from pipx.emojis import SLEEP from pipx.fanout import fan_out, locked_in_order from pipx.interpreter import get_default_python from pipx.options import CooldownOptions, ExposureOptions, InstallOptions, OutdatedOptions, ReinstallOptions from pipx.package_specifier import parse_specifier_for_upgrade, trusted_recorded_pip_args, valid_pypi_name from pipx.pipx_metadata_file import PipxMetadata from pipx.redact import redact_url_credentials from pipx.result import OperationData, OperationError, OperationResult, OutputLevel, OutputMessage, OutputStream from pipx.script import is_script_spec from pipx.shared_libs import SHARED_LIBS from pipx.subprocess_runner import run_subprocess from pipx.util import PipxError from pipx.venv import Venv, VenvContainer from pipx.wrap import pipx_wrap if TYPE_CHECKING: from collections.abc import Collection from pathlib import Path from filelock import BaseFileLock from pipx.commands.outdated import OutdatedData from pipx.options import BackendOptions, UpgradeOptions from pipx.pipx_metadata_file import PackageInfo _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) def upgrade(venv_dirs: dict[str, Path], options: UpgradeOptions) -> OperationResult[UpgradeData]: results: Final[list[_PackageUpgradeResult]] = [] install_messages: Final[list[OutputMessage]] = [] for package_spec, venv_dir in venv_dirs.items(): with VenvContainer(venv_dir.parent).venv_lock(venv_dir) as venv_lock: results.extend( _upgrade_venv( venv_dir, options, package_spec=package_spec, venv_lock=venv_lock, messages=install_messages ) ) package_results: Final[tuple[_PackageUpgradeResult, ...]] = tuple(results) return OperationResult( command=("upgrade",), data=UpgradeData(packages=package_results, skipped=()), messages=( *install_messages, *(message for result in package_results for message in _package_messages(result, upgrading_all=False)), ), ) def _upgrade_venv( venv_dir: Path, options: UpgradeOptions, *, package_spec: str, venv_lock: BaseFileLock, messages: list[OutputMessage], ) -> tuple[_PackageUpgradeResult, ...]: if not venv_dir.is_dir(): if options.install: installed: Final = install_package( None, None, # the venv directory only carries the package name; the original argument keeps any version specifier # or extras the user asked to install [package_spec or venv_dir.name], InstallOptions( local_bin_dir=paths.CTX.bin_dir, local_man_dir=paths.CTX.man_dir, python=options.python, pip_args=options.pip_args, venv_args=options.venv_args, verbose=options.verbose, force=options.force, reinstall=False, exposure=ExposureOptions(), preinstall_packages=None, python_flag_passed=options.python_flag_passed, backend=options.backend, cooldown=CooldownOptions(cli_days=options.cooldown.days), emit_output=False, ), venv_lock=venv_lock, ) # install does not raise when it does not render, so surface a failed result here if installed.errors: raise PipxError(installed.errors[0].message) messages.extend(installed.messages) return () msg: Final[str] = f""" Package is not installed. Expected to find {venv_dir!s}, but it does not exist. """ raise PipxError(msg) if options.venv_args and not options.install: _LOGGER.info("Ignoring %s as not combined with --install", ", ".join(options.venv_args)) if options.python and not options.install: _LOGGER.info("Ignoring --python as not combined with --install") venv: Final[Venv] = _repaired_venv( venv_dir, verbose=options.verbose, backend=options.backend, venv_lock=venv_lock, messages=messages, ) _validate_venv_for_upgrade(venv_dir, venv) return _upgrade_venv_packages(venv, options, packages_to_upgrade=None) def _repaired_venv( venv_dir: Path, *, verbose: bool, backend: BackendOptions, venv_lock: BaseFileLock | None, messages: list[OutputMessage], ) -> Venv: if is_unfinished(venv_dir): _LOGGER.warning("%s was left half changed by an interrupted pipx run; reinstalling it first.", venv_dir.name) messages.extend( reinstall( venv_dir, ReinstallOptions( local_bin_dir=paths.CTX.bin_dir, local_man_dir=paths.CTX.man_dir, python=_base_python(Venv(venv_dir)), verbose=verbose, backend=backend, ), venv_lock=venv_lock, ).messages ) return Venv(venv_dir, verbose=verbose, backend=backend) def _base_python(venv: Venv) -> str: # rebuild on the Python the environment runs on, which only the interpreter knows on Windows, where the # environment's python.exe is a launcher rather than a link if venv.python_path.is_file(): return run_subprocess( [venv.python_path, "-c", "import sys; print(sys._base_executable)"], log_stdout=False ).stdout.strip() return get_default_python() def _validate_venv_for_upgrade(venv_dir: Path, venv: Venv) -> None: if not venv.python_path.is_file(): msg = ( f"Not upgrading {RED(BOLD(venv_dir.name))}. It has an invalid python interpreter {venv.python_path}.\n" f"This usually happens after a system Python upgrade.\n" f"To fix, execute: pipx reinstall-all" ) raise PipxError(msg, wrap_message=False) if not venv.package_metadata: msg = ( f"Not upgrading {RED(BOLD(venv_dir.name))}. It has no internal pipx metadata.\n" "pipx versions before 0.15.0.0 did not record it.\n" "Uninstall and install this package to fix it." ) raise PipxError(msg, wrap_message=False) def _upgrade_venv_packages( venv: Venv, options: UpgradeOptions, *, packages_to_upgrade: Collection[str] | None ) -> tuple[_PackageUpgradeResult, ...]: main_package: Final[PackageInfo] = venv.pipx_metadata.main_package if main_package.lock_file is not None: return ( _PackageUpgradeResult( environment=venv.name, package=venv.name, previous_version=main_package.package_version, version=main_package.package_version, status=_UpgradeStatus.LOCKED, injected=False, location=str(venv.root), interpreter=venv.pipx_metadata.python_version, backend=venv.pipx_metadata.backend, ), ) upgrading: Final[bool] = packages_to_upgrade is None or bool(packages_to_upgrade) main_pip_args: Final[list[str]] = options.pip_args or ( trusted_recorded_pip_args(main_package.pip_args, venv.root) if upgrading else main_package.pip_args ) if upgrading: venv.check_upgrade_shared_libs(pip_args=main_pip_args, verbose=options.verbose) with ( preserve_venv( venv.root, enabled=upgrading and (runs_from_venv(venv.root) or any(package.expected_apps for package in venv.package_metadata.values())), ), marked_unfinished(venv.root), ): return _upgrade_packages(venv, main_pip_args, options, packages_to_upgrade=packages_to_upgrade) def _upgrade_packages( venv: Venv, main_pip_args: list[str], options: UpgradeOptions, *, packages_to_upgrade: Collection[str] | None, ) -> tuple[_PackageUpgradeResult, ...]: package_names: Final[list[str]] = [venv.main_package_name] if options.include_injected: package_names.extend( package_name for package_name in venv.package_metadata if package_name != venv.main_package_name ) selected: Final[set[str]] = ( set(package_names) if packages_to_upgrade is None else set(package_names).intersection(packages_to_upgrade) ) # checked before the cooldown write and the first upgrade, so a refused source changes nothing selected_pip_args: Final[dict[str, list[str]]] = { package_name: main_pip_args if package_name == venv.main_package_name else options.pip_args or trusted_recorded_pip_args(venv.package_metadata[package_name].pip_args, venv.root) for package_name in selected } cooldown_days: Final[int | None] = options.cooldown.days if cooldown_days is not None: cooldown_packages: Final[tuple[str, ...]] = tuple( package_name for package_name in package_names if package_name not in selected and not venv.package_metadata[package_name].pinned and venv.package_metadata[package_name].cooldown_days != cooldown_days ) for package_name in cooldown_packages: updated: Final[PackageInfo] = replace(venv.package_metadata[package_name], cooldown_days=cooldown_days) if package_name == venv.main_package_name: venv.pipx_metadata.main_package = updated else: venv.pipx_metadata.injected_packages[package_name] = updated if cooldown_packages: venv.pipx_metadata.write() if selected: venv.upgrade_packaging_libraries(main_pip_args) return tuple( _upgrade_package( venv, package_name, selected_pip_args[package_name], force=options.force, cooldown_days=cooldown_days, ) if package_name in selected else _package_result( venv, package_name, _UpgradeStatus.PINNED if venv.package_metadata[package_name].pinned else _UpgradeStatus.UNCHANGED, ) for package_name in package_names ) def _upgrade_package( venv: Venv, package_name: str, pip_args: list[str], *, force: bool, cooldown_days: int | None, ) -> _PackageUpgradeResult: package_metadata: Final[PackageInfo] = venv.package_metadata[package_name] if package_metadata.package_or_url is None: msg: Final[str] = f"Internal Error: package {package_name} has corrupt pipx metadata." raise PipxError(msg) if package_metadata.pinned: return _package_result(venv, package_name, _UpgradeStatus.PINNED) previous_resource_paths: Final[set[Path]] = get_expected_venv_resource_paths( venv, paths.CTX.bin_dir, paths.CTX.man_dir ) venv.upgrade_package( package_name, parse_specifier_for_upgrade(package_metadata.package_or_url), pip_args, exposure=ExposureOptions.recorded(package_metadata), is_main_package=package_name == venv.main_package_name, expected_apps=package_metadata.expected_apps, cooldown_days=cooldown_days if cooldown_days is not None else package_metadata.cooldown_days, ) upgraded_metadata: Final[PackageInfo] = venv.package_metadata[package_name] validate_expected_apps(venv, package_name, upgraded_metadata.expected_apps) if venv.pipx_metadata.exposure_enabled: expose_package_resources(upgraded_metadata, paths.CTX.bin_dir, paths.CTX.man_dir, force=force) remove_stale_venv_resources(previous_resource_paths, venv, paths.CTX.bin_dir, paths.CTX.man_dir) return _PackageUpgradeResult( environment=venv.name, package=f"{upgraded_metadata.package}{upgraded_metadata.suffix}", previous_version=package_metadata.package_version, version=upgraded_metadata.package_version, status=( _UpgradeStatus.UNCHANGED if package_metadata.package_version == upgraded_metadata.package_version else _UpgradeStatus.UPGRADED ), injected=package_name != venv.main_package_name, location=str(venv.root), interpreter=venv.pipx_metadata.python_version, backend=venv.pipx_metadata.backend, source=_local_source(upgraded_metadata), ) def _package_result(venv: Venv, package_name: str, status: _UpgradeStatus) -> _PackageUpgradeResult: package_metadata: Final[PackageInfo] = venv.package_metadata[package_name] return _PackageUpgradeResult( environment=venv.name, package=f"{package_metadata.package}{package_metadata.suffix}", previous_version=package_metadata.package_version, version=package_metadata.package_version, status=status, injected=package_name != venv.main_package_name, location=str(venv.root), interpreter=venv.pipx_metadata.python_version, backend=venv.pipx_metadata.backend, source=_local_source(package_metadata), ) def _local_source(package_metadata: PackageInfo) -> str | None: if package_metadata.package_or_url is not None and valid_pypi_name(package_metadata.package_or_url) is None: return redact_url_credentials(package_metadata.package_or_url) return None def _package_messages(result: _PackageUpgradeResult, *, upgrading_all: bool) -> tuple[OutputMessage, ...]: if result.status is _UpgradeStatus.LOCKED: return (OutputMessage(text=locked_package_message(result.environment)),) if result.status is _UpgradeStatus.PINNED: subject: Final[str] = ( f"package {result.package} in venv {result.environment}" if result.injected else f"package {result.environment}" ) return ( OutputMessage( text=f"Not upgrading pinned {subject}. Run `pipx unpin {result.environment}` to unpin it.", stream=OutputStream.LOG, ), ) if result.status is _UpgradeStatus.UNCHANGED: if upgrading_all: return () if result.source is not None: remedy: Final[str] = ( "" if result.injected or is_script_spec(result.source) else f" To track index releases, reinstall by package name: pipx install --force {result.environment}" ) return ( OutputMessage( text=pipx_wrap( f""" {result.package} is unchanged at version {result.previous_version} because it is installed from the local source {result.source}; no package index was checked.{remedy} """ ) ), ) return ( OutputMessage( text=pipx_wrap( f""" {result.package} is already at latest version {result.previous_version} (location: {result.location}) """ ) ), ) return ( OutputMessage( text=pipx_wrap( f""" upgraded package {result.package} from {result.previous_version} to {result.version} (location: {result.location}) """ ) ), ) def upgrade_all(venv_container: VenvContainer, options: UpgradeOptions) -> OperationResult[UpgradeData]: outdated: Final[OutdatedData] = inspect_outdated( venv_container, OutdatedOptions( include_injected=options.include_injected, upgradable_only=True, pip_args=options.pip_args, skip=options.skip, backend=options.backend, ), ) candidates: Final[set[tuple[str, str]]] = { (package.environment, package.package) for package in outdated.packages } | { (package.environment, package.package) for package in outdated.skipped if package.reason in {"editable", "non-index"} } check_failures: Final[dict[str, list[str]]] = {} for failure in outdated.failures: check_failures.setdefault(failure.environment, []).append(failure.error) venv_dirs: Final[list[Path]] = list(venv_container.iter_venv_dirs()) with expose_shared_resources_in_order( (PipxMetadata(venv_dir) for venv_dir in venv_dirs), paths.CTX.bin_dir, paths.CTX.man_dir ): venv_upgrades: Final[list[_VenvUpgrade]] = fan_out( [ partial( _upgrade_listed_venv, venv_container, venv_dir, options, candidates=candidates, check_failures=check_failures, ) for venv_dir in venv_dirs ], sequential=options.verbose, interrupted=lambda index: _interrupted_upgrade(venv_dirs[index]), ) failures: Final[list[_FailedUpgrade]] = [] interrupted: Final[list[str]] = [] messages: Final[list[OutputMessage]] = [] results: Final[list[_PackageUpgradeResult]] = [] skipped: Final[list[_SkippedUpgrade]] = [] for venv_upgrade in venv_upgrades: interrupted.extend(venv_upgrade.interrupted) messages.extend(venv_upgrade.messages) results.extend(venv_upgrade.results) skipped.extend(venv_upgrade.skipped) failures.extend(venv_upgrade.failures) if not any(result.status is _UpgradeStatus.UPGRADED for result in results): messages.append(OutputMessage(text=f"No packages upgraded after running 'pipx upgrade-all' {SLEEP}")) if failures: failed: Final[str] = ",".join(failure.environment for failure in failures) messages.append( OutputMessage( text=f"The following package(s) failed to upgrade: {failed}", stream=OutputStream.STDERR, level=OutputLevel.ERROR, ) ) if interrupted: messages.append( OutputMessage( text=f"Interrupted before upgrading: {','.join(interrupted)}", stream=OutputStream.STDERR, level=OutputLevel.ERROR, ) ) return OperationResult( command=("upgrade-all",), data=UpgradeData(packages=tuple(results), skipped=tuple(skipped)), messages=tuple(messages), exit_code=ExitCode(1 if failures or interrupted else 0), errors=tuple( OperationError(code="package_upgrade_failed", message=failure.error, environment=failure.environment) for failure in failures ), succeeded=bool(results or skipped), ) def _upgrade_listed_venv( venv_container: VenvContainer, venv_dir: Path, options: UpgradeOptions, *, candidates: set[tuple[str, str]], check_failures: dict[str, list[str]], ) -> _VenvUpgrade: if venv_dir.name in options.skip: return _VenvUpgrade(skipped=(_SkippedUpgrade(environment=venv_dir.name, reason="requested"),)) with locked_in_order(venv_container.venv_lock(venv_dir)) as venv_lock: messages: Final[list[OutputMessage]] = [] # the check ran against the half changed environment that the repair replaces checked: Final[bool] = not is_unfinished(venv_dir) try: venv: Final[Venv] = _repaired_venv( venv_dir, verbose=options.verbose, backend=options.backend, venv_lock=venv_lock, messages=messages, ) if "--editable" in venv.pipx_metadata.main_package.pip_args: return _VenvUpgrade( messages=tuple(messages), skipped=(_SkippedUpgrade(environment=venv_dir.name, reason="editable"),), ) _validate_venv_for_upgrade(venv_dir, venv) _raise_check_failures(venv, check_failures if checked else {}) package_results: Final[tuple[_PackageUpgradeResult, ...]] = _upgrade_venv_packages( venv, options, packages_to_upgrade={ package_name for package_name, package in venv.package_metadata.items() if (package_name == venv.main_package_name or options.include_injected) and (venv.name, f"{package_name}{package.suffix}") in candidates }, ) except PipxError as error: messages.append(OutputMessage(text=str(error), stream=OutputStream.STDERR, level=OutputLevel.ERROR)) return _VenvUpgrade( messages=tuple(messages), failures=(_FailedUpgrade(environment=venv_dir.name, error=str(error)),), ) return _VenvUpgrade( messages=( *messages, *(message for result in package_results for message in _package_messages(result, upgrading_all=True)), ), results=package_results, ) def _raise_check_failures(venv: Venv, check_failures: dict[str, list[str]]) -> None: # index-check failures block an unlocked upgrade; raise so the enclosing handler records the environment if venv.pipx_metadata.main_package.lock_file is None and (errors := check_failures.get(venv.name)): raise PipxError("\n".join(errors), wrap_message=False) def _interrupted_upgrade(venv_dir: Path) -> _VenvUpgrade: if not is_unfinished(venv_dir): return _VenvUpgrade(interrupted=(venv_dir.name,)) error: Final[str] = ( f"Interrupted while upgrading {venv_dir.name}; the next `pipx upgrade-all`, `pipx upgrade {venv_dir.name}` or " f"`pipx reinstall {venv_dir.name}` reinstalls it." ) return _VenvUpgrade( messages=(OutputMessage(text=error, stream=OutputStream.STDERR, level=OutputLevel.ERROR),), failures=(_FailedUpgrade(environment=venv_dir.name, error=error),), ) def upgrade_shared(pip_args: list[str], *, verbose: bool) -> OperationResult[SharedData]: # pip-backed installs use this environment regardless of the installed environments' backends SHARED_LIBS.upgrade(verbose=verbose, pip_args=pip_args, raises=True) return OperationResult( command=("upgrade-shared",), data=SharedData(location=str(SHARED_LIBS.root)), messages=(OutputMessage(text=f"Upgraded the shared libraries in {SHARED_LIBS.root}."),), ) class _UpgradeStatus(StrEnum): LOCKED = "locked" PINNED = "pinned" UNCHANGED = "unchanged" UPGRADED = "upgraded" @dataclass(frozen=True, slots=True, kw_only=True) class _PackageUpgradeResult: environment: str package: str previous_version: str version: str status: _UpgradeStatus injected: bool location: str interpreter: str | None backend: str source: str | None = None @dataclass(frozen=True, slots=True, kw_only=True) class _SkippedUpgrade: environment: str reason: str @dataclass(frozen=True, slots=True, kw_only=True) class _FailedUpgrade: environment: str error: str @dataclass(frozen=True, slots=True, kw_only=True) class _VenvUpgrade: messages: tuple[OutputMessage, ...] = () results: tuple[_PackageUpgradeResult, ...] = () skipped: tuple[_SkippedUpgrade, ...] = () failures: tuple[_FailedUpgrade, ...] = () interrupted: tuple[str, ...] = () @dataclass(frozen=True, slots=True, kw_only=True) class UpgradeData(OperationData): packages: tuple[_PackageUpgradeResult, ...] skipped: tuple[_SkippedUpgrade, ...] @dataclass(frozen=True, slots=True, kw_only=True) class SharedData(OperationData): location: str __all__ = [ "SharedData", "UpgradeData", "upgrade", "upgrade_all", "upgrade_shared", ] pipx-1.18.16/src/pipx/constants.py000066400000000000000000000044261527355705200170260ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["sysconfig"] import enum import sys import sysconfig from pathlib import PurePosixPath from typing import Final, NewType class FetchPythonOptions(enum.StrEnum): ALWAYS = "always" MISSING = "missing" NEVER = "never" PIPX_SHARED_PTH: Final[str] = "pipx_shared.pth" TEMP_VENV_EXPIRATION_THRESHOLD_DAYS: Final[int] = 14 MINIMUM_PYTHON_VERSION: Final[str] = "3.11" MAN_SECTIONS: Final[list[str]] = [f"man{i}" for i in range(1, 10)] # the directories a wheel ships completion scripts in, relative to the data scheme's share/, and the layout bash and # fish already read from under a user data directory COMPLETION_SECTIONS: Final[list[PurePosixPath]] = [ PurePosixPath("bash-completion/completions"), PurePosixPath("zsh/site-functions"), PurePosixPath("fish/vendor_completions.d"), ] ExitCode = NewType("ExitCode", int) EXIT_CODE_OK: Final[ExitCode] = ExitCode(0) EXIT_CODE_INJECT_ERROR: Final[ExitCode] = ExitCode(1) EXIT_CODE_UNINJECT_ERROR: Final[ExitCode] = ExitCode(1) EXIT_CODE_LIST_PROBLEM: Final[ExitCode] = ExitCode(1) EXIT_CODE_UNINSTALL_VENV_NONEXISTENT: Final[ExitCode] = ExitCode(1) EXIT_CODE_UNINSTALL_ERROR: Final[ExitCode] = ExitCode(1) EXIT_CODE_REINSTALL_VENV_NONEXISTENT: Final[ExitCode] = ExitCode(1) EXIT_CODE_REINSTALL_INVALID_PYTHON: Final[ExitCode] = ExitCode(1) EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND: Final[ExitCode] = ExitCode(1) def _is_mingw() -> bool: return sysconfig.get_platform().startswith("mingw") # not platform.system(): on Windows it queries WMI, adding tens of milliseconds to every startup WINDOWS: Final[bool] = sys.platform == "win32" MACOS: Final[bool] = sys.platform == "darwin" MINGW: Final[bool] = _is_mingw() __all__ = [ "COMPLETION_SECTIONS", "EXIT_CODE_INJECT_ERROR", "EXIT_CODE_LIST_PROBLEM", "EXIT_CODE_OK", "EXIT_CODE_REINSTALL_INVALID_PYTHON", "EXIT_CODE_REINSTALL_VENV_NONEXISTENT", "EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND", "EXIT_CODE_UNINJECT_ERROR", "EXIT_CODE_UNINSTALL_ERROR", "EXIT_CODE_UNINSTALL_VENV_NONEXISTENT", "MACOS", "MAN_SECTIONS", "MINGW", "MINIMUM_PYTHON_VERSION", "PIPX_SHARED_PTH", "TEMP_VENV_EXPIRATION_THRESHOLD_DAYS", "WINDOWS", "ExitCode", "FetchPythonOptions", ] pipx-1.18.16/src/pipx/emojis.py000066400000000000000000000017721527355705200163010ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["typing"] import os import sys from typing import Final def strtobool(value: str) -> bool: return value.lower() in {"y", "yes", "t", "true", "on", "1"} def use_emojis() -> bool: for variable in ("PIPX_USE_EMOJI", "USE_EMOJI"): if (use_emoji := os.getenv(variable)) is not None: return strtobool(use_emoji) if (encoding := getattr(sys.stderr, "encoding", None)) is None: return False try: "โœจ๐ŸŒŸโš ๏ธ๐Ÿ˜ดโฃทโฃฏโฃŸโกฟโขฟโฃปโฃฝโฃพ".encode(encoding) except UnicodeEncodeError: return False return True EMOJI_SUPPORT: Final[bool] = use_emojis() STARS: Final[str] = "โœจ ๐ŸŒŸ โœจ" if EMOJI_SUPPORT else "" HAZARD: Final[str] = "โš ๏ธ" if EMOJI_SUPPORT else "" ERROR: Final[str] = "โ›”" if EMOJI_SUPPORT else "" SLEEP: Final[str] = "๐Ÿ˜ด" if EMOJI_SUPPORT else "" __all__ = [ "EMOJI_SUPPORT", "ERROR", "HAZARD", "SLEEP", "STARS", "strtobool", "use_emojis", ] pipx-1.18.16/src/pipx/fanout.py000066400000000000000000000410141527355705200163000ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["concurrent.futures", "functools", "signal"] import logging import os import signal import sys from concurrent.futures import Future, ThreadPoolExecutor, wait from contextlib import contextmanager, suppress from contextvars import Context, ContextVar, copy_context from dataclasses import dataclass, field from functools import partial from threading import Condition, Event, RLock, current_thread, main_thread from typing import TYPE_CHECKING, Final, NoReturn, TextIO, TypeVar, cast from pipx.constants import WINDOWS if TYPE_CHECKING: from collections.abc import Callable, Generator, Sequence from subprocess import Popen from types import FrameType from filelock import BaseFileLock _Child = Popen[str] | Popen[bytes] _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) _ResultT = TypeVar("_ResultT") _SLOT: Final[ContextVar[_Slot | None]] = ContextVar("fanout_slot", default=None) _LOOSE_CHILDREN: Final[set[_Child]] = set() # reentrant, as a signal handler stopping the loose children can run while its thread holds the lock _LOOSE_CHILDREN_LOCK: Final[RLock] = RLock() # the interpreter drops the KeyboardInterrupt of a handler it runs inside a weakref callback or a finalizer, so the # handler also leaves this mark, which a fan-out waiting on its tasks raises from in its place _UNRAISED_INTERRUPT: Final[Event] = Event() @contextmanager def stopping_children_on_signals() -> Generator[Event, None, None]: signalled: Final[Event] = Event() if current_thread() is not main_thread(): yield signalled return _UNRAISED_INTERRUPT.clear() def interrupt(_number: int, _frame: FrameType | None) -> NoReturn: signalled.set() _UNRAISED_INTERRUPT.set() # children run in a process group of their own, so a Ctrl-C in the terminal reaches pipx alone with _LOOSE_CHILDREN_LOCK: for process in _LOOSE_CHILDREN: stop_process_group(process) raise KeyboardInterrupt # Windows has no SIGHUP previous: Final = { number: signal.signal(number, interrupt) for number in {signal.SIGINT, signal.SIGTERM, getattr(signal, "SIGHUP", signal.SIGTERM)} } try: yield signalled finally: # a signalled pipx is about to exit, and a further signal landing meanwhile would end it with a traceback # instead of its exit code for number, handler in previous.items(): signal.signal(number, signal.SIG_IGN if signalled.is_set() else handler) def stop_process_group(process: _Child) -> None: if WINDOWS: process.terminate() return # the group outlives its leader while a grandchild such as a build backend still runs; an empty group raises # ProcessLookupError, and macOS raises PermissionError for one holding only zombies with suppress(ProcessLookupError, PermissionError): os.killpg(process.pid, signal.SIGTERM) @contextmanager def uninterrupted() -> Generator[None]: if current_thread() is not main_thread(): yield return received: Final[list[int]] = [] def record(number: int, _frame: FrameType | None) -> None: received.append(number) previous: Final = { number: signal.signal(number, record) for number in {signal.SIGINT, signal.SIGTERM, getattr(signal, "SIGHUP", signal.SIGTERM)} } try: yield finally: for number, handler in previous.items(): signal.signal(number, handler) if received: signal.raise_signal(received[0]) def fan_out( tasks: Sequence[Callable[[], _ResultT]], *, sequential: bool, interrupted: Callable[[int], _ResultT] | None = None, ) -> list[_ResultT]: # the output reads as if the tasks ran one after another: the earliest unfinished task writes to the terminal, the # others buffer until their turn if sequential or not tasks: return [task() for task in tasks] turn: Final[Condition] = _Turn() # the turn's lock also guards the children, which no task holds while waiting for a child to exit children: Final[_Children] = _Children(lock=turn, submitted=Event(), finishes=interrupted is not None) slots: Final[list[_Slot]] = [] for _ in tasks: slots.append(_Slot(turn=turn, children=children, live=not slots, after=slots[-1] if slots else None)) # capped like the outdated check, so a large pipx home does not start dozens of pip or uv runs against the index executor: Final[ThreadPoolExecutor] = ThreadPoolExecutor(max_workers=min(len(tasks), os.cpu_count() or 1, 8)) with _routed_output(children): futures: Final[list[Future[_ResultT]]] = [] try: futures.extend( executor.submit(_in_slot(slot, partial(_once_submitted, slot, task))) for slot, task in zip(slots, tasks, strict=True) ) children.submitted.set() _wait_in_order(futures, slots) except KeyboardInterrupt: _interrupt(futures, children) if interrupted is None: raise if not children.interrupted: # the tasks left to finish keep printing in task order, unless interrupted again try: _wait_in_order(futures, slots) except KeyboardInterrupt: _interrupt(futures, children) finally: children.submitted.set() _settle(futures, slots, children) executor.shutdown(cancel_futures=True) if interrupted is None or not children.stopping: return [future.result() for future in futures] return [ interrupted(index) if future.cancelled() or isinstance(future.exception(), KeyboardInterrupt) else future.result() for index, future in enumerate(futures) ] class _Turn(Condition): # Condition.__enter__ and __exit__ are Python frames around the lock's own methods: a signal handled right after the # lock was taken, or right before it is released, raises KeyboardInterrupt from that frame, so the main thread keeps # the lock and every task waiting for its turn waits forever. The lock's C methods leave no point to raise between. __enter__ = property(lambda self: self._lock.__enter__) __exit__ = property(lambda self: self._lock.__exit__) @dataclass(slots=True, kw_only=True) class _Children: lock: Condition submitted: Event finishes: bool running: dict[_Child, _Slot] = field(default_factory=dict) finishing: set[_Slot] = field(default_factory=set) stopping: bool = False interrupted: bool = False # the writer that left each stream's last line open open_lines: dict[TextIO, object] = field(default_factory=dict) def stop(self) -> int: # the first interrupt stops the children of the tasks not finishing their work, the second stops every child with self.lock: if self.stopping or not self.finishing: self.interrupted = True # a line a stopped child left open stays unfinished, so whatever comes next starts a line of its own self.open_lines = dict.fromkeys(self.open_lines, object()) self.stopping = True for process, slot in self.running.items(): if self.stops(slot): stop_process_group(process) return 0 if self.interrupted else len(self.finishing) def stops(self, slot: _Slot) -> bool: return self.interrupted or (self.stopping and slot not in self.finishing) def write(self, target: TextIO, text: str, writer: object) -> int: with self.lock: # once interrupted, tasks write out of turn and must not run on into a line another one left unfinished if self.interrupted and self.open_lines.get(target, writer) is not writer and text[:1] not in {"\n", "\r"}: target.write("\n") if text.endswith("\n"): self.open_lines.pop(target, None) elif text: self.open_lines[target] = writer return target.write(text) @dataclass(eq=False, slots=True, kw_only=True) class _Slot: turn: Condition children: _Children live: bool after: _Slot | None locked: bool = False chunks: list[tuple[TextIO, str]] = field(default_factory=list) def pass_lock(self) -> None: with self.turn: self.locked = True self.turn.notify_all() def go_live(self) -> None: with self.turn: # a reader that went away must keep neither the task from its turn nor the fan-out from waiting for its # tasks; what the task writes next fails in the task, which rolls back as it does running alone with suppress(OSError, ValueError): for stream, text in self.chunks: self.children.write(stream, text, self) stream.flush() self.chunks.clear() self.live = True self.turn.notify_all() @contextmanager def _routed_output(children: _Children) -> Generator[None, None, None]: stdout, stderr = sys.stdout, sys.stderr # the stream handler bound sys.stderr when logging was set up, so it does not see the replacement below handlers: Final[list[tuple[logging.StreamHandler[TextIO], TextIO]]] = [ (handler, handler.stream) for handler in logging.getLogger("pipx").handlers if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler) ] # Python sets a stream closed at startup to None, which print skips, so a held task must not buffer output for it sys.stdout, sys.stderr = (None if stream is None else _routed(stream, children) for stream in (stdout, stderr)) for handler, stream in handlers: handler.setStream(_routed(stream, children)) try: yield finally: sys.stdout, sys.stderr = stdout, stderr for handler, stream in handlers: handler.setStream(stream) def _routed(target: TextIO, children: _Children) -> TextIO: # a duck-typed stand-in rather than an io.TextIOBase subclass, whose finalizer would flush a target closed by then return cast("TextIO", _RoutedStream(target, children)) class _RoutedStream: def __init__(self, target: TextIO, children: _Children) -> None: self._target: Final[TextIO] = target self._children: Final[_Children] = children def __getattr__(self, name: str) -> object: return getattr(self._target, name) def write(self, text: str) -> int: slot: Final[_Slot | None] = _SLOT.get() with self._children.lock: if slot is None or slot.live: return self._children.write(self._target, text, slot) slot.chunks.append((self._target, text)) return len(text) def flush(self) -> None: if (slot := _SLOT.get()) is None or slot.live: self._target.flush() def _in_slot(slot: _Slot, task: Callable[[], _ResultT]) -> Callable[[], _ResultT]: # executor threads do not inherit the submitter's context, which carries pipx's switches as well as the slot context: Final[Context] = copy_context() context.run(_SLOT.set, slot) return partial(context.run, task) def _once_submitted(slot: _Slot, task: Callable[[], _ResultT]) -> _ResultT: # an interrupt landing while submit starts a worker thread leaves that thread out of the threads shutdown waits on, # so no task may start its work before every task is submitted, and none once the fan-out is interrupted try: slot.children.submitted.wait() if slot.children.stopping: raise KeyboardInterrupt return task() finally: # a task that ends without taking a lock must not hold back the later tasks waiting to take theirs slot.pass_lock() def _wait_in_order(futures: list[Future[_ResultT]], slots: list[_Slot]) -> None: for future, next_slot in zip(futures, slots[1:], strict=False): _wait_interruptibly((future,)) # the first failure in task order propagates, so the tasks not started yet must not start if not future.cancelled() and future.exception() is not None: for later in futures: later.cancel() next_slot.go_live() # the loop stops short of the last task, which shutdown would cancel if no thread has picked it up yet _wait_interruptibly(futures) def _wait_interruptibly(futures: Sequence[Future[_ResultT]]) -> None: # a blocked wait does not wake for a signal another thread handled or one that landed just before it blocked, so # wait in slices for the interpreter to run the handler and raise KeyboardInterrupt promptly while wait(futures, timeout=0.1).not_done: if _UNRAISED_INTERRUPT.is_set(): raise KeyboardInterrupt def _interrupt(futures: list[Future[_ResultT]], children: _Children) -> None: _UNRAISED_INTERRUPT.clear() # the children run in process groups of their own, out of reach of the terminal's Ctrl-C, so pipx stops them itself for future in futures: future.cancel() if finishing := children.stop(): _LOGGER.warning( "Finishing the %d environment(s) already being changed; press Ctrl-C again to stop them too.", finishing ) def _settle(futures: list[Future[_ResultT]], slots: list[_Slot], children: _Children) -> None: # a further Ctrl-C must not cut short the wait for tasks rolling back: Python 3.12 marks a thread whose join was # interrupted as stopped, so the interpreter exits without waiting and kills the task mid rollback while True: try: # an interrupt can leave tasks waiting for their turn, so hand every slot over before waiting on them for slot in slots: slot.go_live() _wait_interruptibly(futures) except KeyboardInterrupt: _interrupt(futures, children) else: return def output_is_held() -> bool: return (slot := _SLOT.get()) is not None and not slot.live @contextmanager def in_turn(done: Callable[[], bool]) -> Generator[None, None, None]: # a change to state all tasks share waits for the earlier tasks unless done() reports it made, so the task making # it, its arguments and its output match a sequential run if (slot := _SLOT.get()) is None: yield return with slot.turn: slot.turn.wait_for(lambda: slot.live or done()) try: yield finally: with slot.turn: slot.turn.notify_all() @contextmanager def locked_in_order(lock: BaseFileLock) -> Generator[BaseFileLock, None, None]: # a task waiting for its turn holds its lock, so two pipx runs taking the same locks in different task orders could # each wait for the other forever; taking them in task order leaves no task waiting its turn on a lock another run # holds while it waits for this run if (slot := _SLOT.get()) is not None: with slot.turn: slot.turn.wait_for(lambda: slot.live or slot.after is None or slot.after.locked) with lock: if slot is not None: slot.pass_lock() yield lock @contextmanager def finished_on_interrupt() -> Generator[None, None, None]: if (slot := _SLOT.get()) is None or not slot.children.finishes: yield return children: Final[_Children] = slot.children with children.lock: if children.stopping: raise KeyboardInterrupt children.finishing.add(slot) try: yield finally: with children.lock: children.finishing.discard(slot) @contextmanager def stopped_on_interrupt(process: _Child) -> Generator[None, None, None]: # stopping ends the child's process group as a whole, so the child must run in one of its own if (slot := _SLOT.get()) is None: with _LOOSE_CHILDREN_LOCK: _LOOSE_CHILDREN.add(process) try: yield finally: with _LOOSE_CHILDREN_LOCK: _LOOSE_CHILDREN.discard(process) return children: Final[_Children] = slot.children with children.lock: children.running[process] = slot if children.stops(slot): stop_process_group(process) try: yield finally: with children.lock: del children.running[process] if children.stops(slot): raise KeyboardInterrupt __all__ = [ "fan_out", "finished_on_interrupt", "in_turn", "locked_in_order", "output_is_held", "stop_process_group", "stopped_on_interrupt", "stopping_children_on_signals", "uninterrupted", ] pipx-1.18.16/src/pipx/interpreter.py000066400000000000000000000241011527355705200173450ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "configparser", "importlib.util", "packaging", "pathlib", "pipx.self_install", "re", "shutil", "subprocess", ] import logging import os import re import shutil import subprocess import sys from configparser import ConfigParser from configparser import Error as ConfigParserError from functools import cache from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING, Final from packaging import version from pipx.constants import WINDOWS, FetchPythonOptions from pipx.self_install import get_environment_value from pipx.util import PipxError _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) if TYPE_CHECKING: DEFAULT_PYTHON: str def find_python_interpreter(python_version: str, fetch_python: FetchPythonOptions) -> str: if fetch_python == FetchPythonOptions.ALWAYS: if _has_path_separator(python_version) or Path(python_version).is_file(): msg = ( f"--fetch-python={FetchPythonOptions.ALWAYS} requires a Python version " f"(e.g. '3.13'), got '{python_version}'." ) raise PipxError(msg) return _fetch_standalone_interpreter(python_version) # a bare name such as 3.11 must not match a file of that name in the working directory if _has_path_separator(python_version) and Path(python_version).is_file(): return python_version # uv reads a bare name such as python3.11 as a version request and may settle on a .venv in a parent directory if found := shutil.which(python_version): return found if not WINDOWS and (python_unix_command := _find_unix_command_python(python_version)): return python_unix_command try: if py_executable := find_py_launcher_python(python_version): return py_executable except (subprocess.CalledProcessError, FileNotFoundError) as e: if fetch_python != FetchPythonOptions.MISSING: raise InterpreterResolutionError(source="py launcher", version=python_version) from e if fetch_python == FetchPythonOptions.MISSING: return _fetch_standalone_interpreter(python_version) raise InterpreterResolutionError(source="PATH", version=python_version) def _has_path_separator(python: str) -> bool: return os.sep in python or "/" in python def _fetch_standalone_interpreter(python_version: str) -> str: from pipx import standalone_python # ruff:ignore[import-outside-top-level] # pulls in http.client, slows startup try: return standalone_python.download_python_build_standalone(python_version) except PipxError as e: raise InterpreterResolutionError(source="the python-build-standalone project", version=python_version) from e class InterpreterResolutionError(PipxError): def __init__(self, source: str, version: str) -> None: message = ( f"No executable for the provided Python version '{version}' found in {source}." " Check that the provided version is " ) match source: case "py launcher": message += "listed when running `py --list`." case "PATH": message += "on your PATH or the file path is valid. " if "/" in version: message += "The provided version looks like a path, but no executable was found there." elif "python" not in version: message += ( "The provided version looks like a version, " "but neither the python command nor the Python Launcher is on PATH." ) case "the python-build-standalone project": message += "listed in https://github.com/astral-sh/python-build-standalone/releases/latest." case "PIPX_DEFAULT_PYTHON": message += ( "on your PATH or the file path is valid. " "Try setting it to an executable name (e.g. python3.10) " "or a full path (e.g. /usr/bin/python3.10)." ) super().__init__(message) def _find_unix_command_python(python_version: str) -> str | None: try: parsed_python_version: Final[version.Version] = version.parse(python_version) except version.InvalidVersion: _LOGGER.info("Invalid Python version: %s", python_version) return None if ( parsed_python_version.epoch != 0 or parsed_python_version.is_devrelease or parsed_python_version.is_postrelease or parsed_python_version.is_prerelease ): _LOGGER.info("Unsupported Python version: %s", python_version) return None # the commands carry at most the minor version, as python3 or python3.12 do python_command: Final[str] = f"python{'.'.join(python_version.split('.')[:2])}" if not (python_path := shutil.which(python_command)): _LOGGER.info("Command `%s` was not found on the system", python_command) return None if parsed_python_version.micro != 0: _LOGGER.warning( "The command '%s' located at '%s' will be used. " "It may not match the specified version %s at the micro/patch level.", python_command, python_path, python_version, ) return python_path def find_py_launcher_python(python_version: str | None = None) -> str | None: py = shutil.which("py") if py and python_version: python_semver = python_version if python_version.startswith("python"): _LOGGER.warning( "Removing `python` from the start of the version, as pylauncher just expects the semantic version" ) python_semver = python_semver.lstrip("python") # the Unix launcher reads only -X and -X.Y as a version and hands any other flag to the first interpreter its # search finds, which can be a .venv in a parent directory; see Action::from_main and venv_path_search in # https://github.com/brettcannon/python-launcher/blob/3c9f207c07d5fed612b04dbf2980f67d40aa24b3/src/cli.rs if not WINDOWS and not re.fullmatch(r"[0-9]+(\.[0-9]+)?", python_semver): return None py = subprocess.run( [py, f"-{python_semver}", "-c", "import sys; print(sys.executable)"], capture_output=True, text=True, check=True, ).stdout.strip() return py def get_default_python_spec() -> str: return get_environment_value("PIPX_DEFAULT_PYTHON") or sys.executable def __getattr__(name: str) -> object: if name == "DEFAULT_PYTHON": return get_default_python() msg = f"module {__name__!r} has no attribute {name!r}" raise AttributeError(msg) @cache def get_default_python() -> str: if (python := get_environment_value("PIPX_DEFAULT_PYTHON")) is None: return _get_sys_executable() return _resolve_python(python) def _get_sys_executable() -> str: if WINDOWS: return _find_default_windows_python() return str(Path(sys.executable).resolve()) def _find_default_windows_python() -> str: # the Windows embeddable distribution ships without venv, so look for a system Python instead; the technique comes # from https://github.com/uranusjr/pipx-standalone if has_venv(): return sys.executable msg: Final[str] = "No suitable Python found" if (python := find_py_launcher_python() or shutil.which("python")) is None: raise PipxError(msg) if "WindowsApps" not in python: return python # a Python under WindowsApps may be the Microsoft Store stub, which exits with 9009 or prints no version: # https://twitter.com/zooba/status/1212454929379581952 proc: Final = subprocess.run([python, "-V"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False) if proc.returncode != 0 or not proc.stdout.strip(): raise PipxError(msg) return python def has_venv() -> bool: return find_spec("venv") is not None def _resolve_python(python: str) -> str: # a bare name goes to PATH only; testing it as a file first would pick up one sitting in the working directory if _has_path_separator(python) and (path := Path(python)).is_file(): return str(path.resolve()) if found := shutil.which(python): return found if not WINDOWS and (found := _find_unix_command_python(python)): return found raise InterpreterResolutionError(source="PIPX_DEFAULT_PYTHON", version=python) def recordable_interpreter(python: Path) -> Path | None: # an interpreter inside a virtual environment, such as an activated bootstrap's python3, goes away with that # environment, and whoever creates the path again decides what install-all runs from a snapshot; the new # environment runs on the base interpreter instead if not (pyvenv_cfg := python.parent.parent / "pyvenv.cfg").is_file(): return python config: Final[dict[str, str]] = read_pyvenv_cfg(pyvenv_cfg) # home keeps the directory the base interpreter ran from, such as a link that survives patch upgrades; executable, # resolved and written by venv since 3.11, covers a home without this name, as a macOS framework build has no python candidates: Final[list[Path]] = [Path(home) / python.name] if (home := config.get("home")) else [] if executable := config.get("executable"): candidates.append(Path(executable)) return next((candidate for candidate in candidates if candidate.is_file()), None) def read_pyvenv_cfg(pyvenv_cfg: Path) -> dict[str, str]: config: Final[ConfigParser] = ConfigParser() try: # pyvenv.cfg has no section header, which ConfigParser requires config.read_string(f"[DEFAULT]\n{pyvenv_cfg.read_text(encoding='utf-8')}") except (OSError, UnicodeDecodeError, ConfigParserError): return {} return dict(config["DEFAULT"]) __all__ = [ "DEFAULT_PYTHON", "InterpreterResolutionError", "find_py_launcher_python", "find_python_interpreter", "get_default_python", "get_default_python_spec", "has_venv", "read_pyvenv_cfg", "recordable_interpreter", ] pipx-1.18.16/src/pipx/main.py000066400000000000000000002626071527355705200157450ustar00rootroot00000000000000# PYTHON_ARGCOMPLETE_OK """The command line interface to pipx""" from __future__ import annotations __lazy_modules__ = [ "argcomplete", "contextlib", "fcntl", "packaging.utils", "pathlib", "pipx", "pipx.backends._base", "pipx.backends.registry", "pipx.commands.environment", "pipx.commands.transaction", "pipx.fanout", "pipx.interpreter", "pipx.options", "pipx.package_specifier", "pipx.redact", "pipx.result", "pipx.shared_libs", "pipx.subprocess_runner", "pipx.util", "pipx.venv", "pipx.version", "pipx.wrap", "platformdirs", "re", "shlex", "textwrap", "urllib.parse", ] import argparse import contextlib import logging import os import re import shlex import sys import textwrap import time import typing import urllib.parse from collections.abc import Callable, Sequence from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Final, NoReturn, TextIO, cast import argcomplete import platformdirs from packaging.utils import canonicalize_name from pipx import constants, paths from pipx.animate import hide_cursor, show_cursor from pipx.backends._base import KNOWN_BACKENDS, UV from pipx.backends.registry import env_default_backend, get_backend, resolve_backend_name from pipx.colors import BOLD, GREEN from pipx.commands.environment import ENVIRONMENT_VALUE_CHOICES, ENVIRONMENT_VARIABLES from pipx.commands.transaction import empty_trash from pipx.constants import ( EXIT_CODE_OK, EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND, MINIMUM_PYTHON_VERSION, WINDOWS, ExitCode, FetchPythonOptions, ) from pipx.emojis import HAZARD from pipx.fanout import stopping_children_on_signals from pipx.interpreter import ( InterpreterResolutionError, find_python_interpreter, get_default_python, get_default_python_spec, ) from pipx.options import ( BackendOptions, CooldownOptions, ExposureOptions, InjectOptions, InstallOptions, ListOptions, ReinstallOptions, RunOptions, SyncOptions, UninjectOptions, UpgradeOptions, ) from pipx.package_specifier import valid_pypi_name from pipx.redact import redact_url_credentials from pipx.result import OperationError, OperationResult, OutputFormat, error_envelope, render_result from pipx.shared_libs import shared_pip_cooldown, skip_shared_libs_maintenance from pipx.subprocess_runner import stream_subprocess_stdout_to_stderr from pipx.util import PipxError, mkdir, open_owner_only, reject_empty_index from pipx.venv import VenvContainer from pipx.version import version as __version__ from pipx.wrap import pipx_wrap if not WINDOWS: import fcntl if TYPE_CHECKING: from io import TextIOWrapper from pipx.commands.cache import CacheData from pipx.commands.expose import ExposureData from pipx.commands.health import HealthData, RepairData from pipx.commands.inject import InjectionData from pipx.commands.install import InstallData from pipx.commands.interpreter import InterpreterData from pipx.commands.manifest import ManifestData from pipx.commands.outdated import OutdatedData from pipx.commands.pin import PinData from pipx.commands.reinstall import ReinstallData from pipx.commands.reset import ResetData from pipx.commands.uninstall import UninstallData from pipx.commands.upgrade import SharedData, UpgradeData _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) _VenvCompleter = Callable[[str], list[str]] _FORCE_INSTALL_HELP: Final[str] = ( "Modify existing virtual environment and files in PIPX_BIN_DIR and PIPX_MAN_DIR; --python does not change an " "existing environment's Python, use `pipx reinstall --python` for that" ) _IGNORED_ON_EXISTING_VENV: Final[str] = ( " Ignored, since the existing environment keeps the venv options it was installed with; accepted for compatibility." ) def cli() -> ExitCode: try: with stopping_children_on_signals() as signalled: hide_cursor() # a command that finished its work after an interrupt still reports being interrupted return ExitCode(_dispatch(sys.argv[1:]) or int(signalled.is_set())) except PipxError as error: print(str(error), file=sys.stderr) # ruff:ignore[print] # user-facing CLI output _LOGGER.debug("PipxError: %s", error, exc_info=True) return ExitCode(1) except KeyboardInterrupt: return ExitCode(1) except Exception: # record any crash's traceback in the log file before Python prints it _LOGGER.debug("Uncaught Exception:", exc_info=True) raise finally: _LOGGER.debug("pipx finished.") show_cursor() def _dispatch(argv: list[str]) -> ExitCode: parser: Final[argparse.ArgumentParser] = get_command_parser() argcomplete.autocomplete(parser, always_complete_options=False) parsed_pipx_args: Final[argparse.Namespace] = parse_pipx_args(parser, argv) try: _setup(parsed_pipx_args) check_args(parsed_pipx_args) if not parsed_pipx_args.command: parser.print_help() return ExitCode(1) return _run_pipx_command(parsed_pipx_args) except PipxError as error: # with JSON output selected, a failure still has to print the result envelope if _output_format(parsed_pipx_args) is not OutputFormat.JSON: raise print( # ruff:ignore[print] # user-facing CLI output error_envelope( _command_tokens(parsed_pipx_args), OperationError(code="pipx_error", message=str(error)), ExitCode(1) ) ) return ExitCode(1) def build_parser() -> argparse.ArgumentParser: # sphinx-argparse-cli renders the CLI reference from this parser, so the docs cannot drift from the arguments. The # program is named here rather than taken from the docs builder's argv[0]: each subcommand parser copies prog when # it is created, and the directive's :prog: option cannot rewrite it afterwards. return get_command_parser(prog="pipx") def get_command_parser(*, prog: str | None = None) -> argparse.ArgumentParser: completer_venvs: Final[InstalledVenvsCompleter] = InstalledVenvsCompleter(VenvContainer(paths.CTX.venvs)) shared_parser: Final[argparse.ArgumentParser] = argparse.ArgumentParser(add_help=False) shared_parser.add_argument( "--quiet", "-q", action="count", default=0, help=( "Give less output. May be used multiple times corresponding to the" " ERROR and CRITICAL logging levels. The count maxes out at 2." ), ) shared_parser.add_argument( "--verbose", "-v", action="count", default=0, help=( "Give more output. May be used multiple times corresponding to the" " INFO, DEBUG and NOTSET logging levels. The count maxes out at 3." ), ) shared_parser.add_argument( "--skip-maintenance", action="store_true", help="Do not upgrade shared libraries; use bundled pip when creating them.", ) if not constants.WINDOWS: shared_parser.add_argument( "--global", action="store_true", dest="is_global", help="Perform action globally for all users.", ) parser: Final[argparse.ArgumentParser] = argparse.ArgumentParser( prog=prog or prog_name(), formatter_class=_LineWrapRawTextHelpFormatter, description=textwrap.dedent( f""" Install and execute apps from Python packages. Binaries can either be installed globally into isolated Virtual Environments or run directly in a temporary Virtual Environment. Virtual Environment location is {paths.CTX.venvs!s}. Symlinks to apps are placed in {paths.CTX.bin_dir!s}. Symlinks to manual pages are placed in {paths.CTX.man_dir!s}. """ ) + pipx_wrap( "optional environment variables:\n" " PIPX_HOME Overrides default pipx location. " "Virtual Environments will be installed to $PIPX_HOME/venvs.\n" " PIPX_GLOBAL_HOME Used instead of PIPX_HOME when the `--global` option is given.\n" " PIPX_BIN_DIR Overrides location of app installations. Apps are symlinked or copied here.\n" " PIPX_GLOBAL_BIN_DIR Used instead of PIPX_BIN_DIR when the `--global` option is given.\n" " PIPX_MAN_DIR Overrides location of manual pages installations. " "Manual pages are symlinked or copied here.\n" " PIPX_GLOBAL_MAN_DIR Used instead of PIPX_MAN_DIR when the `--global` option is given.\n" " PIPX_COMPLETION_DIR Overrides location of shell completion installations. " "Completion scripts are symlinked or copied here.\n" " PIPX_GLOBAL_COMPLETION_DIR\n" " Used instead of PIPX_COMPLETION_DIR when the `--global` option is given.\n" " PIPX_DEFAULT_PYTHON Overrides default python used for commands.\n" " PIPX_DEFAULT_BACKEND Overrides which backend (`pip` or `uv`) is used for new venvs.\n" " PIPX_COOLDOWN Default `--cooldown` in days for the commands that accept it.\n" " PIPX_DISABLE_SHARED_LIBS_AUTO_UPGRADE\n" " Skips automatic shared library upgrades.\n" " PIPX_USE_EMOJI Overrides emoji behavior. Default value varies based on platform.", subsequent_indent=" " * 24, # match the indent of argparse options keep_newlines=True, ), ) subparsers: Final[argparse._SubParsersAction] = parser.add_subparsers( dest="command", description="Get help for commands with pipx COMMAND --help" ) _add_install(subparsers, shared_parser) _add_install_all(subparsers, shared_parser) _add_uninject(subparsers, completer_venvs.use, shared_parser) _add_inject(subparsers, completer_venvs.use, shared_parser) _add_expose(subparsers, completer_venvs.use, shared_parser) _add_unexpose(subparsers, completer_venvs.use, shared_parser) _add_pin(subparsers, shared_parser) _add_unpin(subparsers, shared_parser) _add_upgrade(subparsers, completer_venvs.use, shared_parser) _add_upgrade_all(subparsers, shared_parser) _add_upgrade_shared(subparsers, shared_parser) _add_uninstall(subparsers, completer_venvs.use, shared_parser) _add_uninstall_all(subparsers, shared_parser) _add_reset(subparsers, shared_parser) _add_reinstall(subparsers, completer_venvs.use, shared_parser) _add_reinstall_all(subparsers, shared_parser) _add_health(subparsers, completer_venvs.use, shared_parser) _add_repair(subparsers, completer_venvs.use, shared_parser) _add_list(subparsers, completer_venvs.use, shared_parser) _add_interpreter(subparsers, shared_parser) _add_cache(subparsers, shared_parser) _add_manifest(subparsers, shared_parser) _add_run(subparsers, shared_parser) _add_execute(subparsers, completer_venvs.use, shared_parser) _add_runpip(subparsers, completer_venvs.use, shared_parser) _add_ensurepath(subparsers, shared_parser) _add_environment(subparsers, shared_parser) parser.add_argument("--version", action="store_true", help="Print version and exit") completions_parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "completions", help="Print instructions on enabling shell completions for pipx", description="Print instructions on enabling shell completions for pipx", parents=[shared_parser], ) completions_parser.set_defaults(func=_cmd_completions) help_parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "help", help="Show help for pipx or a command", description="Show help for pipx or a command", parents=[shared_parser], ) help_parser.set_defaults(func=_make_print_help(parser)) return parser class InstalledVenvsCompleter: def __init__(self, venv_container: VenvContainer) -> None: self._venv_container: Final[VenvContainer] = venv_container self._packages: list[str] | None = None def use(self, prefix: str, **_kwargs: argparse.Action | argparse.ArgumentParser | argparse.Namespace) -> list[str]: if self._packages is None: self._packages = [path.name for path in sorted(self._venv_container.iter_venv_dirs())] canonical_prefix: Final[str] = canonicalize_name(prefix) return [ f"{prefix}{name[len(canonical_prefix) :]}" for name in self._packages if name.startswith(canonical_prefix) ] def prog_name() -> str: with contextlib.suppress(IndexError): prog: Final[str] = Path(sys.argv[0]).name return f"{sys.executable} -m pipx" if prog == "__main__.py" else prog return "pipx" class _LineWrapRawTextHelpFormatter(argparse.RawDescriptionHelpFormatter): def _split_lines(self, text: str, width: int) -> list[str]: return textwrap.wrap(self._whitespace_matcher.sub(" ", text).strip(), width) def _add_install(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "install", help="Install a package", formatter_class=_LineWrapRawTextHelpFormatter, description=textwrap.dedent( f""" The install command is the preferred way to globally install apps from Python packages and PEP 723 scripts. It creates an isolated virtual environment for the source, then ensures its apps are accessible on your $PATH. The package's manual pages installed in share/man/man[1-9] can be viewed with man on an operating system where it is available and the path in the environment variable `PIPX_MAN_DIR` (default: {paths.DEFAULT_PIPX_MAN_DIR}) is in the man search path ($MANPATH). You get apps you can run from anywhere, in packages you can cleanly upgrade or uninstall. Each package gets its own virtual environment, so its dependencies cannot conflict with those of other packages or interfere with your OS's python packages. You do not need 'sudo' for this. pipx install PACKAGE_SPEC ... pipx install --python PYTHON PACKAGE_SPEC pipx install VCS_URL pipx install ./LOCAL_PATH pipx install ./SCRIPT.py pipx install ZIP_FILE pipx install TAR_GZ_FILE Package specs are passed to the selected backend. PEP 723 scripts are installed with their declared dependencies. Virtual Environments will be installed to `$PIPX_HOME/venvs`. The default pipx home location is {paths.DEFAULT_PIPX_HOME} and can be overridden by setting the environment variable `PIPX_HOME`. If the `--global` option is used, the default pipx home location instead is {paths.DEFAULT_PIPX_GLOBAL_HOME} and can be overridden by setting the environment variable `PIPX_GLOBAL_HOME`. The default app location is {paths.DEFAULT_PIPX_BIN_DIR} and can be overridden by setting the environment variable `PIPX_BIN_DIR`. If the `--global` option is used, the default app location instead is {paths.DEFAULT_PIPX_GLOBAL_BIN_DIR} and can be overridden by setting the environment variable `PIPX_GLOBAL_BIN_DIR`. The default manual pages location is {paths.DEFAULT_PIPX_MAN_DIR} and can be overridden by setting the environment variable `PIPX_MAN_DIR`. If the `--global` option is used, the default manual pages location instead is {paths.DEFAULT_PIPX_GLOBAL_MAN_DIR} and can be overridden by setting the environment variable `PIPX_GLOBAL_MAN_DIR`. The default python executable used to install a package is {os.getenv("PIPX__DOC_DEFAULT_PYTHON", get_default_python_spec())} and can be overridden by setting the environment variable `PIPX_DEFAULT_PYTHON`. """ ), parents=[shared_parser], ) parser.add_argument("package_spec", help="package name(s), PEP 723 script(s), or installation spec(s)", nargs="+") _add_dependency_app_options(parser) parser.add_argument( "--force", "-f", action="store_true", help=_FORCE_INSTALL_HELP, ) parser.add_argument( "--upgrade", "-U", action="store_true", help=( "Upgrade or downgrade an existing package when its version does not satisfy the supplied spec, " "removing exposed apps, manual pages, and shell completions the new version no longer ships" ), ) parser.add_argument( "--upgrade-strategy", choices=["only-if-needed", "eager"], help="How dependency upgrades are handled when --upgrade changes an existing package", ) parser.add_argument( "--suffix", default="", help=( "Optional suffix for virtual environment and executable names. It goes at the end of each app name " "(pip3.14 becomes pip3.14_a), before the file extension on Windows (pip3.14_a.exe, hello_a.py)." ), ) _add_python_options(parser) parser.add_argument( "--preinstall", action="append", type=_package_spec, help=( "Optional package to be installed into the Virtual Environment before " "installing the main package. Use this flag multiple times if you want to preinstall multiple packages." ), ) parser.add_argument( "--app", action="append", help="Require this application entry point after installation. Repeat to require multiple entry points.", ) parser.add_argument( "--lock", type=Path, help="Install the environment from an explicit pylock.toml file.", ) _add_pip_venv_args(parser) _add_backend_arg(parser) _add_output_option(parser) parser.set_defaults(func=_cmd_install) def _add_dependency_app_options(parser: argparse.ArgumentParser) -> None: group: Final[argparse._MutuallyExclusiveGroup] = parser.add_mutually_exclusive_group() group.add_argument( "--include-deps", help="Include apps and manual pages from all dependencies", action="store_true", ) group.add_argument( "--include-resources-from", action="append", default=[], metavar="PACKAGE", help="Include apps and manual pages from this dependency. Repeat for multiple dependencies", ) def _add_python_options(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--python", help=( "Python to install with. Possible values can be the executable name (python3.11), " "the version of an available system Python or to pass to py launcher (3.11), " f"or the full path to the executable. Requires Python {MINIMUM_PYTHON_VERSION} or above." ), ) fetch_python_group: Final[argparse._MutuallyExclusiveGroup] = parser.add_mutually_exclusive_group() fetch_python_group.add_argument( "--fetch-python", type=FetchPythonOptions, choices=list(FetchPythonOptions), default=_FETCH_PYTHON, help=( f"When to fetch a standalone Python build from python-build-standalone. " f"'{FetchPythonOptions.ALWAYS}' uses a standalone build even when a system Python matches; " f"'{FetchPythonOptions.MISSING}' downloads only when the requested version is not found locally; " f"'{FetchPythonOptions.NEVER}' never downloads. " "Defaults to PIPX_FETCH_PYTHON, or 'never'." ), ) fetch_python_group.add_argument( "--fetch-missing-python", dest="fetch_python", action=_DeprecatedFetchMissingPython, help="Deprecated: alias for --fetch-python=missing.", ) class _DeprecatedFetchMissingPython(argparse.Action): def __init__(self, option_strings: list[str], dest: str, **kwargs: Any) -> None: # ruff:ignore[any-type] # argparse kwargs super().__init__(option_strings, dest, nargs=0, **kwargs) def __call__( self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, values: str | Sequence[str] | None, option_string: str | None = None, ) -> None: del parser, values if not {"--help", "-h"}.intersection(sys.argv): print( # ruff:ignore[print] # user-facing CLI output f"{HAZARD} {option_string} is deprecated; use --fetch-python={FetchPythonOptions.MISSING} instead.", file=sys.stderr, ) setattr(namespace, self.dest, FetchPythonOptions.MISSING) def _package_spec(value: str) -> str: # pip and uv would read a dash-leading value as one of their own options rather than a package if value.startswith("-"): msg = f"{redact_url_credentials(value)!r} is an installer option; pass it with --pip-args" raise argparse.ArgumentTypeError(msg) return value def _add_pip_venv_args(parser: argparse.ArgumentParser, system_site_packages_note: str = "") -> None: parser.add_argument( "--system-site-packages", action="store_true", help=f"Give the virtual environment access to the system site-packages dir.{system_site_packages_note}", ) parser.add_argument("--index-url", "-i", help="Base URL of Python Package Index") parser.add_argument( "--editable", "-e", help="Install a project in editable mode", action="store_true", ) parser.add_argument( "--pip-args", help=( "Arbitrary pip arguments to pass directly to pip install/upgrade commands. " "Under the uv backend, `pipx run` translates a subset (--index-url/-i, --extra-index-url, " "--find-links/-f, --trusted-host, --no-binary, --only-binary, --pre, --upgrade/-U, " "--no-cache-dir) and rejects other flags; other commands pass them to `uv pip install` unchanged." ), ) parser.add_argument( "--cooldown", type=_non_negative_int, metavar="DAYS", help="Ignore index artifacts uploaded fewer than DAYS days ago. Defaults to PIPX_COOLDOWN; pass 0 to opt out.", ) def _non_negative_int(value: str) -> int: with contextlib.suppress(ValueError): if (days := int(value)) >= 0: return days msg = "--cooldown must be a non-negative integer" raise argparse.ArgumentTypeError(msg) def _add_backend_arg(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--backend", choices=list(KNOWN_BACKENDS), default=None, help=( "Backend used for creating venvs and managing packages. Defaults to " "PIPX_DEFAULT_BACKEND when set, else 'uv' when uv is available " "(via the `pipx[uv]` extra or on PATH), else 'pip'. Commands on an existing venv keep its " "recorded backend and warn when this flag differs; `pipx reinstall` and `pipx reinstall-all` " "rebuild venvs with it." ), ) def _add_output_option(parser: argparse.ArgumentParser, *, snapshot_json: bool = False) -> None: group: Final[argparse._MutuallyExclusiveGroup] = parser.add_mutually_exclusive_group() group.add_argument( "--output", choices=[output.value for output in OutputFormat], default=OutputFormat.HUMAN, help="Select the output format.", ) # only ``list`` keeps a ``--json`` flag: it prints the versioned package snapshot that ``install-all`` reads back, # which predates and differs from the ``--output json`` result envelope if snapshot_json: group.add_argument( "--json", action="store_const", const=OutputFormat.JSON, dest="output", help="Print the installed-package snapshot as JSON, the format install-all consumes.", ) def _cmd_install(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[InstallData]: from pipx.commands.install import install return install( None, None, args.package_spec, InstallOptions( local_bin_dir=paths.CTX.bin_dir, local_man_dir=paths.CTX.man_dir, python=ctx.python, pip_args=ctx.pip_args, venv_args=ctx.venv_args, verbose=ctx.verbose, force=args.force, upgrade=args.upgrade, reinstall=False, exposure=ExposureOptions( include_dependencies=args.include_deps, include_resources_from=args.include_resources_from, suffix=args.suffix, ), preinstall_packages=args.preinstall, expected_apps=args.app or (), lock_file=args.lock, python_flag_passed=ctx.python_flag_passed, fetch_python=ctx.fetch_python, backend=ctx.backend, upgrade_strategy=args.upgrade_strategy, cooldown=ctx.cooldown, emit_output=False, ), ) def _add_install_all(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "install-all", help="Install all packages", formatter_class=_LineWrapRawTextHelpFormatter, description=( "Installs all the packages according to spec metadata file. Installs up to eight environments at once " "and prints the output of each in turn, in file order; --verbose installs them one at a time." ), parents=[shared_parser], ) parser.add_argument("spec_metadata_file", help="Spec metadata file generated from pipx list --output json") parser.add_argument( "--force", "-f", action="store_true", help=_FORCE_INSTALL_HELP, ) _add_python_options(parser) _add_pip_venv_args(parser) _add_backend_arg(parser) parser.set_defaults(func=_cmd_install_all) def _cmd_install_all(args: argparse.Namespace, ctx: _DispatchContext) -> ExitCode: from pipx.commands.install import install_all return install_all( args.spec_metadata_file, InstallOptions( local_bin_dir=paths.CTX.bin_dir, local_man_dir=paths.CTX.man_dir, python=ctx.python, pip_args=ctx.pip_args, venv_args=ctx.venv_args, verbose=ctx.verbose, force=args.force, backend=ctx.backend, cooldown=ctx.cooldown, python_flag_passed=ctx.python_flag_passed, ), ) def _add_uninject( subparsers: argparse._SubParsersAction, venv_completer: _VenvCompleter, shared_parser: argparse.ArgumentParser, ) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "uninject", help="Uninstall injected packages from an existing Virtual Environment", description="Uninstalls injected packages from an existing pipx-managed virtual environment.", parents=[shared_parser], ) _complete_with( parser.add_argument( "package", metavar="ENVIRONMENT", help="Name of the existing pipx-managed environment to uninject from" ), venv_completer, ) parser.add_argument( "dependencies", nargs="+", help="the package names to uninject from the environment", ) parser.add_argument( "--leave-deps", action="store_true", help="Only uninstall the main injected package but leave its dependencies installed.", ) _add_output_option(parser) parser.set_defaults(func=_cmd_uninject) def _complete_with(action: argparse.Action, completer: _VenvCompleter) -> None: # argcomplete reads the completer off the action, an attribute argparse does not declare cast("_CompletableAction", action).completer = completer class _CompletableAction(typing.Protocol): completer: _VenvCompleter def _cmd_uninject(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[InjectionData]: venv_dir: Final[Path] = _venv_dir(args, ctx) with ctx.venv_container.venv_lock(venv_dir): from pipx.commands.uninject import uninject return uninject( venv_dir, args.dependencies, UninjectOptions( local_bin_dir=paths.CTX.bin_dir, local_man_dir=paths.CTX.man_dir, leave_deps=args.leave_deps, verbose=ctx.verbose, ), ) def _venv_dir(args: argparse.Namespace, ctx: _DispatchContext) -> Path: venv_dir: Final[Path] = ctx.venv_container.get_venv_dir(valid_pypi_name(args.package) or args.package) _LOGGER.info("Virtual Environment location is %s", venv_dir) return venv_dir def _add_inject( subparsers: argparse._SubParsersAction, venv_completer: _VenvCompleter, shared_parser: argparse.ArgumentParser, ) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "inject", help="Install packages into an existing Virtual Environment", description="Installs packages to an existing pipx-managed virtual environment.", parents=[shared_parser], ) _complete_with( parser.add_argument( "package", metavar="ENVIRONMENT", help="Name of the existing pipx-managed environment to inject into" ), venv_completer, ) parser.add_argument( "dependencies", nargs="*", help="the packages to inject into the environment--either package name or pip package spec", ) parser.add_argument( "-r", "--requirement", dest="requirements", action="append", default=[], metavar="file", help=( "file containing the packages to inject into the Virtual Environment--" "one package name or pip package spec per line. " "May be specified multiple times." ), ) parser.add_argument( "--include-apps", action="store_true", help="Add apps from the injected packages onto your PATH and expose their manual pages", ) _add_dependency_app_options(parser) _add_pip_venv_args(parser, _IGNORED_ON_EXISTING_VENV) parser.add_argument( "--force", "-f", action="store_true", help="Modify existing virtual environment and files in PIPX_BIN_DIR and PIPX_MAN_DIR", ) parser.add_argument( "--with-suffix", action="store_true", help="Add the suffix (if given) of the Virtual Environment to the packages to inject", ) _add_backend_arg(parser) _add_output_option(parser) parser.set_defaults(func=_cmd_inject) def _cmd_inject(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[InjectionData]: _warn_ignored_venv_args(ctx, "inject") venv_dir: Final[Path] = _venv_dir(args, ctx) with ctx.venv_container.venv_lock(venv_dir): from pipx.commands.inject import inject return inject( venv_dir, args.dependencies, args.requirements, InjectOptions( pip_args=ctx.pip_args, verbose=ctx.verbose, include_apps=args.include_apps, include_dependencies=args.include_deps, include_resources_from=args.include_resources_from, force=args.force, with_suffix=args.with_suffix, backend=ctx.backend, cooldown_days=ctx.cooldown.days, emit_output=False, ), ) def _warn_ignored_venv_args(ctx: _DispatchContext, command: str) -> None: if ctx.venv_args: _LOGGER.warning( "%s Ignoring %s: %s works on existing environments, whose venv options were set by install", HAZARD, ", ".join(ctx.venv_args), command, ) def _add_expose( subparsers: argparse._SubParsersAction, venv_completer: _VenvCompleter, shared_parser: argparse.ArgumentParser, ) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "expose", help="Relink hidden or missing resources of an environment", description="Link each missing app, manual page, and shell completion without rebuilding the environment.", parents=[shared_parser], ) _complete_with(parser.add_argument("package", metavar="ENVIRONMENT", help="Environment to expose"), venv_completer) _add_output_option(parser) parser.set_defaults(func=_cmd_expose) def _cmd_expose(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[ExposureData]: venv_dir: Final[Path] = _venv_dir(args, ctx) with ctx.venv_container.venv_lock(venv_dir): from pipx.commands.expose import expose return expose(venv_dir, paths.CTX.bin_dir, paths.CTX.man_dir, verbose=ctx.verbose) def _add_unexpose( subparsers: argparse._SubParsersAction, venv_completer: _VenvCompleter, shared_parser: argparse.ArgumentParser, ) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "unexpose", help="Hide resources without removing an environment", description="Remove global links or copies while retaining the managed environment.", parents=[shared_parser], ) _complete_with(parser.add_argument("package", metavar="ENVIRONMENT", help="Environment to hide"), venv_completer) _add_output_option(parser) parser.set_defaults(func=_cmd_unexpose) def _cmd_unexpose(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[ExposureData]: venv_dir: Final[Path] = _venv_dir(args, ctx) with ctx.venv_container.venv_lock(venv_dir): from pipx.commands.expose import unexpose return unexpose(venv_dir, paths.CTX.bin_dir, paths.CTX.man_dir, verbose=ctx.verbose) def _add_pin(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "pin", help="Pin the specified package to prevent it from being upgraded", description="Pin the specified package to prevent it from being upgraded", parents=[shared_parser], ) parser.add_argument("package", metavar="ENVIRONMENT", help="Environment to pin") parser.add_argument( "--injected-only", action="store_true", help=( "Pin injected packages in venv only, so that they will not be upgraded during upgrade operations. " "Note that this should not be passed if you wish to pin both main package and injected packages." ), ) parser.add_argument( "--skip", nargs="+", default=[], help="Skip these packages. Implies `--injected-only`.", ) _add_output_option(parser) parser.set_defaults(func=_cmd_pin) def _cmd_pin(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[PinData]: venv_dir: Final[Path] = _venv_dir(args, ctx) with ctx.venv_container.venv_lock(venv_dir): from pipx.commands.pin import pin return pin(venv_dir, ctx.skip_list, verbose=ctx.verbose, injected_only=args.injected_only) def _add_unpin(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "unpin", help="Unpin the specified package", description="Unpin the specified package and all injected packages in its venv to allow them to be upgraded", parents=[shared_parser], ) parser.add_argument("package", metavar="ENVIRONMENT", help="Environment to unpin") _add_output_option(parser) parser.set_defaults(func=_cmd_unpin) def _cmd_unpin(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[PinData]: venv_dir: Final[Path] = _venv_dir(args, ctx) with ctx.venv_container.venv_lock(venv_dir): from pipx.commands.pin import unpin return unpin(venv_dir, verbose=ctx.verbose) def _add_upgrade( subparsers: argparse._SubParsersAction, venv_completer: _VenvCompleter, shared_parser: argparse.ArgumentParser, ) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "upgrade", help="Upgrade a package", description=( "Upgrade package(s) in pipx-managed Virtual Environment(s) by running 'pip install --upgrade PACKAGE' " "('uv pip install --upgrade PACKAGE' under the uv backend). " "Apps, manual pages, and shell completions the new version adds are exposed, and those it no longer " "ships are removed." ), parents=[shared_parser], ) _complete_with(parser.add_argument("packages", help="names of the packages to upgrade", nargs="+"), venv_completer) parser.add_argument( "--include-injected", action="store_true", help="Also upgrade packages injected into the main app's environment", ) parser.add_argument( "--force", "-f", action="store_true", help="Modify existing virtual environment and files in PIPX_BIN_DIR and PIPX_MAN_DIR", ) _add_pip_venv_args(parser, " Only used with --install.") parser.add_argument( "--install", action="store_true", help="Install package spec if missing", ) _add_python_options(parser) _add_backend_arg(parser) _add_output_option(parser) parser.set_defaults(func=_cmd_upgrade) def _cmd_upgrade(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[UpgradeData]: from pipx.commands.upgrade import upgrade return upgrade( _venv_dirs(args, ctx), UpgradeOptions( python=ctx.python, pip_args=ctx.pip_args, venv_args=ctx.venv_args, verbose=ctx.verbose, include_injected=args.include_injected, force=args.force, install=args.install, python_flag_passed=ctx.python_flag_passed, backend=ctx.backend, cooldown=ctx.cooldown, ), ) def _venv_dirs(args: argparse.Namespace, ctx: _DispatchContext) -> dict[str, Path]: venv_dirs: Final[dict[str, Path]] = { pkg: ctx.venv_container.get_venv_dir(valid_pypi_name(pkg) or pkg) for pkg in args.packages } _LOGGER.info( "Virtual Environment locations are:\n%s", "\n".join(f"- {key} : {value}" for key, value in venv_dirs.items()) ) return venv_dirs def _add_upgrade_all(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "upgrade-all", help="Upgrade all packages.", description=( "Upgrades all packages within their virtual environments by running 'pip install --upgrade PACKAGE' " "('uv pip install --upgrade PACKAGE' under the uv backend). " "Apps, manual pages, and shell completions a new version adds are exposed, and those it no longer " "ships are removed. Upgrades up to eight environments at once and prints the output of each in turn; " "--verbose upgrades them one at a time." ), parents=[shared_parser], ) parser.add_argument( "--include-injected", action="store_true", help="Also upgrade packages injected into the main app's environment", ) parser.add_argument("--skip", nargs="+", default=[], help="skip these packages") parser.add_argument( "--force", "-f", action="store_true", help="Modify existing virtual environment and files in PIPX_BIN_DIR and PIPX_MAN_DIR", ) _add_pip_venv_args(parser, _IGNORED_ON_EXISTING_VENV) _add_backend_arg(parser) _add_output_option(parser) parser.set_defaults(func=_cmd_upgrade_all) def _cmd_upgrade_all(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[UpgradeData]: _warn_ignored_venv_args(ctx, "upgrade-all") from pipx.commands.upgrade import upgrade_all return upgrade_all( ctx.venv_container, UpgradeOptions( verbose=ctx.verbose, include_injected=args.include_injected, skip=ctx.skip_list, force=args.force, pip_args=ctx.pip_args, python_flag_passed=ctx.python_flag_passed, backend=ctx.backend, cooldown=ctx.cooldown, ), ) def _add_upgrade_shared(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "upgrade-shared", help="Upgrade shared libraries.", description="Upgrade shared libraries now. Other commands refresh them once they are over 30 days old.", parents=[shared_parser], ) parser.add_argument( "--pip-args", help="Arbitrary pip arguments to pass directly to pip install/upgrade commands", ) _add_output_option(parser) parser.set_defaults(func=_cmd_upgrade_shared) def _cmd_upgrade_shared(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[SharedData]: del args from pipx.commands.upgrade import upgrade_shared return upgrade_shared(ctx.pip_args, verbose=ctx.verbose) def _add_uninstall( subparsers: argparse._SubParsersAction, venv_completer: _VenvCompleter, shared_parser: argparse.ArgumentParser, ) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "uninstall", help="Uninstall a package", description=( "Uninstalls a pipx-managed Virtual Environment by deleting it and any files that point to its apps." ), parents=[shared_parser], ) _complete_with(parser.add_argument("package", metavar="ENVIRONMENT"), venv_completer) _add_output_option(parser) parser.set_defaults(func=_cmd_uninstall) def _cmd_uninstall(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[UninstallData]: venv_dir: Final[Path] = _venv_dir(args, ctx) with ctx.venv_container.venv_lock(venv_dir): from pipx.commands.uninstall import uninstall return uninstall(venv_dir, paths.CTX.bin_dir, paths.CTX.man_dir, verbose=ctx.verbose) def _add_uninstall_all(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "uninstall-all", help="Uninstall all packages", description="Uninstall all pipx-managed packages", parents=[shared_parser], ) _add_output_option(parser) parser.set_defaults(func=_cmd_uninstall_all) def _cmd_uninstall_all(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[UninstallData]: del args from pipx.commands.uninstall import uninstall_all return uninstall_all(ctx.venv_container, paths.CTX.bin_dir, paths.CTX.man_dir, verbose=ctx.verbose) def _add_reset(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "reset", help="Return pipx to a fresh install", description=( "Uninstall every pipx-managed package and remove the shared libraries, the caches, the standalone " "interpreters, the logs and the trash, which returns pipx to the state it had when it was installed." ), parents=[shared_parser], ) parser.add_argument("--yes", "-y", action="store_true", help="Reset without asking for confirmation") parser.add_argument("--dry-run", action="store_true", help="Report what a reset would remove and stop") _add_output_option(parser) parser.set_defaults(func=_cmd_reset) def _cmd_reset(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[ResetData]: if not args.dry_run and not args.yes and not _confirmed_reset(_output_format(args)): msg = "Reset cancelled." raise PipxError(msg) from pipx.commands.reset import reset return reset( ctx.venv_container, paths.CTX.bin_dir, paths.CTX.man_dir, verbose=ctx.verbose, dry_run=args.dry_run, ) def _confirmed_reset(output: OutputFormat) -> bool: if not sys.stdin or not sys.stdin.isatty(): msg = "Pass --yes to reset when no terminal can confirm it." raise PipxError(msg) prompt: Final[str] = f"Remove every pipx-installed package and reset pipx's data under {paths.CTX.home}? [y/N] " if output is OutputFormat.JSON: print(prompt, end="", file=sys.stderr) # ruff:ignore[print] # user-facing CLI output try: answer: Final[str] = input("" if output is OutputFormat.JSON else prompt) except EOFError: return False return answer.strip().casefold() in {"y", "yes"} def _output_format(args: argparse.Namespace) -> OutputFormat: return OutputFormat(getattr(args, "output", OutputFormat.HUMAN)) def _add_reinstall( subparsers: argparse._SubParsersAction, venv_completer: _VenvCompleter, shared_parser: argparse.ArgumentParser, ) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "reinstall", formatter_class=_LineWrapRawTextHelpFormatter, help="Reinstall a package", description=textwrap.dedent( """ Reinstalls a package. Package is uninstalled, then installed with pipx install PACKAGE with the same options used in the original install of PACKAGE. """ ), parents=[shared_parser], ) _complete_with(parser.add_argument("package", metavar="ENVIRONMENT"), venv_completer) _add_python_options(parser) _add_backend_arg(parser) _add_output_option(parser) parser.set_defaults(func=_cmd_reinstall) def _cmd_reinstall(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[ReinstallData]: venv_dir: Final[Path] = _venv_dir(args, ctx) with ctx.venv_container.venv_lock(venv_dir) as venv_lock: from pipx.commands.reinstall import reinstall return reinstall(venv_dir, _reinstall_options(ctx), venv_lock=venv_lock) def _reinstall_options(ctx: _DispatchContext) -> ReinstallOptions: return ReinstallOptions( local_bin_dir=paths.CTX.bin_dir, local_man_dir=paths.CTX.man_dir, python=ctx.python, verbose=ctx.verbose, python_flag_passed=ctx.python_flag_passed, backend=ctx.backend, skip=ctx.skip_list, ) def _add_reinstall_all(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "reinstall-all", formatter_class=_LineWrapRawTextHelpFormatter, help="Reinstall all packages", description=textwrap.dedent( """ Reinstalls all packages. Packages are uninstalled, then installed with pipx install PACKAGE with the same options used in the original install of PACKAGE. Run it after upgrading Python to move all your packages to the new version. Rebuilds up to eight environments at once and prints the output of each in turn; --verbose rebuilds them one at a time. """ ), parents=[shared_parser], ) _add_python_options(parser) parser.add_argument("--skip", nargs="+", default=[], help="skip these packages") _add_backend_arg(parser) _add_output_option(parser) parser.set_defaults(func=_cmd_reinstall_all) def _cmd_reinstall_all(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[ReinstallData]: del args from pipx.commands.reinstall import reinstall_all return reinstall_all(ctx.venv_container, _reinstall_options(ctx)) def _add_health( subparsers: argparse._SubParsersAction, venv_completer: _VenvCompleter, shared_parser: argparse.ArgumentParser, ) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "health", help="Check installed package environments", description="Check whether installed package environments can run their Python interpreter.", parents=[shared_parser], ) _complete_with(parser.add_argument("packages", nargs="*", help="Installed packages to check"), venv_completer) _add_output_option(parser) parser.set_defaults(func=_cmd_health) def _cmd_health(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[HealthData]: from pipx.commands.health import health return health(ctx.venv_container, _selected_venv_dirs(args, ctx)) def _selected_venv_dirs(args: argparse.Namespace, ctx: _DispatchContext) -> tuple[Path, ...]: if args.packages: # spellings such as "black" and "Black" resolve to one venv, so drop the repeats return tuple(dict.fromkeys(_venv_dirs(args, ctx).values())) return tuple(sorted(ctx.venv_container.iter_venv_dirs())) def _add_repair( subparsers: argparse._SubParsersAction, venv_completer: _VenvCompleter, shared_parser: argparse.ArgumentParser, ) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "repair", help="Repair broken package environments", description="Reinstall packages whose environments cannot run their Python interpreter.", parents=[shared_parser], ) _complete_with(parser.add_argument("packages", nargs="*", help="Installed packages to repair"), venv_completer) _add_python_options(parser) _add_backend_arg(parser) _add_output_option(parser) parser.set_defaults(func=_cmd_repair) def _cmd_repair(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[RepairData]: from pipx.commands.health import repair return repair(ctx.venv_container, _selected_venv_dirs(args, ctx), _reinstall_options(ctx)) def _add_list( subparsers: argparse._SubParsersAction, venv_completer: _VenvCompleter, shared_parser: argparse.ArgumentParser, ) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "list", help="List installed packages", description="List packages and apps installed with pipx", parents=[shared_parser], ) parser.add_argument( "--include-injected", action="store_true", help="Show packages injected into the main app's environment", ) parser.add_argument("--outdated", action="store_true", help="List packages with an available upgrade.") format_group: Final[argparse._MutuallyExclusiveGroup] = parser.add_mutually_exclusive_group() format_group.add_argument("--short", action="store_true", help="List packages only.") format_group.add_argument( "--pinned", action="store_true", help=( "List pinned packages only. Pass --include-injected at the same time to list injected packages " "that were pinned." ), ) _add_output_option(parser, snapshot_json=True) _complete_with(parser.add_argument("packages", nargs="*", help="Installed packages to list"), venv_completer) parser.set_defaults(func=_cmd_list) def _cmd_list(args: argparse.Namespace, ctx: _DispatchContext) -> ExitCode | OperationResult[OutdatedData]: venv_dirs: Final[tuple[Path, ...]] = _installed_venv_dirs(args, ctx) if args.outdated: if args.short or args.pinned: msg = "--outdated cannot be combined with --short or --pinned." raise PipxError(msg) from pipx.commands.outdated import list_outdated return list_outdated(ctx.venv_container, venv_dirs, include_injected=args.include_injected) output: Final[OutputFormat] = _output_format(args) if output is OutputFormat.JSON and (args.short or args.pinned): msg = "--output json cannot be combined with --short or --pinned." raise PipxError(msg) from pipx.commands.list_packages import list_packages return list_packages( ctx.venv_container, venv_dirs, ListOptions( include_injected=args.include_injected, json_format=output is OutputFormat.JSON, short_format=args.short, pinned_only=args.pinned, ), ) def _installed_venv_dirs(args: argparse.Namespace, ctx: _DispatchContext) -> tuple[Path, ...]: venv_dirs: Final[tuple[Path, ...]] = _selected_venv_dirs(args, ctx) for venv_dir in venv_dirs: if not venv_dir.is_dir(): name: Final[str] = venv_dir.name msg = f"pipx has no venv for {name!r}; run 'pipx list' to see the installed packages" raise PipxError(msg) return venv_dirs def _add_interpreter(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "interpreter", help="Interact with interpreters managed by pipx", description="Interact with interpreters managed by pipx", ) subcommands: Final[argparse._SubParsersAction] = parser.add_subparsers( title="subcommands", description="Get help for commands with pipx interpreter COMMAND --help", dest="interpreter_command", ) list_parser: Final[argparse.ArgumentParser] = subcommands.add_parser( "list", help="List available interpreters", description="List available interpreters", parents=[shared_parser], ) prune_parser: Final[argparse.ArgumentParser] = subcommands.add_parser( "prune", help="Prune unused interpreters", description="Prune unused interpreters", parents=[shared_parser], ) upgrade_parser: Final[argparse.ArgumentParser] = subcommands.add_parser( "upgrade", help="Upgrade installed interpreters to the latest available micro/patch version", description="Upgrade installed interpreters to the latest available micro/patch version", parents=[shared_parser], ) _add_output_option(list_parser) list_parser.set_defaults(func=_cmd_interpreter_list) _add_output_option(prune_parser) prune_parser.set_defaults(func=_cmd_interpreter_prune) _add_output_option(upgrade_parser) upgrade_parser.set_defaults(func=_cmd_interpreter_upgrade) parser.set_defaults(func=_make_print_help(parser)) def _cmd_interpreter_list(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[InterpreterData]: del args from pipx.commands.interpreter import list_interpreters return list_interpreters(ctx.venv_container) def _cmd_interpreter_prune(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[InterpreterData]: del args from pipx.commands.interpreter import prune_interpreters return prune_interpreters(ctx.venv_container) def _cmd_interpreter_upgrade(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[InterpreterData]: del args from pipx.commands.interpreter import upgrade_interpreters return upgrade_interpreters(ctx.venv_container, verbose=ctx.verbose) def _make_print_help( target_parser: argparse.ArgumentParser, ) -> Callable[[argparse.Namespace, _DispatchContext], ExitCode]: def _print_help(args: argparse.Namespace, ctx: _DispatchContext) -> ExitCode: del args, ctx target_parser.print_help() return EXIT_CODE_OK return _print_help def _add_cache(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "cache", help="Manage cached run environments", description="Manage cached run environments", ) subcommands: Final[argparse._SubParsersAction] = parser.add_subparsers( title="subcommands", description="Get help for commands with pipx cache COMMAND --help", dest="cache_command", ) dir_parser: Final[argparse.ArgumentParser] = subcommands.add_parser( "dir", help="Show the cache directory", description="Show the cache directory", parents=[shared_parser], ) purge_parser: Final[argparse.ArgumentParser] = subcommands.add_parser( "purge", help="Remove cached run environments", description="Remove cached run environments", parents=[shared_parser], ) _add_output_option(dir_parser) dir_parser.set_defaults(func=_cmd_cache_dir) _add_output_option(purge_parser) purge_parser.set_defaults(func=_cmd_cache_purge) parser.set_defaults(func=_make_print_help(parser)) def _cmd_cache_dir(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[CacheData]: del args, ctx from pipx.commands.cache import print_cache_dir return print_cache_dir(VenvContainer(paths.CTX.venv_cache)) def _cmd_cache_purge(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[CacheData]: del args, ctx from pipx.commands.cache import purge_cache return purge_cache(VenvContainer(paths.CTX.venv_cache)) def _add_manifest(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "manifest", help="Manage tools declared in an explicit manifest", description="Manage tools declared in an explicit manifest", ) subcommands: Final[argparse._SubParsersAction] = parser.add_subparsers( title="subcommands", description="Get help for commands with pipx manifest COMMAND --help", dest="manifest_command", ) lock_parser: Final[argparse.ArgumentParser] = subcommands.add_parser( "lock", help="Resolve a tool manifest with nab", description="Resolve each locked tool in an explicit manifest into a separate PEP 751 lock file.", parents=[shared_parser], ) lock_parser.add_argument("manifest", type=Path, help="Path to the tool manifest.") _add_output_option(lock_parser) lock_parser.set_defaults(func=_cmd_lock) sync_parser: Final[argparse.ArgumentParser] = subcommands.add_parser( "sync", help="Apply a tool manifest", description="Install, upgrade, or downgrade the tools declared in an explicit manifest.", parents=[shared_parser], ) sync_parser.add_argument("manifest", type=Path, help="Path to the tool manifest.") sync_parser.add_argument("--prune", action="store_true", help="Uninstall environments absent from the manifest.") _add_backend_arg(sync_parser) _add_output_option(sync_parser) sync_parser.set_defaults(func=_cmd_sync) parser.set_defaults(func=_make_print_help(parser)) def _cmd_lock(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[ManifestData]: del ctx from pipx.commands.manifest import lock_manifest return lock_manifest(args.manifest) def _cmd_sync(args: argparse.Namespace, ctx: _DispatchContext) -> OperationResult[ManifestData]: from pipx.commands.manifest import sync_manifest return sync_manifest( args.manifest, ctx.venv_container, SyncOptions( local_bin_dir=paths.CTX.bin_dir, local_man_dir=paths.CTX.man_dir, python=ctx.python, verbose=ctx.verbose, prune=args.prune, backend=ctx.backend, ), ) def _add_run(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "run", formatter_class=_RunHelpFormatter, help=( "Download the latest version of a package to a temporary virtual environment, " "then run an app from it. Also compatible with local `__pypackages__` " "directory (experimental)." ), description=textwrap.dedent( f""" Download the latest version of a package to a temporary virtual environment, then run an app from it. The environment will be cached and reused for up to {constants.TEMP_VENV_EXPIRATION_THRESHOLD_DAYS} days. This means subsequent calls to 'run' for the same package will be faster since they can reuse the cached Virtual Environment. In support of PEP 582 'run' will use apps found in a local __pypackages__ directory, if present. This behavior is experimental and is a companion tool to pythonloc. pipx may change or remove it in a future release. See https://github.com/cs01/pythonloc. """ ), parents=[shared_parser], ) cache_group: Final[argparse._MutuallyExclusiveGroup] = parser.add_mutually_exclusive_group() cache_group.add_argument( "--no-cache", action="store_true", help="Do not reuse cached virtual environment if it exists", ) cache_group.add_argument( "--refresh", action="store_true", help="Rebuild and cache the virtual environment", ) parser.add_argument( "--no-path-check", action="store_true", help="Do not check whether the app is already on PATH", ) parser.add_argument( "--python-args", metavar="ARGS", type=_split_python_args, default=[], help="Arguments to pass to the Python interpreter that runs the application", ) parser.add_argument( "app_with_args", metavar="app ...", nargs=argparse.REMAINDER, help="app/package name and any arguments to be passed to it", default=[], ) parser.add_argument("--path", action="store_true", help="Interpret app name as a local path") parser.add_argument( "--pypackages", action="store_true", help="Require app to be run from local __pypackages__ directory", ) parser.add_argument( "--with", dest="with_", action="append", type=_package_spec, default=[], help="Extra dependencies to add to the temporary environment", ) parser.add_argument( "--spec", help=textwrap.dedent( """\ The package name or specific installation source to install the app from. For example `--spec mypackage==2.0.0` or `--spec git+https://github.com/user/repo.git@branch` """ ), ) _add_python_options(parser) _add_pip_venv_args(parser, " Not supported under the uv backend.") _add_backend_arg(parser) parser.set_defaults(subparser=parser, func=_cmd_run) class _RunHelpFormatter(_LineWrapRawTextHelpFormatter): def _format_args(self, action: argparse.Action, default_metavar: str) -> str: # argparse renders a REMAINDER positional as a bare "..." in the usage line; its metavar names the required app if action.nargs == argparse.REMAINDER: return self._metavar_formatter(action, default_metavar)(1)[0] return super()._format_args(action, default_metavar) def _split_python_args(python_args: str) -> list[str]: try: return shlex.split(python_args, posix=not WINDOWS) except ValueError as exc: msg = f"invalid Python arguments: {exc}" raise argparse.ArgumentTypeError(msg) from exc def _cmd_run(args: argparse.Namespace, ctx: _DispatchContext) -> NoReturn: from pipx.commands.run import run run( args.app_with_args[0], RunOptions( dependencies=args.with_, app_args=args.app_with_args[1:], python=ctx.python, pip_args=ctx.pip_args, venv_args=ctx.venv_args, is_path=args.path, pypackages=args.pypackages, verbose=ctx.verbose, use_cache=not args.no_cache, refresh=args.refresh, no_path_check=args.no_path_check, backend=ctx.backend, cooldown=ctx.cooldown, python_args=args.python_args, python_flag_passed=ctx.python_flag_passed, fetch_python=ctx.fetch_python, ), spec=ctx.spec, ) def _add_execute( subparsers: argparse._SubParsersAction, venv_completer: _VenvCompleter, shared_parser: argparse.ArgumentParser, ) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "exec", help="Run an application from an existing pipx environment", description="Run an application from an existing pipx environment", parents=[shared_parser], ) _complete_with( parser.add_argument("package", metavar="ENVIRONMENT", help="Name of the existing pipx environment"), venv_completer, ) parser.add_argument("app", metavar="APP", help="Application to run") parser.add_argument( "app_args", nargs=argparse.REMAINDER, default=[], help="Arguments to pass to the application", ) parser.set_defaults(func=_cmd_execute) def _cmd_execute(args: argparse.Namespace, ctx: _DispatchContext) -> NoReturn: venv_dir: Final[Path] = _venv_dir(args, ctx) with ctx.venv_container.venv_lock(venv_dir): from pipx.commands.execute import execute execute(args.package, venv_dir, args.app, args.app_args) def _add_runpip( subparsers: argparse._SubParsersAction, venv_completer: _VenvCompleter, shared_parser: argparse.ArgumentParser, ) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "runpip", help="Run pip, or `uv pip` under the uv backend, in an existing pipx-managed Virtual Environment", description="Run pip, or `uv pip` under the uv backend, in an existing pipx-managed Virtual Environment", parents=[shared_parser], ) _complete_with( parser.add_argument("package", help="Name of the existing pipx-managed Virtual Environment to run pip in"), venv_completer, ) parser.add_argument( "pipargs", nargs=argparse.REMAINDER, default=[], help="Arguments to forward to pip command", ) parser.set_defaults(func=_cmd_runpip) def _cmd_runpip(args: argparse.Namespace, ctx: _DispatchContext) -> ExitCode: venv_dir: Final[Path] = _venv_dir(args, ctx) with ctx.venv_container.venv_lock(venv_dir): from pipx.commands.run_pip import run_pip return run_pip(args.package, venv_dir, _split_runpip_args(args.pipargs), verbose=ctx.verbose) def _split_runpip_args(pip_args: list[str]) -> list[str]: # a lone argument holding whitespace is a quoted command line, as in pipx runpip black "install black" if len(pip_args) != 1 or not any(char.isspace() for char in pip_args[0]): return pip_args return shlex.split(pip_args[0], posix=not WINDOWS) or pip_args def _add_ensurepath(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "ensurepath", help=("Ensure directories necessary for pipx operation are in your PATH environment variable."), description=( "Ensure directory where pipx stores apps is in your " "PATH environment variable. Also if pipx was installed via " "`pip install --user`, ensure pipx itself is in your PATH. " "This command may modify your shell configuration, such as '~/.bashrc', " "or the system PATH configuration when used with `--global`." ), parents=[shared_parser], ) parser.add_argument( "--prepend", action="store_true", help="Prepend directories to your PATH instead of appending. Use it to put pipx apps ahead of system apps.", ) parser.add_argument( "--force", "-f", action="store_true", help=( "Add text to your shell's config file even if it looks like your " "PATH already contains paths to pipx and pipx-install apps. With --global, " "also replace a system PATH configuration file that holds content pipx did not write." ), ) parser.add_argument( "--all-shells", action="store_true", help=("Add directories to PATH in all shells instead of just the current one."), ) parser.add_argument( "--dry-run", action="store_true", help=("Report what would be added to PATH without modifying PATH or any shell configuration file."), ) parser.set_defaults(func=_cmd_ensurepath) def _cmd_ensurepath(args: argparse.Namespace, ctx: _DispatchContext) -> ExitCode: del ctx try: from pipx.commands.ensure_path import ensure_pipx_paths return ensure_pipx_paths( prepend=args.prepend, force=args.force, all_shells=args.all_shells, dry_run=args.dry_run, is_global=getattr(args, "is_global", False), ) except Exception as error: # report any failure editing shell or system PATH config as a message, not a traceback _LOGGER.debug("Uncaught Exception:", exc_info=True) raise PipxError(str(error), wrap_message=False) from None def _add_environment(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: parser: Final[argparse.ArgumentParser] = subparsers.add_parser( "environment", formatter_class=_LineWrapRawTextHelpFormatter, help="Print a list of environment variables and paths used by pipx.", description=textwrap.dedent( """ Prints the names and current values of environment variables used by pipx, followed by internal pipx variables which are derived from the environment variables and platform specific default values. Available variables: """ ) + textwrap.fill(", ".join(ENVIRONMENT_VARIABLES), break_long_words=False), parents=[shared_parser], ) parser.add_argument( "--value", "-V", choices=ENVIRONMENT_VALUE_CHOICES, metavar="VARIABLE", help="Print the value of the variable.", ) parser.set_defaults(func=_cmd_environment) def _cmd_environment(args: argparse.Namespace, ctx: _DispatchContext) -> ExitCode: del ctx from pipx.commands.environment import environment return environment(value=args.value) _COMPLETION_INSTRUCTIONS: Final[str] = """ If your shell reports register-python-argcomplete as not found, or you run pipx as a zipapp, first run pipx install argcomplete Add the command for your shell to its config file so the shell runs it on startup. Then open a new shell, or log in again, for autocompletion to start working. bash: eval "$(register-python-argcomplete pipx)" zsh: To activate completions in zsh, first mark compinit for autoload and run it: autoload -U compinit && compinit Then enable completions for pipx: eval "$(register-python-argcomplete pipx)" With argcomplete earlier than v3, also enable bashcompinit in zsh: autoload -U bashcompinit bashcompinit tcsh: eval `register-python-argcomplete --shell tcsh pipx` fish: # Run this once; it does not belong in the config file register-python-argcomplete --shell fish pipx >~/.config/fish/completions/pipx.fish """ def _cmd_completions(args: argparse.Namespace, ctx: _DispatchContext) -> ExitCode: del args, ctx print(textwrap.dedent(_COMPLETION_INSTRUCTIONS)) # ruff:ignore[print] # user-facing CLI output return ExitCode(0) def parse_pipx_args(parser: argparse.ArgumentParser, args: list[str]) -> argparse.Namespace: if args[:1] == ["help"]: args = [*args[1:], "--help"] if args[:1] == ["inject"]: parsed_args: Final[argparse.Namespace] = _get_subparser(parser, "inject").parse_intermixed_args(args[1:]) parsed_args.command = "inject" return parsed_args return parser.parse_args(args) def _get_subparser(parser: argparse.ArgumentParser, command: str) -> argparse.ArgumentParser: return next( action for action in parser._actions # ruff:ignore[private-member-access] # no public API for subparsers if isinstance(action, argparse._SubParsersAction) # ruff:ignore[private-member-access] # no public API for subparsers ).choices[command] def _setup(args: argparse.Namespace) -> None: _validate_fetch_python() _validate_cooldown() _validate_max_logs() if "version" in args and args.version: print(__version__) # ruff:ignore[print] # user-facing CLI output sys.exit(0) if is_global := not constants.WINDOWS and getattr(args, "is_global", False): paths.CTX.make_global() verbose: Final[int] = ( -2 if _output_format(args) is OutputFormat.JSON else getattr(args, "verbose", 0) - getattr(args, "quiet", 0) ) _setup_logging(verbose) paths.CTX.log_warnings() _LOGGER.debug("%s", time.strftime("%Y-%m-%d %H:%M:%S")) _LOGGER.debug("%s", " ".join(map(redact_url_credentials, sys.argv))) _LOGGER.info("pipx version is %s", __version__) _LOGGER.info("Default python interpreter is '%s'", get_default_python()) # these only read paths, so they also work for users who cannot write the (global) home if args.command not in {"environment", "list"}: _create_directories(is_global=is_global) empty_trash() old_pipx_venv_location: Final[Path] = paths.CTX.venvs / "pipx-app" if old_pipx_venv_location.exists(): _LOGGER.warning( pipx_wrap( f""" {HAZARD} A virtual environment for pipx was detected at {old_pipx_venv_location!s}. The 'pipx-app' package has been renamed back to 'pipx' (https://github.com/pypa/pipx/issues/82). """, subsequent_indent=" " * 4, ) ) _FETCH_MISSING_PYTHON_RAW: Final[str | None] = os.environ.get("PIPX_FETCH_MISSING_PYTHON") _FETCH_PYTHON_RAW: Final[str | None] = os.environ.get("PIPX_FETCH_PYTHON") def _compute_fetch_python(missing_raw: str | None, python_raw: str | None) -> tuple[FetchPythonOptions, bool]: fetch_missing: Final[bool] = (missing_raw or "").strip().lower() not in {"", "0", "false", "f", "no", "n", "off"} raw: Final[str] = python_raw or ( FetchPythonOptions.MISSING.value if fetch_missing else FetchPythonOptions.NEVER.value ) try: return FetchPythonOptions(raw), False except ValueError: return FetchPythonOptions.NEVER, True _FETCH_PYTHON, _FETCH_PYTHON_INVALID = _compute_fetch_python(_FETCH_MISSING_PYTHON_RAW, _FETCH_PYTHON_RAW) _COOLDOWN_RAW: Final[str | None] = os.environ.get("PIPX_COOLDOWN") def _compute_cooldown(raw: str | None) -> tuple[int | None, bool]: if raw is None or not (stripped := raw.strip()): return None, False try: days: Final[int] = int(stripped) except ValueError: return None, True return (days, False) if days >= 0 else (None, True) _COOLDOWN, _COOLDOWN_INVALID = _compute_cooldown(_COOLDOWN_RAW) _MAX_LOGS_RAW: Final[str | None] = os.environ.get("PIPX_MAX_LOGS") def _compute_max_logs(raw: str | None) -> tuple[int, bool]: if raw is None or not (stripped := raw.strip()): return 10, False try: logs: Final[int] = int(stripped) except ValueError: return 10, True return (logs, False) if logs >= 0 else (10, True) _MAX_LOGS_INVALID: Final[bool] = _compute_max_logs(_MAX_LOGS_RAW)[1] def _validate_fetch_python() -> None: if _FETCH_PYTHON_INVALID: msg = f"PIPX_FETCH_PYTHON must be unset or one of: {', '.join(str(option) for option in FetchPythonOptions)}." raise PipxError(msg) if _FETCH_MISSING_PYTHON_RAW is not None: if _FETCH_PYTHON_RAW is not None: msg = "Setting both PIPX_FETCH_MISSING_PYTHON and PIPX_FETCH_PYTHON is invalid." raise PipxError(msg) print( # ruff:ignore[print] # user-facing CLI output f"{HAZARD} PIPX_FETCH_MISSING_PYTHON is deprecated; " f'use PIPX_FETCH_PYTHON="{FetchPythonOptions.MISSING}" instead.', file=sys.stderr, ) def _validate_cooldown() -> None: if _COOLDOWN_INVALID: msg = f"PIPX_COOLDOWN must be unset or a non-negative integer, got {_COOLDOWN_RAW!r}." raise PipxError(msg) def _validate_max_logs() -> None: if _MAX_LOGS_INVALID: msg = f"PIPX_MAX_LOGS must be unset or a non-negative integer, got {_MAX_LOGS_RAW!r}." raise PipxError(msg) def _setup_logging(verbose: int) -> None: pipx_str: Final[str] = BOLD(GREEN("pipx >")) if sys.stdout and sys.stdout.isatty() else "pipx >" log_stream: Final[TextIOWrapper] = _setup_log_file() paths.CTX.log_file = Path(log_stream.name) # configured by hand because importing logging.config pulls socket and pickle into every startup stream_handler: Final[logging.StreamHandler[TextIO]] = logging.StreamHandler() stream_handler.setLevel(min(max(0, logging.WARNING - 10 * verbose), 50)) stream_handler.setFormatter( logging.Formatter(f"{pipx_str}({{funcName}}:{{lineno}}): {{message}}" if verbose else "{message}", style="{") ) file_handler: Final[logging.FileHandler] = logging.FileHandler(paths.CTX.log_file, encoding="utf-8", delay=True) file_handler.stream = log_stream file_handler.setFormatter( logging.Formatter("{relativeCreated: >8.1f}ms ({funcName}:{lineno}): {message}", style="{") ) pipx_logger: Final[logging.Logger] = logging.getLogger("pipx") # a test can run several commands in one process; without this each one stacks another pair of handlers for handler in pipx_logger.handlers[:]: pipx_logger.removeHandler(handler) handler.close() pipx_logger.setLevel(logging.DEBUG) pipx_logger.addHandler(stream_handler) pipx_logger.addHandler(file_handler) def _setup_log_file() -> TextIOWrapper: try: return _open_log_file(paths.CTX.logs) except PermissionError: return _open_log_file(platformdirs.user_log_path("pipx")) def _open_log_file(pipx_log_dir: Path) -> TextIOWrapper: # util.mkdir would log, and logging is not set up yet pipx_log_dir.mkdir(mode=0o700, parents=True, exist_ok=True) # a directory created by an older pipx kept the umask mode; one owned by another account keeps whatever it has with contextlib.suppress(OSError): pipx_log_dir.chmod(0o700) log_stream: Final[TextIOWrapper] = _create_log_file(pipx_log_dir) # the validator's parser lets a blank PIPX_MAX_LOGS fall back to the default, where int() would fail on it _rotate_logs(Path(log_stream.name), _compute_max_logs(os.getenv("PIPX_MAX_LOGS"))[0]) return log_stream def _create_log_file(pipx_log_dir: Path) -> TextIOWrapper: datetime_str: Final[str] = time.strftime("%Y-%m-%d_%H.%M.%S") counter = 0 while True: with contextlib.suppress(FileExistsError): log_stream = open( # ruff:ignore[open-file-with-context-handler] # the logging handler owns the stream pipx_log_dir / f"cmd_{datetime_str}{f'_{counter}' if counter else ''}.log", "x", encoding="utf-8", opener=open_owner_only, ) break counter += 1 # a concurrent pipx skips deleting a locked log; Windows already refuses to delete a file another process has open if not WINDOWS: # a filesystem without lock support only loses the protection, never the run with contextlib.suppress(OSError): fcntl.flock(log_stream, fcntl.LOCK_SH) return log_stream def _rotate_logs(current: Path, max_logs: int) -> None: # the current log counts toward the limit and must outlive its run, since a failure points the user at its # error log, so 0 keeps that one log keep_old: Final[int] = max(max_logs - 1, 0) name_pattern: Final[re.Pattern[str]] = re.compile( r"cmd_(\d{4}-\d\d-\d\d_\d\d\.\d\d\.\d\d)(?:_(\d+))?(_[a-z]+_errors)?\.log" ) command_logs: dict[tuple[str, int], Path] = {} error_logs: list[tuple[tuple[str, int], Path]] = [] for path in current.parent.glob("cmd_*.log"): if path != current and (match := name_pattern.fullmatch(path.name)): run = (match[1], int(match[2] or 0)) if match[3]: error_logs.append((run, path)) else: command_logs[run] = path ordered: Final[list[tuple[tuple[str, int], Path]]] = sorted(command_logs.items()) for _, path in ordered[: max(0, len(ordered) - keep_old)]: _delete_unless_running(path) error_logs.sort() for run, path in error_logs[: max(0, len(error_logs) - keep_old)]: if run not in command_logs or not command_logs[run].exists(): _remove_log(path) def _delete_unless_running(log: Path) -> None: if WINDOWS: _remove_log(log) return try: stream: Final = log.open("rb") except OSError: # a log this user cannot read (another user's run, chmod 000, a symlink loop or to a directory) cannot be # checked for a running owner; it is old enough to rotate, so remove it _remove_log(log) return with stream: try: fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: return except OSError: pass # no lock support on this filesystem: rotate as if no other pipx were running _remove_log(log) def _remove_log(log: Path) -> None: # rotation only tidies up, so a log the directory will not let go of (a shared read-only log dir) stays rather # than moving logging to the fallback directory with contextlib.suppress(OSError): log.unlink() def _create_directories(*, is_global: bool) -> None: try: mkdir(paths.CTX.venvs) mkdir(paths.CTX.bin_dir) mkdir(paths.CTX.man_dir) mkdir(paths.CTX.venv_cache) mkdir(paths.CTX.standalone_python_cachedir) except PermissionError as error: fix: Final[str] = ( "run pipx with sudo, or point PIPX_GLOBAL_HOME, PIPX_GLOBAL_BIN_DIR and PIPX_GLOBAL_MAN_DIR at writable" " directories" if is_global else "point PIPX_HOME, PIPX_BIN_DIR and PIPX_MAN_DIR at writable directories" ) msg = f"Cannot create {error.filename}: permission denied; {fix}." # wrapping would split a long path across lines raise PipxError(msg, wrap_message=False) from error for cachedir in [ paths.CTX.venv_cache, paths.CTX.standalone_python_cachedir, ]: if not (cachedir_tag := cachedir / "CACHEDIR.TAG").exists(): _LOGGER.debug("Adding CACHEDIR.TAG to cache directory") cachedir_tag.write_text( "Signature: 8a477f597d28d172789f06886806bc55\n" "# This file is a cache directory tag created by pipx.\n" "# For information about cache directory tags, see:\n" "# https://bford.info/cachedir/\n", encoding="utf-8", ) def check_args(parsed_pipx_args: argparse.Namespace) -> None: if parsed_pipx_args.command == "run": # argparse.REMAINDER keeps the -- that separates pipx's options from the app's if parsed_pipx_args.app_with_args and parsed_pipx_args.app_with_args[0] == "--": parsed_pipx_args.app_with_args.pop(0) # the app is the first item of the REMAINDER positional, which argparse cannot mark required if not parsed_pipx_args.app_with_args: parsed_pipx_args.subparser.error("the following arguments are required: app") def _run_pipx_command(args: argparse.Namespace) -> ExitCode: if "package" in args: _package_is_url(args.package) package_is_path(args.package) if "packages" in args: for package in args.packages: _package_is_url(package) package_is_path(package) spec: str | None = getattr(args, "spec", None) if "package" in args and spec is not None and _package_is_url(spec, raise_error=False) and "#egg=" not in spec: spec = f"{spec}#egg={args.package}" output: Final[OutputFormat] = _output_format(args) python = get_default_python() if "python" in args: try: python = find_python_interpreter(args.python or python, fetch_python=args.fetch_python) except InterpreterResolutionError as error: _LOGGER.debug("Failed to resolve interpreter:", exc_info=True) if output is OutputFormat.JSON: raise print( # ruff:ignore[print] # user-facing CLI output pipx_wrap(f"{HAZARD} {error}", subsequent_indent=" " * 4) ) return EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND backend: Final[BackendOptions] = BackendOptions( cli=getattr(args, "backend", None) or None, env=env_default_backend() ) _validate_backend_available(backend) ctx: Final[_DispatchContext] = _DispatchContext( fetch_python=args.fetch_python if "fetch_python" in args else FetchPythonOptions.NEVER, verbose=bool(args.verbose) if "verbose" in args else False, pip_args=get_pip_args(args), venv_args=["--system-site-packages"] if getattr(args, "system_site_packages", False) else [], venv_container=VenvContainer(paths.CTX.venvs), skip_list=[canonicalize_name(name) for name in args.skip] if "skip" in args else [], python=python, python_flag_passed=bool(getattr(args, "python", None)), spec=spec, backend=backend, cooldown=CooldownOptions( cli_days=getattr(args, "cooldown", None), env_days=_COOLDOWN if "cooldown" in args else None ), ) with ( skip_shared_libs_maintenance(enabled=getattr(args, "skip_maintenance", False)), shared_pip_cooldown(ctx.cooldown.days), # pip writes its progress to stdout, which under JSON belongs to the envelope alone stream_subprocess_stdout_to_stderr(enabled=output is OutputFormat.JSON), ): result: Final = args.func(args, ctx) if isinstance(result, OperationResult): return render_result(result, output=output, quiet=getattr(args, "quiet", 0)) return result def _package_is_url(package: str, *, raise_error: bool = True) -> bool: if (parsed_url := urllib.parse.urlparse(package)).scheme and parsed_url.netloc: if not raise_error: return True msg = "Package cannot be a URL. A valid package name should be passed instead." raise PipxError(msg) return False def package_is_path(package: str) -> None: if any(separator in package for separator in (os.path.sep, os.path.altsep) if separator): raise PipxError( pipx_wrap( f""" Error: '{redact_url_credentials(package)}' looks like a path. Expected the name of an installed package. """ ) ) def _validate_backend_available(backend: BackendOptions) -> None: # no auto-detection: it costs a `uv --version` subprocess, also for the commands that never build a backend name, source = resolve_backend_name(cli_value=backend.cli, env_value=backend.env, auto=False) if source in {"cli", "env"}: _LOGGER.info("Backend resolved to %s (source: %s)", name, source) # only an explicit --backend uv fails early; a uv picked by the environment stays silent so diagnostic commands, # which never build a backend, keep working without it if name == UV and source == "cli": get_backend(name) @dataclass(frozen=True, slots=True, kw_only=True) class _DispatchContext: verbose: bool pip_args: list[str] venv_args: list[str] venv_container: VenvContainer skip_list: list[str] python: str python_flag_passed: bool spec: str | None backend: BackendOptions cooldown: CooldownOptions fetch_python: FetchPythonOptions def get_pip_args(args: argparse.Namespace) -> list[str]: pip_args: Final[list[str]] = [] # keep an empty value so reject_empty_index refuses it rather than the backend falling back to its default index if (index_url := getattr(args, "index_url", None)) is not None: pip_args.extend(["--index-url", index_url]) if value := getattr(args, "pip_args", None): pip_args.extend(_split_pip_args(value)) # --editable applies to the argument after it, the package spec if getattr(args, "editable", False): pip_args.append("--editable") reject_empty_index(pip_args) return pip_args def _split_pip_args(value: str) -> list[str]: # cmd.exe and PowerShell hand the quotes over verbatim, so drop a pair wrapping the whole value; any other pair # belongs to one argument, as in --find-links '/tmp/foo bar' if len(value) > 1 and value[0] == "'" and value.find("'", 1) == len(value) - 1: value = value[1:-1] lexer: Final[shlex.shlex] = shlex.shlex(value, posix=True) lexer.whitespace_split = True lexer.commenters = "" if WINDOWS: # posix=False leaves the quotes inside each token and pip then reads them as part of the path; posix quoting # without escapes keeps the backslash a path separator instead lexer.escape = "" try: return list(lexer) except ValueError as exc: msg = f"invalid pip arguments: {exc}" raise PipxError(msg) from exc def _command_tokens(args: argparse.Namespace) -> tuple[str, ...]: tokens: Final[list[str]] = [args.command] for subcommand in ("cache_command", "interpreter_command", "manifest_command"): if value := getattr(args, subcommand, None): tokens.append(value) return tuple(tokens) if __name__ == "__main__": sys.exit(cli()) __all__ = [ "InstalledVenvsCompleter", "build_parser", "check_args", "cli", "get_command_parser", "get_pip_args", "package_is_path", "parse_pipx_args", "prog_name", ] pipx-1.18.16/src/pipx/options.py000066400000000000000000000124341527355705200165030ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass, field from typing import TYPE_CHECKING, Final, Self from pipx.constants import FetchPythonOptions if TYPE_CHECKING: from collections.abc import Collection, Sequence from pathlib import Path from pipx.pipx_metadata_file import PackageInfo @dataclass(frozen=True, slots=True, kw_only=True) class BackendOptions: cli: str | None = None env: str | None = None # no --backend and no PIPX_DEFAULT_BACKEND handed over: a Venv falls back to its record, then the environment DEFAULT_BACKEND_OPTIONS: Final[BackendOptions] = BackendOptions() # --cooldown and PIPX_COOLDOWN stay apart, so install can let a lock file override the machine-wide policy @dataclass(frozen=True, slots=True, kw_only=True) class CooldownOptions: cli_days: int | None = None env_days: int | None = None @property def days(self) -> int | None: return self.cli_days if self.cli_days is not None else self.env_days DEFAULT_COOLDOWN_OPTIONS: Final[CooldownOptions] = CooldownOptions() @dataclass(frozen=True, slots=True, kw_only=True) class ExposureOptions: include_apps: bool = True include_dependencies: bool = False include_resources_from: Sequence[str] = () suffix: str = "" @classmethod def recorded(cls, package_info: PackageInfo) -> Self: return cls( include_apps=package_info.include_apps, include_dependencies=package_info.include_dependencies, include_resources_from=package_info.include_resources_from, suffix=package_info.suffix, ) @dataclass(frozen=True, slots=True, kw_only=True) class InstallOptions: local_bin_dir: Path local_man_dir: Path python: str | None pip_args: list[str] venv_args: list[str] verbose: bool force: bool backend: BackendOptions reinstall: bool = False exposure: ExposureOptions = ExposureOptions() preinstall_packages: list[str] | None = None expected_apps: Sequence[str] = () lock_file: Path | None = None python_flag_passed: bool = False exposure_enabled: bool | None = None upgrade: bool = False upgrade_strategy: str | None = None cooldown: CooldownOptions = DEFAULT_COOLDOWN_OPTIONS pinned: bool | None = None preserve_existing: bool = False fetch_python: FetchPythonOptions = FetchPythonOptions.NEVER replace_expected_apps: bool = False replace_lock: bool = False emit_output: bool = True @dataclass(frozen=True, slots=True, kw_only=True) class InjectOptions: pip_args: list[str] verbose: bool include_apps: bool include_dependencies: bool include_resources_from: Sequence[str] force: bool force_reinstall: bool = True with_suffix: bool backend: BackendOptions = DEFAULT_BACKEND_OPTIONS cooldown_days: int | None pinned: bool | None = None emit_output: bool = True @dataclass(frozen=True, slots=True, kw_only=True) class UninjectOptions: local_bin_dir: Path local_man_dir: Path leave_deps: bool verbose: bool # upgrade-all shares this record; skip applies to upgrade-all alone, install, python and venv_args to upgrade alone @dataclass(frozen=True, slots=True, kw_only=True) class UpgradeOptions: pip_args: list[str] verbose: bool include_injected: bool force: bool python_flag_passed: bool backend: BackendOptions cooldown: CooldownOptions install: bool = False python: str | None = None venv_args: list[str] = field(default_factory=list) skip: Sequence[str] = () # reinstall-all, repair and the interpreter upgrade rebuild through reinstall; skip applies to reinstall-all alone @dataclass(frozen=True, slots=True, kw_only=True) class ReinstallOptions: local_bin_dir: Path local_man_dir: Path python: str verbose: bool python_flag_passed: bool = False backend: BackendOptions = DEFAULT_BACKEND_OPTIONS skip: Sequence[str] = () @dataclass(frozen=True, slots=True, kw_only=True) class SyncOptions: local_bin_dir: Path local_man_dir: Path python: str verbose: bool prune: bool backend: BackendOptions @dataclass(frozen=True, slots=True, kw_only=True) class ListOptions: include_injected: bool json_format: bool short_format: bool pinned_only: bool @dataclass(frozen=True, slots=True, kw_only=True) class OutdatedOptions: include_injected: bool upgradable_only: bool = False pip_args: Sequence[str] = () skip: Collection[str] = () backend: BackendOptions = DEFAULT_BACKEND_OPTIONS @dataclass(frozen=True, slots=True, kw_only=True) class RunOptions: app_args: list[str] dependencies: list[str] python: str pip_args: list[str] venv_args: list[str] is_path: bool pypackages: bool verbose: bool use_cache: bool python_args: list[str] refresh: bool no_path_check: bool backend: BackendOptions cooldown: CooldownOptions python_flag_passed: bool fetch_python: FetchPythonOptions __all__ = [ "DEFAULT_BACKEND_OPTIONS", "DEFAULT_COOLDOWN_OPTIONS", "BackendOptions", "CooldownOptions", "ExposureOptions", "InjectOptions", "InstallOptions", "ListOptions", "OutdatedOptions", "ReinstallOptions", "RunOptions", "SyncOptions", "UninjectOptions", "UpgradeOptions", ] pipx-1.18.16/src/pipx/package_specifier.py000066400000000000000000001036521527355705200204370ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "packaging.specifiers", "packaging.utils", "pathlib", "pipx.backends._base", "pipx.redact", "pipx.util", "pipx.wrap", "re", "tomllib", "urllib.parse", ] import logging import os import re import stat import sys import tomllib import urllib.parse from dataclasses import dataclass, replace from pathlib import Path from typing import TYPE_CHECKING, Final, TypeAlias from packaging.specifiers import SpecifierSet from packaging.utils import InvalidName, canonicalize_name from pipx.backends._base import PIP from pipx.emojis import HAZARD from pipx.redact import redact_url_credentials from pipx.util import PipxError from pipx.wrap import pipx_wrap if TYPE_CHECKING: from collections.abc import Iterable, Iterator from packaging.requirements import Requirement _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) _PIP_LONG_FLAGS: Final[frozenset[str]] = frozenset({ "--break-system-packages", "--check-build-dependencies", "--compile", "--debug", "--disable-pip-version-check", "--dry-run", "--force-reinstall", "--help", "--ignore-installed", "--ignore-requires-python", "--isolated", "--no-build-isolation", "--no-cache-dir", "--no-clean", "--no-color", "--no-compile", "--no-dependencies", "--no-deps", "--no-index", "--no-input", "--no-proxy-env", "--no-python-version-warning", "--no-require-hashes", "--no-user", "--no-warn-conflicts", "--no-warn-script-location", "--only-dependencies", "--only-deps", "--pre", "--prefer-binary", "--quiet", "--require-hashes", "--require-venv", "--require-virtualenv", "--upgrade", "--use-pep517", "--user", "--verbose", "--version", }) # uv pip install's own flags, recorded for a uv environment _LONG_FLAGS: Final[frozenset[str]] = _PIP_LONG_FLAGS | { "--all-extras", "--check", "--compile-bytecode", "--exact", "--managed-python", "--no-break-system-packages", "--no-build", "--no-cache", "--no-config", "--no-editable", "--no-managed-python", "--no-progress", "--no-python-downloads", "--no-sources", "--no-verify-hashes", "--offline", "--refresh", "--reinstall", "--require-build-hashes", "--strict", "--system", "--system-certs", } # pip install's long options taking a value, from pip's cli/cmdoptions.py and commands/install.py _PIP_VALUE_OPTIONS: Final[frozenset[str]] = frozenset({ "--abi", "--all-releases", "--build-constraint", "--cache-dir", "--cert", "--client-cert", "--config-settings", "--constraint", "--default-timeout", "--editable", "--exists-action", "--extra-index-url", "--find-links", "--group", "--implementation", "--index-url", "--keyring-provider", "--local-log", "--log", "--log-file", "--no-binary", "--only-binary", "--only-final", "--platform", "--prefix", "--progress-bar", "--proxy", "--pypi-url", "--python", "--python-version", "--refresh-package", "--report", "--requirement", "--requirements-from-script", "--resume-retries", "--retries", "--root", "--root-user-action", "--source", "--source-dir", "--source-directory", "--src", "--target", "--timeout", "--trusted-host", "--upgrade-strategy", "--uploaded-prior-to", "--use-deprecated", "--use-feature", }) # a later upgrade or reinstall replays what pipx records, possibly as root, so a one-shot input or an output location # recorded here would be read or written again after its directory has changed hands _PIP_ONE_SHOT_OPTIONS: Final[frozenset[str]] = frozenset({ "-r", "--requirement", "--requirements", "--requirements-from-script", "--group", "-t", "--target", "--root", "--prefix", "--src", "--source", "--source-dir", "--source-directory", "--report", "--log", "--log-file", "--local-log", "--cache-dir", }) # recorded options naming what to install from, what to trust or where uv reads its configuration, by file or # directory _PIP_SOURCE_PATH_OPTIONS: Final[frozenset[str]] = frozenset({ "-b", "-c", "-f", "--build-constraint", "--build-constraints", "--cert", "--client-cert", "--config-file", "--constraint", "--constraints", "--directory", "--exclude", "--excludes", "--find-links", "--override", "--overrides", "--project", }) # value-taking options a recorded snapshot may carry, as their (uv, pip) spellings _VALUE_OPTION_SPELLINGS: Final[dict[str, tuple[str, str]]] = { **dict.fromkeys(("--default-index", "--index-url", "-i"), ("--default-index", "--index-url")), **dict.fromkeys(("--index", "--extra-index-url"), ("--index", "--extra-index-url")), **dict.fromkeys(("--find-links", "-f"), ("--find-links", "--find-links")), **dict.fromkeys(("--constraints", "--constraint", "-c"), ("--constraints", "--constraint")), **dict.fromkeys(("--allow-insecure-host", "--trusted-host"), ("--allow-insecure-host", "--trusted-host")), **{option: (option, option) for option in ("--keyring-provider", "--index-strategy", "--prerelease")}, } def parse_specifier_for_install(package_spec: str, pip_args: list[str]) -> tuple[str, list[str]]: pip_args = pip_args.copy() parsed_package: Final[_ParsedPackage] = _parse_specifier(package_spec) package_or_url: Final[str] = _parsed_package_to_package_or_url(parsed_package, remove_version_specifiers=False) if "--editable" in pip_args and not isinstance(parsed_package, _LocalPath): _LOGGER.warning( pipx_wrap( f""" {HAZARD} Ignoring --editable install option. pipx disallows it for anything but a local path, to avoid having to create a new src/ directory. """, subsequent_indent=" " * 4, ) ) pip_args.remove("--editable") return package_or_url, absolute_path_pip_args(pip_args, Path.cwd()) def _parse_specifier(package_spec: str) -> _ParsedPackage: # match pip's PyPI precedence by checking local paths only for names that PyPI rejects if (valid_pep508 := _requirement(package_spec)) is not None: if not _is_archive_name(valid_pep508.name): return _Pep508(requirement=valid_pep508) if not (package_path := Path(valid_pep508.name).expanduser()).exists(): msg = f"{package_path} does not exist" raise PipxError(msg) if valid_pep508.specifier or valid_pep508.url or valid_pep508.marker: msg = f"Only extras can follow the local archive {package_path}" raise PipxError(msg) # keep the extras as typed so the bare and ./ spellings store the same spec return _LocalPath(path=f"{package_path.resolve()}{_split_path_extras(package_spec.strip())[1]}") parsed_url: Final[urllib.parse.SplitResult] = urllib.parse.urlsplit(package_spec) if parsed_url.scheme and ( parsed_url.netloc or (parsed_url.scheme in {"file", "git+file", "hg+file"} and parsed_url.path.startswith("/")) ): return _Url(url=package_spec) package_path_str, package_extras_str = _split_path_extras(package_spec) if (package_path := Path(package_path_str).expanduser()).exists(): return _LocalPath(path=f"{package_path.resolve()}{package_extras_str}") msg = f"Unable to parse package spec: {redact_url_credentials(package_spec)}" raise PipxError(msg) def _requirement(package_spec: str) -> Requirement | None: from packaging.requirements import ( # ruff:ignore[import-outside-top-level] # parser slows startup InvalidRequirement, Requirement, ) try: return Requirement(package_spec) except InvalidRequirement: return None def _is_archive_name(name: str) -> bool: # same extensions and case folding as pip's is_archive_file (pip._internal.utils.filetypes) return name.lower().endswith(( ".whl", ".zip", ".tar.bz2", ".tbz", ".tar.gz", ".tgz", ".tar", ".tar.xz", ".txz", ".tlz", ".tar.lz", ".tar.lzma", )) @dataclass(frozen=True, slots=True, kw_only=True) class _Pep508: requirement: Requirement @dataclass(frozen=True, slots=True, kw_only=True) class _Url: url: str @dataclass(frozen=True, slots=True, kw_only=True) class _LocalPath: path: str _ParsedPackage: TypeAlias = _Pep508 | _Url | _LocalPath def _split_path_extras(package_spec: str) -> tuple[str, str]: # a bracket can be part of a directory name, so only a trailing group on a path that does not exist is extras if Path(package_spec).expanduser().exists(): return package_spec, "" if extras_match := re.search( r""" (.+) # the path (\[[^\[\]]+\]) # one trailing [extras] group $ """, package_spec, re.VERBOSE, ): return extras_match.group(1), extras_match.group(2) return package_spec, "" def _parsed_package_to_package_or_url(parsed_package: _ParsedPackage, *, remove_version_specifiers: bool) -> str: match parsed_package: case _Pep508(requirement=requirement): if requirement.marker is not None: _LOGGER.warning( pipx_wrap( f""" {HAZARD} Ignoring environment markers ({requirement.marker}) in package specification. Use pipx options to specify this type of information. """, subsequent_indent=" " * 4, ) ) package_or_url = _package_or_url_from_pep508( requirement, remove_version_specifiers=remove_version_specifiers ) case _Url(url=url): package_or_url = url case _LocalPath(path=path): package_or_url = path _LOGGER.info("cleaned package spec: %s", redact_url_credentials(package_or_url)) return package_or_url def _package_or_url_from_pep508(requirement: Requirement, *, remove_version_specifiers: bool = False) -> str: requirement.marker = None requirement.name = canonicalize_name(requirement.name) if remove_version_specifiers: requirement.specifier = SpecifierSet("") return str(requirement) def absolute_path_pip_args(pip_args: list[str], base: Path) -> list[str]: return _render(_absolute_paths(_parse_pip_args(pip_args), base)) def _render(arguments: Iterable[_PipArgument]) -> list[str]: return [token for argument in arguments for token in argument.tokens] def _absolute_paths(arguments: list[_PipArgument], base: Path) -> list[_PipArgument]: # --group takes a group name, with an optional pyproject.toml path in front of it return [ replace(argument, value=_absolute_path_value(argument.value, base)) if argument.value is not None and argument.name != "--group" and (argument.name in _PIP_ONE_SHOT_OPTIONS or argument.name in _PIP_SOURCE_PATH_OPTIONS) else argument for argument in arguments ] def _absolute_path_value(value: str, base: Path) -> str: # pip writes --report - to stdout if value == "-" or urllib.parse.urlsplit(value).scheme: return value return str((base / Path(value).expanduser()).resolve()) def _parse_pip_args(pip_args: list[str]) -> list[_PipArgument]: # read as pip's optparse does, so recording, replay and index handling all see the same options parsed: Final[list[_PipArgument]] = [] arguments: Final[Iterator[str]] = iter(pip_args) for argument in arguments: if argument.startswith("--"): parsed.append(_long_option(argument, arguments)) elif argument.startswith("-") and argument != "-": parsed.extend(_short_options(argument, arguments)) else: parsed.append(_PipArgument(name=argument)) return parsed def _long_option(argument: str, arguments: Iterator[str]) -> _PipArgument: spelled, separator, value = argument.partition("=") if (name := _long_option_name(spelled)) is None: return _unresolved_long_option(argument, arguments) if separator and name in _LONG_FLAGS: # pip rejects a value given to a flag, so keep it as typed return _PipArgument(name=argument) if name in _LONG_FLAGS: return _PipArgument(name=name) if separator: return _PipArgument(name=name, value=value, separator=separator) return _PipArgument(name=name, value=next(arguments, None)) def _unresolved_long_option(argument: str, arguments: Iterator[str]) -> _PipArgument: spelled, separator, value = argument.partition("=") # an older pip, with fewer options, can take a prefix this table finds ambiguous, so keep it as typed, with the # value the option it names there takes; a prefix that could also name a flag, as --b could # --break-system-packages, may take none, so the next argument stays its own candidates: Final[frozenset[str]] = _prefixed_options(spelled, _PIP_VALUE_OPTIONS | _PIP_LONG_FLAGS) if separator and candidates & _PIP_VALUE_OPTIONS: return _PipArgument(name=spelled, value=value, separator=separator) if candidates and candidates <= _PIP_VALUE_OPTIONS: return _PipArgument(name=spelled, value=next(arguments, None)) # pip rejects an unknown option, so keep it as typed return _PipArgument(name=argument) def _long_option_name(spelled: str) -> str | None: # uv's own options taking a value; uv takes no abbreviations, and its full names must not be read as one of pip's, # such as --index for --index-url uv_value_options: Final[frozenset[str]] = frozenset({ "--allow-insecure-host", "--build-constraints", "--color", "--config-file", "--config-setting", "--config-settings-package", "--constraints", "--default-index", "--directory", "--exclude", "--exclude-newer", "--exclude-newer-package", "--excludes", "--extra", "--fork-strategy", "--index", "--index-strategy", "--link-mode", "--no-build-isolation-package", "--no-editable-package", "--no-sources-package", "--output-format", "--override", "--overrides", "--prerelease", "--prerelease-package", "--project", "--python-platform", "--reinstall-package", "--requirements", "--resolution", "--torch-backend", "--upgrade-group", "--upgrade-package", }) if spelled in uv_value_options or spelled in _LONG_FLAGS: return spelled return _pip_long_option_name(spelled) def _pip_long_option_name(spelled: str) -> str | None: if spelled in (pip_options := _PIP_VALUE_OPTIONS | _PIP_LONG_FLAGS): return spelled match list(_prefixed_options(spelled, pip_options)): case [option]: return option case _: return None def _prefixed_options(spelled: str, options: frozenset[str]) -> frozenset[str]: # pip, through optparse, takes any unambiguous prefix of its own long options; "--" alone ends the options return frozenset(option for option in options if option.startswith(spelled)) if spelled != "--" else frozenset() @dataclass(frozen=True, slots=True, kw_only=True) class _PipArgument: name: str value: str | None = None # "=" or "" when the value shares the option's token, None when the value is a token of its own separator: str | None = None @property def tokens(self) -> list[str]: if self.value is None: return [self.name] if self.separator is None: return [self.name, self.value] return [f"{self.name}{self.separator}{self.value}"] def _short_options(argument: str, arguments: Iterator[str]) -> list[_PipArgument]: # optparse reads -qr FILE as -q -r FILE: flags up to the first option taking a value, which takes the rest; pip's # and uv's short options together, as neither assigns a letter differently for index, letter in enumerate(argument[1:], start=1): if letter in "hnqvIUV": continue if letter not in "bcefiprtCP": return [_PipArgument(name=argument)] flags: Final[list[_PipArgument]] = [_PipArgument(name=argument[:index])] if index > 1 else [] name: Final[str] = f"-{letter}" if not (attached := argument[index + 1 :]): return [*flags, _PipArgument(name=name, value=next(arguments, None))] if attached.startswith("="): # uv reads -f=DIR as -f DIR while pip reads the value =DIR, so give both the same separate value return [*flags, _PipArgument(name=name, value=attached[1:])] return [*flags, _PipArgument(name=name, value=attached, separator="")] return [_PipArgument(name=argument)] def install_specs(install_args: list[str]) -> list[tuple[str, bool]]: specs: Final[list[tuple[str, bool]]] = [] for argument in _parse_pip_args(install_args): if argument.name in {"-e", "--editable"} and argument.value is not None: specs.append((argument.value, True)) elif not argument.name.startswith("-"): specs.append((argument.name, False)) return specs def recordable_pip_args(pip_args: list[str]) -> list[str]: return _render(_without_one_shot(_parse_pip_args(pip_args))) def _without_one_shot(arguments: list[_PipArgument]) -> list[_PipArgument]: return [argument for argument in arguments if not _may_name(argument, _PIP_ONE_SHOT_OPTIONS)] def _may_name(argument: _PipArgument, options: frozenset[str]) -> bool: # pip 24.0 reads --requirem as --requirement, a prefix the later --requirements-from-script made ambiguous return argument.name in options or ( argument.value is not None and argument.name.startswith("--") and _long_option_name(argument.name) is None and bool(_prefixed_options(argument.name, options)) ) def replayable_pip_args(pip_args: list[str], venv_dir: Path) -> list[str]: # the record's installer ran in the venv root, so a relative path resolves there, never against the caller's # working directory, which another account may control return _render(_absolute_paths(_without_one_shot(_parse_pip_args(pip_args)), venv_dir)) def trusted_recorded_pip_args(pip_args: list[str], venv_dir: Path) -> list[str]: if (source := changeable_recorded_source(pip_args, venv_dir)) is not None: msg: Final[str] = ( f"{venv_dir.name}: refusing to replay {source}, and with it what gets installed. Move the file or " "directory to a place only you and root can change, or pass the options again with --pip-args to use " "them for this run." ) # a wrapped line would split a long path raise PipxError(msg, wrap_message=False) return pip_args def changeable_recorded_source(pip_args: list[str], venv_dir: Path) -> str | None: for argument in _parse_pip_args(pip_args): if (location := _changeable_source(argument, venv_dir)) is not None: recorded: Final[str] = " ".join(map(redact_url_credentials, argument.tokens)) return f"the recorded {recorded}: another account can change {location}" return None def _changeable_source(argument: _PipArgument, base: Path) -> Path | None: if argument.value is None: return None if _may_name(argument, _PIP_SOURCE_PATH_OPTIONS): # uv splits a --find-links value on commas, pip takes it whole values: set[str] = {argument.value, *argument.value.split(",")} elif _may_name( argument, frozenset({"-i", "--default-index", "--extra", "--extra-index-url", "--index", "--index-url", "--pypi-url"}), ): # index options whose value can be a local directory, with pip reading uv's --extra as --extra-index-url; uv # takes a named index as name=url name, separator, url = argument.value.partition("=") values = {argument.value, url} if separator and re.fullmatch(r"[A-Za-z0-9._-]+", name) else {argument.value} else: return None return next(filter(None, (_changeable_by_others(value, base) for value in sorted(values))), None) def _changeable_by_others(value: str, base: Path) -> Path | None: url: Final[urllib.parse.SplitResult] = urllib.parse.urlsplit(value) if url.scheme not in {"", "file"}: return None # the installer runs in the venv root, where a relative index directory resolves return changeable_by_others(base / (urllib.parse.unquote(url.path) if url.scheme else value)) def changeable_by_others(path: Path) -> Path | None: if sys.platform == "win32": return None trusted: Final[set[int]] = {0, os.geteuid()} # the sticky bit keeps others from replacing an entry they do not own, but not from adding one where none exists has_existing_child = False for location in (path, *path.parents): try: status = location.stat() except OSError: continue if status.st_uid not in trusted or ( status.st_mode & stat.S_IWOTH and not (has_existing_child and status.st_mode & stat.S_ISVTX) ): return location has_existing_child = True return None def translate_pip_args(pip_args: list[str], backend: str, environment: str) -> tuple[list[str], list[str]]: # keeping the recorded index stops a snapshot moved to the other backend from installing from PyPI instead to_pip: Final[bool] = backend == PIP translated: Final[list[str]] = [] dropped: Final[list[str]] = [] arguments: Final[Iterator[str]] = iter(pip_args) for argument in arguments: flag, value = _split_option(argument if to_pip else _expand_pip_abbreviation(argument), arguments) spelling = _VALUE_OPTION_SPELLINGS.get(flag, (flag, flag)) match spelling[0]: case "--no-index": translated.append(flag) case "--pre" | "--prerelease" if flag == "--pre" or value == "allow": translated.extend(["--pre"] if to_pip else ["--prerelease", "allow"]) case "--prerelease": dropped.extend([flag, value]) case "--index": # uv takes a named index as name=url, and the name means nothing to pip name, _, url = value.partition("=") translated.extend([spelling[to_pip], url if url and re.fullmatch(r"[A-Za-z0-9._-]+", name) else value]) case "--find-links": # uv splits a --find-links value on commas, pip takes it as one location translated.extend( part for link in (value.split(",") if to_pip else [value]) for part in (spelling[to_pip], link) ) case "--default-index" | "--constraints": translated.extend([spelling[to_pip], value]) case "--allow-insecure-host" if "://" not in value: translated.extend([spelling[to_pip], value]) case "--keyring-provider" if value in {"disabled", "subprocess"}: translated.extend([flag, value]) case "--index-strategy" if value == "unsafe-best-match": pass # pip always picks the best match across its indexes case "--allow-insecure-host" | "--keyring-provider" | "--index-strategy": msg = ( f"Cannot install {environment} with the {backend} backend: its recorded index option {flag} " f"{redact_url_credentials(value)} has no {backend} equivalent, and dropping it could install from " "a different index." ) raise PipxError(msg) case _: dropped.append(argument) return translated, dropped def _split_option(argument: str, arguments: Iterator[str]) -> tuple[str, str]: flag, _, value = argument.partition("=") if argument.startswith("--") else (argument[:2], "", argument[2:]) if flag not in _VALUE_OPTION_SPELLINGS: return argument, "" return flag, value or next(arguments, "") def _expand_pip_abbreviation(argument: str) -> str: # pip accepts any unambiguous prefix of a long option: a pip recording can hold --index-u for --index-url flag, separator, value = argument.partition("=") if not flag.startswith("--") or (name := _pip_long_option_name(flag)) is None: return argument return f"{name}{separator}{value}" def resolve_local_path(package_spec: str) -> str: return parsed.path if isinstance(parsed := _parse_specifier(package_spec), _LocalPath) else package_spec def parse_specifier_for_metadata(package_spec: str) -> str: return _parsed_package_to_package_or_url(_parse_specifier(package_spec), remove_version_specifiers=False) def parse_specifier_for_upgrade(package_spec: str) -> str: return _parsed_package_to_package_or_url(_parse_specifier(package_spec), remove_version_specifiers=True) def get_extras(package_spec: str) -> set[str]: match _parse_specifier(package_spec): case _Pep508(requirement=requirement): return requirement.extras case _LocalPath(path=path): from packaging.requirements import Requirement # ruff:ignore[import-outside-top-level] # parser slows startup return Requirement(f"notapackage{_split_path_extras(path)[1]}").extras case _Url(): return set() def source_without_extras(package_spec: str) -> str: match _parse_specifier(package_spec): case _Pep508(requirement=requirement): requirement.extras = set() return _package_or_url_from_pep508(requirement) case _Url(url=url): return url case _LocalPath(path=path): return _split_path_extras(path)[0] def add_extras(package_or_url: str, package_name: str, extras: set[str]) -> str: parsed_package: Final[_ParsedPackage] = _parse_specifier(package_or_url) if isinstance(parsed_package, _Pep508): parsed_package.requirement.extras |= extras return str(parsed_package.requirement) merged: Final[str] = ",".join(sorted(get_extras(package_or_url) | extras)) if isinstance(parsed_package, _Url): return f"{package_name}[{merged}] @ {parsed_package.url}" return f"{_split_path_extras(parsed_package.path)[0]}[{merged}]" def valid_pypi_name(package_spec: str) -> str | None: if (package_req := _requirement(package_spec)) is None: return None # a URL spec can name a package other than the one it points at, and an archive-like name can be a local file; # either way the name has to come from the package itself if package_req.url or _is_archive_name(package_req.name): return None return canonicalize_name(package_req.name) def local_project_name(package_spec: str) -> str | None: # Path() reads a URL as a relative path (http://host/x is http:/host/x), so files below the working directory could # otherwise name the environment for a remote spec if not isinstance(parsed := _parse_specifier(package_spec), _LocalPath): return None return static_project_name(parsed.path) or wheel_project_name(parsed.path) def static_project_name(package_spec: str) -> str | None: try: with (Path(_split_path_extras(package_spec)[0]) / "pyproject.toml").open("rb") as file: pyproject: Final = tomllib.load(file) except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): return None if not ( _backend_reads_project_table(pyproject.get("build-system")) and isinstance(project := pyproject.get("project"), dict) and isinstance(name := project.get("name"), str) and isinstance(dynamic := project.get("dynamic", []), list) and "name" not in dynamic ): return None return _valid_name(name) def _backend_reads_project_table(build_system: object) -> bool: # older poetry-core, setuptools and flit-core build from their own tables and ignore [project] minimum_versions: Final[dict[str, tuple[str, str | None]]] = { "flit_core.buildapi": ("flit-core", "3.2"), "hatchling.build": ("hatchling", None), "pdm.backend": ("pdm-backend", None), "poetry.core.masonry.api": ("poetry-core", "2"), "setuptools.build_meta": ("setuptools", "61"), "uv_build": ("uv-build", None), } if not ( isinstance(build_system, dict) and "backend-path" not in build_system and isinstance(backend := build_system.get("build-backend"), str) and (known := minimum_versions.get(backend)) is not None and isinstance(requires := build_system.get("requires"), list) ): return False distribution, minimum = known return minimum is None or any( canonicalize_name(requirement.name) == distribution and _newest_allowed_reaches(requirement.specifier, minimum) for requirement in filter(None, (_requirement(entry) for entry in requires if isinstance(entry, str))) ) def _newest_allowed_reaches(specifier: SpecifierSet, minimum: str) -> bool: from packaging.version import InvalidVersion, Version # ruff:ignore[import-outside-top-level] # slows startup # an isolated build installs the newest version the specifier allows, so it reaches the minimum when the minimum is # allowed or a lower bound already sits at or above it if specifier.contains(minimum, prereleases=True): return True for clause in specifier: try: if clause.operator in {">=", ">", "==", "===", "~="} and Version( clause.version.removesuffix(".*") ) >= Version(minimum): return True except InvalidVersion: continue return False def _valid_name(name: str) -> str | None: try: canonicalize_name(name, validate=True) except InvalidName: return None return name def wheel_project_name(package_spec: str) -> str | None: if (wheel := Path(_split_path_extras(package_spec)[0])).suffix != ".whl": return None import zipfile # ruff:ignore[import-outside-top-level] # slows startup from email.parser import BytesHeaderParser # ruff:ignore[import-outside-top-level] # slows startup try: with zipfile.ZipFile(wheel) as archive: match [entry for entry in archive.namelist() if re.fullmatch(r"[^/]+\.dist-info/METADATA", entry)]: case [metadata]: name: Final = BytesHeaderParser().parsebytes(archive.read(metadata))["Name"] case _: return None except (OSError, zipfile.BadZipFile): return None # a non-ASCII header comes back as an email Header object, never a valid project name return _valid_name(name) if isinstance(name, str) else None def package_spec_satisfied(package_spec: str, package_name: str, installed_version: str, installed_spec: str) -> bool: if (requirement := _requirement(package_spec)) is None or ( installed_requirement := _requirement(installed_spec) ) is None: return False return ( requirement.url is None and canonicalize_name(requirement.name) == canonicalize_name(package_name) and requirement.extras.issubset(installed_requirement.extras) and requirement.specifier.contains(installed_version) ) def extract_index_options(pip_args: list[str]) -> list[str]: return _split_index_options(pip_args)[0] def _split_index_options(pip_args: list[str]) -> tuple[list[str], list[str]]: # pip's and uv's spellings, since a uv environment records options as uv pip install accepts them value_options: Final[frozenset[str]] = frozenset({ "-f", "-i", "--allow-insecure-host", "--default-index", "--extra-index-url", "--find-links", "--index", "--index-strategy", "--index-url", "--keyring-provider", "--pypi-url", "--trusted-host", }) index_args: Final[list[str]] = [] other_args: Final[list[str]] = [] for argument in _parse_pip_args(pip_args): if argument.name in value_options and argument.value is None: msg = f"Missing value for {argument.name!r} in --pip-args." raise PipxError(msg) is_index: bool = argument.name in value_options or argument.name == "--no-index" (index_args if is_index else other_args).extend(argument.tokens) return index_args, other_args def replace_index_options(pip_args: list[str], index_args: list[str]) -> list[str]: return [*_split_index_options(pip_args)[1], *index_args] def fix_package_name(package_or_url: str, package_name: str) -> str: if (package_req := _requirement(package_or_url)) is None: return package_or_url if _is_archive_name(package_req.name): return str(package_req) if canonicalize_name(package_req.name) != canonicalize_name(package_name): _LOGGER.warning( pipx_wrap( f""" {HAZARD} Name supplied in package specifier was {package_req.name!r} but package found has name {package_name!r}. Using {package_name!r}. """, subsequent_indent=" " * 4, ) ) package_req.name = package_name return str(package_req) __all__ = [ "absolute_path_pip_args", "add_extras", "changeable_by_others", "changeable_recorded_source", "extract_index_options", "fix_package_name", "get_extras", "install_specs", "local_project_name", "package_spec_satisfied", "parse_specifier_for_install", "parse_specifier_for_metadata", "parse_specifier_for_upgrade", "recordable_pip_args", "replace_index_options", "replayable_pip_args", "resolve_local_path", "source_without_extras", "static_project_name", "translate_pip_args", "trusted_recorded_pip_args", "valid_pypi_name", "wheel_project_name", ] pipx-1.18.16/src/pipx/paths.py000066400000000000000000000161311527355705200161250ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["pipx.self_install", "pipx.wrap", "typing"] import logging from functools import cached_property from pathlib import Path from typing import Final from platformdirs import user_cache_path, user_data_path, user_log_path from pipx.constants import WINDOWS from pipx.self_install import DEFAULT_PIPX_GLOBAL_HOME, get_environment_value from pipx.wrap import pipx_wrap DEFAULT_PIPX_HOME: Final[Path] = user_data_path("pipx") FALLBACK_PIPX_HOMES: Final[list[Path]] = [Path.home() / ".local/pipx", *([Path.home() / "pipx"] if WINDOWS else [])] DEFAULT_PIPX_BIN_DIR: Final[Path] = Path.home() / ".local/bin" DEFAULT_PIPX_MAN_DIR: Final[Path] = Path.home() / ".local/share/man" DEFAULT_PIPX_GLOBAL_BIN_DIR: Final[Path] = Path("/usr/local/bin") DEFAULT_PIPX_GLOBAL_MAN_DIR: Final[Path] = Path("/usr/local/share/man") # tests point these at temporary directories so no test touches the real pipx locations OVERRIDE_PIPX_HOME: Path | None = None OVERRIDE_PIPX_BIN_DIR: Path | None = None OVERRIDE_PIPX_MAN_DIR: Path | None = None OVERRIDE_PIPX_COMPLETION_DIR: Path | None = None OVERRIDE_PIPX_SHARED_LIBS: Path | None = None OVERRIDE_PIPX_GLOBAL_HOME: Path | None = None OVERRIDE_PIPX_GLOBAL_BIN_DIR: Path | None = None OVERRIDE_PIPX_GLOBAL_MAN_DIR: Path | None = None OVERRIDE_PIPX_GLOBAL_COMPLETION_DIR: Path | None = None OVERRIDE_PIPX_GLOBAL_SHARED_LIBS: Path | None = None _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) class _PathContext: _base_home: Path | None _default_home: Path _base_bin: Path | None _default_bin: Path _base_man: Path | None _default_man: Path _base_completion: Path | None _default_completion: Path _default_log: Path _default_cache: Path _default_trash: Path _base_shared_libs: Path | None _fallback_home: Path | None _home_exists: bool log_file: Path | None = None def __init__(self) -> None: self.make_local() def make_local(self) -> None: self._base_home = OVERRIDE_PIPX_HOME or get_expanded_environ("PIPX_HOME") self._default_home = DEFAULT_PIPX_HOME self._base_bin = OVERRIDE_PIPX_BIN_DIR or get_expanded_environ("PIPX_BIN_DIR") self._default_bin = DEFAULT_PIPX_BIN_DIR self._base_man = OVERRIDE_PIPX_MAN_DIR or get_expanded_environ("PIPX_MAN_DIR") self._default_man = DEFAULT_PIPX_MAN_DIR self._base_completion = OVERRIDE_PIPX_COMPLETION_DIR or get_expanded_environ("PIPX_COMPLETION_DIR") self._default_completion = Path.home() / ".local/share" self._base_shared_libs = OVERRIDE_PIPX_SHARED_LIBS or get_expanded_environ("PIPX_SHARED_LIBS") self._default_log = user_log_path("pipx") self._default_cache = user_cache_path("pipx") self._default_trash = self._default_home / "trash" self._fallback_home = next((fallback for fallback in FALLBACK_PIPX_HOMES if fallback.exists()), None) self._home_exists = self._base_home is not None or any(fallback.exists() for fallback in FALLBACK_PIPX_HOMES) self._clear_cached_paths() def _clear_cached_paths(self) -> None: for attribute in ("bin_dir", "completion_dir", "home", "man_dir", "shared_libs"): self.__dict__.pop(attribute, None) def make_global(self) -> None: self._base_home = OVERRIDE_PIPX_GLOBAL_HOME or get_expanded_environ("PIPX_GLOBAL_HOME") self._default_home = DEFAULT_PIPX_GLOBAL_HOME self._base_bin = OVERRIDE_PIPX_GLOBAL_BIN_DIR or get_expanded_environ("PIPX_GLOBAL_BIN_DIR") self._default_bin = DEFAULT_PIPX_GLOBAL_BIN_DIR self._base_man = OVERRIDE_PIPX_GLOBAL_MAN_DIR or get_expanded_environ("PIPX_GLOBAL_MAN_DIR") self._default_man = DEFAULT_PIPX_GLOBAL_MAN_DIR self._base_completion = OVERRIDE_PIPX_GLOBAL_COMPLETION_DIR or get_expanded_environ( "PIPX_GLOBAL_COMPLETION_DIR" ) self._default_completion = Path("/usr/local/share") self._default_log = self._default_home / "logs" self._default_cache = self._default_home / ".cache" self._default_trash = self._default_home / "trash" self._base_shared_libs = OVERRIDE_PIPX_GLOBAL_SHARED_LIBS self._fallback_home = None self._home_exists = self._base_home is not None self._clear_cached_paths() @property def venvs(self) -> Path: return self.home / "venvs" @cached_property def home(self) -> Path: return (self._base_home or self._fallback_home or self._default_home).absolute() @property def logs(self) -> Path: # a legacy home keeps holding the venvs, yet its logs and cache belong in the platform locations, so only an # explicit PIPX_HOME pulls them back under the home return self.home / "logs" if self._base_home else self._default_log @property def venv_cache(self) -> Path: return self.home / ".cache" if self._base_home else self._default_cache @property def trash(self) -> Path: # renaming into the trash has to stay on one filesystem, so it follows the venvs under the home return self.home / ".trash" if self._home_exists else self._default_trash @cached_property def bin_dir(self) -> Path: return (self._base_bin or self._default_bin).resolve() @cached_property def man_dir(self) -> Path: return (self._base_man or self._default_man).resolve() @cached_property def completion_dir(self) -> Path: return (self._base_completion or self._default_completion).resolve() @cached_property def shared_libs(self) -> Path: return (self._base_shared_libs or self.home / "shared").resolve() @property def standalone_python_cachedir(self) -> Path: return self.home / "py" def log_warnings(self) -> None: if self._fallback_home is not None and self._fallback_home.exists() and self.home != self._fallback_home: _LOGGER.info( pipx_wrap( ( f"Both a specific pipx home folder ({self.home}) and the fallback " f"pipx home folder ({self._fallback_home}) exist. If you are done migrating from the " "fallback to the new location, it is safe to delete the fallback location." ), subsequent_indent=" " * 4, ) ) def get_expanded_environ(env_name: str) -> Path | None: return Path(value).expanduser().absolute() if (value := get_environment_value(env_name)) else None CTX: Final[_PathContext] = _PathContext() __all__ = [ "CTX", "DEFAULT_PIPX_BIN_DIR", "DEFAULT_PIPX_GLOBAL_BIN_DIR", "DEFAULT_PIPX_GLOBAL_HOME", "DEFAULT_PIPX_GLOBAL_MAN_DIR", "DEFAULT_PIPX_HOME", "DEFAULT_PIPX_MAN_DIR", "OVERRIDE_PIPX_BIN_DIR", "OVERRIDE_PIPX_COMPLETION_DIR", "OVERRIDE_PIPX_GLOBAL_BIN_DIR", "OVERRIDE_PIPX_GLOBAL_COMPLETION_DIR", "OVERRIDE_PIPX_GLOBAL_HOME", "OVERRIDE_PIPX_GLOBAL_MAN_DIR", "OVERRIDE_PIPX_GLOBAL_SHARED_LIBS", "OVERRIDE_PIPX_HOME", "OVERRIDE_PIPX_MAN_DIR", "OVERRIDE_PIPX_SHARED_LIBS", "get_expanded_environ", ] pipx-1.18.16/src/pipx/pipx_metadata_file.py000066400000000000000000000364161527355705200206350ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "pathlib", "pipx.backends._base", "pipx.package_specifier", "pipx.redact", "pipx.util", "pipx.wrap", "tempfile", ] import json import logging from contextlib import contextmanager, suppress from dataclasses import asdict, dataclass, field, replace from pathlib import Path from tempfile import NamedTemporaryFile from typing import TYPE_CHECKING, Any, Final, TypedDict, cast from pipx.backends._base import KNOWN_BACKENDS from pipx.emojis import HAZARD from pipx.package_specifier import replayable_pip_args from pipx.redact import redact_url_credentials from pipx.util import PipxError from pipx.wrap import pipx_wrap if TYPE_CHECKING: from collections.abc import Generator _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) PIPX_INFO_FILENAME: Final[str] = "pipx_metadata.json" # bump only when the file format changes _METADATA_VERSION: Final[str] = "0.12" # not total, as files an older pipx wrote lack some of these keys class _RawPackageInfo(TypedDict, total=False): package: str | None package_or_url: str | None pip_args: list[str] include_dependencies: bool include_apps: bool apps: list[str] app_paths: list[Path] apps_of_dependencies: list[str] app_paths_of_dependencies: dict[str, list[Path]] package_version: str expected_apps: list[str] lock_file: Path | None include_resources_from: list[str] cooldown_days: int | None man_pages: list[str] man_paths: list[Path] man_pages_of_dependencies: list[str] man_paths_of_dependencies: dict[str, list[Path]] completions: list[str] completion_paths: list[Path] completions_of_dependencies: list[str] completion_paths_of_dependencies: dict[str, list[Path]] suffix: str pinned: bool class _RawMetadata(TypedDict, total=False): environment: str | None main_package: _RawPackageInfo python_version: str | None source_interpreter: Path | None venv_args: list[str] injected_packages: dict[str, _RawPackageInfo] backend: str exposure_enabled: bool pipx_metadata_version: str pinned: bool class _RawSpecVenvEntry(TypedDict): metadata: _RawMetadata # what `pipx list --json` writes and `pipx install-all` reads class _RawSpecFile(TypedDict, total=False): venvs: dict[str, _RawSpecVenvEntry] pipx_spec_version: str class PipxMetadata: def __init__(self, venv_dir: Path, *, read: bool = True) -> None: self.venv_dir = venv_dir self.main_package = PackageInfo( package=None, package_or_url=None, pip_args=[], include_dependencies=False, include_apps=True, # writing refuses a main package without its apps apps=[], app_paths=[], apps_of_dependencies=[], app_paths_of_dependencies={}, package_version="", ) self.environment: str | None = None self.python_version: str | None = None self.source_interpreter: Path | None = None self.venv_args: list[str] = [] self.injected_packages: dict[str, PackageInfo] = {} self.backend: str = "pip" self.exposure_enabled: bool = True # stays None until a read succeeds, which tells a fresh instance from one holding the values on disk self.read_metadata_version: str | None = None if read: self.read() def read(self) -> None: with ( suppress(AttributeError, KeyError, OSError, PipxError, TypeError, ValueError), (self.venv_dir / PIPX_INFO_FILENAME).open("rb") as pipx_metadata_fh, ): payload: _RawMetadata = json.load(pipx_metadata_fh, object_hook=_json_decoder_object_hook) self.from_dict(payload) def from_dict(self, input_dict: _RawMetadata) -> None: self._convert_legacy_metadata(input_dict) self.environment = input_dict.get("environment") self.main_package = self._package_info(input_dict["main_package"]) self.python_version = input_dict.get("python_version") self.source_interpreter = ( Path(source_interpreter) if (source_interpreter := input_dict.get("source_interpreter")) else None ) # the command line can only record --system-site-packages; anything else came from an edited file and would # reach python -m venv, where an extra operand or --clear DIR rewrites a directory outside pipx recorded_venv_args: Final[list[str]] = input_dict.get("venv_args", []) self.venv_args = [arg for arg in recorded_venv_args if arg == "--system-site-packages"] if ignored_venv_args := [arg for arg in recorded_venv_args if arg != "--system-site-packages"]: _LOGGER.warning( "%s: ignoring recorded venv arguments %s; only --system-site-packages is replayed.", self.venv_dir.name, " ".join(ignored_venv_args), ) # key by the record's own package name, as Venv.update_package_metadata writes it, so a read-modify-write cycle # keeps keys stable and files where an earlier pipx saved a suffixed key (black@1) heal self.injected_packages = { data.get("package") or name: self._package_info(data) for (name, data) in input_dict.get("injected_packages", {}).items() } # an unknown backend, from an edited file or one a newer pipx wrote, reads as pip so that list and uninstall # keep working recorded_backend = input_dict.get("backend") or "pip" if recorded_backend not in KNOWN_BACKENDS: _LOGGER.warning( "%s: ignoring unknown recorded backend %r; treating as 'pip'.", self.venv_dir.name, recorded_backend, ) recorded_backend = "pip" self.backend = recorded_backend self.exposure_enabled = input_dict["exposure_enabled"] self.read_metadata_version = input_dict.get("pipx_metadata_version") def _convert_legacy_metadata(self, metadata_dict: _RawMetadata) -> None: version: Final[str] = metadata_dict["pipx_metadata_version"] if version == "0.4": metadata_dict["pinned"] = False elif version in {"0.2", "0.3"}: metadata_dict["source_interpreter"] = None elif version == "0.1": main_package_data: Final[_RawPackageInfo] = metadata_dict["main_package"] package_name: Final[str | None] = main_package_data["package"] if package_name is not None and package_name != self.venv_dir.name: # 0.1 recorded no suffix, so take it from the venv name main_package_data["suffix"] = self.venv_dir.name.replace(package_name, "") metadata_dict["source_interpreter"] = None elif version not in {_METADATA_VERSION, "0.11", "0.10", "0.9", "0.8", "0.7", "0.6", "0.5"}: msg = f""" {self.venv_dir.name}: Unknown metadata version {version}, written by a later version of pipx. Upgrade pipx to manage this package. """ raise PipxError(msg) # files before 0.6 record no backend metadata_dict.setdefault("backend", "pip") metadata_dict.setdefault("exposure_enabled", True) # an earlier pipx named include_resources_from include_apps_from; PackageInfo takes no such field, so the # TypedDict leaves the old key out and the pop goes through an untyped view for package_data in (metadata_dict["main_package"], *(metadata_dict.get("injected_packages") or {}).values()): raw_package_data = cast("dict[str, Any]", package_data) if "include_apps_from" in raw_package_data: package_data.setdefault("include_resources_from", raw_package_data.pop("include_apps_from")) def _package_info(self, data: _RawPackageInfo) -> PackageInfo: return replace(PackageInfo(**data), pip_args=replayable_pip_args(data["pip_args"], self.venv_dir)) def write(self) -> None: if ( self.main_package.package is None or self.main_package.package_or_url is None or not self.main_package.include_apps ): _LOGGER.debug("PipxMetadata corrupt:\n%s", redact_url_credentials(str(self.to_dict()))) msg = "Internal Error: PipxMetadata is corrupt, cannot write." raise PipxError(msg) self._replace_file( json.dumps(self.to_dict(), indent=4, sort_keys=True, cls=JsonEncoderHandlesPath).encode("utf-8") ) def to_dict(self) -> dict[str, Any]: # a plain dict, as asdict() returns one and consumers only serialize it to JSON return { "environment": self.environment, "main_package": asdict(self.main_package), "python_version": self.python_version, "source_interpreter": self.source_interpreter, "venv_args": self.venv_args, "injected_packages": {name: asdict(data) for (name, data) in self.injected_packages.items()}, "backend": self.backend, "exposure_enabled": self.exposure_enabled, "pipx_metadata_version": _METADATA_VERSION, } def _replace_file(self, content: bytes) -> None: # replace instead of writing through: uv hard-links installed files to its cache temporary_path: Path | None = None try: with NamedTemporaryFile( dir=self.venv_dir, prefix=f".{PIPX_INFO_FILENAME}.", suffix=".tmp", delete=False, ) as pipx_metadata_fh: temporary_path = Path(pipx_metadata_fh.name) pipx_metadata_fh.write(content) temporary_path.replace(self.venv_dir / PIPX_INFO_FILENAME) except OSError: _LOGGER.warning( pipx_wrap( f""" {HAZARD} Unable to write {PIPX_INFO_FILENAME} to {self.venv_dir}. This may cause future pipx operations involving {self.venv_dir.name} to fail or behave incorrectly. """, subsequent_indent=" " * 4, ) ) finally: if temporary_path is not None: with suppress(OSError): temporary_path.unlink() @contextmanager def preserved(self) -> Generator[None, None, None]: # installers unpack a wheel's data files into the venv root, so any package can ship a file of this name before: Final[bytes | None] = self._file_content() try: yield finally: if self._file_content() != before: if before is None: (self.venv_dir / PIPX_INFO_FILENAME).unlink(missing_ok=True) else: self._replace_file(before) def _file_content(self) -> bytes | None: try: return (self.venv_dir / PIPX_INFO_FILENAME).read_bytes() except OSError: return None @dataclass(frozen=True, slots=True, kw_only=True) class PackageInfo: package: str | None package_or_url: str | None pip_args: list[str] include_dependencies: bool include_apps: bool apps: list[str] app_paths: list[Path] apps_of_dependencies: list[str] app_paths_of_dependencies: dict[str, list[Path]] package_version: str expected_apps: list[str] = field(default_factory=list) lock_file: Path | None = None include_resources_from: list[str] = field(default_factory=list) cooldown_days: int | None = None man_pages: list[str] = field(default_factory=list) man_paths: list[Path] = field(default_factory=list) man_pages_of_dependencies: list[str] = field(default_factory=list) man_paths_of_dependencies: dict[str, list[Path]] = field(default_factory=dict) completions: list[str] = field(default_factory=list) completion_paths: list[Path] = field(default_factory=list) completions_of_dependencies: list[str] = field(default_factory=list) completion_paths_of_dependencies: dict[str, list[Path]] = field(default_factory=dict) suffix: str = "" pinned: bool = False @property def app_paths_to_expose(self) -> list[Path]: return [ *(self.app_paths if self.include_apps else ()), *self._included_dependency_paths(self.app_paths_of_dependencies), ] def _included_dependency_paths(self, paths_by_package: dict[str, list[Path]]) -> list[Path]: included_packages: Final[set[str]] = set(self.include_resources_from) return [ path for package, paths in paths_by_package.items() if self.include_dependencies or package in included_packages for path in paths ] @property def apps_to_expose(self) -> list[str]: return [*(self.apps if self.include_apps else ()), *self.included_dependency_apps] @property def included_dependency_apps(self) -> list[str]: included_names: Final[set[str]] = { path.name for path in self._included_dependency_paths(self.app_paths_of_dependencies) } return [app for app in self.apps_of_dependencies if app in included_names] @property def man_paths_to_expose(self) -> list[Path]: return [ *(self.man_paths if self.include_apps else ()), *self._included_dependency_paths(self.man_paths_of_dependencies), ] @property def man_pages_to_expose(self) -> list[str]: included_names: Final[set[str]] = { str(Path(path.parent.name) / path.name) for path in self._included_dependency_paths(self.man_paths_of_dependencies) } return [ *(self.man_pages if self.include_apps else ()), *(page for page in self.man_pages_of_dependencies if page in included_names), ] @property def completion_paths_to_expose(self) -> list[Path]: return [ *(self.completion_paths if self.include_apps else ()), *self._included_dependency_paths(self.completion_paths_of_dependencies), ] @property def completions_to_expose(self) -> list[str]: included_names: Final[set[str]] = { f"{path.parent.parent.name}/{path.parent.name}/{path.name}" for path in self._included_dependency_paths(self.completion_paths_of_dependencies) } return [ *(self.completions if self.include_apps else ()), *(completion for completion in self.completions_of_dependencies if completion in included_names), ] def _json_decoder_object_hook(json_dict: dict[str, Any]) -> dict[str, Any] | Path: if json_dict.get("__type__") == "Path" and "__Path__" in json_dict: return Path(json_dict["__Path__"]) return json_dict class JsonEncoderHandlesPath(json.JSONEncoder): def default(self, o: object) -> dict[str, str]: if isinstance(o, Path): return {"__type__": "Path", "__Path__": str(o)} return super().default(o) def load_spec_file(path: Path) -> _RawSpecFile: with Path(path).open(encoding="utf-8") as handle: return json.load(handle, object_hook=_json_decoder_object_hook) __all__ = [ "PIPX_INFO_FILENAME", "JsonEncoderHandlesPath", "PackageInfo", "PipxMetadata", "load_spec_file", ] pipx-1.18.16/src/pipx/redact.py000066400000000000000000000024541527355705200162530ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["re"] import re from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Sequence from pathlib import Path def redact_command_line(cmd: Sequence[str | Path]) -> str: return " ".join(redact_url_credentials(str(c)) for c in cmd) def redact_url_credentials(text: str) -> str: # index and package URLs can carry credentials, which must not reach the log file or the verbose output. A bare user # such as git@ may be a token, and so may the user before an empty or placeholder password (TOKEN:@), so with a # colon both parts are masked. # # The authority ends at the first /, ? or #, and its userinfo runs to the last @ before that. A password may hold a # space, so callers mask each argument before joining a command line; a line break still ends it, as installer # output holds a URL per line. Across a space the match can start at a bare token and end at a later word's @, so a # user part holding an @ is masked as a bare user. return re.sub( r"(?<=://)([^/?#\r\n]*)@", lambda match: "****:****@" if (userinfo := match[1].partition(":"))[1] and "@" not in userinfo[0] else "****@", text, ) __all__ = [ "redact_command_line", "redact_url_credentials", ] pipx-1.18.16/src/pipx/requires_python.py000066400000000000000000000133001527355705200202410ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["packaging.specifiers", "pipx.interpreter", "pipx.subprocess_runner", "re"] import logging import re from typing import TYPE_CHECKING, Final from packaging.specifiers import InvalidSpecifier, SpecifierSet from pipx.constants import MINIMUM_PYTHON_VERSION, FetchPythonOptions from pipx.interpreter import InterpreterResolutionError, find_python_interpreter from pipx.subprocess_runner import run_subprocess from pipx.util import PipxError if TYPE_CHECKING: from collections.abc import Iterator _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) # the newest release pipx looks for when a package rules out the default interpreter _NEWEST_PYTHON_MINOR: Final[int] = 15 class IncompatiblePythonError(PipxError): def __init__(self, constraint: SpecifierSet) -> None: self.constraint: Final[SpecifierSet] = constraint super().__init__(f"This package needs a Python matching {str(constraint)!r}.") def rejected_constraint(backend_output: str) -> SpecifierSet | None: # pip and uv print these and install nothing; uv only checks lower bounds, so the metadata check covers the rest match: Final[re.Match[str] | None] = re.search( r""" requires\ a\ different\ Python:\s*\S+\s*not\ in\ '(?P[^']*)' | current\s+Python\s+version\s+\(\S+\)\s+does\s+not\s+satisfy\s+Python(?P\S+?),?(?=\s|$) | current\s+Python\s+version\s+\(\S+\)\s+does\s+not\s+satisfy\s+all\s+of:\n (?P(?:[ \t]*Python\S+[ \t]*\n?)+) """, backend_output, re.VERBOSE, ) if match is None: return None if match["uv_union"] is None: return _parse(match["pip"] or match["uv"]) # uv splits a constraint with an exclusion, such as >=3.12,!=3.15.*, into ranges any of which will do; a set of # specifiers cannot express that union, so pipx takes the range that holds the newest Python it would pick anyway ranges: Final[list[SpecifierSet]] = [ constraint for text in re.findall(r"Python(\S+)", match["uv_union"]) if (constraint := _parse(text)) is not None ] return next( (constraint for minor in _candidate_versions() for constraint in ranges if _minor_overlaps(constraint, minor)), next(iter(ranges), None), ) def _parse(requires_python: str) -> SpecifierSet | None: try: return SpecifierSet(requires_python) except InvalidSpecifier: _LOGGER.info("Cannot read the Python constraint %r", requires_python) return None def _candidate_versions() -> Iterator[str]: return (f"3.{minor}" for minor in range(_NEWEST_PYTHON_MINOR, int(MINIMUM_PYTHON_VERSION.split(".")[1]) - 1, -1)) def _minor_overlaps(constraint: SpecifierSet, minor: str) -> bool: # no CPython minor has reached 40 patch releases, so the scan misses none return any(constraint.contains(f"{minor}.{patch}", prereleases=True) for patch in range(40)) def unsatisfied_constraint(requires_python: str | None, python_version: str) -> SpecifierSet | None: if not requires_python or (constraint := _parse(requires_python)) is None: return None return None if constraint.contains(python_version, prereleases=True) else constraint def unsatisfied_by_interpreter(requires_python: str | None, interpreter: str) -> SpecifierSet | None: # an interpreter whose version pipx cannot read passes if (version := _interpreter_version(interpreter)) is None: return None return unsatisfied_constraint(requires_python, version) def _interpreter_version(interpreter: str) -> str | None: process: Final = run_subprocess( [interpreter, "-c", "import sys; print('.'.join(str(part) for part in sys.version_info[:3]))"], capture_stderr=False, log_stdout=False, ) return process.stdout.strip() or None if process.returncode == 0 else None def interpreter_for(constraint: SpecifierSet, fetch_python: FetchPythonOptions) -> str: overlapping: Final[list[str]] = [minor for minor in _candidate_versions() if _minor_overlaps(constraint, minor)] if not overlapping: msg = f"No Python that pipx supports satisfies {str(constraint)!r}." raise PipxError(msg) for minor in overlapping: if (interpreter := _matching_interpreter(minor, constraint, FetchPythonOptions.NEVER)) is not None: return interpreter if fetch_python is FetchPythonOptions.NEVER: msg = ( f"This package needs a Python matching {str(constraint)!r} and none of the interpreters on this " f"system does. Install one of {', '.join(overlapping)}, name it with --python, or pass " f"--fetch-python=missing to let pipx download it." ) raise PipxError(msg) for minor in overlapping: if (interpreter := _matching_interpreter(minor, constraint, FetchPythonOptions.MISSING)) is not None: return interpreter msg = f"pipx could not obtain a Python matching {str(constraint)!r}." raise PipxError(msg) def _matching_interpreter(minor: str, constraint: SpecifierSet, fetch_python: FetchPythonOptions) -> str | None: try: interpreter: Final[str] = find_python_interpreter(minor, fetch_python) except (InterpreterResolutionError, PipxError): return None # the resolved interpreter is some patch release of the minor, so pipx checks the constraint against its own version if (version := _interpreter_version(interpreter)) is not None and constraint.contains(version, prereleases=True): _LOGGER.info("Python %s satisfies %s", version, constraint) return interpreter return None __all__ = [ "interpreter_for", "rejected_constraint", "unsatisfied_by_interpreter", "unsatisfied_constraint", ] pipx-1.18.16/src/pipx/result.py000066400000000000000000000075401527355705200163300ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["json"] import enum import json import logging import sys from dataclasses import asdict, dataclass from typing import Any, Final, Generic, TypeVar from pipx.constants import EXIT_CODE_OK, ExitCode _PIPX_RESULT_VERSION: Final[str] = "1" _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) class OutputLevel(enum.IntEnum): NORMAL = 0 ERROR = 1 CRITICAL = 2 class OutputFormat(enum.StrEnum): HUMAN = "human" JSON = "json" class OutputStream(enum.StrEnum): LOG = "log" STDOUT = "stdout" STDERR = "stderr" @dataclass(frozen=True, slots=True, kw_only=True) class OutputMessage: text: str stream: OutputStream = OutputStream.STDOUT level: OutputLevel = OutputLevel.NORMAL # a choice pipx made on the user's behalf, which JSON output still reports on stderr beside the envelope notice: bool = False # scripts key on the code and the identity fields, so they stay stable across releases @dataclass(frozen=True, slots=True, kw_only=True) class OperationError: code: str message: str environment: str | None = None package: str | None = None @dataclass(frozen=True, slots=True, kw_only=True) class OperationData: def to_dict(self) -> dict[str, Any]: return asdict(self) _PayloadT = TypeVar("_PayloadT", bound=OperationData) @dataclass(frozen=True, slots=True, kw_only=True) class OperationResult(Generic[_PayloadT]): command: tuple[str, ...] data: _PayloadT messages: tuple[OutputMessage, ...] = () exit_code: ExitCode = EXIT_CODE_OK errors: tuple[OperationError, ...] = () # whether the operation produced at least one successful outcome; distinguishes a partial run from a total failure succeeded: bool = False @property def status(self) -> str: if not self.errors: return "success" return "partial" if self.succeeded else "error" def render_result(result: OperationResult[_PayloadT], *, output: OutputFormat, quiet: int) -> ExitCode: if output is OutputFormat.JSON: envelope: Final[str] = _render_envelope( result.command, result.status, result.exit_code, result.data.to_dict(), result.errors ) print(envelope) # ruff:ignore[print] # the JSON envelope is the command's machine-readable stdout payload for message in result.messages: if message.notice: print(message.text, file=sys.stderr) # ruff:ignore[print] # user-facing CLI output return result.exit_code render_messages(result.messages, quiet=quiet) return result.exit_code def _render_envelope( command: tuple[str, ...], status: str, exit_code: ExitCode, data: dict[str, Any], errors: tuple[OperationError, ...], ) -> str: return json.dumps( { "pipx_result_version": _PIPX_RESULT_VERSION, "command": list(command), "status": status, "exit_code": int(exit_code), "data": data, "errors": [asdict(error) for error in errors], }, indent=2, sort_keys=True, ) def render_messages(messages: tuple[OutputMessage, ...], *, quiet: int) -> None: for message in messages: if quiet <= message.level: if message.stream is OutputStream.LOG: _LOGGER.warning(message.text) else: print(message.text, file=sys.stderr if message.stream is OutputStream.STDERR else sys.stdout) def error_envelope(command: tuple[str, ...], error: OperationError, exit_code: ExitCode) -> str: return _render_envelope(command, "error", exit_code, {}, (error,)) __all__ = [ "OperationData", "OperationError", "OperationResult", "OutputFormat", "OutputLevel", "OutputMessage", "OutputStream", "error_envelope", "render_messages", "render_result", ] pipx-1.18.16/src/pipx/script.py000066400000000000000000000307031527355705200163130ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "base64", "packaging.specifiers", "packaging.utils", "pathlib", "pipx.redact", "pipx.util", "tempfile", "tomllib", "urllib.parse", ] import base64 import re import stat import tomllib import urllib.parse from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Final from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.utils import canonicalize_name from pipx.redact import redact_url_credentials from pipx.util import PipxError if TYPE_CHECKING: from collections.abc import Generator from http.client import HTTPResponse from urllib.response import addinfourl from zipfile import ZipFile _INLINE_METADATA: Final[re.Pattern[str]] = re.compile( r""" ^\#\ ///\ (?P[a-zA-Z0-9-]+)$ \s (?P (^\#(|\ .*)$ \s)+ ) ^\#\ ///$ """, re.VERBOSE | re.MULTILINE, ) @contextmanager def installable_script( package_name: str, package_or_url: str, expected_apps: tuple[str, ...] ) -> Generator[str, None, None]: if (resolved_name := script_name_from_spec(package_or_url, expected_apps)) is None: yield package_or_url return if canonicalize_name(package_name) != resolved_name: msg = f"Script app {resolved_name!r} does not match package name {package_name!r}." raise PipxError(msg) script: Final[_Script] = _read_script(package_or_url, expected_apps) with TemporaryDirectory(prefix="pipx-script-") as directory: yield str(_write_wheel(Path(directory), package_name, script)) def script_name_from_spec(package_spec: str, expected_apps: tuple[str, ...]) -> str | None: if not is_script_spec(package_spec): return None if len(expected_apps) > 1: msg = "A script can provide one --app name." raise PipxError(msg) app: Final[str] = _script_app(package_spec, expected_apps) if re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9._-]*", app) is None: msg = f"Invalid script app name {app!r}. Pass --app with a portable command name." raise PipxError(msg) return canonicalize_name(app) def is_script_spec(package_spec: str) -> bool: if local_script(package_spec) is not None: return True parsed: Final[urllib.parse.SplitResult] = urllib.parse.urlsplit(package_spec) return parsed.scheme in {"http", "https"} and parsed.path.lower().endswith(".py") def local_script(package_spec: str) -> Path | None: # a single-letter scheme is a Windows drive; any longer one is a URL, whose path must not resolve below the cwd if len(urllib.parse.urlsplit(package_spec).scheme) > 1: return None path: Final[Path] = Path(package_spec).expanduser() if not path.is_file(): return None if path.suffix.lower() == ".py": return path.resolve() # a bare name is a package on the index, as pip reads it, so a file in the working directory must not shadow it if not path.suffix and package_spec != path.name and _has_inline_script_metadata(path): return path.resolve() return None def _has_inline_script_metadata(path: Path) -> bool: try: content: Final[str] = path.read_text(encoding="utf-8") except UnicodeDecodeError: return False return any(match.group("type") == "script" for match in _INLINE_METADATA.finditer(content.removeprefix("๏ปฟ"))) def _script_app(package_spec: str, expected_apps: tuple[str, ...]) -> str: if expected_apps: return expected_apps[0] path: Final[Path] = Path(package_spec).expanduser() return path.stem if path.is_file() else Path(urllib.parse.urlsplit(package_spec).path).stem def _read_script(package_or_url: str, expected_apps: tuple[str, ...]) -> _Script: content: Final[str] = ( script.read_text(encoding="utf-8") if (script := local_script(package_or_url)) is not None else download_script(package_or_url) ).removeprefix("๏ปฟ") try: metadata: Final[ScriptMetadata | None] = read_script_metadata(content) except ValueError as error: msg = f"Invalid inline metadata in script {redact_url_credentials(package_or_url)}: {error}." raise PipxError(msg) from error if metadata is None: msg = f"Script {redact_url_credentials(package_or_url)} has no PEP 723 inline metadata block." raise PipxError(msg) return _Script(app=_script_app(package_or_url, expected_apps), content=content, metadata=metadata) # a PEP 723 script larger than this is almost certainly the wrong URL; bound the read so a stalled or huge response # cannot hang pipx or exhaust memory _MAX_SCRIPT_BYTES: Final[int] = 10 * 1024 * 1024 def download_script(url: str) -> str: from http.client import HTTPException # ruff:ignore[import-outside-top-level] # slows startup from urllib.request import urlopen # ruff:ignore[import-outside-top-level] # pulls in http.client, slows startup _reject_url_credentials(url) try: with urlopen(url, timeout=30) as response: # ruff:ignore[suspicious-url-open-usage] # callers choose the allowed schemes data: Final[bytes] = response.read(_MAX_SCRIPT_BYTES + 1) _reject_incomplete_script(url, response, data) return data.decode(response.headers.get_content_charset() or "utf-8") # a body in another encoding than declared or an unknown charset name fails the decode, not the download except (OSError, HTTPException, UnicodeDecodeError, LookupError) as error: msg = f"Unable to read script {redact_url_credentials(url)}: {error}" raise PipxError(msg) from error def _reject_url_credentials(url: str) -> None: # urllib's HTTP handler keeps the userinfo in the host it connects to (python/cpython#78809), so credentials never # authenticate the download and a failed host:port split puts the password into the error message; its FTP handler # does log in with them if (parts := urllib.parse.urlsplit(url)).scheme in {"http", "https"} and parts.username is not None: msg = f"Script URL {redact_url_credentials(url)} must not contain credentials." raise PipxError(msg) def _reject_incomplete_script(url: str, response: HTTPResponse | addinfourl, data: bytes) -> None: if len(data) > _MAX_SCRIPT_BYTES: msg = f"Script {redact_url_credentials(url)} is larger than {_MAX_SCRIPT_BYTES} bytes; refusing to download it." raise PipxError(msg) # read(amt) returns a body cut short of its Content-Length without raising (python/cpython#115997) and leaves the # missing byte count in length, which file:// responses lack; a script missing its tail can still run halfway if missing := getattr(response, "length", None): msg = f"Script {redact_url_credentials(url)} ended {missing} bytes short of its Content-Length." raise PipxError(msg) def read_script_metadata(content: str | Path) -> ScriptMetadata | None: normalized_text: Final[str] = ( (content.read_text(encoding="utf-8") if isinstance(content, Path) else content) .removeprefix("๏ปฟ") .replace("\r\n", "\n") ) matches: Final[list[re.Match[str]]] = [ match for match in _INLINE_METADATA.finditer(normalized_text) if match.group("type") == "script" ] if not matches: if any(match.group("type") == "pyproject" for match in _INLINE_METADATA.finditer(normalized_text)): msg = "Use `# /// script` instead of the obsolete `# /// pyproject` metadata block" raise ValueError(msg) return None if len(matches) > 1: msg = "Multiple `# /// script` metadata blocks found" raise ValueError(msg) raw: Final[dict[str, object]] = tomllib.loads( "".join( line[2:] if line.startswith("# ") else line[1:] for line in matches[0].group("content").splitlines(keepends=True) ) ) dependencies: Final[object] = raw.get("dependencies", []) if not isinstance(dependencies, list) or not all(isinstance(dependency, str) for dependency in dependencies): msg = "Inline script `dependencies` must be an array of strings." raise PipxError(msg) from packaging.requirements import ( # ruff:ignore[import-outside-top-level] # parser slows startup InvalidRequirement, Requirement, ) normalized: Final[list[str]] = [] try: for dependency in dependencies: normalized.append(str(Requirement(dependency))) except InvalidRequirement as error: msg = f"Invalid requirement {redact_url_credentials(dependency)}: {redact_url_credentials(str(error))}" # packaging quotes the requirement in its message, so chaining it would log the credentials with the traceback raise PipxError(msg) from None return ScriptMetadata( dependencies=tuple(normalized), requires_python=_normalize_requires_python(raw.get("requires-python")) ) def _normalize_requires_python(value: object) -> str | None: if value is None: return None if not isinstance(value, str): msg = "Inline script `requires-python` must be a string." raise PipxError(msg) try: return str(SpecifierSet(value)) except InvalidSpecifier as error: msg = f"Invalid `requires-python` value {value}: {error}" raise PipxError(msg) from error @dataclass(frozen=True, slots=True, kw_only=True) class ScriptMetadata: dependencies: tuple[str, ...] requires_python: str | None @dataclass(frozen=True, slots=True, kw_only=True) class _Script: app: str content: str metadata: ScriptMetadata def _write_wheel(directory: Path, package_name: str, script: _Script) -> Path: import hashlib # ruff:ignore[import-outside-top-level] # loads OpenSSL, slows startup from zipfile import ZIP_DEFLATED, ZipFile # ruff:ignore[import-outside-top-level] # slows startup digest: Final[str] = hashlib.sha256(script.content.encode()).hexdigest()[:12] version: Final[str] = f"0+{digest}" distribution: Final[str] = re.sub(r"[-_.]+", "_", package_name) module: Final[str] = f"_pipx_script_{distribution}" stem: Final[str] = f"{distribution}-{version}" dist_info: Final[str] = f"{stem}.dist-info" members: Final[dict[str, bytes]] = { f"{module}/__init__.py": b"", f"{module}/_runner.py": b"""import runpy from pathlib import Path def main() -> None: runpy.run_path(str(Path(__file__).with_name("_script.py")), run_name="__main__") __all__ = [ "main", ] """, f"{module}/_script.py": script.content.encode(), f"{dist_info}/entry_points.txt": f"[console_scripts]\n{script.app} = {module}._runner:main\n".encode(), f"{dist_info}/METADATA": _metadata(package_name, version, script.metadata), f"{dist_info}/WHEEL": b"Wheel-Version: 1.0\nGenerator: pipx\nRoot-Is-Purelib: true\nTag: py3-none-any\n", } record: Final[str] = f"{dist_info}/RECORD" wheel: Final[Path] = directory / f"{stem}-py3-none-any.whl" with ZipFile(wheel, "w", compression=ZIP_DEFLATED) as archive: for name, data in members.items(): _write_member(archive, name, data) archive.writestr( record, "".join(f"{name},sha256={_digest(data)},{len(data)}\n" for name, data in members.items()) + f"{record},,\n", ) return wheel def _metadata(package_name: str, version: str, metadata: ScriptMetadata) -> bytes: lines: Final[list[str]] = ["Metadata-Version: 2.3", f"Name: {package_name}", f"Version: {version}"] if metadata.requires_python is not None: lines.append(f"Requires-Python: {metadata.requires_python}") lines.extend(f"Requires-Dist: {dependency}" for dependency in metadata.dependencies) return ("\n".join(lines) + "\n\n").encode() def _write_member(archive: ZipFile, name: str, data: bytes) -> None: from zipfile import ZIP_DEFLATED, ZipInfo # ruff:ignore[import-outside-top-level] # slows startup info: Final[ZipInfo] = ZipInfo(name) info.create_system = 3 info.external_attr = (stat.S_IFREG | 0o644) << 16 archive.writestr(info, data, compress_type=ZIP_DEFLATED) def _digest(data: bytes) -> str: import hashlib # ruff:ignore[import-outside-top-level] # loads OpenSSL, slows startup return base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode() __all__ = [ "ScriptMetadata", "download_script", "installable_script", "is_script_spec", "local_script", "read_script_metadata", "script_name_from_spec", ] pipx-1.18.16/src/pipx/self_install.py000066400000000000000000000064731527355705200174750ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["json", "shutil", "typing"] import json import os import shutil import sys from pathlib import Path from typing import Final, TypeAlias _JsonValue: TypeAlias = bool | int | float | str | list["_JsonValue"] | dict[str, "_JsonValue"] | None _PIPX_METADATA_FILENAME: Final[str] = "pipx_metadata.json" DEFAULT_PIPX_GLOBAL_HOME: Final[Path] = Path("/opt/pipx") def discover_self_managed_environment() -> dict[str, str]: if os.name == "nt": return {} venv: Final[Path] = Path(sys.prefix) if venv.parent.name != "venvs": return {} try: metadata: Final[_JsonValue] = json.loads((venv / _PIPX_METADATA_FILENAME).read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError): return {} if not isinstance(metadata, dict): return {} main_package: Final[_JsonValue] = metadata.get("main_package") if not isinstance(main_package, dict) or main_package.get("package") != "pipx": return {} environment: Final[dict[str, str]] = {} global_home: Final[Path] = Path(os.environ.get("PIPX_GLOBAL_HOME") or DEFAULT_PIPX_GLOBAL_HOME).expanduser() # a pipx installed with --global serves every user, so only --global may select the global paths if (home := venv.parent.parent).resolve() != global_home.resolve(): environment["PIPX_HOME"] = str(home) invoked_executable: Final[Path | None] = _invoked_executable(sys.argv[0]) # only the exposed link, a link outside the venv that leads into it, names the bin directory; compare real # locations, as ".." or a symlinked directory spell the venv's own script as a path outside the venv real_venv: Final[Path] = venv.resolve() if ( invoked_executable is not None and not invoked_executable.parent.resolve().is_relative_to(real_venv) and invoked_executable.resolve().is_relative_to(real_venv) ): environment["PIPX_BIN_DIR"] = str(invoked_executable.parent) source_interpreter: Final[_JsonValue] = metadata.get("source_interpreter") if ( isinstance(source_interpreter, dict) and source_interpreter.get("__type__") == "Path" and isinstance(source_path := source_interpreter.get("__Path__"), str) and backs_self_managed_venv(Path(source_path), venv) ): environment["PIPX_DEFAULT_PYTHON"] = source_path return environment def backs_self_managed_venv(source_interpreter: Path, venv: Path) -> bool: # a record pointing at a since-removed bootstrap venv must not outlive it or adopt whatever appears there later return source_interpreter.resolve() == (venv / "bin" / "python").resolve() def _invoked_executable(executable: str) -> Path | None: if os.sep in executable or (os.altsep is not None and os.altsep in executable): return Path(executable).absolute() if found := shutil.which(executable): return Path(found) return None _SELF_MANAGED_ENVIRONMENT: Final[dict[str, str]] = discover_self_managed_environment() def get_environment_value(name: str) -> str | None: return os.environ.get(name, _SELF_MANAGED_ENVIRONMENT.get(name)) __all__ = [ "DEFAULT_PIPX_GLOBAL_HOME", "backs_self_managed_venv", "discover_self_managed_environment", "get_environment_value", ] pipx-1.18.16/src/pipx/shared_libs.py000066400000000000000000000373361527355705200172770ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "packaging.version", "pathlib", "pipx", "pipx.fanout", "pipx.interpreter", "pipx.package_specifier", "pipx.requires_python", "pipx.subprocess_runner", "pipx.util", "threading", ] import datetime as dt import logging import os from contextlib import contextmanager from contextvars import ContextVar from pathlib import Path from threading import RLock from time import time as _time from typing import TYPE_CHECKING, Final from packaging.version import InvalidVersion, Version from pipx import paths from pipx.animate import animate from pipx.constants import WINDOWS from pipx.emojis import strtobool from pipx.fanout import in_turn from pipx.interpreter import get_default_python, read_pyvenv_cfg from pipx.package_specifier import absolute_path_pip_args from pipx.requires_python import unsatisfied_by_interpreter from pipx.subprocess_runner import get_site_packages, run_subprocess, subprocess_post_check from pipx.util import PipxError, get_venv_paths if TYPE_CHECKING: from collections.abc import Callable, Generator from subprocess import CompletedProcess from filelock import BaseFileLock _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) SHARED_LIBS_MAX_AGE_SEC: Final[float] = dt.timedelta(days=30).total_seconds() DISABLE_SHARED_LIBS_AUTO_UPGRADE: Final[str] = "PIPX_DISABLE_SHARED_LIBS_AUTO_UPGRADE" _SKIP_MAINTENANCE: Final[ContextVar[bool]] = ContextVar("skip_maintenance", default=False) _COOLDOWN_DAYS: Final[ContextVar[int | None]] = ContextVar("cooldown_days", default=None) # pip 26.2.2 vendors a certifi without the distrusted emSign root CA, see https://github.com/pypa/pipx/issues/2183 _PIP_FLOOR: Final[str] = "26.2.2" @contextmanager def skip_shared_libs_maintenance(*, enabled: bool) -> Generator[None, None, None]: token: Final = _SKIP_MAINTENANCE.set(enabled) try: yield finally: _SKIP_MAINTENANCE.reset(token) @contextmanager def shared_pip_cooldown(days: int | None) -> Generator[None]: token: Final = _COOLDOWN_DAYS.set(days) try: yield finally: _COOLDOWN_DAYS.reset(token) class _SharedLibs: def __init__(self) -> None: self._site_packages: dict[Path, list[Path]] = {} self._is_valid: bool | None = None self._rebuilds = 0 # fan-out tasks check the validity from several threads while one of them may rebuild the shared venv self._lock: Final[RLock] = RLock() self.has_been_updated_this_run = False self.has_been_logged_this_run = False def create(self, pip_args: list[str], *, verbose: bool) -> None: with in_turn(lambda: self._is_valid is True), self._lock, self._maintenance_lock(): self._create(pip_args, verbose=verbose) def _maintenance_lock(self) -> BaseFileLock: from filelock import FileLock # ruff:ignore[import-outside-top-level] # pulls in asyncio, slows startup self.root.parent.mkdir(parents=True, exist_ok=True) return FileLock(self.root.with_name(f".{self.root.name}.lock")) @property def root(self) -> Path: return paths.CTX.shared_libs def _create(self, pip_args: list[str], *, verbose: bool, reinstall_pip: bool | None = None) -> None: if not self.is_valid: python: Final[str] = get_default_python() if unsatisfied_by_interpreter(">=3.10", python) is not None: msg = ( "Shared pip requires Python 3.10 or newer. Set PIPX_DEFAULT_PYTHON to a compatible interpreter " "and run pipx upgrade-shared." ) raise PipxError(msg) # Run the venv CLI, then print the pip wheel the base interpreter's ensurepip would install, checking the # distro wheel directory first as ensurepip does. Debian strips both locations, which prints an empty line. script: Final[str] = ( "import sys, sysconfig, venv; from pathlib import Path; venv.main(sys.argv[1:]); " "wheel_dirs = [sysconfig.get_config_var('WHEEL_PKG_DIR'), " "Path(sysconfig.get_path('stdlib'), 'ensurepip', '_bundled')]; " "wheels = (str(wheel) for folder in wheel_dirs if folder " "for wheel in sorted(Path(folder).glob('pip-*.whl'), reverse=True)); " "print(next(wheels, ''))" ) with animate("creating shared libraries", do_animation=not verbose): create_process: Final[CompletedProcess[str]] = run_subprocess( [python, "-c", script, "--without-pip", "--clear", self.root], run_dir=str(self.root) ) subprocess_post_check(create_process) self._site_packages.clear() self._is_valid = None should_reinstall_pip: Final[bool] = ( not shared_libs_auto_upgrade_disabled() if reinstall_pip is None else reinstall_pip ) # Reinstall pip so OS-vendor patches cannot enter the shared environment. Importing pip straight from the # base interpreter's bundled wheel skips the ensurepip install that the reinstall would replace anyway; it # goes through PYTHONPATH and -m because pip on Windows refuses to replace itself when started by path. if should_reinstall_pip and (bundled_pip := create_process.stdout.strip()): try: self._install_pip( pip_args=[*pip_args, "--force-reinstall"], verbose=verbose, raises=True, pythonpath=bundled_pip ) except PipxError: # keep a working pip so a later offline run can still use the shared venv self._bootstrap_pip() raise self._is_valid = True else: self._bootstrap_pip() if should_reinstall_pip: self._upgrade(pip_args=[*pip_args, "--force-reinstall"], verbose=verbose, raises=True) else: _LOGGER.info("Skipping the shared pip reinstall because maintenance is disabled") # Remove setuptools before the .pth file exposes shared libraries to apps. Python <3.12 ensurepip installs # a copy that fails under 3.12+ because the standard library no longer includes distutils. if any(self.root.glob("*/site-packages/setuptools")) or any(self.root.glob("*/*/site-packages/setuptools")): run_subprocess([self.python_path, "-m", "pip", "--no-input", "uninstall", "-y", "setuptools"]) @property def is_valid(self) -> bool: with self._lock: if self._is_valid is None: self._is_valid = ( self.python_path.is_file() and self._python_supports_pip() and self._base_interpreter_exists(self.python_path) and self.pip_path.is_file() and (self.site_packages[0] / "pip" / "_internal" / "cli" / "main.py").is_file() ) return self._is_valid def _python_supports_pip(self) -> bool: if (version := read_pyvenv_cfg(self.root / "pyvenv.cfg").get("version")) is None: return True try: compatible = Version(version) >= Version("3.10") except InvalidVersion: return True if not compatible: _LOGGER.info( "Shared libraries use Python %s, but pip %s requires Python 3.10 or newer", version, _PIP_FLOOR ) return compatible @property def python_path(self) -> Path: _, python_path, _ = get_venv_paths(self.root) return python_path @staticmethod def _base_interpreter_exists(python_path: Path) -> bool: # on Windows the venv's python.exe is a launcher that finds the base interpreter through pyvenv.cfg, so it # stays on disk but cannot start once that interpreter is uninstalled if not WINDOWS: return True # a missing or unreadable pyvenv.cfg gives no evidence against the venv if (home := read_pyvenv_cfg(python_path.parent.parent / "pyvenv.cfg").get("home")) and not ( original_python := Path(home) / "python.exe" ).is_file(): _LOGGER.info("Shared libs venv references a missing Python interpreter: %s", original_python) return False return True @property def pip_path(self) -> Path: bin_path, _, _ = get_venv_paths(self.root) return bin_path / ("pip.exe" if WINDOWS else "pip") @property def site_packages(self) -> list[Path]: if self.python_path not in self._site_packages: self._site_packages[self.python_path] = get_site_packages(self.python_path) return self._site_packages[self.python_path] def _install_pip(self, *, pip_args: list[str], verbose: bool, raises: bool, pythonpath: str | None = None) -> None: cooldown_days: Final[int | None] = _COOLDOWN_DAYS.get() # pip before 26.1 rejects --uploaded-prior-to, and a reinstall under a cooldown fails when no release is old # enough, so first lay down the oldest pip pipx accepts; the cooldown upgrade then keeps it as a candidate if floor_first := bool(cooldown_days) and ( pythonpath is not None or "--force-reinstall" in pip_args or self._pip_below_floor() ): self._run_pip_install( pip_args, f"pip == {_PIP_FLOOR}", verbose=verbose, raises=raises, pythonpath=pythonpath ) pip_args = [arg for arg in pip_args if arg != "--force-reinstall"] pythonpath = None self._run_pip_install( [*pip_args, *(["--uploaded-prior-to", f"P{cooldown_days}D"] if cooldown_days else [])], f"pip >= {_PIP_FLOOR}", verbose=verbose, raises=raises and not floor_first, pythonpath=pythonpath, ) def _pip_below_floor(self) -> bool: from importlib.metadata import distributions # ruff:ignore[import-outside-top-level] # slows startup return any( Version(dist.version) < Version(_PIP_FLOOR) for dist in distributions(name="pip", path=[str(path) for path in self.site_packages]) ) def _run_pip_install( self, pip_args: list[str], requirement: str, *, verbose: bool, raises: bool, pythonpath: str | None ) -> None: _LOGGER.info("Upgrading shared libraries in %s", self.root) # pip runs inside the shared venv, so relative path values such as --cert must not follow that directory filtered_pip_args: Final[list[str]] = [ arg for arg in absolute_path_pip_args(pip_args, Path.cwd()) if arg != "--editable" ] if not verbose: filtered_pip_args.append("-q") try: with animate("upgrading shared libraries", do_animation=not verbose): upgrade_process: Final[CompletedProcess[str]] = run_subprocess( [ self.python_path, "-m", "pip", "--no-input", "--disable-pip-version-check", "install", "--upgrade", *filtered_pip_args, requirement, ], run_dir=str(self.root), env_overrides={"PYTHONPATH": pythonpath}, ) subprocess_post_check(upgrade_process) self.has_been_updated_this_run = True self.pip_path.touch() except Exception: # an automatic upgrade that fails must not abort the command that triggered it _LOGGER.exception("Failed to upgrade shared libraries", exc_info=not raises) if raises: raise def _bootstrap_pip(self) -> None: subprocess_post_check(run_subprocess([self.python_path, "-m", "ensurepip", "--upgrade", "--default-pip"])) def _upgrade(self, *, pip_args: list[str], verbose: bool, raises: bool) -> None: if not self.is_valid: self._create(verbose=verbose, pip_args=pip_args, reinstall_pip=True) return if self.has_been_updated_this_run: _LOGGER.info("Already upgraded libraries in %s", self.root) return self._install_pip(pip_args=pip_args, verbose=verbose, raises=raises) def run_repairing_pip( self, run: Callable[[], CompletedProcess[str]], *, pip_args: list[str], verbose: bool ) -> CompletedProcess[str]: # a failed pip run implicates the shared pip only when pip no longer imports, so ordinary install failures (an # unresolvable requirement, a build error) come back untouched rebuilds: Final[int] = self._rebuilds if (process := run()).returncode and self._rebuild_if_pip_broken(rebuilds, pip_args=pip_args, verbose=verbose): return run() return process def _rebuild_if_pip_broken(self, rebuilds: int, *, pip_args: list[str], verbose: bool) -> bool: # under the lock a rebuild holds: pip imports again midway through one, which would pass for an ordinary failure with self._lock: if self._rebuilds == rebuilds and self._pip_imports(): return False # fan-out tasks fail together on a broken pip; the first in task order rebuilds it and the others retry on # that, as when running one after another, instead of each clearing the venv the others are retrying from with in_turn(lambda: self._rebuilds != rebuilds), self._lock, self._maintenance_lock(): # rechecked as another pipx process may have rebuilt it while this one waited for the lock if self._rebuilds == rebuilds and not self._pip_imports(): _LOGGER.warning("pip in the shared libraries at %s does not import, rebuilding them", self.root) self._is_valid = False self._create(pip_args, verbose=verbose) self._rebuilds += 1 return True def _pip_imports(self) -> bool: return ( self.python_path.is_file() and not run_subprocess( [self.python_path, "-c", "from pip._internal.cli.main import main"], log_cmd_str="", log_stdout=False, log_stderr=False, ).returncode ) @property def needs_upgrade(self) -> bool: if self.has_been_updated_this_run: return False if not self.pip_path.is_file() or self._pip_below_floor(): return True time_since_last_update_sec: Final[float] = _time() - self.pip_path.stat().st_mtime if not self.has_been_logged_this_run: _LOGGER.info( "Time since last upgrade of shared libs, in seconds: %.0f. " "Upgrade will be run by pipx if greater than %.0f.", time_since_last_update_sec, SHARED_LIBS_MAX_AGE_SEC, ) self.has_been_logged_this_run = True return time_since_last_update_sec > SHARED_LIBS_MAX_AGE_SEC def upgrade(self, *, pip_args: list[str], verbose: bool, raises: bool = False) -> None: with in_turn(lambda: self.has_been_updated_this_run), self._lock, self._maintenance_lock(): self._upgrade(pip_args=pip_args, verbose=verbose, raises=raises) SHARED_LIBS = _SharedLibs() def shared_libs_auto_upgrade_disabled() -> bool: return _SKIP_MAINTENANCE.get() or strtobool(os.getenv(DISABLE_SHARED_LIBS_AUTO_UPGRADE, "0")) __all__ = [ "DISABLE_SHARED_LIBS_AUTO_UPGRADE", "SHARED_LIBS", "SHARED_LIBS_MAX_AGE_SEC", "shared_libs_auto_upgrade_disabled", "shared_pip_cooldown", "skip_shared_libs_maintenance", ] pipx-1.18.16/src/pipx/standalone_python.py000066400000000000000000000435641527355705200205510ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "datetime", "functools", "hashlib", "json", "pathlib", "pipx", "pipx.util", "platform", "re", "shutil", "tarfile", "urllib.request", ] import datetime as dt import hashlib import http.client import json import logging import os import platform import re import shutil import tarfile import urllib.error from contextlib import contextmanager from functools import partial from http import HTTPStatus from pathlib import Path, PurePosixPath from time import sleep as _sleep from typing import TYPE_CHECKING, Final, TypedDict, cast from urllib.request import Request, urlopen from pipx import constants, paths from pipx.animate import animate from pipx.util import PipxError if TYPE_CHECKING: from collections.abc import Generator from typing import TypeIs from filelock import BaseFileLock _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) # much of this module comes from https://github.com/tusharsadhwani/yen/blob/main/src/yen/github.py MACHINE_SUFFIX: Final[dict[str, dict[str, list[str] | dict[str, list[str]]]]] = { "Darwin": { "arm64": ["aarch64-apple-darwin-install_only.tar.gz"], "x86_64": ["x86_64-apple-darwin-install_only.tar.gz"], }, "Linux": { "aarch64": { "glibc": ["aarch64-unknown-linux-gnu-install_only.tar.gz"], # no musl build exists for aarch64 }, "x86_64": { "glibc": [ "x86_64_v3-unknown-linux-gnu-install_only.tar.gz", "x86_64-unknown-linux-gnu-install_only.tar.gz", ], "musl": ["x86_64_v3-unknown-linux-musl-install_only.tar.gz"], }, }, "Windows": { "AMD64": ["x86_64-pc-windows-msvc-install_only.tar.gz"], "ARM64": ["aarch64-pc-windows-msvc-install_only.tar.gz"], }, } GITHUB_API_URL: Final[str] = "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest" _URL_OPEN_TIMEOUT: Final[float] = 30 # GitHub can also drop the connection mid-response (RemoteDisconnected is a ConnectionError, a truncated chunked body # an IncompleteRead), so failures while reading the body are as retryable as ones while opening the connection. _TRANSIENT_NETWORK_ERRORS: Final[tuple[type[Exception], ...]] = ( urllib.error.URLError, http.client.IncompleteRead, ConnectionError, TimeoutError, ) def download_python_build_standalone(python_version: str, *, override: bool = False) -> str: # accept a command name such as python3.10 as well as the bare version 3.10 python_version = re.sub(r"[c]?python", "", python_version) # the version names the cache directory that a failed attempt is cleared from below, so a path, "." or ".." would # point that rmtree outside the cache; one to three numeric parts is all resolve_python_version can match anyway if not re.fullmatch(r"[0-9]+(\.[0-9]+){0,2}", python_version): msg = f"Cannot fetch a standalone python build for {python_version!r}: expected a version such as '3.13'." raise PipxError(msg) install_dir: Final[Path] = paths.CTX.standalone_python_cachedir / python_version installed_python: Final[Path] = install_dir / "python.exe" if constants.WINDOWS else install_dir / "bin" / "python3" if not override and installed_python.exists(): return str(installed_python) # the staging and backup directories of the swap are named after the version, and whatever already has those names # is cleared as a leftover, so a second pipx fetching this version must wait instead of deleting a live copy with _version_lock(install_dir): if not override and installed_python.exists(): # another pipx installed this version while this one waited for the lock return str(installed_python) if not override and install_dir.exists(): # a leftover directory without a usable interpreter is a failed prior attempt with nothing to preserve, so # clear it now; an override upgrade keeps its working install until the atomic swap below _LOGGER.warning("A previous attempt to install python %s failed. Retrying.", python_version) shutil.rmtree(install_dir) full_version, (download_link, digest) = resolve_python_version(python_version) with _staging_dir(install_dir) as staging_dir: archive: Final[Path] = staging_dir / f"python-{full_version}.tar.gz" download_dir: Final[Path] = staging_dir / "download" _download(full_version, download_link, archive) _unpack(full_version, archive, download_dir, digest) # the python installation we want is nested in the tarball under a directory named 'python'; only after it # is fully extracted do we swap it into place, so a failed download or unpack never destroys a working # interpreter _install_atomically(download_dir / "python", install_dir) return str(installed_python) def _version_lock(install_dir: Path) -> BaseFileLock: from filelock import FileLock # ruff:ignore[import-outside-top-level] # pulls in asyncio, slows startup install_dir.parent.mkdir(parents=True, exist_ok=True) return FileLock(install_dir.with_name(f".{install_dir.name}.lock")) def resolve_python_version(requested_version: str) -> tuple[str, tuple[str, str]]: requested_release: Final[list[str]] = requested_version.split(".") for full_version, download_link in list_pythons().items(): if requested_release == full_version.split(".")[: len(requested_release)]: return full_version, download_link msg = f"Unable to acquire a standalone python build matching {requested_version}." raise PipxError(msg) def list_pythons(*, use_cache: bool = True) -> dict[str, tuple[str, str]]: system, machine = platform.system(), platform.machine() try: download_link_suffixes = MACHINE_SUFFIX[system][machine] except KeyError as error: msg = f"No standalone Python builds are available for {system} on {machine}." raise PipxError(msg) from error # Linux entries nest their suffixes by libc if isinstance(download_link_suffixes, dict): # libc_ver() finds no glibc version on a musl system libc_version: Final[str] = platform.libc_ver()[0] or "musl" try: download_link_suffixes = download_link_suffixes[libc_version] except KeyError as error: msg = f"No standalone Python builds are available for {system} on {machine} with {libc_version}." raise PipxError(msg) from error python_releases: Final[list[tuple[str, str]]] = get_or_update_index(use_cache=use_cache)["releases"] python_versions: Final[dict[str, tuple[str, str]]] = {} # suffixes are in order of preference, so the first link found for a version wins for download_link_suffix in download_link_suffixes: for link, digest in python_releases: if not link.endswith(download_link_suffix): continue if (match := re.search(r"cpython-(\d+\.\d+\.\d+)", link)) is None: msg = f"Could not parse a Python version from {link!r}." raise PipxError(msg) python_versions.setdefault(match[1], (link, digest)) return { version: python_versions[version] for version in sorted(python_versions, key=lambda version: [int(k) for k in version.split(".")], reverse=True) } def get_or_update_index(*, use_cache: bool = True) -> _PythonIndex: index_file: Final[Path] = paths.CTX.standalone_python_cachedir / "index.json" if use_cache and (loaded := _load_cached_index(index_file)) is not None: return loaded index: Final[_PythonIndex] = { "fetched": dt.datetime.now(tz=dt.UTC).timestamp(), "releases": get_latest_python_releases(), } index_file.parent.mkdir(parents=True, exist_ok=True) if index_file.is_dir(): shutil.rmtree(index_file) index_file.write_text(json.dumps(index), encoding="utf-8") return index def _load_cached_index(index_file: Path) -> _PythonIndex | None: if not index_file.is_file(): return None try: if not _is_valid_python_index(loaded := json.loads(index_file.read_text(encoding="utf-8"))): return None except (OSError, UnicodeDecodeError, json.JSONDecodeError): return None try: fetched: Final[dt.datetime] = dt.datetime.fromtimestamp(loaded["fetched"], tz=dt.UTC) except (OSError, ValueError, OverflowError): return None return loaded if dt.datetime.now(tz=dt.UTC) - fetched <= dt.timedelta(days=30) else None def _is_valid_python_index(index: object) -> TypeIs[_PythonIndex]: match index: case {"fetched": int() | float(), "releases": list() as releases}: link_and_digest: Final[int] = 2 return all( isinstance(release, list | tuple) and len(release) == link_and_digest and all(isinstance(value, str) for value in release) for release in releases ) case _: return False class _PythonIndex(TypedDict): fetched: float releases: list[tuple[str, str]] def get_latest_python_releases() -> list[tuple[str, str]]: try: release_data: Final[dict[str, list[dict[str, str]]]] = _fetch_release_data() except _TRANSIENT_NETWORK_ERRORS as e: msg = f"Unable to fetch python-build-standalone release data (from {GITHUB_API_URL})." raise PipxError(msg) from e return [(asset["browser_download_url"], asset["digest"]) for asset in release_data["assets"]] def _fetch_release_data() -> dict[str, list[dict[str, str]]]: attempt = 0 while True: attempt += 1 try: # the index body is small, so unlike the archive download a retry re-fetches it whole with urlopen(GITHUB_API_URL, timeout=_URL_OPEN_TIMEOUT) as response: return cast("dict[str, list[dict[str, str]]]", json.load(response)) except _TRANSIENT_NETWORK_ERRORS as error: _backoff_or_raise(error, attempt, GITHUB_API_URL) def _backoff_or_raise(error: Exception, attempt: int, url: str) -> None: fetch_attempts: Final[int] = 3 # GitHub's release CDN answers with transient 5xx (and 429 under rate limiting) often enough that a single failure # should not abort a download; these statuses are safe to retry, unlike a 404 for a missing build retryable: Final[bool] = not isinstance(error, urllib.error.HTTPError) or error.code in {429, 500, 502, 503, 504} if not retryable or attempt >= fetch_attempts: raise error _LOGGER.debug("fetch attempt %d/%d for %s failed (%s); retrying", attempt, fetch_attempts, url, error) _sleep(0.5 * 2 ** (attempt - 1)) @contextmanager def _staging_dir(install_dir: Path) -> Generator[Path]: # unpack next to install_dir rather than in the system temp dir: from a tmpfs /tmp or another btrfs subvolume, # moving the build into place copies all of it and splits its hard links into separate files; the dot prefix hides # a fetch in progress from interpreter list, prune and upgrade staging_dir: Final[Path] = install_dir.with_name(f".{install_dir.name}.incoming") # under the version lock, anything already at this name is a killed fetch's leftover if staging_dir.exists(): shutil.rmtree(staging_dir) # owner-only like a system temp dir; mkdir fails rather than unpack into a directory created here since the cleanup staging_dir.mkdir(mode=0o700) try: yield staging_dir finally: shutil.rmtree(staging_dir, ignore_errors=True) def _download(full_version: str, download_link: str, archive: Path) -> None: with animate(f"Downloading python {full_version} build", do_animation=True): try: _download_resuming(download_link, archive) except _TRANSIENT_NETWORK_ERRORS as e: msg = f"Unable to download python {full_version} build." raise PipxError(msg) from e def _download_resuming(url: str, archive: Path) -> None: archive.touch() attempt = 0 while True: attempt += 1 with archive.open("r+b") as file_handle: written = file_handle.seek(0, os.SEEK_END) # url comes from the pinned GitHub release index; a build archive is ~32MB, so on a dropped connection ask # the CDN to continue from the bytes already on disk instead of downloading everything again request = Request(url, headers={"Range": f"bytes={written}-"} if written else {}) # ruff:ignore[suspicious-url-open-usage] try: with urlopen(request, timeout=_URL_OPEN_TIMEOUT) as response: # ruff:ignore[suspicious-url-open-usage] if written and response.status != HTTPStatus.PARTIAL_CONTENT: # the server ignored the range request and answered with the whole file file_handle.seek(0) file_handle.truncate() # 32KB chunks keep memory flat file_handle.writelines(iter(partial(response.read, 32768), b"")) except _TRANSIENT_NETWORK_ERRORS as error: _backoff_or_raise(error, attempt, url) else: return def _unpack(full_version: str, archive: Path, download_dir: Path, expected_checksum: str) -> None: with animate(f"Unpacking python {full_version} build", do_animation=True): sha256_hash: Final = hashlib.sha256() with Path(archive).open("rb") as python_zip: for chunk in iter(lambda: python_zip.read(32768), b""): sha256_hash.update(chunk) if (checksum := f"sha256:{sha256_hash.hexdigest()}") != expected_checksum: msg = f"Checksum mismatch for python {full_version} build. Expected {expected_checksum}, got {checksum}." raise PipxError(msg) try: with tarfile.open(archive, mode="r:gz") as tar: if hasattr(tarfile, "data_filter"): tar.extractall(download_dir, filter="data") else: # Python 3.11.0-3.11.3 predate tarfile's data filter, so validate and extract by hand _extract_safely(tar, download_dir) except tarfile.TarError as error: msg = f"Unable to unpack python {full_version} build." raise PipxError(msg) from error def _extract_safely(tar: tarfile.TarFile, dest: Path) -> None: root: Final[Path] = dest.resolve() for member in tar.getmembers(): if member.isdev(): continue # the data filter drops device, block, and fifo entries _reject_escape(member, root) _extract_member(tar, member, dest) def _reject_escape(member: tarfile.TarInfo, root: Path) -> None: # like the data filter, extract under normalized names: "dir/.." leads elsewhere as soon as "dir" is a link member.name = os.path.normpath(member.name) if _escapes(root, root / member.name): msg = f"Refusing to extract {member.name!r} outside the download directory." raise PipxError(msg) if member.issym() or member.islnk(): member.linkname = os.path.normpath(member.linkname) base: Final[Path] = root if member.islnk() else (root / member.name).parent # a hard link to a symlink re-creates that symlink, and so its relative target, at another depth relocates_symlink: Final[bool] = member.islnk() and (root / member.linkname).is_symlink() if PurePosixPath(member.linkname).is_absolute() or relocates_symlink or _escapes(root, base / member.linkname): msg = f"Refusing to extract link {member.name!r} pointing outside the download directory." raise PipxError(msg) def _escapes(root: Path, target: Path) -> bool: # members are extracted one at a time, so resolve the links unpacked so far for as much of the path as exists for existing in (target, *target.parents): try: # strict: a lenient realpath takes a link it cannot follow (a loop, an overlong chain) for a directory followed = os.path.realpath(existing, strict=True) except FileNotFoundError: continue except OSError: return True resolved = Path(os.path.normpath(Path(followed, target.relative_to(existing)))) return resolved != root and root not in resolved.parents return True # pragma: no cover # target is below root, which exists, so the loop always returns def _extract_member(tar: tarfile.TarFile, member: tarfile.TarInfo, dest: Path) -> None: target: Final[Path] = dest / member.name if member.isdir(): target.mkdir(parents=True, exist_ok=True) return target.parent.mkdir(parents=True, exist_ok=True) if target.is_symlink() or target.exists(): target.unlink() if member.issym(): target.symlink_to(member.linkname) elif member.islnk(): os.link(dest / member.linkname, target) else: source: Final = tar.extractfile(member) with target.open("wb") as handle: if source is not None: shutil.copyfileobj(source, handle) target.chmod(member.mode & 0o777) # drop setuid, setgid, and sticky bits like the data filter def _install_atomically(source: Path, install_dir: Path) -> None: backup_dir: Final[Path] = install_dir.with_name(f".{install_dir.name}.previous") if backup_dir.exists(): shutil.rmtree(backup_dir) if replaced_existing := install_dir.exists(): install_dir.replace(backup_dir) try: source.replace(install_dir) except OSError: if replaced_existing: backup_dir.replace(install_dir) raise if replaced_existing: shutil.rmtree(backup_dir, ignore_errors=True) __all__ = [ "GITHUB_API_URL", "MACHINE_SUFFIX", "download_python_build_standalone", "get_latest_python_releases", "get_or_update_index", "list_pythons", "resolve_python_version", ] pipx-1.18.16/src/pipx/subprocess_runner.py000066400000000000000000000406451527355705200205760ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "concurrent.futures", "fcntl", "itertools", "pathlib", "pipx.fanout", "pipx.redact", "pipx.util", "pty", "re", "select", "shutil", "struct", "subprocess", "termios", ] import codecs import errno import logging import os import re import shutil import subprocess import sys import time from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager, suppress from contextvars import ContextVar, copy_context from dataclasses import dataclass from itertools import chain from pathlib import Path from typing import ( TYPE_CHECKING, Final, NoReturn, TextIO, ) from pipx.animate import show_cursor from pipx.constants import WINDOWS from pipx.fanout import output_is_held, stop_process_group, stopped_on_interrupt from pipx.redact import redact_command_line, redact_url_credentials from pipx.util import PipxError if TYPE_CHECKING: from collections.abc import Generator, Sequence from concurrent.futures import Future if not WINDOWS: import fcntl import pty import select import struct import termios _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) _STREAM_STDOUT_TO_STDERR: Final[ContextVar[bool]] = ContextVar("stream_stdout_to_stderr", default=False) def run_pypackage_bin(bin_path: Path, args: list[str]) -> NoReturn: exec_app( [str(bin_path.resolve()), *args], extra_python_paths=[".", str(bin_path.parent.parent)], ) def exec_app( cmd: Sequence[str | Path], env: dict[str, str] | None = None, extra_python_paths: list[str] | None = None, ) -> NoReturn: environment: Final[dict[str, str]] = _fix_subprocess_env(dict(os.environ) if env is None else env, force_utf8=False) if extra_python_paths is not None: environment["PYTHONPATH"] = os.path.pathsep.join( extra_python_paths + (inherited.split(os.path.pathsep) if (inherited := os.getenv("PYTHONPATH")) else []) ) # the app takes over the terminal, so bring back a cursor a spinner may have hidden show_cursor() _LOGGER.info("exec_app: %s", redact_command_line(cmd)) if WINDOWS: sys.exit(subprocess.run(cmd, env=environment, check=False).returncode) os.execvpe(str(cmd[0]), [str(x) for x in cmd], environment) # ruff:ignore[start-process-with-no-shell] # pipx replaces itself with the requested app by design def _fix_subprocess_env(env: dict[str, str], *, force_utf8: bool = True) -> dict[str, str]: # some platforms (macOS with Homebrew) add pipx directories to PYTHONPATH, which makes pipx's dependencies look # installed in the venv (#233), and __PYVENV_LAUNCHER__ can start the wrong python binary (#334) for env_to_remove in ("PYTHONPATH", "__PYVENV_LAUNCHER__"): env.pop(env_to_remove, None) env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" if force_utf8: env["PYTHONIOENCODING"] = "utf-8" env["PYTHONLEGACYWINDOWSSTDIO"] = "utf-8" # install into the venv, not the user base or a custom target directory env["PIP_USER"] = "0" env.pop("PIP_TARGET", None) env.setdefault("PIP_KEYRING_PROVIDER", "subprocess") return env def get_site_packages(python: Path) -> list[Path]: if (site_packages := _site_packages_from_layout(python.parent.parent)) is not None: return site_packages purelib, _, platlib = ( run_subprocess( [ python, "-c", "import sysconfig; print(sysconfig.get_path('purelib')); print(sysconfig.get_path('platlib'))", ], capture_stderr=False, ) .stdout.strip() .partition("\n") ) purelib_path: Final[Path] = Path(purelib) # some Linux layouts symlink lib64 to lib, so compare the resolved targets rather than the scheme strings if (platlib_path := Path(platlib)).resolve() == purelib_path.resolve(): return [purelib_path] return [purelib_path, platlib_path] def _site_packages_from_layout(venv: Path) -> list[Path] | None: # reading the directories a CPython venv creates spares starting its interpreter; any other layout asks sysconfig if (windows_site_packages := venv / "Lib" / "site-packages").is_dir(): return [windows_site_packages] match list(venv.glob("lib/python3*/site-packages")): case [purelib]: platlib: Final[Path] = venv / "lib64" / purelib.parent.name / "site-packages" return [purelib, platlib] if platlib.is_dir() and not platlib.samefile(purelib) else [purelib] case _: return None def run_subprocess( # ruff:ignore[too-many-arguments] # thin wrapper over subprocess.run exposing its capture and logging switches cmd: Sequence[str | Path], *, capture_stdout: bool = True, capture_stderr: bool = True, log_cmd_str: str | None = None, log_stdout: bool = True, log_stderr: bool = True, run_dir: str | None = None, env_overrides: dict[str, str | None] | None = None, stream_output: bool = False, interactive: bool = False, ) -> subprocess.CompletedProcess[str]: env: Final[dict[str, str]] = _fix_subprocess_env(dict(os.environ)) for key, value in (env_overrides or {}).items(): if value is None: env.pop(key, None) else: env[key] = value _LOGGER.info("running %s", redact_command_line(cmd) if log_cmd_str is None else log_cmd_str) if run_dir: Path(run_dir).mkdir(parents=True, exist_ok=True) # windows cannot take Path objects, only strings cmd_str_list: Final[list[str]] = [str(c) for c in cmd] # keeps the working directory off a child Python's sys.path, so a local file cannot shadow a standard library # module (#1575) if cmd_str_list and "python" in Path(cmd_str_list[0]).name.lower(): env.setdefault("PYTHONSAFEPATH", "1") if stream_output: completed_process = _run_streaming_subprocess( cmd_str_list, capture_stdout=capture_stdout, capture_stderr=capture_stderr, cwd=run_dir, env=env, ) elif interactive: completed_process = subprocess.run( cmd_str_list, env=env, stdout=subprocess.PIPE if capture_stdout else None, stderr=subprocess.PIPE if capture_stderr else None, encoding="utf-8", errors="replace", check=False, cwd=run_dir, ) else: # a child writing straight to the terminal would cut into the output of the fan-out task whose turn it is hold_stderr: Final[bool] = not capture_stderr and output_is_held() with ( subprocess.Popen( cmd_str_list, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE if capture_stdout else None, stderr=subprocess.PIPE if capture_stderr or hold_stderr else None, encoding="utf-8", errors="replace", text=True, cwd=run_dir, # a terminal's Ctrl-C then reaches pipx alone, which decides whether the child finishes; a new session # rather than just a new group, as the terminal stops a background group reading or writing it start_new_session=True, creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0), ) as process, stopped_on_interrupt(process), ): try: stdout, stderr = process.communicate() except BaseException: # like subprocess.run, so an interrupt does not leave the child running process.kill() raise if hold_stderr: sys.stderr.write(stderr) completed_process = subprocess.CompletedProcess( process.args, process.returncode, _without_terminal_escapes(stdout), None if hold_stderr else _without_terminal_escapes(stderr), ) # an installer quotes an operand it rejects as typed, so its output can carry the credentials of a URL completed_process.stdout, completed_process.stderr = ( None if output is None else redact_url_credentials(output) for output in (completed_process.stdout, completed_process.stderr) ) if capture_stdout and log_stdout and not stream_output: _LOGGER.debug(f"stdout: {completed_process.stdout}".rstrip()) if capture_stderr and log_stderr and not stream_output: _LOGGER.debug(f"stderr: {completed_process.stderr}".rstrip()) _LOGGER.debug("returncode: %s", completed_process.returncode) return completed_process def _run_streaming_subprocess( cmd: list[str], *, capture_stdout: bool, capture_stderr: bool, cwd: str | None, env: dict[str, str], ) -> subprocess.CompletedProcess[str]: stdout_destination: Final[TextIO] = sys.stderr if _STREAM_STDOUT_TO_STDERR.get() else sys.stdout # Windows has no pseudo-terminal to hand the child, so it reads a pipe and rich, which pip draws its bar with, # takes TTY_COMPATIBLE for the terminal we forward the output to and COLUMNS for a width it cannot measure. if stdout_destination.isatty(): env.setdefault("TTY_COMPATIBLE", "1") env.setdefault("COLUMNS", str(shutil.get_terminal_size().columns)) stdout_chunks: Final[list[str]] = [] stderr_chunks: Final[list[str]] = [] channels: Final[dict[str, _StreamChannel]] = { name: _open_stream_channel(destination) for name, destination, capture in ( ("stdout", stdout_destination, capture_stdout), ("stderr", sys.stderr, capture_stderr), ) if capture } try: with ( subprocess.Popen( cmd, env=env, stdin=subprocess.DEVNULL, stdout=channels["stdout"].child if capture_stdout else None, stderr=channels["stderr"].child if capture_stderr else None, cwd=cwd, start_new_session=True, creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0), ) as process, stopped_on_interrupt(process), ThreadPoolExecutor(max_workers=2) as executor, ): for channel in channels.values(): if not channel.terminal: channel.close_child() sinks: Final = {"stdout": (stdout_destination, stdout_chunks), "stderr": (sys.stderr, stderr_chunks)} try: futures: Final[dict[str, Future[None]]] = { # each reader needs the caller's context to buffer the output of a fan-out task waiting its turn name: executor.submit(copy_context().run, _tee_subprocess_output, channel.reader, *sinks[name]) for name, channel in channels.items() } returncode: Final[int] = process.wait() for name, channel in channels.items(): if channel.terminal: # macOS discards what a pseudo-terminal still queues once its last child end closes, so hold # ours open until the reader has taken everything the exited child wrote _wait_until_drained(channel.reader, futures[name]) except BaseException: # the readers return only once nothing holds the child's ends, a build backend the child started # included, so the whole group must go before the executor joins them stop_process_group(process) raise finally: # a pseudo-terminal reader returns only once every child end is closed, so ours must close before the # executor joins the readers, also when Ctrl-C lands while the readers start or the child runs for channel in channels.values(): channel.close_child() for future in futures.values(): future.result() finally: for channel in channels.values(): channel.close() return subprocess.CompletedProcess( cmd, returncode, _without_terminal_escapes("".join(stdout_chunks) if capture_stdout else None), _without_terminal_escapes("".join(stderr_chunks) if capture_stderr else None), ) def _open_stream_channel(destination: TextIO) -> _StreamChannel: # pip and uv draw a progress bar only onto a terminal, and a pipe hides the one pipx forwards the output to, so # hand the child a pseudo-terminal instead whenever the platform offers one if not WINDOWS and destination.isatty(): reader, child = pty.openpty() _match_terminal_size(child) return _StreamChannel(reader=reader, child=child, terminal=True) reader, child = os.pipe() return _StreamChannel(reader=reader, child=child, terminal=False) def _match_terminal_size(child: int) -> None: size: Final[os.terminal_size] = shutil.get_terminal_size() with suppress(OSError): fcntl.ioctl(child, termios.TIOCSWINSZ, struct.pack("HHHH", size.lines, size.columns, 0, 0)) @dataclass(slots=True, kw_only=True) class _StreamChannel: reader: int child: int terminal: bool def close_child(self) -> None: if self.child != -1: os.close(self.child) self.child = -1 def close(self) -> None: self.close_child() if self.reader != -1: os.close(self.reader) self.reader = -1 def _tee_subprocess_output(source: int, destination: TextIO, chunks: list[str]) -> None: write_error: OSError | UnicodeError | None = None pending_carriage_return: bool = False for decoded in chain( codecs.iterdecode(iter(lambda: _read_stream(source), b""), "utf-8", errors="replace"), (None,), ): chunk = ("\r" if pending_carriage_return else "") + (decoded or "") if decoded is not None: # hold a trailing \r back until the next read shows whether it starts a \r\n pending_carriage_return = chunk.endswith("\r") chunk = (chunk[:-1] if pending_carriage_return else chunk).replace("\r\n", "\n") if not chunk: continue chunks.append(chunk) if write_error is None: try: destination.write(chunk) destination.flush() except (OSError, UnicodeError) as error: write_error = error if write_error is not None: raise write_error def _read_stream(source: int) -> bytes: try: return os.read(source, 8 * 1024) except OSError as error: # a pseudo-terminal reports the departed child this way rather than as an end of file if error.errno == errno.EIO: return b"" raise def _wait_until_drained(reader: int, reading: Future[None]) -> None: poll_seconds: Final[float] = 0.01 while not reading.done() and select.select([reader], [], [], poll_seconds)[0]: time.sleep(poll_seconds) def _without_terminal_escapes(output: str | None) -> str | None: # a child colours what it writes to a terminal, or to a pipe under FORCE_COLOR, and the escapes split the words pipx # reads out of the captured copy if output is None: return None return re.sub(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])", "", output) @contextmanager def stream_subprocess_stdout_to_stderr(*, enabled: bool) -> Generator[None, None, None]: token: Final = _STREAM_STDOUT_TO_STDERR.set(enabled) try: yield finally: _STREAM_STDOUT_TO_STDERR.reset(token) def subprocess_post_check(completed_process: subprocess.CompletedProcess[str], *, streamed: bool = False) -> None: if not completed_process.returncode: return # a streamed run already showed its output live if not streamed and completed_process.stdout is not None: print(completed_process.stdout, file=sys.stdout, end="") # ruff:ignore[print] # relays subprocess output to the user if not streamed and completed_process.stderr is not None: print(completed_process.stderr, file=sys.stderr, end="") # ruff:ignore[print] # relays subprocess output to the user msg = f"{redact_command_line(completed_process.args)!r} failed" raise PipxError(msg) __all__ = [ "exec_app", "get_site_packages", "run_pypackage_bin", "run_subprocess", "stream_subprocess_stdout_to_stderr", "subprocess_post_check", ] pipx-1.18.16/src/pipx/trash.py000066400000000000000000000066301527355705200161320ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["pathlib", "pipx", "random", "shutil", "string"] import logging import random import shutil import string import sys from contextlib import contextmanager from pathlib import Path from typing import TYPE_CHECKING, Final from pipx import paths if TYPE_CHECKING: from collections.abc import Generator _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) def rmdir(path: Path, *, safe_rm: bool = True) -> None: if not path.is_dir(): return _LOGGER.info("removing directory %s", path) # Windows refuses to delete a running file but lets it be renamed, so pipx moves a directory that survives to the # trash for a later run to delete; ignoring errors keeps a locked file from making cleanup fatal shutil.rmtree(path, ignore_errors=True) if path.is_dir(): if safe_rm: _LOGGER.warning("Failed to delete %s. Will move it to a temp folder to delete later.", path) try: path.rename(_get_trash_file(path)) except OSError as error: _LOGGER.warning("Failed to move %s to the trash; remove it by hand (%s).", path, error) else: _LOGGER.warning("Failed to delete %s. You may need to delete it manually.", path) def _get_trash_file(path: Path) -> Path: paths.CTX.trash.mkdir(exist_ok=True) prefix: Final[str] = "".join(random.choices(string.ascii_lowercase, k=8)) # ruff:ignore[suspicious-non-cryptographic-random-usage] # temp filename, not security-sensitive return paths.CTX.trash / f"{prefix}.{path.name}" def rmdir_via_trash(path: Path) -> None: # a removal killed halfway leaves the directory whole or gone from its parent, never partly deleted in place try: path.rename(trash := _get_trash_file(path)) except OSError: rmdir(path) else: rmdir(trash) def safe_unlink(file: Path) -> None: if not file.is_file() and not file.is_symlink(): return try: file.unlink() except PermissionError: # Windows lets a running file be renamed but not deleted file.rename(_get_trash_file(file)) @contextmanager def running_launcher_moved_aside(scripts_dir: Path) -> Generator[None]: # Windows lets a running file be renamed but not deleted, so the launcher this pipx started from waits in the trash # while an installer that deletes files in place rewrites the scripts directory if (launcher := _running_launcher(scripts_dir)) is None: yield return aside: Final[Path] = _get_trash_file(launcher) try: launcher.rename(aside) except OSError as error: _LOGGER.debug("could not move the running launcher %s aside: %s", launcher, error) yield return try: yield finally: # the installer had nothing to replace, or failed before writing the new launcher if not launcher.exists(): aside.rename(launcher) def _running_launcher(scripts_dir: Path) -> Path | None: # the Windows launcher passes its own path, without the .exe suffix, to Python as the script name for candidate in (Path(sys.argv[0]), Path(f"{sys.argv[0]}.exe")): if candidate.is_file() and (launcher := candidate.resolve()).parent == scripts_dir.resolve(): return launcher return None __all__ = [ "rmdir", "rmdir_via_trash", "running_launcher_moved_aside", "safe_unlink", ] pipx-1.18.16/src/pipx/util.py000066400000000000000000000071131527355705200157630ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["pathlib", "pipx.redact", "pipx.wrap"] import logging import os import sys from pathlib import Path from typing import TYPE_CHECKING, Final from pipx.constants import MINGW, WINDOWS from pipx.redact import redact_url_credentials from pipx.wrap import pipx_wrap if TYPE_CHECKING: from collections.abc import Sequence _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) class PipxError(Exception): def __init__(self, message: str, *, wrap_message: bool = True, backend_output: str = "") -> None: self.raw_message: Final[str] = message # what pip or uv said, for a result envelope to carry when the terminal already showed it live self.backend_output: Final[str] = backend_output super().__init__(pipx_wrap(message) if wrap_message else message) class InstalledNameMismatchError(PipxError): def __init__(self, installed_name: str) -> None: self.installed_name: Final[str] = installed_name super().__init__(f"The package spec installed {installed_name!r}, a different name than expected.") def mkdir(path: Path) -> None: if path.is_dir(): return _LOGGER.info("creating directory %s", path) path.mkdir(parents=True, exist_ok=True) def get_pypackage_bin_path(binary_name: str) -> Path: return Path("__pypackages__") / f"{sys.version_info.major}.{sys.version_info.minor}" / "lib" / "bin" / binary_name if WINDOWS: def get_venv_paths(root: Path) -> tuple[Path, Path, Path]: # the Microsoft Store Python redirects paths for sandboxing, so work from the real root: # https://github.com/pypa/pipx/issues/1164 real_root: Final[Path] = root.resolve() bin_path: Final[Path] = real_root / ("bin" if MINGW else "Scripts") return bin_path, bin_path / "python.exe", real_root / "share" / "man" else: def get_venv_paths(root: Path) -> tuple[Path, Path, Path]: bin_path: Final[Path] = root / "bin" return bin_path, bin_path / "python", root / "share" / "man" def open_owner_only(path: str | os.PathLike[str], flags: int) -> int: # logs record command lines that can carry secrets, and a global store keeps them under a world-readable /opt/pipx return os.open(path, flags, 0o600) def full_package_description(package_name: str, package_spec: str) -> str: if package_name == package_spec: return redact_url_credentials(package_name) return f"{package_name} from spec {redact_url_credentials(package_spec)!r}" def is_file_name(name: str) -> bool: # joined to a directory, ".." names its parent and a separator or a drive leads anywhere return name not in {"", ".."} and Path(name).name == name def reject_empty_index(pip_args: Sequence[str]) -> None: # an empty value usually comes from an unset variable, as in --index-url "$INDEX_URL", and uv reads it as the # default index for position, argument in enumerate(pip_args): option, separator, value = argument.partition("=") if option not in {"--default-index", "--extra-index-url", "--index", "--index-url", "-i"} or ( not separator and position + 1 == len(pip_args) ): continue if not (value if separator else pip_args[position + 1]).strip(): msg = f"{option} is empty; pass the index URL or leave the option out" raise PipxError(msg) __all__ = [ "InstalledNameMismatchError", "PipxError", "full_package_description", "get_pypackage_bin_path", "get_venv_paths", "is_file_name", "mkdir", "open_owner_only", "reject_empty_index", ] pipx-1.18.16/src/pipx/venv.py000066400000000000000000001146041527355705200157700ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "packaging.utils", "pathlib", "pipx.backends.pip", "pipx.backends.registry", "pipx.interpreter", "pipx.package_specifier", "pipx.pipx_metadata_file", "pipx.redact", "pipx.requires_python", "pipx.script", "pipx.shared_libs", "pipx.subprocess_runner", "pipx.trash", "pipx.util", "pipx.wrap", "shutil", ] import logging import shutil import time from pathlib import Path from typing import TYPE_CHECKING, Final, NoReturn if TYPE_CHECKING: from collections.abc import Collection, Generator, Iterable, Iterator, Sequence from importlib.metadata import Distribution, EntryPoint from subprocess import CompletedProcess from filelock import BaseFileLock from packaging.utils import NormalizedName from pipx.backends._base import Backend, OutdatedPackage from pipx.venv_inspect import VenvMetadata from packaging.utils import InvalidName, canonicalize_name from packaging.version import Version from pipx.animate import animate from pipx.backends.pip import subprocess_post_check_handle_pip_error from pipx.backends.registry import ( assert_not_pip_under_uv, env_default_backend, get_backend, resolve_backend_name, ) from pipx.constants import PIPX_SHARED_PTH, ExitCode from pipx.emojis import HAZARD from pipx.interpreter import get_default_python, recordable_interpreter from pipx.options import DEFAULT_BACKEND_OPTIONS, BackendOptions, ExposureOptions from pipx.package_specifier import ( absolute_path_pip_args, fix_package_name, get_extras, package_spec_satisfied, parse_specifier_for_install, parse_specifier_for_metadata, recordable_pip_args, valid_pypi_name, ) from pipx.pipx_metadata_file import PackageInfo, PipxMetadata from pipx.redact import redact_url_credentials from pipx.requires_python import IncompatiblePythonError, rejected_constraint from pipx.script import installable_script, local_script from pipx.shared_libs import ( DISABLE_SHARED_LIBS_AUTO_UPGRADE, SHARED_LIBS, shared_libs_auto_upgrade_disabled, ) from pipx.subprocess_runner import exec_app, get_site_packages, subprocess_post_check from pipx.trash import rmdir from pipx.util import InstalledNameMismatchError, PipxError, full_package_description, get_venv_paths from pipx.wrap import pipx_wrap _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) _BACKEND_METADATA_VERSION: Final[Version] = Version("0.6") # keyed on the full path so a global and a user venv of one name stay apart; kept for the session because # `upgrade-all --backend uv` over many pip-backed venvs would otherwise repeat the multi-line warning for each _BACKEND_OVERRIDE_WARNED: Final[set[str]] = set() def reset_backend_override_warnings() -> None: # test-only: production callers never reset this _BACKEND_OVERRIDE_WARNED.clear() class VenvContainer: def __init__(self, root: Path) -> None: self._root: Final[Path] = root def __repr__(self) -> str: return f"VenvContainer({str(self._root)!r})" def __str__(self) -> str: return str(self._root) def iter_venv_dirs(self) -> Generator[Path, None, None]: if self._root.is_dir(): yield from (entry for entry in self._root.iterdir() if entry.is_dir()) def get_venv_dir(self, package_name: str) -> Path: venv_dir: Final[Path] = self._root.joinpath(canonicalize_name(package_name)) # an empty name is the store itself, a separator reaches into another venv and an absolute path leaves the store if venv_dir.parent != self._root: msg = f"Invalid environment name {package_name!r}. Expected the name of a package, not a path." raise PipxError(msg) return venv_dir def iter_locked_venv_dirs( self, venv_dirs: Iterable[Path], *, allow_permission_error: bool = False ) -> Generator[Path, None, None]: for venv_dir in venv_dirs: lock: Final[BaseFileLock] = self.venv_lock(venv_dir) try: lock.acquire() except PermissionError: if not allow_permission_error: raise if venv_dir.is_dir(): yield venv_dir continue try: if venv_dir.is_dir(): yield venv_dir finally: lock.release() def venv_lock(self, venv_dir: Path) -> BaseFileLock: from filelock import FileLock # ruff:ignore[import-outside-top-level] # pulls in asyncio, slows startup self._root.mkdir(parents=True, exist_ok=True) return FileLock(self._root / f".{canonicalize_name(venv_dir.name)}.lock") class Venv: # ruff:ignore[too-many-public-methods] # single facade over a pipx-managed virtual environment; splitting would scatter its state def __init__( self, path: Path, *, verbose: bool = False, python: str | None = None, backend: BackendOptions = DEFAULT_BACKEND_OPTIONS, ) -> None: self.root: Final[Path] = path self.python = python or get_default_python() self.bin_path, self.python_path, self.man_path = get_venv_paths(self.root) self.pipx_metadata: Final[PipxMetadata] = PipxMetadata(venv_dir=path) self.verbose = verbose self.do_animation: Final[bool] = not verbose self._existing: Final[bool] = self.root.exists() and next(self.root.iterdir(), None) is not None # a directory without a readable record (an install that failed before writing it) has no backend to keep recorded: Final[bool] = self._existing and self.pipx_metadata.read_metadata_version is not None self._backend_name, self._backend_source = self._resolve_backend( root=self.root, existing=recorded, metadata_backend=self.pipx_metadata.backend if recorded else None, cli_backend=backend.cli, env_backend=backend.env, ) self._backend: Backend | None = None self._site_packages: list[Path] | None = None self._uses_shared_libs_cache: bool | None = None @staticmethod def _resolve_backend( *, root: Path, existing: bool, metadata_backend: str | None, cli_backend: str | None, env_backend: str | None, ) -> tuple[str, str]: # an existing venv keeps its recorded backend, since `uv pip install` into a pip-backed venv leaves it # inconsistent; `pipx reinstall NAME --backend X` is the supported switch if existing and cli_backend is not None and cli_backend != metadata_backend: if (warning_key := str(root)) not in _BACKEND_OVERRIDE_WARNED: _BACKEND_OVERRIDE_WARNED.add(warning_key) _LOGGER.warning( pipx_wrap( f""" {HAZARD} Ignoring --backend={cli_backend} for existing venv {root.name!r} (recorded backend is {metadata_backend!r}). Run `pipx reinstall {root.name} --backend {cli_backend}` to flip the venv. """, subsequent_indent=" " * 4, ) ) cli_backend = None # callers constructing a Venv directly do not pass env_backend; reading PIPX_DEFAULT_BACKEND keeps auto-detect # from overriding a backend the user already chose through the environment if env_backend is None: env_backend = env_default_backend() return resolve_backend_name(cli_value=cli_backend, env_value=env_backend, metadata_value=metadata_backend) @property def backend(self) -> Backend: if self._backend is None: self._backend = get_backend(self._backend_name) return self._backend @property def backend_name(self) -> str: return self._backend_name @property def backend_source(self) -> str: return self._backend_source def check_upgrade_shared_libs(self, *, verbose: bool, pip_args: list[str]) -> None: if self._existing and self.uses_shared_libs: if SHARED_LIBS.is_valid: if shared_libs_auto_upgrade_disabled(): _LOGGER.info( "Skipping shared libs auto-upgrade because %s is set.", DISABLE_SHARED_LIBS_AUTO_UPGRADE ) elif SHARED_LIBS.needs_upgrade: SHARED_LIBS.upgrade(verbose=verbose, pip_args=pip_args) else: SHARED_LIBS.create(verbose=verbose, pip_args=pip_args) if not SHARED_LIBS.is_valid: raise PipxError( pipx_wrap( f""" Error: pipx's shared venv {SHARED_LIBS.root} is invalid and needs re-installation. To fix this, install or reinstall a package. For example: """ ) + f"\n pipx install {self.root.name} --force", wrap_message=False, ) @property def uses_shared_libs(self) -> bool: if not self._existing: return self.backend.needs_shared_libs() if self._uses_shared_libs_cache is not None: return self._uses_shared_libs_cache # metadata 0.6 and later records the backend; an older or missing record falls back to probing for the .pth file recorded_version: Final[str | None] = self.pipx_metadata.read_metadata_version if recorded_version is not None and Version(recorded_version) >= _BACKEND_METADATA_VERSION: uses_shared = self.pipx_metadata.backend == "pip" else: uses_shared = next(self.root.glob(f"**/{PIPX_SHARED_PTH}"), None) is not None self._uses_shared_libs_cache = uses_shared return uses_shared @property def name(self) -> str: if (package := self.pipx_metadata.main_package.package) is None: return self.root.name return f"{package}{self.pipx_metadata.main_package.suffix}" @property def package_metadata(self) -> dict[str, PackageInfo]: packages: Final[dict[str, PackageInfo]] = self.pipx_metadata.injected_packages.copy() if (main_package := self.pipx_metadata.main_package).package is not None: packages[main_package.package] = main_package return packages @property def main_package_name(self) -> str: if (package := self.pipx_metadata.main_package.package) is None: # without a record this venv comes from pipx before 0.15, which named it after the main package return self.root.name return package def create_venv(self, venv_args: list[str], pip_args: list[str], *, override_shared: bool = False) -> None: _LOGGER.info("Creating virtual environment") if override_shared: assert_not_pip_under_uv("pip", self._backend_name) self.backend.create_venv( self.root, python=self.python, venv_args=venv_args, pip_args=pip_args, include_pip=override_shared, verbose=self.verbose, ) self._site_packages = None self.pipx_metadata.venv_args = venv_args # recorded only here, so a --backend override on an existing venv (`pipx upgrade --backend uv`) cannot flip it self.pipx_metadata.backend = self._backend_name # update_package_metadata records it from the package inspection, sparing a `python --version` run self.pipx_metadata.python_version = None if source_interpreter := shutil.which(self.python): self.pipx_metadata.source_interpreter = recordable_interpreter(Path(source_interpreter)) def remove_venv(self) -> None: if self.safe_to_remove(): rmdir(self.root) else: _LOGGER.warning( pipx_wrap( f""" {HAZARD} Not removing existing venv {self.root} because it was not created in this session """, subsequent_indent=" " * 4, ) ) def safe_to_remove(self) -> bool: return not self._existing def upgrade_packaging_libraries(self, pip_args: list[str]) -> None: # check_upgrade_shared_libs owns the shared pip, refreshing it when stale or below its floor unless disabled; a # uv venv has nothing to upgrade, and checking before building UvBackend keeps `upgrade-all` working once uv is # gone if self.uses_shared_libs or self._backend_name == "uv": return with self.pipx_metadata.preserved(): self.backend.upgrade_packaging_libraries(self._installer_python, pip_args) @property def _installer_python(self) -> Path: # without pyvenv.cfg the venv's python link starts the base interpreter, and the installer would write there if not (self.root / "pyvenv.cfg").is_file(): msg = ( f"{self.root} has no pyvenv.cfg, so it is no longer a virtual environment.\n" "Running the installer there would change the Python it was created from instead.\n" f"To fix, execute: pipx reinstall {self.root.name}" ) raise PipxError(msg, wrap_message=False) if self.uses_shared_libs and SHARED_LIBS.is_valid: shared_pth: Final[Path] = self.site_packages[0] / PIPX_SHARED_PTH shared_paths: Final[str] = "".join(f"{path}\n" for path in SHARED_LIBS.site_packages) # Rebuilding shared pip with another Python minor moves its site-packages on POSIX. if shared_pth.is_file() and shared_pth.read_text() != shared_paths: shared_pth.write_text(shared_paths) return self.python_path def uninstall_packages(self, *packages: str, was_injected: bool = False) -> None: for package in packages: _LOGGER.info("Uninstalling %s", package) names: Final[str] = ", ".join(packages) # resolved outside the try, which would replace the reason with a generic "Error uninstalling" venv_python: Final[Path] = self._installer_python try: with animate(f"uninstalling {names}", do_animation=self.do_animation), self.pipx_metadata.preserved(): self.backend.uninstall( venv_root=self.root, venv_python=venv_python, packages=packages, verbose=self.verbose, ) except PipxError as error: _LOGGER.info(error) msg = f"Error uninstalling {names}." raise PipxError(msg) from None if was_injected: for package in packages: self.pipx_metadata.injected_packages.pop(package) self.pipx_metadata.write() def install_package( # ruff:ignore[too-many-arguments] # flat install API mirroring the CLI's install options self, package_name: str, package_or_url: str, pip_args: list[str], *, exposure: ExposureOptions, is_main_package: bool, install_only_pip_args: list[str] | None = None, expected_apps: Sequence[str] | None = None, lock_file: Path | None = None, pinned: bool | None = None, cooldown_days: int | None = None, extra_requirements: Sequence[str] = (), ) -> VenvMetadata: # a script path can also read as a PyPI name (bare.py) or as extras (dir[1]/app.py), so pin it to the file if (script := local_script(package_or_url)) is not None: package_or_url = str(script) # a user can pass a package name that does not match the one in the URL package_or_url = fix_package_name(package_or_url, package_name) package_or_url, pip_args = parse_specifier_for_install(package_or_url, pip_args) install_pip_args: Final[list[str]] = self._with_cooldown( [*(install_only_pip_args or []), *pip_args], cooldown_days ) with installable_script(package_name, package_or_url, tuple(expected_apps or ())) as install_spec: if lock_file is None: _LOGGER.info("Installing %s", package_descr := full_package_description(package_name, package_or_url)) with ( animate(f"installing {package_descr}", do_animation=self.do_animation), self.pipx_metadata.preserved(), ): process: Final[CompletedProcess[str]] = self.backend.install( venv_root=self.root, venv_python=self._installer_python, requirements=[ install_spec, *(parse_specifier_for_install(spec, pip_args)[0] for spec in extra_requirements), ], pip_args=install_pip_args, log_pip_errors=False, verbose=self.verbose, ) if process.returncode: # the backend refuses the interpreter and installs nothing, so its complaint is the only place the # constraint appears; uv ignores upper bounds, which the caller catches from the metadata instead if constraint := rejected_constraint(process.stderr or ""): raise IncompatiblePythonError(constraint) subprocess_post_check_handle_pip_error(process, tool_name=self.backend_name, streamed=self.verbose) msg = f"Error installing {full_package_description(package_name, package_or_url)}." raise PipxError(msg, backend_output=self._as_displayed(process.stderr or process.stdout or "")) else: self._install_locked_package(package_name, install_spec, lock_file, install_pip_args) if not self.has_package(package_name) and (installed := self._name_installed_from(package_or_url)) is not None: raise InstalledNameMismatchError(installed) venv_metadata: Final[VenvMetadata] = self.update_package_metadata( package_name=package_name, package_or_url=package_or_url, pip_args=pip_args, exposure=exposure, is_main_package=is_main_package, expected_apps=expected_apps, lock_file=lock_file, pinned=pinned, cooldown_days=cooldown_days, ) if self.package_metadata[package_name].package_version is None: msg = ( f"Unable to install {full_package_description(package_name, package_or_url)}.\n" "Check the name or spec for errors, and verify that it can be installed with pip." ) raise PipxError(msg, wrap_message=False) return venv_metadata def _with_cooldown(self, pip_args: list[str], cooldown_days: int | None) -> list[str]: return [*pip_args, *self.backend.cooldown_args(cooldown_days)] @staticmethod def _as_displayed(output: str) -> str: # a progress bar redraws its line after each carriage return, so a terminal keeps only the text after the last return "\n".join(line.rpartition("\r")[2].rstrip() for line in output.split("\n")).strip() def _install_locked_package( self, package_name: str, package_or_url: str, lock_file: Path, pip_args: list[str] ) -> None: _LOGGER.info("Installing packages from %s", lock_file) with ( animate(f"installing packages from {lock_file.name}", do_animation=self.do_animation), self.pipx_metadata.preserved(), ): process = self.backend.install_lock( venv_root=self.root, venv_python=self._installer_python, lock_file=lock_file, pip_args=[argument for argument in pip_args if argument != "--editable"], verbose=self.verbose, ) if process.returncode: msg = f"Error installing packages from {lock_file}." raise PipxError(msg) if valid_pypi_name(package_or_url) is None: package_descr: Final[str] = full_package_description(package_name, package_or_url) with animate(f"installing {package_descr}", do_animation=self.do_animation), self.pipx_metadata.preserved(): process = self.backend.install( venv_root=self.root, venv_python=self._installer_python, requirements=[package_or_url], pip_args=pip_args, no_deps=True, verbose=self.verbose, ) if process.returncode: msg = f"Error installing {full_package_description(package_name, package_or_url)}." raise PipxError(msg) elif (distribution := next(self._distributions(package_name), None)) is None: msg = f"Lock file {lock_file} does not contain {package_name}." raise PipxError(msg) elif not package_spec_satisfied(package_or_url, package_name, distribution.version, package_or_url): msg = ( f"Lock file {lock_file} provides {package_name} {distribution.version}, " f"which does not satisfy {redact_url_credentials(package_or_url)}." ) raise PipxError(msg) process = self.backend.run_raw_pip( venv_root=self.root, venv_python=self._installer_python, args=["check"], verbose=self.verbose, ) if process.returncode: error: Final[str] = (process.stdout or process.stderr or "dependency check failed").strip() msg = f"Lock file {lock_file} does not satisfy {package_name}: {error}" raise PipxError(msg, wrap_message=False) def _distributions(self, name: str | None = None) -> Iterator[Distribution]: from importlib.metadata import Distribution # ruff:ignore[import-outside-top-level] # slows startup return iter(Distribution.discover(name=name, path=[str(path) for path in self.site_packages])) @property def site_packages(self) -> list[Path]: if self._site_packages is None: self._site_packages = get_site_packages(self.python_path) return self._site_packages def has_package(self, package_name: str) -> bool: return next(self._distributions(package_name), None) is not None def _name_installed_from(self, package_or_url: str) -> str | None: import json # ruff:ignore[import-outside-top-level] # slows startup from urllib.parse import urlsplit # ruff:ignore[import-outside-top-level] # slows startup from urllib.request import url2pathname # ruff:ignore[import-outside-top-level] # slows startup # installers record the local path a distribution came from (PEP 610), naming what the spec built locations: Final[dict[str, str]] = { url2pathname(urlsplit(json.loads(direct_url).get("url", "")).path): distribution.name for distribution in self._distributions() if (direct_url := distribution.read_text("direct_url.json")) is not None } return locations.get(package_or_url) or locations.get(package_or_url.rpartition("[")[0]) def update_package_metadata( # ruff:ignore[too-many-arguments] # writes the full PackageInfo record from the CLI's install options self, package_name: str, package_or_url: str, pip_args: list[str], *, exposure: ExposureOptions, is_main_package: bool, pinned: bool | None = None, expected_apps: Sequence[str] | None = None, lock_file: Path | None = None, cooldown_days: int | None = None, ) -> VenvMetadata: venv_package_metadata: Final[VenvMetadata] = self.get_venv_metadata_for_package( package_name, get_extras(package_or_url) ) if self.pipx_metadata.python_version is None: self.pipx_metadata.python_version = venv_package_metadata.python_version if expected_apps is None: expected_apps = ( self.package_metadata[package_name].expected_apps if package_name in self.package_metadata else () ) if lock_file is None: lock_file = self.package_metadata[package_name].lock_file if package_name in self.package_metadata else None included_dependencies: Final[list[str]] = list( dict.fromkeys(canonicalize_name(name) for name in exposure.include_resources_from) ) available_dependencies: Final[set[str]] = ( venv_package_metadata.app_paths_of_dependencies.keys() | venv_package_metadata.man_paths_of_dependencies.keys() | venv_package_metadata.completion_paths_of_dependencies.keys() ) if missing_dependencies := sorted(set(included_dependencies) - available_dependencies): msg = ( f"Cannot expose resources from {', '.join(missing_dependencies)} for package {package_name}. " "Dependencies with apps, manual pages, or shell completions: " f"{', '.join(sorted(available_dependencies)) or 'none'}." ) raise PipxError(msg) if cooldown_days is None: cooldown_days = ( self.package_metadata[package_name].cooldown_days if package_name in self.package_metadata else None ) if pinned is None: pinned = self.package_metadata[package_name].pinned if package_name in self.package_metadata else False package_info: Final[PackageInfo] = PackageInfo( package=package_name, package_or_url=parse_specifier_for_metadata(package_or_url), pip_args=recordable_pip_args(pip_args), include_apps=exposure.include_apps, include_dependencies=exposure.include_dependencies, include_resources_from=included_dependencies, apps=venv_package_metadata.apps, app_paths=venv_package_metadata.app_paths, apps_of_dependencies=venv_package_metadata.apps_of_dependencies, app_paths_of_dependencies=venv_package_metadata.app_paths_of_dependencies, man_pages=venv_package_metadata.man_pages, man_paths=venv_package_metadata.man_paths, man_pages_of_dependencies=venv_package_metadata.man_pages_of_dependencies, man_paths_of_dependencies=venv_package_metadata.man_paths_of_dependencies, completions=venv_package_metadata.completions, completion_paths=venv_package_metadata.completion_paths, completions_of_dependencies=venv_package_metadata.completions_of_dependencies, completion_paths_of_dependencies=venv_package_metadata.completion_paths_of_dependencies, package_version=venv_package_metadata.package_version, expected_apps=list(dict.fromkeys(expected_apps)), lock_file=lock_file, cooldown_days=cooldown_days, suffix=exposure.suffix, pinned=pinned, ) if is_main_package: self.pipx_metadata.main_package = package_info else: self.pipx_metadata.injected_packages[package_name] = package_info self.pipx_metadata.write() return venv_package_metadata def get_venv_metadata_for_package(self, package_name: str, package_extras: set[str]) -> VenvMetadata: from pipx.venv_inspect import inspect_venv # ruff:ignore[import-outside-top-level] # pulls in packaging.requirements, slows startup data_start: Final[float] = time.time() venv_metadata: Final[VenvMetadata] = inspect_venv( package_name, package_extras, self.bin_path, self.python_path, self.man_path ) _LOGGER.info("get_venv_metadata_for_package: %.0fms", 1e3 * (time.time() - data_start)) return venv_metadata def install_unmanaged_packages( self, requirements: list[str], pip_args: list[str], cooldown_days: int | None ) -> None: _LOGGER.info("Installing %s", package_descr := ", ".join(map(redact_url_credentials, requirements))) with animate(f"installing {package_descr}", do_animation=self.do_animation), self.pipx_metadata.preserved(): process: Final[CompletedProcess[str]] = self.backend.install( venv_root=self.root, venv_python=self._installer_python, requirements=list(requirements), pip_args=self._with_cooldown(pip_args, cooldown_days), verbose=self.verbose, ) if process.returncode: msg = f"Error installing {package_descr}." raise PipxError(msg) def install_package_no_deps( self, package_or_url: str, pip_args: list[str], cooldown_days: int | None, ) -> str: with animate( f"determining package name from {redact_url_credentials(package_or_url)!r}", do_animation=self.do_animation ): old_package_set: Final[set[str]] = self.list_installed_packages() old_dist_info_dirs: Final[set[Path]] = self._dist_info_dirs() process: Final[CompletedProcess[str]] = self.backend.install( venv_root=self.root, venv_python=self._installer_python, requirements=[package_or_url], pip_args=self._with_cooldown(pip_args, cooldown_days), no_deps=True, log_pip_errors=False, verbose=self.verbose, ) if process.returncode: error_output: Final[str] = (process.stderr or process.stdout or "").strip() if constraint := rejected_constraint(error_output): raise IncompatiblePythonError(constraint) if error_output and not self.verbose: raise PipxError(error_output, wrap_message=False) msg = f""" Error determining package name from spec {redact_url_credentials(package_or_url)!r}. See installer output for details. """ raise PipxError(msg, backend_output=self._as_displayed(error_output)) installed_packages: Final[set[str]] = self.list_installed_packages() - old_package_set if len(installed_packages) == 1: package_name: Final[str] = installed_packages.pop() _LOGGER.info("Determined package name: %s", package_name) else: _LOGGER.info("old_package_set = %s", old_package_set) _LOGGER.info("install_packages = %s", installed_packages) msg = f""" Cannot determine package name from spec {redact_url_credentials(package_or_url)!r}. Check package spec for errors. """ raise PipxError(msg) # the installer unpacks a wheel's data files after validating its METADATA, so they can rewrite the listed name, # but the .dist-info directory keeps the name the installer accepted if any( self._distribution_name(path) != canonicalize_name(package_name) for path in self._dist_info_dirs() - old_dist_info_dirs ): msg = f""" The package installed from spec {redact_url_credentials(package_or_url)!r} declares the name {package_name!r}, which is not the name the installer installed it under. """ raise PipxError(msg) return package_name def list_installed_packages(self, *, not_required: bool = False) -> set[str]: return self.backend.list_installed( venv_root=self.root, venv_python=self._installer_python, not_required=not_required, ) def _dist_info_dirs(self) -> set[Path]: return {path for directory in self.site_packages for path in directory.glob("*.dist-info")} @staticmethod def _distribution_name(metadata_dir: Path) -> NormalizedName | None: # an interrupted pip leaves `~name-1.0.dist-info` behind, and uv rejects such a name as an `--exclude` value try: return canonicalize_name(metadata_dir.name.partition("-")[0], validate=True) except InvalidName: return None def list_outdated_packages(self, index_args: list[str], packages: Collection[str]) -> tuple[OutdatedPackage, ...]: # pip and uv query the index for every installed distribution unless told to exclude it return self.backend.list_outdated( venv_root=self.root, venv_python=self._installer_python, index_args=index_args, exclude=sorted(self._installed_distribution_names().difference(packages)), ) def _installed_distribution_names(self) -> set[str]: # the directory name carries the distribution name, so a broken METADATA file cannot fail the listing site_packages: Final[list[Path]] = list(self.site_packages) if (shared_pth := site_packages[0] / PIPX_SHARED_PTH).is_file(): site_packages.extend(Path(line) for line in shared_pth.read_text().splitlines()) return { name for directory in site_packages for pattern in ("*.dist-info", "*.egg-info") for path in directory.glob(pattern) if (name := self._distribution_name(path)) } def run_app( self, app: str, filename: str, app_args: list[str], *, python_args: list[str], ) -> NoReturn: entry_point = self._find_entry_point(app, "pipx.run") if entry_point is None and python_args: entry_point = self._find_entry_point(app, "console_scripts") if entry_point is None: if python_args: msg = f"Cannot pass Python arguments because {app!r} is not a Python entry point." raise PipxError(msg) exec_app([str(self.bin_path / filename), *app_args]) _LOGGER.info("Using discovered entry point for 'pipx run'") code: Final[str] = ( "import sys\nfrom importlib.metadata import EntryPoint\n" f"sys.argv[0] = {entry_point.name!r}\n" f"sys.exit(EntryPoint(name={entry_point.name!r}, value={entry_point.value!r}, " f"group={entry_point.group!r}).load()())\n" ) exec_app([str(self.python_path), "-P", *python_args, "-c", code, *app_args]) def _find_entry_point(self, app: str, group: str) -> EntryPoint | None: if not self.python_path.exists(): return None for dist in self._distributions(self.main_package_name): for entry_point in dist.entry_points: if entry_point.group == group: if entry_point.name == app: return entry_point # a local project path does not name an app, so its matching pipx.run entry point stands in if group == "pipx.run" and Path(app).exists() and dist.metadata["Name"] == entry_point.name: return entry_point return None def has_app(self, app: str, filename: str) -> bool: if self._find_entry_point(app, "pipx.run") is not None: return True return (self.bin_path / filename).is_file() def upgrade_package_no_metadata(self, package_name: str, pip_args: list[str], cooldown_days: int | None) -> None: _LOGGER.info("Upgrading %s", package_descr := full_package_description(package_name, package_name)) with animate(f"upgrading {package_descr}", do_animation=self.do_animation), self.pipx_metadata.preserved(): process: Final[CompletedProcess[str]] = self.backend.install( venv_root=self.root, venv_python=self._installer_python, requirements=[package_name], pip_args=self._with_cooldown(pip_args, cooldown_days), upgrade_package=package_name, log_pip_errors=False, verbose=self.verbose, ) subprocess_post_check(process, streamed=self.verbose) def upgrade_package( # ruff:ignore[too-many-arguments] # flat upgrade API mirroring the CLI's upgrade options self, package_name: str, package_or_url: str, pip_args: list[str], *, exposure: ExposureOptions, is_main_package: bool, upgrade_only_pip_args: list[str] | None = None, expected_apps: Sequence[str] | None, cooldown_days: int | None, ) -> None: _LOGGER.info("Upgrading %s", package_descr := full_package_description(package_name, package_or_url)) pip_args = absolute_path_pip_args(pip_args, Path.cwd()) with ( installable_script(package_name, package_or_url, tuple(expected_apps or ())) as install_spec, animate(f"upgrading {package_descr}", do_animation=self.do_animation), self.pipx_metadata.preserved(), ): process: Final[CompletedProcess[str]] = self.backend.install( venv_root=self.root, venv_python=self._installer_python, requirements=[install_spec], pip_args=self._with_cooldown([*(upgrade_only_pip_args or []), *pip_args], cooldown_days), upgrade_package=package_name, log_pip_errors=False, verbose=self.verbose, ) subprocess_post_check(process, streamed=self.verbose) self.update_package_metadata( package_name=package_name, package_or_url=package_or_url, pip_args=pip_args, exposure=exposure, is_main_package=is_main_package, expected_apps=expected_apps, cooldown_days=cooldown_days, ) def run_pip_get_exit_code(self, cmd: list[str]) -> ExitCode: with self.pipx_metadata.preserved(): process: Final[CompletedProcess[str]] = self.backend.run_raw_pip( venv_root=self.root, venv_python=self._installer_python, args=cmd, capture_stdout=False, capture_stderr=False, verbose=self.verbose, # the user wrote these for their working directory, so relative paths must not name venv-root files in_working_dir=True, ) if process.returncode: _LOGGER.error("%r failed", " ".join(redact_url_credentials(str(arg)) for arg in process.args)) return ExitCode(process.returncode) __all__ = [ "Venv", "VenvContainer", "reset_backend_override_warnings", ] pipx-1.18.16/src/pipx/venv_inspect.py000066400000000000000000000560201527355705200175120ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = [ "ast", "configparser", "importlib", "json", "packaging.requirements", "packaging.utils", "pathlib", "pipx.requires_python", "pipx.subprocess_runner", "pipx.util", "shutil", "textwrap", "tomllib", "urllib.parse", ] import ast import configparser import json import logging import os import shutil import textwrap import tomllib from dataclasses import dataclass from importlib import metadata from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Final, TypedDict from urllib.parse import urlparse from packaging.requirements import Requirement from packaging.utils import InvalidName, canonicalize_name from pipx.constants import COMPLETION_SECTIONS, MAN_SECTIONS, WINDOWS from pipx.requires_python import unsatisfied_constraint from pipx.subprocess_runner import run_subprocess from pipx.util import PipxError, is_file_name if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Mapping from subprocess import CompletedProcess from packaging.specifiers import SpecifierSet _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) def inspect_venv( root_package_name: str, root_package_extras: set[str], venv_bin_path: Path, venv_python_path: Path, venv_man_path: Path, ) -> VenvMetadata: root_req: Final[Requirement] = Requirement(root_package_name) root_req.extras = root_package_extras venv_sys_path, venv_env, venv_python_version = fetch_info_in_venv(venv_python_path) venv_inspect_info: Final[_VenvInspectInformation] = _VenvInspectInformation( bin_path=venv_bin_path, man_path=venv_man_path, env=venv_env, distributions=get_distributions_by_name(venv_sys_path), ) if (root_dist := venv_inspect_info.distributions.get(canonicalize_name(root_req.name))) is None: msg = f"Pipx Internal Error: cannot find package {root_req.name!r} metadata." raise PipxError(msg) resources_of_dependencies: Final[_DependencyResources] = _DependencyResources( app_paths={}, man_paths={}, completion_paths={} ) _dfs_package_resources( root_dist, root_req, venv_inspect_info, resources_of_dependencies, {canonicalize_name(root_req.name): set(root_package_extras)}, ) venv_share_path: Final[Path] = venv_man_path.parent apps, man_pages, completions = _get_resources(root_dist, venv_bin_path, venv_man_path) app_paths: Final[list[Path]] = [venv_bin_path / app for app in apps] # named before the Windows companion files join the paths, so those stay out of the listed apps apps_of_dependencies: Final[list[str]] = [ dep_path.name for dep_paths in resources_of_dependencies.app_paths.values() for dep_path in dep_paths ] if WINDOWS: for dep, dep_paths in resources_of_dependencies.app_paths.items(): resources_of_dependencies.app_paths[dep] = _windows_extra_app_paths(dep_paths) return VenvMetadata( apps=apps, app_paths=_windows_extra_app_paths(app_paths) if WINDOWS else app_paths, apps_of_dependencies=apps_of_dependencies, app_paths_of_dependencies=resources_of_dependencies.app_paths, man_pages=man_pages, man_paths=[venv_man_path / man_page for man_page in man_pages], man_pages_of_dependencies=[ str(Path(dep_path.parent.name) / dep_path.name) for dep_paths in resources_of_dependencies.man_paths.values() for dep_path in dep_paths ], man_paths_of_dependencies=resources_of_dependencies.man_paths, completions=completions, completion_paths=[venv_share_path / completion for completion in completions], completions_of_dependencies=[ dep_path.relative_to(venv_share_path).as_posix() for dep_paths in resources_of_dependencies.completion_paths.values() for dep_path in dep_paths ], completion_paths_of_dependencies=resources_of_dependencies.completion_paths, package_version=root_dist.version, python_version=f"Python {venv_env['python_full_version']}", unsupported_python=unsatisfied_constraint(root_dist.metadata.get("Requires-Python"), venv_python_version), ) def fetch_info_in_venv(venv_python_path: Path) -> tuple[list[str], dict[str, str], str]: command_str: Final[str] = textwrap.dedent( """ import json import os import platform import sys impl_ver = sys.implementation.version implementation_version = "{0.major}.{0.minor}.{0.micro}".format(impl_ver) if impl_ver.releaselevel != "final": implementation_version = "{}{}{}".format( implementation_version, impl_ver.releaselevel[0], impl_ver.serial, ) sys_path = sys.path try: sys_path.remove("") except ValueError: pass print( json.dumps( { "sys_path": sys_path, "python_version": "{0.major}.{0.minor}.{0.micro}".format(sys.version_info), "environment": { "implementation_name": sys.implementation.name, "implementation_version": implementation_version, "os_name": os.name, "platform_machine": platform.machine(), "platform_release": platform.release(), "platform_system": platform.system(), "platform_version": platform.version(), "python_full_version": platform.python_version(), "platform_python_implementation": platform.python_implementation(), "python_version": ".".join(platform.python_version_tuple()[:2]), "sys_platform": sys.platform, }, } ) ) """ ) try: process: Final[CompletedProcess[str]] = run_subprocess( [venv_python_path, "-c", command_str], capture_stderr=False, log_cmd_str="", ) except OSError as error: msg = f"Unable to run {venv_python_path}: {error}" raise PipxError(msg, wrap_message=False) from error if process.returncode: msg = f"{venv_python_path} exited with code {process.returncode} while pipx inspected its environment." raise PipxError(msg, wrap_message=False) try: venv_info: Final[_VenvInfo] = json.loads(process.stdout) except json.JSONDecodeError as error: msg = f"{venv_python_path} printed output pipx cannot parse while inspecting its environment." raise PipxError(msg, wrap_message=False) from error return venv_info["sys_path"], venv_info["environment"], venv_info["python_version"] class _VenvInfo(TypedDict): sys_path: list[str] environment: dict[str, str] python_version: str def get_distributions_by_name(paths: list[str]) -> dict[str, metadata.Distribution]: metadata.MetadataPathFinder().invalidate_caches() distributions: Final[dict[str, metadata.Distribution]] = {} for dist in metadata.distributions(path=paths): if name := _distribution_name(dist): # the interpreter resolves a name to its first record on sys.path, so a later one must not shadow it distributions.setdefault(canonicalize_name(name), dist) return distributions def _distribution_name(dist: metadata.Distribution) -> str | None: # uninject passes these names to the installer, which would read a metadata Name like `-t/dir` as an option try: return canonicalize_name(dist.name or "", validate=True) except (KeyError, InvalidName): # Python 3.15+ raises KeyError instead of returning None when there is no Name return None @dataclass(frozen=True, slots=True, kw_only=True) class _VenvInspectInformation: distributions: Mapping[str, metadata.Distribution] env: dict[str, str] bin_path: Path man_path: Path def _dfs_package_resources( dist: metadata.Distribution, package_req: Requirement, venv_inspect_info: _VenvInspectInformation, resources: _DependencyResources, dep_visited: dict[str, set[str]], ) -> None: share_path: Final[Path] = venv_inspect_info.man_path.parent for dep_req in _get_package_dependencies(dist, package_req.extras, venv_inspect_info.env): dep_name = canonicalize_name(dep_req.name) if dep_name in dep_visited and dep_req.extras <= dep_visited[dep_name]: # revisiting a dependency with no new extras would recurse forever and repeat its resources continue if (dep_dist := venv_inspect_info.distributions.get(dep_name)) is None: msg = f"Pipx Internal Error: cannot find package {dep_req.name!r} metadata." raise PipxError(msg) if dep_name not in dep_visited: app_names, man_names, completion_names = _get_resources( dep_dist, venv_inspect_info.bin_path, venv_inspect_info.man_path ) if app_names: resources.app_paths[dep_name] = [venv_inspect_info.bin_path / name for name in app_names] if man_names: resources.man_paths[dep_name] = [venv_inspect_info.man_path / name for name in man_names] if completion_names: resources.completion_paths[dep_name] = [share_path / name for name in completion_names] dep_visited.setdefault(dep_name, set()).update(dep_req.extras) _dfs_package_resources(dep_dist, dep_req, venv_inspect_info, resources, dep_visited) @dataclass(frozen=True, slots=True, kw_only=True) class _DependencyResources: app_paths: dict[str, list[Path]] man_paths: dict[str, list[Path]] completion_paths: dict[str, list[Path]] def _get_package_dependencies(dist: metadata.Distribution, extras: set[str], env: dict[str, str]) -> list[Requirement]: eval_env: Final[dict[str, str]] = env.copy() # an empty extra lets the markers that name no extra evaluate if not extras: extras.add("") dependencies: Final[list[Requirement]] = [] for req in map(Requirement, dist.requires or []): if not req.marker: dependencies.append(req) else: for extra in extras: eval_env["extra"] = extra if req.marker.evaluate(eval_env): dependencies.append(req) break return dependencies def _get_resources( dist: metadata.Distribution, bin_path: Path, man_path: Path ) -> tuple[list[str], list[str], list[str]]: entry_point_apps: Final[set[str]] = _get_apps_from_entry_points(dist, bin_path) dist_file_apps, dist_file_man_pages, dist_file_completions = _get_resources_from_dist_files( dist, bin_path, man_path ) inst_file_apps, inst_file_man_pages, inst_file_completions = _get_resources_from_inst_files( dist, bin_path, man_path ) return ( sorted(entry_point_apps | dist_file_apps | inst_file_apps), sorted(dist_file_man_pages | inst_file_man_pages | _get_man_pages_from_editable_project(dist, man_path)), sorted(dist_file_completions | inst_file_completions), ) def _get_apps_from_entry_points(dist: metadata.Distribution, bin_path: Path) -> set[str]: if not _owns_entry_points(dist): return set() app_names: Final[set[str]] = set() for entry_point in dist.entry_points: if entry_point.group not in {"console_scripts", "gui_scripts"} or not is_file_name(entry_point.name): continue if (bin_path / entry_point.name).exists(): app_names.add(entry_point.name) # Windows installs a script entry point as an .exe if WINDOWS and (bin_path / f"{entry_point.name}.exe").exists(): app_names.add(f"{entry_point.name}.exe") return app_names def _owns_entry_points(dist: metadata.Distribution) -> bool: # every wheel unpacks into one site-packages, so a dependency can plant entry_points.txt in this .dist-info if dist.read_text("RECORD") is None: return True return any(path.name == "entry_points.txt" and path.parent.suffix == ".dist-info" for path in dist.files or []) def _get_resources_from_dist_files( dist: metadata.Distribution, bin_path: Path, man_path: Path ) -> tuple[set[str], set[str], set[str]]: app_names: Final[set[str]] = set() man_names: Final[set[str]] = set() completion_names: Final[set[str]] = set() # setup.py "scripts" (awscli ships its commands that way) appear here for path in dist.files or []: # apps and man pages sit above the distribution root (venv/bin, venv/Scripts), and skipping the rest is fast if Path(path).parts[0] != ".." or not is_file_name(path.name): continue dist_file_path: Final[Path] = Path(str(dist.locate_file(path))) try: if dist_file_path.parent.samefile(bin_path): app_names.add(path.name) if dist_file_path.parent.name in MAN_SECTIONS and dist_file_path.parent.parent.samefile(man_path): man_names.add(str(Path(dist_file_path.parent.name) / path.name)) except FileNotFoundError: pass if completion_name := _get_completion_name(dist_file_path, man_path.parent): completion_names.add(completion_name) return app_names, man_names, completion_names def _get_completion_name(file_path: Path, share_path: Path) -> str | None: # a wheel ships its completion scripts in the data scheme, which lands them beside the man pages under share/ for section in COMPLETION_SECTIONS: if _same_file(file_path.parent, share_path / section): return str(section / file_path.name) return None def _same_file(left: Path, right: Path) -> bool: try: return left.samefile(right) except OSError: return False def _get_resources_from_inst_files( dist: metadata.Distribution, bin_path: Path, man_path: Path ) -> tuple[set[str], set[str], set[str]]: app_names: Final[set[str]] = set() man_names: Final[set[str]] = set() completion_names: Final[set[str]] = set() # only a legacy egg-info owns this file; next to a RECORD another wheel planted it, and importlib ignores it too inst_files: Final[str] = "" if dist.read_text("RECORD") is not None else dist.read_text("installed-files.txt") or "" for line in inst_files.splitlines(): # Path.resolve() raises RuntimeError on a symlink loop before Python 3.13, os.path.realpath() does not inst_file_path: Final[Path] = Path(os.path.realpath(str(dist.locate_file(line.split(",")[0])))) try: if inst_file_path.parent.samefile(bin_path): app_names.add(inst_file_path.name) if inst_file_path.parent.name in MAN_SECTIONS and inst_file_path.parent.parent.samefile(man_path): man_names.add(str(Path(inst_file_path.parent.name) / inst_file_path.name)) except FileNotFoundError: pass if completion_name := _get_completion_name(inst_file_path, man_path.parent): completion_names.add(completion_name) return app_names, man_names, completion_names def _get_man_pages_from_editable_project(dist: metadata.Distribution, man_path: Path) -> set[str]: if (editable_root := _get_editable_project_root(dist)) is None: return set() # Path.resolve() raises RuntimeError on a symlink loop before Python 3.13, os.path.realpath() does not project_root: Final[Path] = Path(os.path.realpath(editable_root)) man_names: Final[set[str]] = set() for target_dir, source_paths in _iter_editable_project_data_files(project_root): if (man_section := _get_man_section_from_data_files_target(target_dir)) is None: continue for source_path in source_paths: source: Final[Path] = project_root / source_path # a wheel can ship its own direct_url.json, so only files inside the project count as its man pages if not (resolved := Path(os.path.realpath(source))).is_relative_to(project_root) or not resolved.is_file(): continue dest: Final[Path] = man_path / man_section / source.name try: dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(resolved, dest) except OSError as exc: _LOGGER.warning("Unable to copy editable man page %s to %s: %s", source, dest, exc) continue man_names.add(str(Path(man_section) / source.name)) return man_names def _get_editable_project_root(dist: metadata.Distribution) -> Path | None: if not (direct_url := dist.read_text("direct_url.json")): return None try: data: Final = json.loads(direct_url) except json.JSONDecodeError: return None if not isinstance(dir_info := data.get("dir_info", {}), dict) or not dir_info.get("editable"): return None if not isinstance(url := data.get("url"), str): return None if (parsed_url := urlparse(url)).scheme != "file": return None from urllib.request import url2pathname # ruff:ignore[import-outside-top-level] # pulls in http.client, slows startup return Path(url2pathname(f"//{parsed_url.netloc}{parsed_url.path}" if parsed_url.netloc else parsed_url.path)) def _iter_editable_project_data_files(project_root: Path) -> Iterator[tuple[str, list[str]]]: yield from _iter_pyproject_data_files(project_root) yield from _iter_setup_cfg_data_files(project_root) yield from _iter_setup_py_data_files(project_root) def _iter_pyproject_data_files(project_root: Path) -> Iterator[tuple[str, list[str]]]: try: with (project_root / "pyproject.toml").open("rb") as file: pyproject_data: Final = tomllib.load(file) except (OSError, tomllib.TOMLDecodeError): return yield from _iter_normalized_data_files(pyproject_data.get("tool", {}).get("setuptools", {}).get("data-files", {})) def _iter_normalized_data_files(data_files: object) -> Iterator[tuple[str, list[str]]]: items: Iterable[object] if isinstance(data_files, dict): items = data_files.items() elif isinstance(data_files, (list, tuple)): items = data_files else: return pair_length: Final[int] = 2 for item in items: if not isinstance(item, (list, tuple)) or len(item) != pair_length: continue target_dir, source_paths = item if not isinstance(target_dir, str): continue if isinstance(source_paths, str): yield target_dir, [line.strip() for line in source_paths.splitlines() if line.strip()] elif isinstance(source_paths, (list, tuple)) and all(isinstance(path, str) for path in source_paths): yield target_dir, [path for path in source_paths if isinstance(path, str)] def _iter_setup_cfg_data_files(project_root: Path) -> Iterator[tuple[str, list[str]]]: config: Final[configparser.ConfigParser] = configparser.ConfigParser(interpolation=None) try: config.read(project_root / "setup.cfg") except configparser.Error: return if not config.has_section("options.data_files"): return yield from _iter_normalized_data_files(dict(config.items("options.data_files"))) def _iter_setup_py_data_files(project_root: Path) -> Iterator[tuple[str, list[str]]]: try: tree: Final[ast.Module] = ast.parse((project_root / "setup.py").read_text(encoding="utf-8")) except (OSError, SyntaxError, UnicodeDecodeError): return for node in ast.walk(tree): if not isinstance(node, ast.Call) or not _is_setup_call(node): continue for keyword in node.keywords: if keyword.arg != "data_files": continue try: data_files: Final = ast.literal_eval(keyword.value) except (ValueError, SyntaxError): return yield from _iter_normalized_data_files(data_files) return def _is_setup_call(node: ast.Call) -> bool: return (isinstance(node.func, ast.Name) and node.func.id == "setup") or ( isinstance(node.func, ast.Attribute) and node.func.attr == "setup" ) def _get_man_section_from_data_files_target(target_dir: str) -> str | None: parts: Final[tuple[str, ...]] = PurePosixPath(target_dir.replace("\\", "/")).parts min_parts: Final[int] = 2 if len(parts) < min_parts or parts[-2] != "man" or parts[-1] not in MAN_SECTIONS: return None return parts[-1] def _windows_extra_app_paths(app_paths: list[Path]) -> list[Path]: # an editable app on Windows runs only with its *-script.py and *.exe.manifest companions in the same directory, so # they join app_paths while staying out of the listed apps app_paths_output: Final[list[Path]] = app_paths.copy() for app_path in app_paths: for companion_suffix in ("-script.py", ".exe.manifest"): if (companion := app_path.parent / f"{app_path.stem}{companion_suffix}").exists(): app_paths_output.append(companion) return app_paths_output @dataclass(frozen=True, slots=True, kw_only=True) class VenvMetadata: apps: list[str] app_paths: list[Path] apps_of_dependencies: list[str] app_paths_of_dependencies: dict[str, list[Path]] man_pages: list[str] man_paths: list[Path] man_pages_of_dependencies: list[str] man_paths_of_dependencies: dict[str, list[Path]] completions: list[str] completion_paths: list[Path] completions_of_dependencies: list[str] completion_paths_of_dependencies: dict[str, list[Path]] package_version: str python_version: str unsupported_python: SpecifierSet | None def list_not_required_packages(venv_python: Path) -> set[str]: venv_sys_path, venv_env, _ = fetch_info_in_venv(venv_python) return not_required_packages(venv_sys_path, venv_env) def not_required_packages(sys_path: list[str], env: dict[str, str]) -> set[str]: # mirrors `pip list --not-required` from the installed metadata, so every backend reports the same packages, uv # included, which lacks the flag installed: Final[set[str]] = set() required: Final[set[str]] = set() for dist in metadata.distributions(path=sys_path): if (name := _distribution_name(dist)) is not None: installed.add(canonicalize_name(name)) required.update(get_required_dependency_names(dist, env)) return installed - required def get_required_dependency_names(dist: metadata.Distribution, env: dict[str, str]) -> set[str]: # every declared extra counts, so a dependency only an extra pulls in stays required, and pipx does not report it # removable while the package declaring it is installed return { canonicalize_name(req.name) for req in _get_package_dependencies(dist, set(dist.metadata.get_all("Provides-Extra") or []), env) } __all__ = [ "VenvMetadata", "fetch_info_in_venv", "get_distributions_by_name", "get_required_dependency_names", "inspect_venv", "list_not_required_packages", "not_required_packages", ] pipx-1.18.16/src/pipx/version.pyi000066400000000000000000000003621527355705200166430ustar00rootroot00000000000000version: str version_tuple: tuple[int, int, int, str, str] | tuple[int, int, int] # Note that newer versions of setuptools_scm also add __version__, but we are # not forcing new versions of setuptools_scm, so only these imports are allowed. pipx-1.18.16/src/pipx/wrap.py000066400000000000000000000011511527355705200157530ustar00rootroot00000000000000from __future__ import annotations __lazy_modules__ = ["shutil", "textwrap", "typing"] import shutil import textwrap from typing import Final def pipx_wrap(text: str, subsequent_indent: str = "", *, keep_newlines: bool = False) -> str: width: Final[int] = max(shutil.get_terminal_size((80, 40)).columns, 40) - 2 dedented: Final[str] = textwrap.dedent(text).strip() return "\n".join( textwrap.fill(line, width=width, subsequent_indent=subsequent_indent, break_on_hyphens=False) for line in (dedented.splitlines() if keep_newlines else [dedented]) ) __all__ = [ "pipx_wrap", ] pipx-1.18.16/testdata/000077500000000000000000000000001527355705200144745ustar00rootroot00000000000000pipx-1.18.16/testdata/empty_project/000077500000000000000000000000001527355705200173605ustar00rootroot00000000000000pipx-1.18.16/testdata/empty_project/README.md000066400000000000000000000000441527355705200206350ustar00rootroot00000000000000Empty project used for testing only pipx-1.18.16/testdata/empty_project/empty_project/000077500000000000000000000000001527355705200222445ustar00rootroot00000000000000pipx-1.18.16/testdata/empty_project/empty_project/__init__.py000066400000000000000000000000001527355705200243430ustar00rootroot00000000000000pipx-1.18.16/testdata/empty_project/empty_project/main.py000066400000000000000000000000751527355705200235440ustar00rootroot00000000000000def main(): pass if __name__ == "__main__": main() pipx-1.18.16/testdata/empty_project/pyproject.toml000066400000000000000000000013031527355705200222710ustar00rootroot00000000000000[build-system] build-backend = "flit_core.buildapi" requires = [ "flit-core", ] [project] name = "empty-project" version = "0.1.0" description = "Empty Python Project" authors = [ { name = "My Name", email = "me@example.com" }, ] requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", ] scripts.empty-project = "empty_project.main:cli" entry-points."pipx.run".empty-project = "empty_project.main:cli" pipx-1.18.16/testdata/pipx_metadata_multiple_errors.json000066400000000000000000000042231527355705200235170ustar00rootroot00000000000000{ "pipx_spec_version": "0.1", "venvs": { "dotenv": { "metadata": { "injected_packages": {}, "main_package": { "app_paths": [ ], "app_paths_of_dependencies": {}, "apps": [ ], "apps_of_dependencies": [], "include_apps": true, "include_dependencies": false, "man_pages": [], "man_pages_of_dependencies": [], "man_paths": [], "man_paths_of_dependencies": {}, "package": "dotenv", "package_or_url": "dotenv", "package_version": "0.0.5", "pip_args": [], "suffix": "" }, "pipx_metadata_version": "0.4", "python_version": "Python 3.10.12", "source_interpreter": { }, "venv_args": [] } }, "weblate": { "metadata": { "injected_packages": {}, "main_package": { "app_paths": [ ], "app_paths_of_dependencies": {}, "apps": [ ], "apps_of_dependencies": [], "include_apps": true, "include_dependencies": false, "man_pages": [ ], "man_pages_of_dependencies": [], "man_paths": [ ], "man_paths_of_dependencies": {}, "package": "weblate", "package_or_url": "weblate", "package_version": "4.3.1", "pip_args": [], "suffix": "" }, "pipx_metadata_version": "0.4", "python_version": "Python 3.10.12", "source_interpreter": { }, "venv_args": [] } } } } pipx-1.18.16/testdata/test_package_specifier/000077500000000000000000000000001527355705200211575ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_completion/000077500000000000000000000000001527355705200245025ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_completion/data/000077500000000000000000000000001527355705200254135ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_completion/data/share/000077500000000000000000000000001527355705200265155ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_completion/data/share/bash-completion/000077500000000000000000000000001527355705200316015ustar00rootroot00000000000000completions/000077500000000000000000000000001527355705200340565ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_completion/data/share/bash-completionlocal-completion000066400000000000000000000000571527355705200372440ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_completion/data/share/bash-completion/completionscomplete -F _local_completion local-completion pipx-1.18.16/testdata/test_package_specifier/local_completion/data/share/fish/000077500000000000000000000000001527355705200274465ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_completion/data/share/fish/vendor_completions.d/000077500000000000000000000000001527355705200336015ustar00rootroot00000000000000local-completion.fish000066400000000000000000000000351527355705200376340ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_completion/data/share/fish/vendor_completions.dcomplete -c local-completion pipx-1.18.16/testdata/test_package_specifier/local_completion/data/share/zsh/000077500000000000000000000000001527355705200273215ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_completion/data/share/zsh/site-functions/000077500000000000000000000000001527355705200322735ustar00rootroot00000000000000_local-completion000066400000000000000000000000321527355705200355320ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_completion/data/share/zsh/site-functions#compdef local-completion pipx-1.18.16/testdata/test_package_specifier/local_completion/local_completion/000077500000000000000000000000001527355705200300255ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_completion/local_completion/__init__.py000066400000000000000000000000001527355705200321240ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_completion/local_completion/main.py000066400000000000000000000000621527355705200313210ustar00rootroot00000000000000def main() -> None: print("local-completion") pipx-1.18.16/testdata/test_package_specifier/local_completion/pyproject.toml000066400000000000000000000010651527355705200274200ustar00rootroot00000000000000[build-system] build-backend = "flit_core.buildapi" requires = [ "flit-core", ] [project] name = "local-completion" version = "0.1" description = "Ships shell completions." classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", ] scripts.local-completion = "local_completion.main:main" [tool.flit] external-data.directory = "data" pipx-1.18.16/testdata/test_package_specifier/local_extras/000077500000000000000000000000001527355705200236375ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_extras/pyproject.toml000066400000000000000000000012601527355705200265520ustar00rootroot00000000000000[build-system] build-backend = "flit_core.buildapi" requires = [ "flit-core", ] [project] name = "repeatme" version = "0.1" description = "Repeat arguments." requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", ] optional-dependencies.cow = [ "pycowsay==0.0.0.2", ] optional-dependencies.tools = [ "black==22.8", "pycowsay==0.0.0.2", ] scripts.repeatme = "repeatme.main:main" pipx-1.18.16/testdata/test_package_specifier/local_extras/repeatme/000077500000000000000000000000001527355705200254415ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_extras/repeatme/__init__.py000066400000000000000000000000001527355705200275400ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_extras/repeatme/main.py000066400000000000000000000004321527355705200267360ustar00rootroot00000000000000import sys try: import pycowsay.main has_pycowsay = True except ImportError: has_pycowsay = False def main(): print(f"You said:\n {' '.join(sys.argv[1:])}") if has_pycowsay: print() print("In cow, you said:") pycowsay.main.main() pipx-1.18.16/testdata/test_package_specifier/local_manpage/000077500000000000000000000000001527355705200237415ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_manpage/data/000077500000000000000000000000001527355705200246525ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_manpage/data/share/000077500000000000000000000000001527355705200257545ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_manpage/data/share/man/000077500000000000000000000000001527355705200265275ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_manpage/data/share/man/man1/000077500000000000000000000000001527355705200273635ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_manpage/data/share/man/man1/local-manpage.1000066400000000000000000000000751527355705200321470ustar00rootroot00000000000000.TH local-manpage 1 .SH NAME local-manpage \- local test app pipx-1.18.16/testdata/test_package_specifier/local_manpage/local_manpage/000077500000000000000000000000001527355705200265235ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_manpage/local_manpage/__init__.py000066400000000000000000000000001527355705200306220ustar00rootroot00000000000000pipx-1.18.16/testdata/test_package_specifier/local_manpage/local_manpage/main.py000066400000000000000000000000471527355705200300220ustar00rootroot00000000000000def main(): print("local-manpage") pipx-1.18.16/testdata/test_package_specifier/local_manpage/pyproject.toml000066400000000000000000000010451527355705200266550ustar00rootroot00000000000000[build-system] build-backend = "flit_core.buildapi" requires = [ "flit-core", ] [project] name = "local-manpage" version = "0.1" description = "Ships a man page." classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", ] scripts.local-manpage = "local_manpage.main:main" [tool.flit] external-data.directory = "data" pipx-1.18.16/testdata/tests_packages/000077500000000000000000000000001527355705200174745ustar00rootroot00000000000000pipx-1.18.16/testdata/tests_packages/README.md000066400000000000000000000017631527355705200207620ustar00rootroot00000000000000# Introduction `primary_packages.txt` is the master list, containing all packages installed or injected in the pipx tests `tests`. Platform-specific list files listing both these primary packages and their dependencies are generated from it. These platform-specific list files are used to populate the directory `.pipx_tests/package_cache`. # Generating the platform-specific lists from the master list Make sure that `primary_packages.txt` lists every package and version the tests install or inject, then on each target platform execute: - `python scripts/list_test_packages.py testdata/tests_packages/primary_packages.txt testdata/tests_packages` # Updating / Populating the directory `.pipx_tests/package_cache` before running the tests Pre-populating this directory allows the pipx `tests` to run completely offline. Execute from the top-level pipx repo directory: - `mkdir -p .pipx_tests/package_cache` - `python3 scripts/update_package_cache.py testdata/tests_packages .pipx_tests/package_cache` pipx-1.18.16/testdata/tests_packages/macos24-python3.13.txt000066400000000000000000000157711527355705200233440ustar00rootroot00000000000000Weblate==4.3.1 argcomplete==1.12.3 argcomplete==3.6.2 astroid==3.0.3 black==22.10.0 black==22.8.0 bleach==6.2.0 build==1.3.0 cachetools==6.2.0 certifi==2025.8.3 cffi==2.0.0 chardet==5.2.0 charset_normalizer==3.4.3 click==8.3.0 cmarkgfm==2024.11.20 colorama==0.4.6 colorlog==6.9.0 dill==0.4.0 distlib==0.4.0 docutils==0.22.2 filelock==3.19.1 flit_core==4.1.0 id==1.5.0 idna==3.10 iniconfig==2.1.0 isort==5.13.2 isort==5.6.4 isort==6.0.1 jaraco.classes==3.4.0 jaraco.clipboard==2.0.1 jaraco.context==6.0.1 jaraco_functools==4.3.0 jinja2==3.1.6 kaggle==1.6.11 keyring==25.6.0 markdown_it_py==4.0.0 markupsafe==3.0.3 mccabe==0.7.0 mdurl==0.1.2 more_itertools==10.8.0 mypy_extensions==1.1.0 nh3==0.3.0 nox==2022.1.7 nox==2023.4.22 packaging==25.0 pathspec==0.12.1 pbr==5.6.0 pip==23.3.2 pip==24.0 pip==26.1.2 pip==26.2.2 platformdirs==4.4.0 pluggy==1.6.0 py==1.11.0 pycowsay==0.0.0.2 pycparser==2.23 pygdbmi==0.10.0.0 pygments==2.19.2 pylint==3.0.4 pyobjc==11.1 pyobjc_core==11.1 pyobjc_framework_accessibility==11.1 pyobjc_framework_accounts==11.1 pyobjc_framework_addressbook==11.1 pyobjc_framework_adservices==11.1 pyobjc_framework_adsupport==11.1 pyobjc_framework_applescriptkit==11.1 pyobjc_framework_applescriptobjc==11.1 pyobjc_framework_applicationservices==11.1 pyobjc_framework_apptrackingtransparency==11.1 pyobjc_framework_audiovideobridging==11.1 pyobjc_framework_authenticationservices==11.1 pyobjc_framework_automaticassessmentconfiguration==11.1 pyobjc_framework_automator==11.1 pyobjc_framework_avfoundation==11.1 pyobjc_framework_avkit==11.1 pyobjc_framework_avrouting==11.1 pyobjc_framework_backgroundassets==11.1 pyobjc_framework_browserenginekit==11.1 pyobjc_framework_businesschat==11.1 pyobjc_framework_calendarstore==11.1 pyobjc_framework_callkit==11.1 pyobjc_framework_carbon==11.1 pyobjc_framework_cfnetwork==11.1 pyobjc_framework_cinematic==11.1 pyobjc_framework_classkit==11.1 pyobjc_framework_cloudkit==11.1 pyobjc_framework_cocoa==11.1 pyobjc_framework_collaboration==11.1 pyobjc_framework_colorsync==11.1 pyobjc_framework_contacts==11.1 pyobjc_framework_contactsui==11.1 pyobjc_framework_coreaudio==11.1 pyobjc_framework_coreaudiokit==11.1 pyobjc_framework_corebluetooth==11.1 pyobjc_framework_coredata==11.1 pyobjc_framework_corehaptics==11.1 pyobjc_framework_corelocation==11.1 pyobjc_framework_coremedia==11.1 pyobjc_framework_coremediaio==11.1 pyobjc_framework_coremidi==11.1 pyobjc_framework_coreml==11.1 pyobjc_framework_coremotion==11.1 pyobjc_framework_coreservices==11.1 pyobjc_framework_corespotlight==11.1 pyobjc_framework_coretext==11.1 pyobjc_framework_corewlan==11.1 pyobjc_framework_cryptotokenkit==11.1 pyobjc_framework_datadetection==11.1 pyobjc_framework_devicecheck==11.1 pyobjc_framework_devicediscoveryextension==11.1 pyobjc_framework_dictionaryservices==11.1 pyobjc_framework_discrecording==11.1 pyobjc_framework_discrecordingui==11.1 pyobjc_framework_diskarbitration==11.1 pyobjc_framework_dvdplayback==11.1 pyobjc_framework_eventkit==11.1 pyobjc_framework_exceptionhandling==11.1 pyobjc_framework_executionpolicy==11.1 pyobjc_framework_extensionkit==11.1 pyobjc_framework_externalaccessory==11.1 pyobjc_framework_fileprovider==11.1 pyobjc_framework_fileproviderui==11.1 pyobjc_framework_findersync==11.1 pyobjc_framework_fsevents==11.1 pyobjc_framework_fskit==11.1 pyobjc_framework_gamecenter==11.1 pyobjc_framework_gamecontroller==11.1 pyobjc_framework_gamekit==11.1 pyobjc_framework_gameplaykit==11.1 pyobjc_framework_healthkit==11.1 pyobjc_framework_imagecapturecore==11.1 pyobjc_framework_inputmethodkit==11.1 pyobjc_framework_installerplugins==11.1 pyobjc_framework_instantmessage==11.1 pyobjc_framework_intents==11.1 pyobjc_framework_intentsui==11.1 pyobjc_framework_iobluetooth==11.1 pyobjc_framework_iobluetoothui==11.1 pyobjc_framework_iosurface==11.1 pyobjc_framework_ituneslibrary==11.1 pyobjc_framework_kernelmanagement==11.1 pyobjc_framework_latentsemanticmapping==11.1 pyobjc_framework_launchservices==11.1 pyobjc_framework_libdispatch==11.1 pyobjc_framework_libxpc==11.1 pyobjc_framework_linkpresentation==11.1 pyobjc_framework_localauthentication==11.1 pyobjc_framework_localauthenticationembeddedui==11.1 pyobjc_framework_mailkit==11.1 pyobjc_framework_mapkit==11.1 pyobjc_framework_mediaaccessibility==11.1 pyobjc_framework_mediaextension==11.1 pyobjc_framework_medialibrary==11.1 pyobjc_framework_mediaplayer==11.1 pyobjc_framework_mediatoolbox==11.1 pyobjc_framework_metal==11.1 pyobjc_framework_metalfx==11.1 pyobjc_framework_metalkit==11.1 pyobjc_framework_metalperformanceshaders==11.1 pyobjc_framework_metalperformanceshadersgraph==11.1 pyobjc_framework_metrickit==11.1 pyobjc_framework_mlcompute==11.1 pyobjc_framework_modelio==11.1 pyobjc_framework_multipeerconnectivity==11.1 pyobjc_framework_naturallanguage==11.1 pyobjc_framework_netfs==11.1 pyobjc_framework_network==11.1 pyobjc_framework_networkextension==11.1 pyobjc_framework_notificationcenter==11.1 pyobjc_framework_opendirectory==11.1 pyobjc_framework_osakit==11.1 pyobjc_framework_oslog==11.1 pyobjc_framework_passkit==11.1 pyobjc_framework_pencilkit==11.1 pyobjc_framework_phase==11.1 pyobjc_framework_photos==11.1 pyobjc_framework_photosui==11.1 pyobjc_framework_preferencepanes==11.1 pyobjc_framework_pushkit==11.1 pyobjc_framework_quartz==11.1 pyobjc_framework_quicklookthumbnailing==11.1 pyobjc_framework_replaykit==11.1 pyobjc_framework_safariservices==11.1 pyobjc_framework_safetykit==11.1 pyobjc_framework_scenekit==11.1 pyobjc_framework_screencapturekit==11.1 pyobjc_framework_screensaver==11.1 pyobjc_framework_screentime==11.1 pyobjc_framework_scriptingbridge==11.1 pyobjc_framework_searchkit==11.1 pyobjc_framework_security==11.1 pyobjc_framework_securityfoundation==11.1 pyobjc_framework_securityinterface==11.1 pyobjc_framework_securityui==11.1 pyobjc_framework_sensitivecontentanalysis==11.1 pyobjc_framework_servicemanagement==11.1 pyobjc_framework_sharedwithyou==11.1 pyobjc_framework_sharedwithyoucore==11.1 pyobjc_framework_shazamkit==11.1 pyobjc_framework_social==11.1 pyobjc_framework_soundanalysis==11.1 pyobjc_framework_speech==11.1 pyobjc_framework_spritekit==11.1 pyobjc_framework_storekit==11.1 pyobjc_framework_symbols==11.1 pyobjc_framework_syncservices==11.1 pyobjc_framework_systemconfiguration==11.1 pyobjc_framework_systemextensions==11.1 pyobjc_framework_threadnetwork==11.1 pyobjc_framework_uniformtypeidentifiers==11.1 pyobjc_framework_usernotifications==11.1 pyobjc_framework_usernotificationsui==11.1 pyobjc_framework_videosubscriberaccount==11.1 pyobjc_framework_videotoolbox==11.1 pyobjc_framework_virtualization==11.1 pyobjc_framework_vision==11.1 pyobjc_framework_webkit==11.1 pyproject_api==1.9.1 pyproject_hooks==1.2.0 pytest==8.4.2 python_dateutil==2.9.0.post0 python_slugify==8.0.4 readme_renderer==44.0 requests==2.31.0 requests==2.32.5 requests_toolbelt==1.0.0 rfc3986==2.0.0 rich==14.1.0 richxerox==1.0.1 setuptools==80.9.0 setuptools_scm==9.2.0 shell-functools==0.3.0 six==1.17.0 text_unidecode==1.3 tomlkit==0.13.3 tox==3.28.0 tox==4.30.2 tox_ini_fmt==0.5.0 tqdm==4.67.1 twine==6.2.0 urllib3==2.5.0 virtualenv==20.34.0 webencodings==0.5.1 wheel==0.45.1 zest.releaser==9.1.2 pipx-1.18.16/testdata/tests_packages/primary_packages.txt000066400000000000000000000012261527355705200235570ustar00rootroot00000000000000# Modify this list if the packages pipx installs in 'tests' changes # Comments ignored after # # Space-separated values on same row. # spec no-deps chardet black==22.8.0 black==22.10.0 flit-core==4.1.0 # build backend of the testdata projects # cloudtoken==2.1.0 isort==5.6.4 isort jaraco-clipboard==2.0.1 zest-releaser==9.1.2 kaggle==1.6.11 nox==2022.1.7 nox[tox_to_nox]==2023.4.22 pbr==5.6.0 pip==23.3.2 pip==24.0 pip==26.1.2 pip==26.2.2 pip pycowsay==0.0.0.2 pygdbmi==0.10.0.0 pylint==3.0.4 requests==2.31.0 setuptools-scm setuptools>=41.0 shell-functools==0.3.0 tox tox-ini-fmt==0.5.0 weblate==4.3.1 True # expected fail in tests wheel pipx-1.18.16/testdata/tests_packages/unix-python3.10.txt000066400000000000000000000031411527355705200230400ustar00rootroot00000000000000Weblate==4.3.1 argcomplete==1.12.3 argcomplete==3.6.2 astroid==3.0.3 backports.tarfile==1.2.0 black==22.10.0 black==22.8.0 bleach==6.2.0 build==1.3.0 cachetools==6.2.0 certifi==2025.8.3 cffi==2.0.0 chardet==5.2.0 charset_normalizer==3.4.3 click==8.3.0 cmarkgfm==2024.11.20 colorama==0.4.6 colorlog==6.9.0 cryptography==46.0.1 dill==0.4.0 distlib==0.4.0 docutils==0.22.2 exceptiongroup==1.3.0 filelock==3.19.1 flit_core==4.1.0 id==1.5.0 idna==3.10 importlib_metadata==8.7.0 iniconfig==2.1.0 isort==5.13.2 isort==5.6.4 isort==6.0.1 jaraco.classes==3.4.0 jaraco.clipboard==2.0.1 jaraco.context==6.0.1 jaraco_functools==4.3.0 jeepney==0.9.0 jinja2==3.1.6 kaggle==1.6.11 keyring==25.6.0 markdown_it_py==4.0.0 markupsafe==3.0.3 mccabe==0.7.0 mdurl==0.1.2 more_itertools==10.8.0 mypy_extensions==1.1.0 nh3==0.3.0 nox==2022.1.7 nox==2023.4.22 packaging==25.0 pathspec==0.12.1 pbr==5.6.0 pip==23.3.2 pip==24.0 pip==26.1.2 pip==26.2.2 platformdirs==4.4.0 pluggy==1.6.0 py==1.11.0 pycowsay==0.0.0.2 pycparser==2.23 pygdbmi==0.10.0.0 pygments==2.19.2 pylint==3.0.4 pyperclip==1.11.0 pyproject_api==1.9.1 pyproject_hooks==1.2.0 pytest==8.4.2 python_dateutil==2.9.0.post0 python_slugify==8.0.4 readme_renderer==44.0 requests==2.31.0 requests==2.32.5 requests_toolbelt==1.0.0 rfc3986==2.0.0 rich==14.1.0 secretstorage==3.4.0 setuptools==80.9.0 setuptools_scm==9.2.0 shell-functools==0.3.0 six==1.17.0 text_unidecode==1.3 tomli==2.2.1 tomlkit==0.13.3 tox==3.28.0 tox==4.30.2 tox_ini_fmt==0.5.0 tqdm==4.67.1 twine==6.2.0 typing_extensions==4.15.0 urllib3==2.5.0 virtualenv==20.34.0 webencodings==0.5.1 wheel==0.45.1 zest.releaser==9.1.2 zipp==3.23.0 pipx-1.18.16/testdata/tests_packages/unix-python3.11.txt000066400000000000000000000030441527355705200230430ustar00rootroot00000000000000Weblate==4.3.1 argcomplete==1.12.3 argcomplete==3.6.2 astroid==3.0.3 backports.tarfile==1.2.0 black==22.10.0 black==22.8.0 bleach==6.2.0 build==1.3.0 cachetools==6.2.0 certifi==2025.8.3 cffi==2.0.0 chardet==5.2.0 charset_normalizer==3.4.3 click==8.3.0 cmarkgfm==2024.11.20 colorama==0.4.6 colorlog==6.9.0 cryptography==46.0.1 dill==0.4.0 distlib==0.4.0 docutils==0.22.2 filelock==3.19.1 flit_core==4.1.0 id==1.5.0 idna==3.10 importlib_metadata==8.7.0 iniconfig==2.1.0 isort==5.13.2 isort==5.6.4 isort==6.0.1 jaraco.classes==3.4.0 jaraco.clipboard==2.0.1 jaraco.context==6.0.1 jaraco_functools==4.3.0 jeepney==0.9.0 jinja2==3.1.6 kaggle==1.6.11 keyring==25.6.0 markdown_it_py==4.0.0 markupsafe==3.0.3 mccabe==0.7.0 mdurl==0.1.2 more_itertools==10.8.0 mypy_extensions==1.1.0 nh3==0.3.0 nox==2022.1.7 nox==2023.4.22 packaging==25.0 pathspec==0.12.1 pbr==5.6.0 pip==23.3.2 pip==24.0 pip==26.1.2 pip==26.2.2 platformdirs==4.4.0 pluggy==1.6.0 py==1.11.0 pycowsay==0.0.0.2 pycparser==2.23 pygdbmi==0.10.0.0 pygments==2.19.2 pylint==3.0.4 pyperclip==1.11.0 pyproject_api==1.9.1 pyproject_hooks==1.2.0 pytest==8.4.2 python_dateutil==2.9.0.post0 python_slugify==8.0.4 readme_renderer==44.0 requests==2.31.0 requests==2.32.5 requests_toolbelt==1.0.0 rfc3986==2.0.0 rich==14.1.0 secretstorage==3.4.0 setuptools==80.9.0 setuptools_scm==9.2.0 shell-functools==0.3.0 six==1.17.0 text_unidecode==1.3 tomlkit==0.13.3 tox==3.28.0 tox==4.30.2 tox_ini_fmt==0.5.0 tqdm==4.67.1 twine==6.2.0 urllib3==2.5.0 virtualenv==20.34.0 webencodings==0.5.1 wheel==0.45.1 zest.releaser==9.1.2 zipp==3.23.0 pipx-1.18.16/testdata/tests_packages/unix-python3.12.txt000066400000000000000000000027441527355705200230520ustar00rootroot00000000000000Weblate==4.3.1 argcomplete==1.12.3 argcomplete==3.6.2 astroid==3.0.3 black==22.10.0 black==22.8.0 bleach==6.2.0 build==1.3.0 cachetools==6.2.0 certifi==2025.8.3 cffi==2.0.0 chardet==5.2.0 charset_normalizer==3.4.3 click==8.3.0 cmarkgfm==2024.11.20 colorama==0.4.6 colorlog==6.9.0 cryptography==46.0.1 dill==0.4.0 distlib==0.4.0 docutils==0.22.2 filelock==3.19.1 flit_core==4.1.0 id==1.5.0 idna==3.10 iniconfig==2.1.0 isort==5.13.2 isort==5.6.4 isort==6.0.1 jaraco.classes==3.4.0 jaraco.clipboard==2.0.1 jaraco.context==6.0.1 jaraco_functools==4.3.0 jeepney==0.9.0 jinja2==3.1.6 kaggle==1.6.11 keyring==25.6.0 markdown_it_py==4.0.0 markupsafe==3.0.3 mccabe==0.7.0 mdurl==0.1.2 more_itertools==10.8.0 mypy_extensions==1.1.0 nh3==0.3.0 nox==2022.1.7 nox==2023.4.22 packaging==25.0 pathspec==0.12.1 pbr==5.6.0 pip==23.3.2 pip==24.0 pip==26.1.2 pip==26.2.2 platformdirs==4.4.0 pluggy==1.6.0 py==1.11.0 pycowsay==0.0.0.2 pycparser==2.23 pygdbmi==0.10.0.0 pygments==2.19.2 pylint==3.0.4 pyperclip==1.11.0 pyproject_api==1.9.1 pyproject_hooks==1.2.0 pytest==8.4.2 python_dateutil==2.9.0.post0 python_slugify==8.0.4 readme_renderer==44.0 requests==2.31.0 requests==2.32.5 requests_toolbelt==1.0.0 rfc3986==2.0.0 rich==14.1.0 secretstorage==3.4.0 setuptools==80.9.0 setuptools_scm==9.2.0 shell-functools==0.3.0 six==1.17.0 text_unidecode==1.3 tomlkit==0.13.3 tox==3.28.0 tox==4.30.2 tox_ini_fmt==0.5.0 tqdm==4.67.1 twine==6.2.0 urllib3==2.5.0 virtualenv==20.34.0 webencodings==0.5.1 wheel==0.45.1 zest.releaser==9.1.2 pipx-1.18.16/testdata/tests_packages/unix-python3.13.txt000066400000000000000000000027441527355705200230530ustar00rootroot00000000000000Weblate==4.3.1 argcomplete==1.12.3 argcomplete==3.6.2 astroid==3.0.3 black==22.10.0 black==22.8.0 bleach==6.2.0 build==1.3.0 cachetools==6.2.0 certifi==2025.8.3 cffi==2.0.0 chardet==5.2.0 charset_normalizer==3.4.3 click==8.3.0 cmarkgfm==2024.11.20 colorama==0.4.6 colorlog==6.9.0 cryptography==46.0.1 dill==0.4.0 distlib==0.4.0 docutils==0.22.2 filelock==3.19.1 flit_core==4.1.0 id==1.5.0 idna==3.10 iniconfig==2.1.0 isort==5.13.2 isort==5.6.4 isort==6.0.1 jaraco.classes==3.4.0 jaraco.clipboard==2.0.1 jaraco.context==6.0.1 jaraco_functools==4.3.0 jeepney==0.9.0 jinja2==3.1.6 kaggle==1.6.11 keyring==25.6.0 markdown_it_py==4.0.0 markupsafe==3.0.3 mccabe==0.7.0 mdurl==0.1.2 more_itertools==10.8.0 mypy_extensions==1.1.0 nh3==0.3.0 nox==2022.1.7 nox==2023.4.22 packaging==25.0 pathspec==0.12.1 pbr==5.6.0 pip==23.3.2 pip==24.0 pip==26.1.2 pip==26.2.2 platformdirs==4.4.0 pluggy==1.6.0 py==1.11.0 pycowsay==0.0.0.2 pycparser==2.23 pygdbmi==0.10.0.0 pygments==2.19.2 pylint==3.0.4 pyperclip==1.11.0 pyproject_api==1.9.1 pyproject_hooks==1.2.0 pytest==8.4.2 python_dateutil==2.9.0.post0 python_slugify==8.0.4 readme_renderer==44.0 requests==2.31.0 requests==2.32.5 requests_toolbelt==1.0.0 rfc3986==2.0.0 rich==14.1.0 secretstorage==3.4.0 setuptools==80.9.0 setuptools_scm==9.2.0 shell-functools==0.3.0 six==1.17.0 text_unidecode==1.3 tomlkit==0.13.3 tox==3.28.0 tox==4.30.2 tox_ini_fmt==0.5.0 tqdm==4.67.1 twine==6.2.0 urllib3==2.5.0 virtualenv==20.34.0 webencodings==0.5.1 wheel==0.45.1 zest.releaser==9.1.2 pipx-1.18.16/testdata/tests_packages/unix-python3.9.txt000066400000000000000000000031231527355705200227700ustar00rootroot00000000000000SecretStorage==3.3.3 Weblate==4.3.1 argcomplete==1.12.3 argcomplete==3.6.2 astroid==3.0.3 backports.tarfile==1.2.0 black==22.10.0 black==22.8.0 bleach==6.2.0 build==1.3.0 cachetools==6.2.0 certifi==2025.8.3 cffi==2.0.0 chardet==5.2.0 charset_normalizer==3.4.3 click==8.1.8 cmarkgfm==2024.11.20 colorama==0.4.6 colorlog==6.9.0 cryptography==46.0.1 dill==0.4.0 distlib==0.4.0 docutils==0.22.2 exceptiongroup==1.3.0 filelock==3.19.1 flit_core==4.1.0 id==1.5.0 idna==3.10 importlib_metadata==8.7.0 iniconfig==2.1.0 isort==5.13.2 isort==5.6.4 isort==6.0.1 jaraco.classes==3.4.0 jaraco.clipboard==2.0.1 jaraco.context==6.0.1 jaraco_functools==4.3.0 jeepney==0.9.0 jinja2==3.1.6 kaggle==1.6.11 keyring==25.6.0 markdown_it_py==3.0.0 markupsafe==3.0.3 mccabe==0.7.0 mdurl==0.1.2 more_itertools==10.8.0 mypy_extensions==1.1.0 nh3==0.3.0 nox==2022.1.7 nox==2023.4.22 packaging==25.0 pathspec==0.12.1 pbr==5.6.0 pip==23.3.2 pip==24.0 pip==25.2 platformdirs==4.4.0 pluggy==1.6.0 py==1.11.0 pycowsay==0.0.0.2 pycparser==2.23 pygdbmi==0.10.0.0 pygments==2.19.2 pylint==3.0.4 pyperclip==1.11.0 pyproject_api==1.9.1 pyproject_hooks==1.2.0 pytest==8.4.2 python_dateutil==2.9.0.post0 python_slugify==8.0.4 readme_renderer==44.0 requests==2.31.0 requests==2.32.5 requests_toolbelt==1.0.0 rfc3986==2.0.0 rich==14.1.0 setuptools==80.9.0 setuptools_scm==9.2.0 shell-functools==0.3.0 six==1.17.0 text_unidecode==1.3 tomli==2.2.1 tomlkit==0.13.3 tox==3.28.0 tox==4.30.2 tox_ini_fmt==0.5.0 tqdm==4.67.1 twine==6.2.0 typing_extensions==4.15.0 urllib3==2.5.0 virtualenv==20.34.0 webencodings==0.5.1 wheel==0.45.1 zest.releaser==9.1.2 zipp==3.23.0 pipx-1.18.16/testdata/tests_packages/win-python3.13.txt000066400000000000000000000033521527355705200226610ustar00rootroot00000000000000Weblate==4.3.1 argcomplete==1.12.3 argcomplete==3.6.2 astroid==3.0.3 autocommand==2.2.2 black==22.10.0 black==22.8.0 bleach==6.2.0 build==1.3.0 cachetools==6.2.0 certifi==2025.8.3 cffi==2.0.0 chardet==5.2.0 charset_normalizer==3.4.3 click==8.3.0 cmarkgfm==2024.11.20 colorama==0.4.6 colorlog==6.9.0 dill==0.4.0 distlib==0.4.0 docutils==0.22.2 filelock==3.19.1 flit_core==4.1.0 id==1.5.0 idna==3.10 iniconfig==2.1.0 isort==5.13.2 isort==5.6.4 isort==6.0.1 jaraco.classes==3.4.0 jaraco.clipboard==2.0.1 jaraco.context==6.0.1 jaraco.structures==2.2.0 jaraco.text==4.0.0 jaraco.ui==2.4.0 jaraco_collections==5.2.1 jaraco_functools==4.3.0 jaraco_windows==5.10.0 jinja2==3.1.6 kaggle==1.6.11 keyring==25.6.0 markdown_it_py==4.0.0 markupsafe==3.0.3 mccabe==0.7.0 mdurl==0.1.2 more_itertools==10.8.0 mypy_extensions==1.1.0 named==1.4.2 nh3==0.3.0 nox==2022.1.7 nox==2023.4.22 packaging==25.0 path==17.1.1 pathspec==0.12.1 pbr==5.6.0 pip==23.3.2 pip==24.0 pip==26.1.2 platformdirs==4.4.0 pluggy==1.6.0 py==1.11.0 pycowsay==0.0.0.2 pycparser==2.23 pygdbmi==0.10.0.0 pygments==2.19.2 pylint==3.0.4 pyproject_api==1.9.1 pyproject_hooks==1.2.0 pytest==8.4.2 python_dateutil==2.9.0.post0 python_slugify==8.0.4 pywin32_ctypes==0.2.3 readme_renderer==44.0 requests==2.31.0 requests==2.32.5 requests_toolbelt==1.0.0 rfc3986==2.0.0 rich==14.1.0 setuptools==80.9.0 setuptools_scm==9.2.0 shell-functools==0.3.0 shellingham==1.5.4 six==1.17.0 tempora==5.8.1 text_unidecode==1.3 tomlkit==0.13.3 tox==3.28.0 tox==4.30.2 tox_ini_fmt==0.5.0 tqdm==4.67.1 twine==6.2.0 typer==0.19.2 typing_extensions==4.15.0 urllib3==2.5.0 virtualenv==20.34.0 webencodings==0.5.1 wheel==0.45.1 zest.releaser==9.1.2 pipx-1.18.16/tests/000077500000000000000000000000001527355705200140255ustar00rootroot00000000000000pipx-1.18.16/tests/conftest.py000066400000000000000000000716111527355705200162320ustar00rootroot00000000000000from __future__ import annotations import hashlib import importlib import io import ipaddress import logging import os import pkgutil import shutil import signal import socket import subprocess import sys import tarfile import time import types import zipfile from contextlib import closing from http import HTTPStatus from pathlib import Path from typing import TYPE_CHECKING, Final, ParamSpec, TypeVar from urllib.error import HTTPError, URLError from urllib.request import urlopen from venv import EnvBuilder import platformdirs import pytest from filelock import FileLock from flit_core.wheel import make_wheel_in import pipx from helpers import PACKAGE_CACHE_DIR_NAME, WIN, MakePylock, app_name, run_pipx_cli, write_import_probe from pipx import paths, shared_libs, standalone_python, venv from pipx.backends import pip as _pip_backend_module from pipx.backends import uv as _uv_backend_module from pipx.backends.registry import get_backend from pipx.backends.uv import find_uv_binary, uv_meets_minimum_version from pipx.commands import common from pipx.interpreter import get_default_python from pipx.venv import reset_backend_override_warnings if TYPE_CHECKING: from collections.abc import Callable, Iterator from pytest_mock import MockerFixture _IGNORE_PROJECT_OUTPUT: Final[Callable[[str, list[str]], set[str]]] = shutil.ignore_patterns("build", "*.egg-info") _P = ParamSpec("_P") _R = TypeVar("_R") @pytest.fixture(scope="session") def root() -> Path: return Path(__file__).parents[1] @pytest.fixture(scope="session") def package_cache_dir(root: Path) -> Path: return root / ".pipx_tests" / "package_cache" / PACKAGE_CACHE_DIR_NAME @pytest.fixture def signal_handlers(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: # a signal landing while the main thread runs a weakref callback or finalizer (garbage collection can run at any # allocation) raises KeyboardInterrupt there, which the interpreter reports as unraisable; pipx's handler leaves a # mark it acts on, so drop that report and keep failing on any other unraisable exception report: Final = sys.unraisablehook monkeypatch.setattr( sys, "unraisablehook", lambda unraisable: None if issubclass(unraisable.exc_type, KeyboardInterrupt) else report(unraisable), ) # an interrupted pipx ignores further signals, so each test starts from Python's default handlers numbers: Final = (signal.SIGINT, signal.SIGTERM, getattr(signal, "SIGHUP", signal.SIGTERM)) previous: Final = {number: signal.getsignal(number) for number in numbers} signal.signal(signal.SIGINT, signal.default_int_handler) yield for number, handler in previous.items(): signal.signal(number, handler) @pytest.fixture def before_venv_subprocess(mocker: MockerFixture) -> Callable[[Path, Callable[[], None]], None]: def patch(venv_dir: Path, action: Callable[[], None]) -> None: # the command list is matched through its repr, which escapes the backslashes of a Windows path marker: Final[str] = repr(str(venv_dir))[1:-1] mocker.patch.object(subprocess, "Popen", side_effect=_preceded(subprocess.Popen, marker, action)) return patch def _preceded(call: Callable[_P, _R], marker: str, action: Callable[[], None]) -> Callable[_P, _R]: def preceded(*args: _P.args, **kwargs: _P.kwargs) -> _R: if marker in str(args[0]): action() return call(*args, **kwargs) return preceded @pytest.fixture def make_pylock(package_cache_dir: Path, tmp_path: Path) -> MakePylock: def create(package: str, version: str, group: str | None = None) -> Path: wheel: Final[Path] = next(package_cache_dir.glob(f"{package}-{version}-*.whl")) groups: Final[str] = "" if group is None else f'dependency-groups = ["{group}"]\ndefault-groups = ["{group}"]\n' marker: Final[str] = "" if group is None else f'marker = "\\"{group}\\" in dependency_groups"\n' (lock_file := tmp_path / "pylock.test.toml").write_text( f"""lock-version = "1.0" created-by = "pipx tests" {groups} [[packages]] name = "{package}" version = "{version}" {marker} [[packages.wheels]] name = "{wheel.name}" url = "{wheel.as_uri()}" [packages.wheels.hashes] sha256 = "{hashlib.sha256(wheel.read_bytes()).hexdigest()}" """, encoding="utf-8", ) return lock_file return create @pytest.fixture def make_project_with_dependency(root: Path, tmp_path: Path) -> Callable[[str], Path]: def create(dependency: str) -> Path: project: Final[Path] = tmp_path / "locked-project" shutil.copytree(root / "testdata/empty_project", project, ignore=_IGNORE_PROJECT_OUTPUT) pyproject: Final[Path] = project / "pyproject.toml" pyproject.write_text( pyproject.read_text(encoding="utf-8").replace( 'requires-python = ">=3.10"\n', f'dependencies = ["{dependency}"]\nrequires-python = ">=3.10"\n', ), encoding="utf-8", ) return project return create @pytest.fixture def make_wheel(tmp_path: Path) -> Callable[[dict[str, str]], Path]: def create(entries: dict[str, str]) -> Path: with zipfile.ZipFile(wheel := tmp_path / "foo_bar_x-1-py3-none-any.whl", "w") as archive: for name, content in entries.items(): archive.writestr(name, content) return wheel return create @pytest.fixture def uv_symlinked_install( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing monkeypatch: pytest.MonkeyPatch, make_wheel: Callable[[dict[str, str]], Path], ) -> None: if shutil.which("uv") is None: pytest.skip("needs the uv binary") # uv links a wheel's data files into the venv from its cache, so the venv's man page is itself a symlink monkeypatch.setenv("UV_LINK_MODE", "symlink") wheel: Final[Path] = make_wheel({ "foo_bar_x.py": "", "foo_bar_x-1.data/scripts/tool": "echo tool\n", "foo_bar_x-1.data/data/share/man/man1/tool.1": "manual\n", "foo_bar_x-1.data/data/share/bash-completion/completions/tool": "complete -F _tool tool\n", "foo_bar_x-1.dist-info/METADATA": "Metadata-Version: 2.1\nName: foo-bar-x\nVersion: 1\n", "foo_bar_x-1.dist-info/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", "foo_bar_x-1.dist-info/RECORD": "", }) assert not run_pipx_cli(["install", str(wheel), "--backend", "uv"]) @pytest.fixture def dotted_app_wheel(make_wheel: Callable[[dict[str, str]], Path]) -> Path: # tool.12 carries no shebang, so only its content ties a copy of it to the venv return make_wheel({ "foo_bar_x.py": "def main() -> None:\n pass\n", "foo_bar_x-1.data/scripts/tool.12": "print('tool')\n", "foo_bar_x-1.dist-info/METADATA": "Metadata-Version: 2.1\nName: foo-bar-x\nVersion: 1\n", "foo_bar_x-1.dist-info/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", "foo_bar_x-1.dist-info/entry_points.txt": "[console_scripts]\ntool3.12 = foo_bar_x:main\n", "foo_bar_x-1.dist-info/RECORD": "", }) @pytest.fixture def project_ignoring_project_table(tmp_path: Path) -> Path: # stands in for poetry-core<2 or setuptools<61: the backend names the wheel itself and never reads [project] project: Final[Path] = tmp_path / "ignores-project-table" project.mkdir() (project / "pyproject.toml").write_text( '[build-system]\nrequires = []\nbuild-backend = "backend"\nbackend-path = ["."]\n' '[project]\nname = "declared-name"\nversion = "1.0"\n', encoding="utf-8", ) (project / "backend.py").write_text( """import zipfile from pathlib import Path def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): name = "real_pkg-1.0-py3-none-any.whl" with zipfile.ZipFile(Path(wheel_directory) / name, "w") as archive: archive.writestr("real_pkg.py", "def main():\\n print('real')\\n") info = "real_pkg-1.0.dist-info" archive.writestr(f"{info}/METADATA", "Metadata-Version: 2.1\\nName: real-pkg\\nVersion: 1.0\\n") archive.writestr(f"{info}/WHEEL", "Wheel-Version: 1.0\\nRoot-Is-Purelib: true\\nTag: py3-none-any\\n") archive.writestr(f"{info}/entry_points.txt", "[console_scripts]\\nrealapp = real_pkg:main\\n") archive.writestr(f"{info}/RECORD", "") return name """, encoding="utf-8", ) return project @pytest.fixture def inline_script(tmp_path: Path) -> Path: script: Final[Path] = tmp_path / "hello.py" script.write_text( """# /// script # dependencies = ["pycowsay"] # requires-python = ">=3.10" # /// import pycowsay print("installed") """, encoding="utf-8", ) return script @pytest.fixture def local_extras_project(copy_local_package: Callable[[str], Path]) -> Path: return copy_local_package("local_extras") @pytest.fixture def local_manpage_project(copy_local_package: Callable[[str], Path]) -> Path: return copy_local_package("local_manpage") @pytest.fixture def copy_local_package(root: Path, tmp_path: Path) -> Callable[[str], Path]: # a build backend may write into the project directory, so each test builds its own copy def copy(name: str) -> Path: project: Final[Path] = tmp_path / name shutil.copytree(root / "testdata/test_package_specifier" / name, project, ignore=_IGNORE_PROJECT_OUTPUT) return project return copy @pytest.fixture(scope="session") def empty_project_wheel(root: Path, tmp_path_factory: pytest.TempPathFactory) -> Path: return _build_wheel(root / "testdata/empty_project", tmp_path_factory.mktemp("wheel")) @pytest.fixture(scope="session") def local_extras_wheel(root: Path, tmp_path_factory: pytest.TempPathFactory) -> Path: return _build_wheel(root / "testdata/test_package_specifier/local_extras", tmp_path_factory.mktemp("wheel")) @pytest.fixture(scope="session") def local_completion_wheel(root: Path, tmp_path_factory: pytest.TempPathFactory) -> Path: return _build_wheel(root / "testdata/test_package_specifier/local_completion", tmp_path_factory.mktemp("wheel")) @pytest.fixture(scope="session") def local_manpage_wheel(root: Path, tmp_path_factory: pytest.TempPathFactory) -> Path: return _build_wheel(root / "testdata/test_package_specifier/local_manpage", tmp_path_factory.mktemp("wheel")) def _build_wheel(project: Path, wheel_dir: Path) -> Path: # pip and uv build an installed directory in a fresh isolated environment, which takes seconds on Windows; tests # that do not exercise the source tree install this in-process build instead return make_wheel_in(project / "pyproject.toml", wheel_dir).file @pytest.fixture def copied_dependency_resource( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while building make_project_with_dependency: Callable[[str], Path], mocker: MockerFixture, tmp_path: Path, ) -> tuple[Path, bytes]: mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) first_package: Final[Path] = _build_wheel(make_project_with_dependency("pycowsay==0.0.0.2"), tmp_path) exposed_app: Final[Path] = paths.CTX.bin_dir / app_name("pycowsay") assert not run_pipx_cli(["install", str(first_package), "--include-deps"]) return exposed_app, exposed_app.read_bytes() @pytest.fixture def empty_project(root: Path, tmp_path: Path) -> Path: project: Final[Path] = tmp_path / "empty_project" shutil.copytree(root / "testdata/empty_project", project, ignore=_IGNORE_PROJECT_OUTPUT) return project @pytest.fixture def two_app_project(empty_project: Path) -> Path: pyproject: Final[Path] = empty_project / "pyproject.toml" pyproject.write_text( pyproject.read_text(encoding="utf-8").replace( "scripts.empty-project = ", 'scripts.empty-project-extra = "empty_project.main:cli"\nscripts.empty-project = ', ), encoding="utf-8", ) return empty_project @pytest.fixture(autouse=True) def _backend_test_baseline(monkeypatch: pytest.MonkeyPatch) -> None: # without the pin, a test building a Venv outside pipx_temp_env detects uv on CI's PATH and runs a real # `uv --version`; uv-backend tests opt back in with --backend uv monkeypatch.setenv("PIPX_DEFAULT_BACKEND", "pip") # a cached result would carry the previous test's patched shutil.which into this one find_uv_binary.cache_clear() uv_meets_minimum_version.cache_clear() _uv_backend_module._run_uv_version.cache_clear() # ruff:ignore[private-member-access] # no public cache reset get_backend.cache_clear() reset_backend_override_warnings() @pytest.fixture(autouse=True) def _block_external_network(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None: # a test reaching the internet flakes on someone else's rate limit: a 403 from github.com once left an unclosed # SSLSocket whose ResourceWarning the GC pinned on whichever test ran next; the local pypiserver is on loopback, and # --net-pypiserver wants the real index if request.config.option.net_pypiserver: return connect: Final = socket.socket.connect def guarded_connect(self: socket.socket, address: tuple[str | int, ...] | str | bytes) -> None: if isinstance(address, tuple) and not _is_loopback(str(address[0])): msg = f"test connected to {address[0]}; mock the remote instead of reaching it" raise RuntimeError(msg) connect(self, address) monkeypatch.setattr(socket.socket, "connect", guarded_connect) def _is_loopback(host: str) -> bool: if host == "localhost": return True try: return ipaddress.ip_address(host).is_loopback except ValueError: # a hostname other than localhost, which may resolve anywhere return False @pytest.fixture(autouse=True, scope="session") def _resolve_lazy_imports() -> None: # mock.patch reads the original from the module __dict__, where an unresolved lazy import (PEP 810) is still a # proxy that autospec turns into a non-callable mock, so whether a test hit that depended on the tests before it if (lazy_type := getattr(types, "LazyImportType", None)) is None: return for module_info in pkgutil.walk_packages(pipx.__path__, "pipx."): importlib.import_module(module_info.name) for module in [module for name, module in sys.modules.items() if name == "pipx" or name.startswith("pipx.")]: for name, value in list(vars(module).items()): if type(value) is lazy_type: getattr(module, name) @pytest.fixture(autouse=True) def _isolate_pipx_paths(tmp_path: Path) -> Iterator[None]: # a test that skips pipx_temp_env, or whose patch misses (as one did under 3.15 lazy imports), would otherwise write # logs, cached venvs and shared libs into the developer's pipx home; tests resolving the defaults themselves clear # these overrides in a scoped monkeypatch with pytest.MonkeyPatch.context() as scoped: for name, location in ( ("OVERRIDE_PIPX_HOME", "subdir/pipxhome"), ("OVERRIDE_PIPX_BIN_DIR", "otherdir/pipxbindir"), ("OVERRIDE_PIPX_MAN_DIR", "otherdir/pipxmandir"), ("OVERRIDE_PIPX_COMPLETION_DIR", "otherdir/pipxcompletiondir"), ("OVERRIDE_PIPX_GLOBAL_HOME", "global/pipxhome"), ("OVERRIDE_PIPX_GLOBAL_BIN_DIR", "global_otherdir/pipxbindir"), ("OVERRIDE_PIPX_GLOBAL_MAN_DIR", "global_otherdir/pipxmandir"), ("OVERRIDE_PIPX_GLOBAL_COMPLETION_DIR", "global_otherdir/pipxcompletiondir"), ): scoped.setattr(paths, name, tmp_path / location) # where pipx logs when the pipx home refuses a log file; resolved per call, so it needs its own redirect scoped.setattr(platformdirs, "user_log_path", lambda *_args, **_kwargs: tmp_path / "fallback_logs") paths.CTX.make_local() yield @pytest.fixture(autouse=True) def _isolate_pipx_logging() -> Iterator[None]: yield # _setup_logging binds a StreamHandler to the sys.stderr of its time, and pytest swaps that stream per test, so a # handler left by an earlier verbose run writes to a stale stream; on a Windows cp1252 console that crashes # ("--- Logging error ---") once a later test logs a non-ASCII record pipx_logger: Final[logging.Logger] = logging.getLogger("pipx") for handler in pipx_logger.handlers[:]: pipx_logger.removeHandler(handler) handler.close() @pytest.fixture(scope="session") def standalone_python_build(tmp_path_factory: pytest.TempPathFactory) -> tuple[bytes, str]: # a local python-build-standalone archive in the upstream layout, so tests run the real download, unpack and # install code against a working interpreter; on POSIX bin/python3 is a shim that execs the running interpreter, # since a copied binary breaks its relative dylib/RPATH lookups on some builds, and on Windows it is the venv # launcher beside pyvenv.cfg, the relocatable pair Windows venvs ship env_dir: Final[Path] = tmp_path_factory.mktemp("standalone_python") / "python" if WIN: EnvBuilder(with_pip=False).create(env_dir) for launcher in (env_dir / "Scripts").glob("*.exe"): shutil.copy2(launcher, env_dir / launcher.name) else: (env_dir / "bin").mkdir(parents=True) (shim := env_dir / "bin" / "python3").write_text(f'#!/bin/sh\nexec "{sys.executable}" "$@"\n', encoding="utf-8") shim.chmod(0o755) buffer: Final = io.BytesIO() with tarfile.open(fileobj=buffer, mode="w:gz") as tar: tar.add(env_dir, arcname="python") return buffer.getvalue(), ".".join(map(str, sys.version_info[:3])) @pytest.fixture(scope="session") def standalone_python_index(standalone_python_build: tuple[bytes, str]) -> Callable[[str], dict[str, object]]: archive_bytes, _ = standalone_python_build digest: Final[str] = f"sha256:{hashlib.sha256(archive_bytes).hexdigest()}" def build(full_version: str) -> dict[str, object]: suffixes: Final[list[str]] = [ suffix for machines in standalone_python.MACHINE_SUFFIX.values() for entry in machines.values() for suffix_list in (entry.values() if isinstance(entry, dict) else [entry]) for suffix in suffix_list ] return { "fetched": time.time(), "releases": [(f"https://example.invalid/cpython-{full_version}-{suffix}", digest) for suffix in suffixes], } return build @pytest.fixture def mocked_github_api( monkeypatch: pytest.MonkeyPatch, standalone_python_build: tuple[bytes, str], standalone_python_index: Callable[[str], dict[str, object]], ) -> None: archive_bytes, full_version = standalone_python_build index: Final = standalone_python_index(full_version) monkeypatch.setattr(standalone_python, "get_or_update_index", lambda **_kwargs: index) monkeypatch.setattr(standalone_python, "urlopen", lambda *_args, **_kwargs: io.BytesIO(archive_bytes)) def pytest_addoption(parser: pytest.Parser) -> None: parser.addoption( "--net-pypiserver", action="store_true", dest="net_pypiserver", default=False, help="Start local pypi server and use in tests.", ) @pytest.fixture(scope="session", autouse=True) def pipx_local_pypiserver( request: pytest.FixtureRequest, root: Path, package_cache_dir: Path, tmp_path_factory: pytest.TempPathFactory ) -> Iterator[str]: if request.config.option.net_pypiserver: yield "" # tests use the real index, so there is no local URL to hand out return update_cache: Final[list[str]] = [sys.executable, "scripts/update_package_cache.py"] cache_paths: Final[list[str]] = ["testdata/tests_packages", str(package_cache_dir.parent)] # every xdist worker runs this fixture; without the lock they write the list and cache concurrently with FileLock(package_cache_dir.parent.with_suffix(".lock")): if subprocess.run([*update_cache, "--check-only", *cache_paths], check=False, cwd=root).returncode: subprocess.run([*update_cache, *cache_paths], check=True, cwd=root) with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: sock.bind(("", 0)) port: Final[int] = sock.getsockname()[1] os.environ["NO_PROXY"] = "127.0.0.1" pypiserver_process: Final = subprocess.Popen( [ str(Path(sys.executable).parent / "pypi-server"), "run", "--verbose", "--disable-fallback", "--backend", "simple-dir", "--cache-control=3600", "--host", "127.0.0.1", "--port", str(port), "--log-file", str(tmp_path_factory.mktemp("log") / "pypiserver.log"), str(package_cache_dir), ], cwd=root, ) try: while not _accepts_connections(port): if pypiserver_process.poll() is not None: pytest.fail(f"pypiserver exited with {pypiserver_process.returncode} before listening on {port}") time.sleep(0.05) # 127.0.0.1, not localhost: localhost made Windows runs far slower, likely from IPv6/IPv4 fallback timeouts url: Final[str] = f"http://127.0.0.1:{port}/simple/" while True: try: with urlopen(url) as response: if response.code == HTTPStatus.OK: break except (URLError, HTTPError): continue yield url finally: pypiserver_process.terminate() # reap the child before the Popen is collected, else its finalizer raises ResourceWarning pypiserver_process.wait() def _accepts_connections(port: int) -> bool: try: sock = socket.create_connection(("127.0.0.1", port), timeout=1) except OSError: return False with closing(sock): # while nothing listens, the OS may hand the client the target port as its own source port, so it connects # to itself, reads its own request back, and holds the port the server still has to bind return sock.getsockname() != sock.getpeername() @pytest.fixture(scope="session") def pipx_session_shared_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: return tmp_path_factory.mktemp("session_shareddir") @pytest.fixture(scope="session") def utils_path_dirs(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: # one directory of symlinks keeps whatever else lives beside git and uv off PATH, which the โš ๏ธ and WARNING # assertions in test_install depend on; an unelevated Windows user without Developer Mode cannot create symlinks, # so uv gets copied, while git falls back to its own directory: git.exe finds its helpers and DLLs relative to its # image path, so it only runs from where it was installed link_dir: Final[Path] = tmp_path_factory.mktemp("session_utilstempdir") path_dirs: Final[list[Path]] = [link_dir] git: Final = shutil.which("git") assert git is not None, "git must be on PATH to run the test suite" git_path: Final[Path] = Path(git) try: (link_dir / git_path.name).symlink_to(git_path) except OSError: # no symlink privilege, e.g. Windows raises WinError 1314 path_dirs.append(git_path.parent) if uv := shutil.which("uv"): # exposed only when present so the uv-backend smoke test can find it exposed: Final[Path] = link_dir / Path(uv).name try: exposed.symlink_to(uv) except OSError: # uv is standalone, so a copy runs the same and keeps its directory, often ~/.local/bin, off PATH shutil.copy2(uv, exposed) return path_dirs @pytest.fixture def pipx_temp_env( monkeypatch: pytest.MonkeyPatch, pipx_session_shared_dir: Path, request: pytest.FixtureRequest, utils_path_dirs: list[Path], *, pipx_local_pypiserver: str, ) -> Iterator[None]: # shared libs are built once per session, every other pipx directory once per test _activate_temp_env(pipx_session_shared_dir, monkeypatch, request, utils_path_dirs, pypi=pipx_local_pypiserver) yield monkeypatch.undo() paths.CTX.make_local() @pytest.fixture def installed_pycowsay( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing capsys: pytest.CaptureFixture[str], ) -> Path: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() return paths.CTX.venvs / "pycowsay" @pytest.fixture def pipx_ultra_temp_env( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest, utils_path_dirs: list[Path], pipx_local_pypiserver: str, ) -> Iterator[None]: # every test starts as if pipx had never run, shared libs included _activate_temp_env(tmp_path / "shareddir", monkeypatch, request, utils_path_dirs, pypi=pipx_local_pypiserver) yield monkeypatch.undo() paths.CTX.make_local() def _activate_temp_env( pipx_shared_dir: Path, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest, utils_path_dirs: list[Path], *, pypi: str, ) -> None: monkeypatch.setattr(paths, "OVERRIDE_PIPX_SHARED_LIBS", pipx_shared_dir) monkeypatch.setattr(paths, "OVERRIDE_PIPX_GLOBAL_SHARED_LIBS", pipx_shared_dir) paths.CTX.make_local() # paths.CTX reads the overrides only here # each importer binds SHARED_LIBS at import time, so each one gets the fresh instance fresh_shared_libs: Final = shared_libs._SharedLibs() # ruff:ignore[private-member-access] # no public factory # importers before the module: monkeypatch reads the old value, which on 3.15 resolves their lazy import, and # resolving it after the module is patched would make teardown restore the fresh instance, not the original for module in (venv, _pip_backend_module, importlib.import_module("pipx.commands.upgrade"), shared_libs): monkeypatch.setattr(module, "SHARED_LIBS", fresh_shared_libs) if "PIPX_DEFAULT_PYTHON" in os.environ: monkeypatch.delenv("PIPX_DEFAULT_PYTHON") get_default_python.cache_clear() # macOS needs /usr/bin on PATH to compile some packages, yet apps there (ansible on GitHub runners) trigger the # warnings test_install.py fails on, so PATH_ORIG keeps the full PATH for the tests that need it monkeypatch.setenv("PATH_ORIG", f"{paths.CTX.bin_dir}{os.pathsep}{os.environ['PATH']}") monkeypatch.setenv("PATH_TEST", str(paths.CTX.bin_dir)) monkeypatch.setenv("PATH", os.pathsep.join(str(entry) for entry in (paths.CTX.bin_dir, *utils_path_dirs))) if WIN: # Windows tests take the copying path even when running elevated, where symlinks would work symlink_cache: Final = common._CAN_SYMLINK_CACHE # ruff:ignore[private-member-access] # no public seeding API monkeypatch.setitem(symlink_cache, paths.CTX.bin_dir, False) monkeypatch.setitem(symlink_cache, paths.CTX.man_dir, False) if not request.config.option.net_pypiserver: monkeypatch.setenv("PIP_INDEX_URL", pypi) monkeypatch.setenv("UV_INDEX_URL", pypi) # uv ignores PIP_INDEX_URL, so uv-backend tests would reach PyPI @pytest.fixture def permissive_umask() -> Iterator[None]: previous: Final[int] = os.umask(0o022) yield os.umask(previous) @pytest.fixture def age_shared_pip() -> Iterator[Callable[[float], float]]: # the pip lives in the session's shared libs: left stale, the next pipx install on this worker would upgrade it with # whatever pip arguments that test passes, so the original time goes back afterwards originals: Final[dict[Path, int]] = {} def age(age_sec: float) -> float: pip_path: Final[Path] = shared_libs.SHARED_LIBS.pip_path originals.setdefault(pip_path, pip_path.stat().st_mtime_ns) last_upgrade: Final[float] = time.time() - age_sec os.utime(pip_path, (last_upgrade, last_upgrade)) shared_libs.SHARED_LIBS.has_been_updated_this_run = False # filesystems round timestamps, so return the stored value for exact comparison return pip_path.stat().st_mtime yield age for pip_path, mtime_ns in originals.items(): os.utime(pip_path, ns=(mtime_ns, mtime_ns)) @pytest.fixture def site_packages(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: (site_packages := tmp_path / "site-packages").mkdir() monkeypatch.setattr( "pipx.venv_inspect.fetch_info_in_venv", lambda _venv_python: ([str(site_packages)], {}, "Python 3.13.0") ) return site_packages @pytest.fixture def run_from_venv(monkeypatch: pytest.MonkeyPatch) -> Iterator[Callable[[Path], None]]: # stands in for pipx changing itself: the interpreter runs from the venv and has loaded a package from it def activate(venv_dir: Path) -> None: write_import_probe(venv_dir, "old") monkeypatch.syspath_prepend(str(venv_dir)) monkeypatch.setattr(sys, "prefix", str(venv_dir)) importlib.import_module("running_probe") yield activate for name in ("running_probe", "running_probe.late"): sys.modules.pop(name, None) pipx-1.18.16/tests/helpers.py000066400000000000000000000237631527355705200160540ustar00rootroot00000000000000from __future__ import annotations import json import os import re import subprocess import sys import sysconfig import zipfile from dataclasses import replace from typing import TYPE_CHECKING, Final, Protocol, TypedDict from unittest import mock import pytest from packaging.utils import canonicalize_name from package_info import PKG from pipx import paths from pipx.main import cli from pipx.pipx_metadata_file import JsonEncoderHandlesPath, PackageInfo, PipxMetadata from pipx.util import get_venv_paths from pipx.venv import Venv if TYPE_CHECKING: from pathlib import Path WIN: Final[bool] = sys.platform.startswith("win") # mirrors scripts/test_packages_support.py: free-threaded builds seed a cache of cp3XXt wheels apart from the # same-version GIL build's directory PACKAGE_CACHE_DIR_NAME: Final[str] = ( f"{sys.version_info[0]}.{sys.version_info[1]}{'t' if sysconfig.get_config_var('Py_GIL_DISABLED') else ''}" ) # None stands for a venv from before pipx 0.15.0.0, which wrote no metadata file PIPX_METADATA_LEGACY_VERSIONS: Final[list[str | None]] = [None, "0.1", "0.2", "0.3"] class MakePylock(Protocol): def __call__(self, package: str, version: str, group: str | None = None) -> Path: ... class LegacyPackageInfo(TypedDict): package: str package_or_url: str pip_args: list[str] include_dependencies: bool include_apps: bool apps: list[str] app_paths: list[str] apps_of_dependencies: list[str] app_paths_of_dependencies: dict[str, list[str]] man_pages: list[str] man_paths: list[str] man_pages_of_dependencies: list[str] man_paths_of_dependencies: dict[str, list[str]] package_version: str suffix: str pinned: bool class LegacyMetadata(TypedDict): main_package: LegacyPackageInfo python_version: str source_interpreter: None venv_args: list[str] injected_packages: dict[str, LegacyPackageInfo] pipx_metadata_version: str class CurrentMetadata(LegacyMetadata): backend: str LEGACY_MAIN_PACKAGE: Final[LegacyPackageInfo] = { "package": "demo", "package_or_url": "demo", "pip_args": [], "include_dependencies": False, "include_apps": True, "apps": ["demo"], "app_paths": [], "apps_of_dependencies": [], "app_paths_of_dependencies": {}, "man_pages": [], "man_paths": [], "man_pages_of_dependencies": [], "man_paths_of_dependencies": {}, "package_version": "1.0", "suffix": "", "pinned": False, } def app_name(app: str) -> str: return f"{app}.exe" if WIN else app def run_pipx_cli(pipx_args: list[str]) -> int: with mock.patch.object(sys, "argv", ["pipx", *pipx_args]): return cli() def pipx_subprocess_env() -> dict[str, str]: return os.environ | { "PIPX_BIN_DIR": str(paths.CTX.bin_dir), "PIPX_HOME": str(paths.CTX.home), "PIPX_MAN_DIR": str(paths.CTX.man_dir), "PIPX_SHARED_LIBS": str(paths.CTX.shared_libs), } def unwrap_log_text(log_text: str) -> str: # a captured log line starts with its severity, so a line opening with spaces continues a wrapped pipx message return re.sub(r"\n\s+", " ", log_text) def current_log_file() -> Path: log_file: Final = paths.CTX.log_file assert log_file is not None return log_file def mock_legacy_venv(venv_name: str, metadata_version: str | None = None) -> None: venv_dir: Final[Path] = paths.CTX.venvs / canonicalize_name(venv_name) if metadata_version is None: (venv_dir / "pipx_metadata.json").unlink() return package_keys: Final[list[str]] = [ "package", "package_or_url", "pip_args", "include_dependencies", "include_apps", "apps", "app_paths", "apps_of_dependencies", "app_paths_of_dependencies", "package_version", *(["suffix"] if metadata_version != "0.1" else []), ] metadata_keys: Final[list[str]] = [ "main_package", "python_version", "venv_args", *( ["man_pages", "man_paths", "man_pages_of_dependencies", "man_paths_of_dependencies"] if metadata_version == "0.3" else [] ), ] modern_metadata: Final = PipxMetadata(venv_dir).to_dict() legacy_metadata: Final = { **{key: modern_metadata.get(key) for key in metadata_keys}, "injected_packages": { name: {key: package[key] for key in package_keys} for name, package in modern_metadata["injected_packages"].items() }, "pipx_metadata_version": metadata_version, } # written the way PipxMetadata.write() does, which would stamp the current metadata version instead with (venv_dir / "pipx_metadata.json").open("w", encoding="utf-8") as pipx_metadata_fh: json.dump(legacy_metadata, pipx_metadata_fh, indent=4, sort_keys=True, cls=JsonEncoderHandlesPath) def create_package_info_ref( venv_name: str, package_name: str, pipx_venvs_dir: Path, *, include_apps: bool = True, app_paths_of_dependencies: dict[str, list[Path]] | None = None, ) -> PackageInfo: package: Final = PKG[package_name] man_pages: Final[list[str]] = package.get("man_pages", []) return PackageInfo( package=package_name, package_or_url=package["spec"], pip_args=[], include_apps=include_apps, include_dependencies=False, apps=package["apps"], app_paths=[pipx_venvs_dir / venv_name / ("Scripts" if WIN else "bin") / app for app in package["apps"]], apps_of_dependencies=package["apps_of_dependencies"], app_paths_of_dependencies=app_paths_of_dependencies or {}, man_pages=man_pages, man_paths=[pipx_venvs_dir / venv_name / "share" / "man" / man_page for man_page in man_pages], man_pages_of_dependencies=package.get("man_pages_of_dependencies", []), man_paths_of_dependencies={}, package_version=package["spec"].split("==")[-1], ) def assert_package_metadata(test_metadata: PackageInfo, ref_metadata: PackageInfo) -> None: assert test_metadata.package_version assert isinstance(test_metadata.apps, list) assert isinstance(test_metadata.app_paths, list) assert _sorted_apps(test_metadata) == _sorted_apps(ref_metadata) def _sorted_apps(package: PackageInfo) -> PackageInfo: # pipx records apps in no fixed order return replace( package, apps=sorted(package.apps), app_paths=sorted(package.app_paths), apps_of_dependencies=sorted(package.apps_of_dependencies), app_paths_of_dependencies={key: sorted(value) for key, value in package.app_paths_of_dependencies.items()}, ) def drop_extra_app(project: Path) -> None: pyproject: Final[Path] = project / "pyproject.toml" pyproject.write_text( pyproject .read_text(encoding="utf-8") .replace('version = "0.1.0"', 'version = "0.2.0"') .replace('scripts.empty-project-extra = "empty_project.main:cli"\n', ""), encoding="utf-8", ) def remove_venv_interpreter(venv_name: str) -> None: _, venv_python_path, _ = get_venv_paths(paths.CTX.venvs / venv_name) assert venv_python_path.is_file() venv_python_path.unlink() SKIP_IF_WINDOWS: Final[pytest.MarkDecorator] = pytest.mark.skipif(WIN, reason="This behavior is undefined on Windows") def write_wheel( directory: Path, name: str, version: str, requires: tuple[str, ...] = (), *, module_code: str = "" ) -> Path: dist_info: Final[str] = f"{name}-{version}.dist-info" members: Final[dict[str, str]] = { f"{name}.py": f"{module_code}VERSION = {version!r}\n", f"{dist_info}/METADATA": "".join([ f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n", *(f"Requires-Dist: {requirement}\n" for requirement in requires), ]), f"{dist_info}/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", f"{dist_info}/entry_points.txt": f"[console_scripts]\n{name} = {name}:VERSION\n", } directory.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(wheel := directory / f"{name}-{version}-py3-none-any.whl", "w") as archive: for member, content in members.items(): archive.writestr(member, content) archive.writestr( f"{dist_info}/RECORD", "".join(f"{member},,\n" for member in [*members, f"{dist_info}/RECORD"]) ) return wheel def write_dist_info( site_packages: Path, name: str, requires: list[str] | None = None, provides_extra: list[str] | None = None ) -> None: (dist_info := site_packages / f"{name}-1.0.dist-info").mkdir() (dist_info / "METADATA").write_text( "\n".join([ f"Name: {name}", "Version: 1.0", *(f"Provides-Extra: {extra}" for extra in provides_extra or []), *(f"Requires-Dist: {requirement}" for requirement in requires or []), ]), encoding="utf-8", ) def module_version(venv_name: str) -> str: return subprocess.check_output( [Venv(paths.CTX.venvs / venv_name).python_path, "-c", f"import {venv_name}; print({venv_name}.VERSION)"], text=True, ).strip() def write_import_probe(venv_dir: Path, value: str) -> None: (package := venv_dir / "running_probe").mkdir(exist_ok=True) (package / "__init__.py").touch() (package / "late.py").write_text(f"VALUE = {value!r}\n", encoding="utf-8") def file_or_symlink(path: Path) -> bool: # exists() is false for a broken symlink return path.exists() or path.is_symlink() __all__ = [ "LEGACY_MAIN_PACKAGE", "PACKAGE_CACHE_DIR_NAME", "PIPX_METADATA_LEGACY_VERSIONS", "SKIP_IF_WINDOWS", "WIN", "CurrentMetadata", "LegacyMetadata", "LegacyPackageInfo", "MakePylock", "app_name", "assert_package_metadata", "create_package_info_ref", "current_log_file", "drop_extra_app", "file_or_symlink", "mock_legacy_venv", "module_version", "pipx_subprocess_env", "remove_venv_interpreter", "run_pipx_cli", "unwrap_log_text", "write_dist_info", "write_import_probe", "write_wheel", ] pipx-1.18.16/tests/package_info.py000066400000000000000000000655131527355705200170170ustar00rootroot00000000000000from __future__ import annotations import sys from pathlib import Path from typing import Any, Final _WIN: Final[bool] = sys.platform.startswith("win") def _exe_if_win(apps: list[str]) -> list[str]: return [f"{app}.exe" if _WIN else app for app in apps] # Windows wraps entry points in an .exe launcher but installs scripts as they are, so only entry points go through # _exe_if_win PKG: Final[dict[str, dict[str, Any]]] = { "ansible": { "spec": "ansible==6.7.0", "apps": [ "ansible", "ansible-config", "ansible-connection", "ansible-console", "ansible-doc", "ansible-galaxy", "ansible-inventory", "ansible-playbook", "ansible-pull", "ansible-test", "ansible-vault", ], "apps_of_dependencies": [], }, "awscli": { "spec": "awscli==1.18.168", "apps": [ "aws", "aws.cmd", "aws_bash_completer", "aws_completer", "aws_zsh_completer.sh", ], "apps_of_dependencies": [ *_exe_if_win([ "pyrsa-decrypt", # rsa EXE "pyrsa-encrypt", # rsa EXE "pyrsa-keygen", # rsa EXE "pyrsa-priv2pub", # rsa EXE "pyrsa-sign", # rsa EXE "pyrsa-verify", # rsa EXE ]), "jp.py", # jmespath.py NO_EXE "rst2html.py", # docutils NO_EXE "rst2html4.py", # docutils NO_EXE "rst2html5.py", # docutils NO_EXE "rst2latex.py", # docutils NO_EXE "rst2man.py", # docutils NO_EXE "rst2odt.py", # docutils NO_EXE "rst2odt_prepstyles.py", # docutils NO_EXE "rst2pseudoxml.py", # docutils NO_EXE "rst2s5.py", # docutils NO_EXE "rst2xetex.py", # docutils NO_EXE "rst2xml.py", # docutils NO_EXE "rstpep2html.py", # docutils NO_EXE ], }, "b2": { "spec": "b2==3.12.0", "apps": _exe_if_win(["b2"]), "apps_of_dependencies": [ *_exe_if_win(["docutils", "rst2ansi", "tabulate", "tqdm", "normalizer"]), "activate-global-python-argcomplete", "python-argcomplete-check-easy-install-script", "register-python-argcomplete", "rst2html4.py", "rst2html5.py", "rst2html.py", "rst2latex.py", "rst2man.py", "rst2odt_prepstyles.py", "rst2odt.py", "rst2pseudoxml.py", "rst2s5.py", "rst2xetex.py", "rst2xml.py", "rstpep2html.py", ], }, "beancount": { "spec": "beancount==2.3.6", "apps": _exe_if_win([ "bean-bake", "bean-check", "bean-doctor", "bean-example", "bean-extract", "bean-file", "bean-format", "bean-identify", "bean-price", "bean-query", "bean-report", "bean-sql", "bean-web", "treeify", "upload-to-sheets", ]), "apps_of_dependencies": [ *_exe_if_win([ "chardetect", # chardet EXE "py.test", # pytest EXE "pyrsa-decrypt", # rsa EXE "pyrsa-encrypt", # rsa EXE "pyrsa-keygen", # rsa EXE "pyrsa-priv2pub", # rsa EXE "pyrsa-sign", # rsa EXE "pyrsa-verify", # rsa EXE "pytest", # pytest EXE "normalizer", "py.test", ]), "bottle.py", "dumppdf.py", "latin2ascii.py", "pdf2txt.py", ], # bottle NO_EXE }, "beets": { "spec": "beets==1.4.9", "apps": _exe_if_win(["beet"]), "apps_of_dependencies": _exe_if_win([ "mid3cp", "mid3iconv", "mid3v2", "moggsplit", "mutagen-inspect", "mutagen-pony", "unidecode", # unidecode EXE ]), "man_pages": [], "man_pages_of_dependencies": [ str(Path("man1") / "mutagen-pony.1"), str(Path("man1") / "mutagen-inspect.1"), str(Path("man1") / "moggsplit.1"), str(Path("man1") / "mid3v2.1"), str(Path("man1") / "mid3iconv.1"), str(Path("man1") / "mid3cp.1"), ], }, "black": { "spec": "black==22.8.0", "apps": _exe_if_win(["black", "blackd"]), "apps_of_dependencies": [], }, "cactus": { "spec": "cactus==3.3.3", "apps": _exe_if_win(["cactus"]), "apps_of_dependencies": [ *_exe_if_win(["keyring", "markdown2"]), "asadmin", "bundle_image", "cfadmin", "cq", "cwutil", "django-admin.py", "dynamodb_dump", "dynamodb_load", "elbadmin", "fetch_file", "glacier", "instance_events", "keyring", "kill_instance", "launch_instance", "list_instances", "lss3", "mturk", "pyami_sendmail", "route53", "s3put", "sdbadmin", "taskadmin", ], }, "chert": { "spec": "chert==19.1.0", "apps": _exe_if_win(["chert"]), "apps_of_dependencies": [*_exe_if_win(["ashes", "markdown_py"]), "ashes.py"], }, "coala": { "spec": "coala==0.11.0", "apps": _exe_if_win(["coala", "coala-ci", "coala-delete-orig", "coala-format", "coala-json"]), "apps_of_dependencies": [*_exe_if_win(["normalizer", "pygmentize"]), "unidiff"], }, "cookiecutter": { "spec": "cookiecutter==2.4.0", "apps": _exe_if_win(["cookiecutter"]), "apps_of_dependencies": _exe_if_win(["chardetect", "normalizer", "markdown-it", "pygmentize", "slugify"]), }, "cython": { "spec": "cython==0.29.21", "apps": _exe_if_win(["cygdb", "cython", "cythonize"]), "apps_of_dependencies": [], }, "datasette": { "spec": "datasette==0.50.2", "apps": _exe_if_win(["datasette"]), "apps_of_dependencies": [*_exe_if_win(["httpx", "hupper", "uvicorn"]), "pint-convert"], }, "diffoscope": { "spec": "diffoscope==154", "apps": _exe_if_win(["diffoscope"]), "apps_of_dependencies": [], }, "doc2dash": { "spec": "doc2dash==3.0.0", "apps": _exe_if_win(["doc2dash"]), "apps_of_dependencies": _exe_if_win( ["markdown-it", "pygmentize"] # pygments EXE ), }, "doitlive": { "spec": "doitlive==4.3.0", "apps": _exe_if_win(["doitlive"]), "apps_of_dependencies": [], }, "gdbgui": { "spec": "gdbgui==0.14.0.1", "apps": _exe_if_win(["gdbgui"]), "apps_of_dependencies": _exe_if_win(["flask", "pygmentize"]), }, "gns3-gui": { "spec": "gns3-gui==2.2.15", "apps": _exe_if_win(["gns3"]), "apps_of_dependencies": _exe_if_win(["distro", "jsonschema"]), }, "grow": { "spec": "grow==1.0.0a10", "apps": ["grow"], "apps_of_dependencies": _exe_if_win([ "chardetect", # chardet EXE "gen_protorpc", "html2text", # html2text EXE "markdown_py", # Markdown EXE "pybabel", # babel EXE "pygmentize", # pygments EXE "pyrsa-decrypt", # rsa EXE "pyrsa-encrypt", # rsa EXE "pyrsa-keygen", # rsa EXE "pyrsa-priv2pub", # rsa EXE "pyrsa-sign", # rsa EXE "pyrsa-verify", # rsa EXE "slugify", # python_slugify EXE "watchmedo", # watchdog EXE ]), }, "guake": { "spec": "guake==3.7.0", "apps": _exe_if_win(["guake", "guake-toggle"]), "apps_of_dependencies": _exe_if_win(["pbr"]), }, "gunicorn": { "spec": "gunicorn==20.0.4", "apps": _exe_if_win(["gunicorn"]), "apps_of_dependencies": [], }, "howdoi": { "spec": "howdoi==2.0.20", "apps": _exe_if_win(["howdoi"]), "apps_of_dependencies": _exe_if_win(["markdown-it", "keep", "normalizer", "pygmentize"]), }, "httpie": { "spec": "httpie==3.2.2", "apps": _exe_if_win(["http", "httpie", "https"]), "apps_of_dependencies": _exe_if_win(["markdown-it", "normalizer", "pygmentize"]), "man_pages": [ str(Path("man1") / "http.1"), str(Path("man1") / "httpie.1"), str(Path("man1") / "https.1"), ], "man_pages_of_dependencies": [], }, "hyde": { "spec": "hyde==0.8.9", "apps": _exe_if_win(["hyde"]), "apps_of_dependencies": [*_exe_if_win(["markdown_py", "pygmentize"]), "smartypants"], }, "ipython": { "spec": "ipython==7.16.1", "apps": _exe_if_win(["iptest", "iptest3", "ipython", "ipython3"]), "apps_of_dependencies": _exe_if_win(["pygmentize"]), # pygments EXE "man_pages": [str(Path("man1") / "ipython.1.gz")], "man_pages_of_dependencies": [], }, "isort": { "spec": "isort==5.6.4", "apps": _exe_if_win(["isort"]), "apps_of_dependencies": [], }, "zest-releaser": { "spec": "zest.releaser==9.1.2", "apps": _exe_if_win([ "addchangelogentry", "bumpversion", "fullrelease", "lasttagdiff", "lasttaglog", "longtest", "postrelease", "prerelease", "release", ]), "apps_of_dependencies": [ *_exe_if_win([ "markdown-it", "normalizer", "twine", "pkginfo", "pyproject-build", "docutils", "pygmentize", "keyring", ]), "rst2html.py", "rst2html4.py", "rst2html5.py", "rst2latex.py", "rst2man.py", "rst2odt.py", "rst2odt_prepstyles.py", "rst2pseudoxml.py", "rst2s5.py", "rst2xetex.py", "rst2xml.py", "rstpep2html.py", ], }, "jupyter": { "spec": "jupyter==1.0.0", "apps": [], "apps_of_dependencies": _exe_if_win([ "iptest", "iptest3", "ipython", "ipython3", "jsonschema", # jsonschema EXE "jupyter", "jupyter-bundlerextension", "jupyter-console", "jupyter-kernel", "jupyter-kernelspec", "jupyter-migrate", "jupyter-nbconvert", "jupyter-nbextension", "jupyter-notebook", "jupyter-qtconsole", "jupyter-run", "jupyter-serverextension", "jupyter-troubleshoot", "jupyter-trust", "pygmentize", # pygments EXE ]), }, "kaggle": { "spec": "kaggle==1.6.11", "apps": _exe_if_win(["kaggle"]), "apps_of_dependencies": list(set(_exe_if_win(["slugify", "normalizer", "tqdm"]))), }, "kibitzr": { "spec": "kibitzr==7.0.5", "apps": _exe_if_win(["kibitzr"]), "apps_of_dependencies": _exe_if_win(["doesitcache", "httpx", "normalizer"]), }, "klaus": { "spec": "klaus==1.5.2", "apps": ["klaus"], "apps_of_dependencies": [ *_exe_if_win(["dulwich", "flask", "pygmentize"]), "dul-receive-pack", "dul-upload-pack", ], }, "kolibri": { "spec": "kolibri==0.14.3", "apps": _exe_if_win(["kolibri"]), "apps_of_dependencies": [], }, "lektor": { "spec": "Lektor==3.3.10", "apps": _exe_if_win(["lektor"]), "apps_of_dependencies": [ *_exe_if_win(["filetype", "flask", "pybabel", "normalizer", "slugify", "watchmedo"]), "EXIF.py", ], }, "localstack": { "spec": "localstack==0.12.1", "apps": ["localstack", "localstack.bat"], "apps_of_dependencies": [ *_exe_if_win([ "dotenv", "markdown-it", "pbr", "pygmentize", "pyrsa-decrypt", "pyrsa-encrypt", "pyrsa-keygen", "pyrsa-priv2pub", "pyrsa-sign", "pyrsa-verify", "pysemver", "pytail", "tabulate", "normalizer", ]), "get_objgraph", "jp.py", "localstack-supervisor", "undill", ], }, "mackup": { "spec": "mackup==0.8.29", "apps": _exe_if_win(["mackup"]), "apps_of_dependencies": [], }, # macOS and Linux only "magic-wormhole": { "spec": "magic-wormhole==0.13.0", "apps": _exe_if_win(["wormhole"]), "apps_of_dependencies": _exe_if_win([ "automat-visualize", "cftp", "ckeygen", "conch", "mailmail", "pyhtmlizer", "tkconch", "tqdm", # tqdm EXE "trial", "twist", "twistd", "wamp", "xbrnetwork", "xbrnetwork-ui", ]) + (["pywin32_postinstall.py", "pywin32_testall.py"] if _WIN else []), }, "mayan-edms": { "spec": "mayan-edms==3.5.2", "apps": ["mayan-edms.py"], "apps_of_dependencies": [ *_exe_if_win([ "celery", "chardetect", # chardet EXE "django-admin", "gunicorn", "jsonschema", # jsonschema EXE "sqlformat", # sqlparse EXE "swagger-flex", "update-tld-names", # # EXE ]), "django-admin.py", "jsonpointer", ], }, "mkdocs": { "spec": "mkdocs==1.1.2", "apps": _exe_if_win(["mkdocs"]), "apps_of_dependencies": _exe_if_win([ "livereload", "futurize", # future EXE "pasteurize", # future EXE "nltk", "tqdm", # tqdm EXE "markdown_py", # Markdown EXE ]), }, "mycli": { "spec": "mycli==1.22.2", "apps": _exe_if_win(["mycli"]), "apps_of_dependencies": _exe_if_win(["pygmentize", "sqlformat", "tabulate"]), }, "nikola": { "spec": "nikola==8.2.4", "apps": _exe_if_win(["nikola"]), "apps_of_dependencies": [ *_exe_if_win([ "docutils", "doit", "mako-render", # mako EXE "markdown_py", # Markdown EXE "natsort", "pybabel", # babel EXE "pygmentize", # pygments EXE "unidecode", # unidecode EXE "normalizer", ]), "rst2html.py", # docutils NO_EXE "rst2html4.py", # docutils NO_EXE "rst2html5.py", # docutils NO_EXE "rst2latex.py", # docutils NO_EXE "rst2man.py", # docutils NO_EXE "rst2odt.py", # docutils NO_EXE "rst2odt_prepstyles.py", # docutils NO_EXE "rst2pseudoxml.py", # docutils NO_EXE "rst2s5.py", # docutils NO_EXE "rst2xetex.py", # docutils NO_EXE "rst2xml.py", # docutils NO_EXE "rstpep2html.py", # docutils NO_EXE ], "man_pages": [str(Path("man1") / "nikola.1.gz")], "man_pages_of_dependencies": [], }, "nox": { "spec": "nox==2023.4.22", "apps": _exe_if_win(["nox", "tox-to-nox"]), "apps_of_dependencies": [ *_exe_if_win(["virtualenv"]), "activate-global-python-argcomplete", "python-argcomplete-check-easy-install-script", "register-python-argcomplete", ], # from argcomplete }, "pbr": {"spec": "pbr==5.6.0", "apps": _exe_if_win(["pbr"])}, "pelican": { "spec": "pelican==4.8.0", "apps": _exe_if_win([ "pelican", "pelican-import", "pelican-plugins", "pelican-quickstart", "pelican-themes", ]), "apps_of_dependencies": [ *_exe_if_win(["docutils", "markdown-it", "pygmentize", "unidecode"]), "rst2html.py", # docutils NO_EXE "rst2html4.py", # docutils NO_EXE "rst2html5.py", # docutils NO_EXE "rst2latex.py", # docutils NO_EXE "rst2man.py", # docutils NO_EXE "rst2odt.py", # docutils NO_EXE "rst2odt_prepstyles.py", # docutils NO_EXE "rst2pseudoxml.py", # docutils NO_EXE "rst2s5.py", # docutils NO_EXE "rst2xetex.py", # docutils NO_EXE "rst2xml.py", # docutils NO_EXE "rstpep2html.py", # docutils NO_EXE ], }, "platformio": { "spec": "platformio==6.1.11", "apps": _exe_if_win(["pio", "piodebuggdb", "platformio"]), "apps_of_dependencies": [ *_exe_if_win([ "async-json-rpc-server", "pyserial-miniterm", "pyserial-ports", "tabulate", "uvicorn", "normalizer", ]), "bottle.py", "readelf.py", ], }, "ppci": { "spec": "ppci==0.5.8", "apps": _exe_if_win([ "ppci-archive", "ppci-asm", "ppci-build", "ppci-c3c", "ppci-cc", "ppci-dbg", "ppci-disasm", "ppci-hexdump", "ppci-hexutil", "ppci-java", "ppci-ld", "ppci-llc", "ppci-mkuimage", "ppci-objcopy", "ppci-objdump", "ppci-ocaml", "ppci-opt", "ppci-pascal", "ppci-pedump", "ppci-pycompile", "ppci-readelf", "ppci-wabt", "ppci-wasm2wat", "ppci-wasmcompile", "ppci-wat2wasm", "ppci-yacc", ]), "apps_of_dependencies": [], }, "prosopopee": { "spec": "prosopopee==1.1.3", "apps": _exe_if_win(["prosopopee"]), "apps_of_dependencies": _exe_if_win(["futurize", "pasteurize", "pybabel"]), }, "ptpython": { "spec": "ptpython==3.0.7", "apps": _exe_if_win([ "ptipython", "ptipython3", "ptipython3.8", "ptpython", "ptpython3", "ptpython3.8", ]), "apps_of_dependencies": _exe_if_win(["pygmentize"]), # pygments EXE }, "pycowsay": { "spec": "pycowsay==0.0.0.2", "apps": _exe_if_win(["pycowsay"]), "apps_of_dependencies": [], "man_pages": [str(Path("man6") / "pycowsay.6")], "man_pages_of_dependencies": [], }, "pygdbmi": {"spec": "pygdbmi==0.10.0.0", "apps": [], "apps_of_dependencies": []}, "pylint": { "spec": "pylint==3.0.4", "apps": _exe_if_win(["pylint", "pylint-config", "pyreverse", "symilar"]), "apps_of_dependencies": [ *_exe_if_win(["isort", "isort-identify-imports"]), "get_gprof", "get_objgraph", "undill", ], }, "retext": { "spec": "ReText==8.0.1", "apps": _exe_if_win(["retext"]), "apps_of_dependencies": [ *_exe_if_win([ "docutils", "markdown_py", # Markdown EXE "pygmentize", # pygments EXE "pylupdate6", "pyuic6", "chardetect", ]), "rst2html.py", # docutils NO_EXE "rst2html4.py", # docutils NO_EXE "rst2html5.py", # docutils NO_EXE "rst2latex.py", # docutils NO_EXE "rst2man.py", # docutils NO_EXE "rst2odt.py", # docutils NO_EXE "rst2odt_prepstyles.py", # docutils NO_EXE "rst2pseudoxml.py", # docutils NO_EXE "rst2s5.py", # docutils NO_EXE "rst2xetex.py", # docutils NO_EXE "rst2xml.py", # docutils NO_EXE "rstpep2html.py", # docutils NO_EXE ], }, "robotframework": { "spec": "robotframework==3.2.2", "apps": _exe_if_win(["rebot", "robot"]), "apps_of_dependencies": [], }, "shell-functools": { "spec": "shell-functools==0.3.0", "apps": [ "filter", "foldl", "foldl1", "ft-functions", "map", "sort_by", "take_while", ], "apps_of_dependencies": [], }, "speedtest-cli": { "spec": "speedtest-cli==2.1.2", "apps": _exe_if_win(["speedtest", "speedtest-cli"]), "apps_of_dependencies": [], }, "sphinx": { "spec": "Sphinx==7.2.6", "apps": _exe_if_win(["sphinx-apidoc", "sphinx-autogen", "sphinx-build", "sphinx-quickstart"]), "apps_of_dependencies": [ *_exe_if_win(["docutils", "pybabel", "normalizer", "pygmentize"]), "rst2html.py", # docutils NO_EXE "rst2html4.py", # docutils NO_EXE "rst2html5.py", # docutils NO_EXE "rst2latex.py", # docutils NO_EXE "rst2man.py", # docutils NO_EXE "rst2odt.py", # docutils NO_EXE "rst2odt_prepstyles.py", # docutils NO_EXE "rst2pseudoxml.py", # docutils NO_EXE "rst2s5.py", # docutils NO_EXE "rst2xetex.py", # docutils NO_EXE "rst2xml.py", # docutils NO_EXE "rstpep2html.py", # docutils NO_EXE ], }, "sqlmap": { "spec": "sqlmap==1.4.10", "apps": _exe_if_win(["sqlmap"]), "apps_of_dependencies": [], }, "streamlink": { "spec": "streamlink==6.3.1", "apps": _exe_if_win(["streamlink"] + (["streamlinkw"] if _WIN else [])), "apps_of_dependencies": _exe_if_win(["normalizer", "wsdump"]), "man_pages": [str(Path("man1") / "streamlink.1")], "man_pages_of_dependencies": [], }, "taguette": { "spec": "taguette==0.9.2", "apps": _exe_if_win(["taguette"]), "apps_of_dependencies": [*_exe_if_win(["alembic", "mako-render"]), "vba_extract.py"], }, "term2048": { "spec": "term2048==0.2.7", "apps": _exe_if_win(["term2048"]), "apps_of_dependencies": [], }, "tox-ini-fmt": { "spec": "tox-ini-fmt==0.5.0", "apps": _exe_if_win(["tox-ini-fmt"]), "apps_of_dependencies": _exe_if_win(["py.test", "pytest"]), # pytest EXE }, "visidata": { "spec": "visidata==2.0.1", "apps": [*_exe_if_win(["visidata"]), "vd"], "apps_of_dependencies": [], "man_pages": [str(Path("man1") / "vd.1")], "man_pages_of_dependencies": [], }, "vulture": { "spec": "vulture==2.1", "apps": _exe_if_win(["vulture"]), "apps_of_dependencies": [], }, "weblate": { "spec": "Weblate==4.3.1", "apps": _exe_if_win(["weblate"]), "apps_of_dependencies": _exe_if_win( # _exe_if_win unverified: weblate can't install in tests [ "borg", "borgfs", "build_firefox.sh", "build_tmdb", "buildxpi.py", "celery", "chardetect", # chardet EXE "csv2po", "csv2tbx", "cygdb", "cython", "cythonize", "django-admin", "django-admin.py", # NO_EXE "flatxml2po", "get_moz_enUS.py", "html2po", "html2text", # html2text EXE "ical2po", "idml2po", "ini2po", "json2po", "jsonschema", # jsonschema EXE "junitmsgfmt", "misaka", "moz2po", "mozlang2po", "odf2xliff", "oo2po", "oo2xliff", "php2po", "phppo2pypo", "po2csv", "po2flatxml", "po2html", "po2ical", "po2idml", "po2ini", "po2json", "po2moz", "po2mozlang", "po2oo", "po2php", "po2prop", "po2rc", "po2resx", "po2sub", "po2symb", "po2tiki", "po2tmx", "po2ts", "po2txt", "po2web2py", "po2wordfast", "po2xliff", "po2yaml", "poclean", "pocommentclean", "pocompendium", "pocompile", "poconflicts", "pocount", "podebug", "pofilter", "pogrep", "pomerge", "pomigrate2", "popuretext", "poreencode", "porestructure", "posegment", "posplit", "poswap", "pot2po", "poterminology", "pretranslate", "prop2po", "pydiff", "pypo2phppo", "rc2po", "resx2po", "sqlformat", # sqlparse EXE "sub2po", "symb2po", "tbx2po", "tiki2po", "tmserver", "ts2po", "txt2po", "web2py2po", "weblate-discover", "xliff2odf", "xliff2oo", "xliff2po", "yaml2po", ] ), }, "youtube-dl": { "spec": "youtube-dl==2020.9.20", "apps": _exe_if_win(["youtube-dl"]), "apps_of_dependencies": [], "man_pages": [str(Path("man1") / "youtube-dl.1")], "man_pages_of_dependencies": [], }, "zeo": { "spec": "ZEO==5.2.2", "apps": _exe_if_win(["runzeo", "zeo-nagios", "zeoctl", "zeopack"]), "apps_of_dependencies": _exe_if_win([ "fsdump", "fsoids", "fsrefs", "fstail", "repozo", "zconfig", "zconfig_schema2html", "zdaemon", ]), }, } __all__ = [ "PKG", ] pipx-1.18.16/tests/test_animate.py000066400000000000000000000154501527355705200170610ustar00rootroot00000000000000from __future__ import annotations import io import sys import threading import time from timeit import default_timer from typing import TYPE_CHECKING, Final import pytest import pipx.animate from pipx.animate import ( CLEAR_LINE, EMOJI_ANIMATION_FRAMES, NONEMOJI_ANIMATION_FRAMES, hide_cursor, show_cursor, ) from pipx.fanout import fan_out if TYPE_CHECKING: from collections.abc import Callable from _pytest.capture import CaptureResult from pytest_mock import MockerFixture _TEST_STRING_40_CHAR: Final[str] = "".join(f"{number:02}" for number in range(2, 41, 2)) def test_delay_suppresses_output( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(pipx.animate, "STDERR_IS_TTY", True) monkeypatch.setenv("COLUMNS", "80") # stays inside the 0.9 s delay, so no frame may appear yet with pipx.animate.animate("asdf", do_animation=True, delay=0.9): time.sleep(0.5) assert "asdf" not in capsys.readouterr().err @pytest.mark.parametrize( ("cursor_control", "escape_sequence"), [pytest.param(hide_cursor, "\033[?25l", id="hide"), pytest.param(show_cursor, "\033[?25h", id="show")], ) def test_cursor_control_uses_ansi( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, cursor_control: Callable[[], None], escape_sequence: str, ) -> None: monkeypatch.setattr(pipx.animate, "STDERR_IS_TTY", True) monkeypatch.setattr(pipx.animate, "WINDOWS", False) cursor_control() assert capsys.readouterr().err == escape_sequence def test_animate_clears_after_thread_stops(monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture) -> None: animation_clear_started: Final[threading.Event] = threading.Event() main_clear_started: Final[threading.Event] = threading.Event() writes: Final[list[str]] = [] clear: Final[str] = f"\r{CLEAR_LINE}" def record_write(text: str) -> int: if text == clear: if threading.current_thread() is threading.main_thread(): main_clear_started.set() else: animation_clear_started.set() main_clear_started.wait(timeout=1) writes.append(text) return len(text) mocker.patch.object(pipx.animate.sys, "stderr", mocker.MagicMock(write=mocker.Mock(side_effect=record_write))) monkeypatch.setattr(pipx.animate, "STDERR_IS_TTY", True) monkeypatch.setattr(pipx.animate, "EMOJI_SUPPORT", True) monkeypatch.setenv("COLUMNS", "80") with pipx.animate.animate("message", do_animation=True): assert animation_clear_started.wait(timeout=1) assert writes[-1] == clear @pytest.mark.parametrize( ("env_columns", "expected_frame_message"), [ pytest.param(45, f"{_TEST_STRING_40_CHAR:.{45 - 6}}...", id="truncated"), pytest.param(46, _TEST_STRING_40_CHAR, id="exact"), pytest.param(47, _TEST_STRING_40_CHAR, id="spare-column"), ], ) def test_line_lengths_emoji( monkeypatch: pytest.MonkeyPatch, check_animate_output: Callable[[list[str], int], None], env_columns: int, expected_frame_message: str, ) -> None: monkeypatch.setattr(pipx.animate, "STDERR_IS_TTY", True) monkeypatch.setattr(pipx.animate, "EMOJI_SUPPORT", True) monkeypatch.setenv("COLUMNS", str(env_columns)) check_animate_output([f"\r{CLEAR_LINE}{x} {expected_frame_message}" for x in EMOJI_ANIMATION_FRAMES], 4) @pytest.fixture def check_animate_output(monkeypatch: pytest.MonkeyPatch) -> Callable[[list[str], int], None]: def check(frame_strings: list[str], frames_to_test: int) -> None: # draining capsys while the animation thread writes loses a frame written between the read and the truncation stderr: Final[io.StringIO] = io.StringIO() monkeypatch.setattr(sys, "stderr", stderr) chars_to_test: Final[int] = len("".join(frame_strings[:frames_to_test])) deadline: Final[float] = default_timer() + 5 with pipx.animate.animate(_TEST_STRING_40_CHAR, do_animation=True): while default_timer() < deadline and len(stderr.getvalue()) < chars_to_test: time.sleep(0.01) assert stderr.getvalue()[:chars_to_test] == "".join(frame_strings)[:chars_to_test] return check @pytest.mark.parametrize( ("env_columns", "expected_frame_message"), [ pytest.param(43, f"{_TEST_STRING_40_CHAR:.{43 - 4}}", id="truncated"), pytest.param(44, _TEST_STRING_40_CHAR, id="exact"), pytest.param(45, _TEST_STRING_40_CHAR, id="spare-column"), ], ) def test_line_lengths_no_emoji( monkeypatch: pytest.MonkeyPatch, check_animate_output: Callable[[list[str], int], None], env_columns: int, expected_frame_message: str, ) -> None: monkeypatch.setattr(pipx.animate, "STDERR_IS_TTY", True) monkeypatch.setattr(pipx.animate, "EMOJI_SUPPORT", False) monkeypatch.setenv("COLUMNS", str(env_columns)) check_animate_output([f"\r{CLEAR_LINE}{expected_frame_message}{x}" for x in NONEMOJI_ANIMATION_FRAMES], 2) @pytest.mark.parametrize( ("env_columns", "supports_tty"), [ pytest.param(0, True, id="zero-columns"), pytest.param(8, True, id="narrow-terminal"), pytest.param(16, True, id="threshold"), pytest.param(17, False, id="not-tty"), ], ) def test_env_no_animate( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, env_columns: int, supports_tty: bool, ) -> None: monkeypatch.setattr(pipx.animate, "STDERR_IS_TTY", supports_tty) monkeypatch.setenv("COLUMNS", str(env_columns)) with pipx.animate.animate(_TEST_STRING_40_CHAR, do_animation=True): pass captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err) == ("", f"{_TEST_STRING_40_CHAR}...\n") def test_animate_skips_spinner_of_task_waiting_its_turn( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(pipx.animate, "STDERR_IS_TTY", True) monkeypatch.setenv("COLUMNS", "80") second_done: Final[threading.Event] = threading.Event() def second() -> None: with pipx.animate.animate("waiting its turn", do_animation=True): time.sleep(0.3) second_done.set() fan_out([lambda: second_done.wait(timeout=5), second], sequential=False) assert "waiting its turn" not in capsys.readouterr().err def test_animate_spins_for_task_whose_turn_it_is( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(pipx.animate, "STDERR_IS_TTY", True) monkeypatch.setenv("COLUMNS", "80") def first() -> None: with pipx.animate.animate("its turn", do_animation=True): time.sleep(0.3) fan_out([first, lambda: None], sequential=False) assert "its turn" in capsys.readouterr().err pipx-1.18.16/tests/test_backends.py000066400000000000000000000566151527355705200172250ustar00rootroot00000000000000from __future__ import annotations import subprocess from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from helpers import write_dist_info from pipx import paths from pipx.backends import registry from pipx.backends._base import KNOWN_BACKENDS, PIP, UV, OutdatedPackage # ruff:ignore[import-private-name] # no public home from pipx.backends.pip import PipBackend from pipx.backends.registry import assert_not_pip_under_uv, env_default_backend, resolve_backend_name from pipx.backends.uv import UvBackend, find_uv_binary, resolve_uv_binary from pipx.constants import WINDOWS from pipx.util import PipxError if TYPE_CHECKING: from collections.abc import Callable, Sequence from unittest.mock import MagicMock from pytest_mock import MockerFixture def test_known_backends() -> None: assert KNOWN_BACKENDS == ("pip", "uv") def test_env_default_backend_unset(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PIPX_DEFAULT_BACKEND", raising=False) assert env_default_backend() is None @pytest.mark.parametrize( ("env", "expected"), [ pytest.param("", None, id="empty"), pytest.param("uv", "uv", id="uv"), pytest.param(" pip ", "pip", id="trims-whitespace"), ], ) def test_env_default_backend(monkeypatch: pytest.MonkeyPatch, env: str, expected: str | None) -> None: monkeypatch.setenv("PIPX_DEFAULT_BACKEND", env) assert env_default_backend() == expected @pytest.mark.parametrize( ("cli_value", "env_value", "metadata_value", "expected"), [ pytest.param("pip", None, None, ("pip", "cli"), id="cli-pip"), pytest.param("uv", None, None, ("uv", "cli"), id="cli-uv"), pytest.param(None, "pip", None, ("pip", "env"), id="env-pip"), pytest.param(None, None, "uv", ("uv", "metadata"), id="metadata-uv"), pytest.param("pip", "uv", "uv", ("pip", "cli"), id="cli-wins"), pytest.param(None, "uv", "pip", ("pip", "metadata"), id="metadata-locks-env-out"), pytest.param(None, "uv", None, ("uv", "env"), id="env-when-no-metadata"), ], ) def test_resolve_backend_name_precedence( cli_value: str | None, env_value: str | None, metadata_value: str | None, expected: tuple[str, str] ) -> None: assert resolve_backend_name(cli_value=cli_value, env_value=env_value, metadata_value=metadata_value) == expected def test_resolve_backend_name_rejects_unknown() -> None: with pytest.raises(PipxError, match="Unknown backend"): resolve_backend_name(cli_value="poetry", env_value=None) @pytest.fixture def path_uv_reports(mocker: MockerFixture) -> Callable[[str], None]: mocker.patch("pipx.backends.uv._FIND_UV_BIN_FROM_EXTRA", None) mocker.patch("shutil.which", return_value="/usr/local/bin/uv") def report(version: str) -> None: mocker.patch( "pipx.backends.uv.subprocess.run", return_value=subprocess.CompletedProcess(["uv", "--version"], 0, stdout=f"uv {version}", stderr=""), ) return report @pytest.mark.parametrize( ("version", "expected"), [ pytest.param("0.9.17", (UV, "auto-path"), id="supported"), pytest.param("0.5.0", (PIP, "auto-pip"), id="too-old"), ], ) def test_resolve_backend_name_auto_path_uv( path_uv_reports: Callable[[str], None], version: str, expected: tuple[str, str] ) -> None: path_uv_reports(version) assert resolve_backend_name(env_value=None) == expected def test_resolve_backend_name_auto_skips_too_old_bundled_uv( path_uv_reports: Callable[[str], None], mocker: MockerFixture, tmp_path: Path ) -> None: path_uv_reports("0.5.0") (bundled := tmp_path / "uv").write_text("") mocker.patch("pipx.backends.uv._FIND_UV_BIN_FROM_EXTRA", return_value=str(bundled)) mocker.patch("shutil.which", return_value=None) assert resolve_backend_name(env_value=None) == (PIP, "auto-pip") def test_resolve_backend_name_auto_warns_once_about_too_old_uv( path_uv_reports: Callable[[str], None], caplog: pytest.LogCaptureFixture ) -> None: path_uv_reports("0.5.0") resolve_backend_name(env_value=None) resolve_backend_name(env_value=None) assert [record.getMessage() for record in caplog.records if record.levelname == "WARNING"] == [ ( f"Skipping uv 0.5.0 at {Path('/usr/local/bin/uv')} since pipx needs uv>=0.9.17; using pip. " "Upgrade uv, or set PIPX_DEFAULT_BACKEND=pip to silence this." ) ] @pytest.mark.parametrize( ("cli_value", "env_value", "metadata_value", "expected"), [ pytest.param("uv", None, None, (UV, "cli"), id="cli"), pytest.param(None, "uv", None, (UV, "env"), id="env"), pytest.param(None, None, "uv", (UV, "metadata"), id="metadata"), ], ) def test_resolve_backend_name_keeps_explicit_uv_when_too_old( path_uv_reports: Callable[[str], None], cli_value: str | None, env_value: str | None, metadata_value: str | None, expected: tuple[str, str], ) -> None: path_uv_reports("0.5.0") assert resolve_backend_name(cli_value=cli_value, env_value=env_value, metadata_value=metadata_value) == expected def test_resolve_backend_name_auto_no_uv(mocker: MockerFixture) -> None: mocker.patch.object(registry, "find_uv_binary", return_value=(None, "missing")) assert resolve_backend_name(env_value=None) == (PIP, "auto-pip") def test_resolve_backend_name_auto_disabled(mocker: MockerFixture) -> None: mocker.patch.object(registry, "find_uv_binary", return_value=(Path("/opt/uv"), "path")) assert resolve_backend_name(env_value=None, auto=False) == (PIP, "auto-pip") def test_find_uv_binary_prefers_bundled(mocker: MockerFixture, tmp_path: Path) -> None: (bundled := tmp_path / "bundled-uv").write_text("") mocker.patch("pipx.backends.uv._FIND_UV_BIN_FROM_EXTRA", return_value=str(bundled)) mocker.patch("shutil.which", return_value="/usr/local/bin/uv") # the stub bundled file is not a real uv, so bypass the launch probe mocker.patch("pipx.backends.uv._binary_runs", return_value=True) assert find_uv_binary() == (bundled, "bundled") def test_find_uv_binary_skips_broken_bundled(mocker: MockerFixture, tmp_path: Path) -> None: (bundled := tmp_path / "bundled-uv").write_text("") mocker.patch("pipx.backends.uv._FIND_UV_BIN_FROM_EXTRA", return_value=str(bundled)) mocker.patch("pipx.backends.uv._binary_runs", side_effect=lambda candidate: candidate != bundled) mocker.patch("shutil.which", return_value="/usr/local/bin/uv") assert find_uv_binary() == (Path("/usr/local/bin/uv"), "path") @pytest.mark.parametrize( ("runs", "expected"), [ pytest.param(True, (Path("/usr/local/bin/uv"), "path"), id="falls-back-to-path"), pytest.param(False, (None, "missing"), id="skips-broken-path"), ], ) def test_find_uv_binary_path_candidate(mocker: MockerFixture, runs: bool, expected: tuple[Path | None, str]) -> None: mocker.patch("pipx.backends.uv._FIND_UV_BIN_FROM_EXTRA", None) mocker.patch("pipx.backends.uv._binary_runs", return_value=runs) mocker.patch("shutil.which", return_value="/usr/local/bin/uv") assert find_uv_binary() == expected def test_find_uv_binary_probe_survives_a_hanging_path_uv(mocker: MockerFixture) -> None: mocker.patch("pipx.backends.uv._FIND_UV_BIN_FROM_EXTRA", None) mocker.patch("shutil.which", return_value="/usr/local/bin/uv") mocker.patch( "pipx.backends.uv.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd=["uv", "--version"], timeout=10), ) assert find_uv_binary() == (None, "missing") def test_find_uv_binary_missing(mocker: MockerFixture) -> None: mocker.patch("pipx.backends.uv._FIND_UV_BIN_FROM_EXTRA", None) mocker.patch("shutil.which", return_value=None) assert find_uv_binary() == (None, "missing") def test_find_uv_binary_is_cached(mocker: MockerFixture) -> None: mocker.patch("pipx.backends.uv._FIND_UV_BIN_FROM_EXTRA", None) mocker.patch("pipx.backends.uv._binary_runs", return_value=True) which_mock: Final[MagicMock] = mocker.patch("shutil.which", return_value="/usr/local/bin/uv") find_uv_binary() find_uv_binary() find_uv_binary() assert which_mock.call_count == 1 def test_resolve_uv_binary_runs_uv_version_once(mocker: MockerFixture) -> None: mocker.patch("pipx.backends.uv._FIND_UV_BIN_FROM_EXTRA", None) mocker.patch("shutil.which", return_value="/usr/local/bin/uv") run: Final[MagicMock] = mocker.patch( "pipx.backends.uv.subprocess.run", return_value=subprocess.CompletedProcess(["uv", "--version"], 0, stdout="uv 0.11.28", stderr=""), ) resolve_uv_binary() assert run.call_count == 1 def test_resolve_uv_binary_reports_a_hanging_version_probe(mocker: MockerFixture) -> None: binary: Final[Path] = Path("/usr/local/bin/uv") mocker.patch("pipx.backends.uv.find_uv_binary", return_value=(binary, "path")) mocker.patch( "pipx.backends.uv.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd=[str(binary), "--version"], timeout=10), ) with pytest.raises(PipxError, match="Could not run"): resolve_uv_binary() def test_uv_backend_rejects_pre_cooldown_version(mocker: MockerFixture) -> None: binary: Final[Path] = Path("/usr/local/bin/uv") mocker.patch("pipx.backends.uv.resolve_uv_binary", return_value=binary) mocker.patch("pipx.backends.uv.find_uv_binary", return_value=(binary, "path")) mocker.patch( "pipx.backends.uv.subprocess.run", return_value=subprocess.CompletedProcess([str(binary), "--version"], 0, stdout="uv 0.9.16", stderr=""), ) with pytest.raises(PipxError, match=r"uv>=0\.9\.17"): UvBackend() @pytest.fixture def uv_binary(tmp_path: Path, mocker: MockerFixture) -> Path: binary: Final[Path] = tmp_path / "uv" mocker.patch("pipx.backends.uv.resolve_uv_binary", return_value=binary) mocker.patch("pipx.backends.uv.find_uv_binary", return_value=(binary, "path")) mocker.patch( "pipx.backends.uv.subprocess.run", return_value=subprocess.CompletedProcess([str(binary), "--version"], 0, stdout="uv 0.11.28", stderr=""), ) return binary @pytest.fixture def uv_run_subprocess( uv_binary: Path, # ruff:ignore[unused-function-argument] # UvBackend() must find the patched uv mocker: MockerFixture, ) -> MagicMock: return mocker.patch( "pipx.backends.uv.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""), ) @pytest.fixture def pip_run_subprocess(mocker: MockerFixture) -> MagicMock: return mocker.patch( "pipx.backends.pip.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""), ) @pytest.mark.parametrize( ("verbose", "flags"), [pytest.param(True, ["--progress-bar=on"], id="verbose-streams"), pytest.param(False, [], id="quiet-captures")], ) def test_pip_backend_install_streams_output_only_when_verbose( verbose: bool, flags: list[str], tmp_path: Path, pip_run_subprocess: MagicMock ) -> None: venv_python: Final[Path] = tmp_path / "bin" / "python" PipBackend().install( venv_root=tmp_path, venv_python=venv_python, requirements=["demo"], pip_args=[], verbose=verbose ) assert (pip_run_subprocess.call_args.args[0], pip_run_subprocess.call_args.kwargs["stream_output"]) == ( [str(venv_python), "-m", "pip", "--no-input", "install", *flags, "demo"], verbose, ) @pytest.mark.parametrize( ("verbose", "flag"), [pytest.param(True, "--verbose", id="verbose-streams"), pytest.param(False, "--quiet", id="quiet-captures")], ) def test_uv_backend_install_streams_output_only_when_verbose( verbose: bool, flag: str, tmp_path: Path, uv_run_subprocess: MagicMock ) -> None: venv_python: Final[Path] = tmp_path / "bin" / "python" UvBackend().install( venv_root=tmp_path, venv_python=venv_python, requirements=["demo"], pip_args=[], verbose=verbose ) assert (uv_run_subprocess.call_args.args[0], uv_run_subprocess.call_args.kwargs["stream_output"]) == ( [tmp_path / "uv", "pip", "install", "--python", str(venv_python), flag, "demo"], verbose, ) def test_uv_install_rejects_recorded_empty_index(tmp_path: Path, uv_run_subprocess: MagicMock) -> None: with pytest.raises(PipxError, match=r"^--index-url is empty"): UvBackend().install( venv_root=tmp_path, venv_python=tmp_path / "bin" / "python", requirements=["demo"], pip_args=["--index-url="], verbose=False, ) uv_run_subprocess.assert_not_called() def test_uv_list_outdated_rejects_recorded_empty_index(tmp_path: Path, uv_run_subprocess: MagicMock) -> None: with pytest.raises(PipxError, match=r"^--default-index is empty"): UvBackend().list_outdated( venv_root=tmp_path, venv_python=tmp_path / "bin" / "python", index_args=["--default-index="], exclude=[] ) uv_run_subprocess.assert_not_called() def test_uv_install_silences_progress_bar(tmp_path: Path, uv_run_subprocess: MagicMock) -> None: UvBackend().install( venv_root=tmp_path, venv_python=tmp_path / "bin" / "python", requirements=["demo"], pip_args=[], verbose=False ) assert uv_run_subprocess.call_args.kwargs["env_overrides"] == { "VIRTUAL_ENV": None, "UV_NO_PROGRESS": "1", "UV_PROJECT": str(paths.CTX.home), } @pytest.mark.parametrize( ("pip_args", "expected"), [ pytest.param( ["--index-url", "https://index.example/simple"], ["--default-index", "https://index.example/simple"], id="separate", ), pytest.param( ["--index-url=https://index.example/simple"], ["--default-index=https://index.example/simple"], id="attached", ), pytest.param( ["-i", "https://index.example/simple"], ["--default-index", "https://index.example/simple"], id="short-separate", ), pytest.param( ["-ihttps://index.example/simple"], ["--default-index=https://index.example/simple"], id="short-attached" ), ], ) def test_uv_install_passes_index_url_as_default_index( pip_args: list[str], expected: list[str], tmp_path: Path, uv_run_subprocess: MagicMock ) -> None: venv_python: Final[Path] = tmp_path / "bin" / "python" UvBackend().install( venv_root=tmp_path, venv_python=venv_python, requirements=["demo"], pip_args=pip_args, verbose=False ) assert uv_run_subprocess.call_args.args[0] == [ tmp_path / "uv", "pip", "install", "--python", str(venv_python), "--quiet", *expected, "demo", ] @pytest.mark.parametrize( ("verbose", "args", "flags"), [ # verbose means "do not silence", matching pip; uv's --verbose would print DEBUG logs on every `pipx runpip` pytest.param(True, ["show", "demo"], ["demo"], id="verbose-passes-through"), pytest.param(True, ["install", "-q", "demo"], ["-q", "demo"], id="verbose-keeps-user-quiet"), pytest.param(False, ["show", "-q", "demo"], ["--quiet", "demo"], id="plain-quiet"), ], ) def test_uv_run_raw_pip_verbosity( verbose: bool, args: list[str], flags: list[str], tmp_path: Path, uv_run_subprocess: MagicMock ) -> None: venv_python: Final[Path] = tmp_path / "bin" / "python" UvBackend().run_raw_pip(venv_root=tmp_path, venv_python=venv_python, args=args, verbose=verbose) assert uv_run_subprocess.call_args.args[0] == [ tmp_path / "uv", "pip", args[0], "--python", str(venv_python), *flags, ] def test_assert_not_pip_under_uv_blocks_pip_on_uv() -> None: with pytest.raises(PipxError, match="cannot be installed or exposed via the uv backend"): assert_not_pip_under_uv("pip", UV) @pytest.mark.parametrize( ("package_name", "backend_name"), [ pytest.param("pip", PIP, id="allows-pip-on-pip"), pytest.param("ruff", UV, id="allows-other-packages-on-uv"), ], ) def test_assert_not_pip_under_uv_allows(package_name: str, backend_name: str) -> None: assert_not_pip_under_uv(package_name, backend_name) def test_uv_list_installed_not_required_uses_distribution_metadata( site_packages: Path, tmp_path: Path, uv_run_subprocess: MagicMock ) -> None: write_dist_info(site_packages, "root-package") write_dist_info(site_packages, "top-package", ["child-package>=1"]) write_dist_info(site_packages, "child-package") installed: Final[set[str]] = UvBackend().list_installed( venv_root=tmp_path, venv_python=tmp_path / "bin" / "python", not_required=True ) assert (installed, uv_run_subprocess.called) == ({"root-package", "top-package"}, False) @pytest.mark.parametrize( "run_pip", [ pytest.param( lambda _root, python: PipBackend().upgrade_packaging_libraries(python, []), id="upgrade-packaging-libraries", ), pytest.param( lambda root, python: PipBackend().install( venv_root=root, venv_python=python, requirements=["demo"], pip_args=[], verbose=False ), id="install", ), pytest.param( lambda root, python: PipBackend().uninstall( venv_root=root, venv_python=python, packages=["demo"], verbose=False ), id="uninstall", ), pytest.param( lambda root, python: PipBackend().list_installed(venv_root=root, venv_python=python, not_required=False), id="list-installed", ), pytest.param( lambda root, python: PipBackend().list_outdated( venv_root=root, venv_python=python, index_args=[], exclude=[] ), id="list-outdated", ), pytest.param( lambda root, python: PipBackend().run_raw_pip( venv_root=root, venv_python=python, args=["check"], verbose=False ), id="run-raw-pip", ), ], ) def test_pip_backend_starts_pip_without_site_config( run_pip: Callable[[Path, Path], set[str] | tuple[OutdatedPackage, ...] | subprocess.CompletedProcess[str] | None], tmp_path: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture, ) -> None: (site_config := tmp_path / ("pip.ini" if WINDOWS else "pip.conf")).write_text( "[install]\ntarget = ..\n", encoding="utf-8" ) seen_by_pip: Final[list[bool]] = [] def record( *_args: Sequence[str | Path], **_kwargs: bool | str | dict[str, str | None] | None ) -> subprocess.CompletedProcess[str]: seen_by_pip.append(site_config.exists()) return subprocess.CompletedProcess(args=[], returncode=0, stdout="[]", stderr="") mocker.patch("pipx.backends.pip.run_subprocess", autospec=True, side_effect=record) run_pip(tmp_path, tmp_path / "bin" / "python") assert (seen_by_pip, f"Removing {site_config}" in caplog.text) == ([False], True) @pytest.fixture def outdated_process() -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess( args=[], returncode=0, stdout='[{"name":"demo","version":"1","latest_version":"2"}]', stderr="" ) def test_pip_backend_list_outdated_forwards_index_settings( tmp_path: Path, mocker: MockerFixture, outdated_process: subprocess.CompletedProcess[str] ) -> None: run_subprocess: Final[MagicMock] = mocker.patch( "pipx.backends.pip.run_subprocess", autospec=True, return_value=outdated_process ) result: Final = PipBackend().list_outdated( venv_root=tmp_path, venv_python=tmp_path / "bin" / "python", index_args=["--index-url", "https://index.example/simple"], exclude=["click", "pip"], ) assert (result, run_subprocess.call_args.args[0]) == ( (OutdatedPackage(name="demo", version="1", latest_version="2"),), [ str(tmp_path / "bin" / "python"), "-m", "pip", "list", "--outdated", "--format=json", "--index-url", "https://index.example/simple", "--exclude=click", "--exclude=pip", ], ) def test_uv_backend_list_outdated_forwards_index_settings( tmp_path: Path, uv_binary: Path, mocker: MockerFixture, outdated_process: subprocess.CompletedProcess[str] ) -> None: run_subprocess: Final[MagicMock] = mocker.patch( "pipx.backends.uv.run_subprocess", autospec=True, return_value=outdated_process ) result: Final = UvBackend().list_outdated( venv_root=tmp_path, venv_python=tmp_path / "bin" / "python", index_args=["--index-url", "https://index.example/simple"], exclude=["click", "pip"], ) assert (result, run_subprocess.call_args.args[0]) == ( (OutdatedPackage(name="demo", version="1", latest_version="2"),), [ uv_binary, "pip", "list", "--python", str(tmp_path / "bin" / "python"), "--quiet", "--outdated", "--format=json", "--default-index", "https://index.example/simple", "--exclude=click", "--exclude=pip", ], ) @pytest.mark.parametrize( ("backend_type", "expected"), [ pytest.param(PipBackend, "--uploaded-prior-to", id="pip"), pytest.param(UvBackend, "--exclude-newer", id="uv"), ], ) def test_backend_cooldown_args( backend_type: type[PipBackend | UvBackend], expected: str, ) -> None: assert ( backend_type.cooldown_args(None), backend_type.cooldown_args(0), backend_type.cooldown_args(7), ) == ([], [], [expected, "P7D"]) @pytest.mark.parametrize( ("version_output", "reported"), [ pytest.param("pip 24.0 from /site-packages/pip (python 3.11)\n", "pip 24.0", id="before-lock-support"), pytest.param("", "pip of an unknown version", id="unknown"), ], ) def test_pip_backend_install_lock_refuses_pip_without_lock_support( version_output: str, reported: str, tmp_path: Path, mocker: MockerFixture ) -> None: run: Final[MagicMock] = mocker.patch( "pipx.backends.pip.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout=version_output, stderr=""), ) with pytest.raises(PipxError) as error: PipBackend().install_lock( venv_root=tmp_path, venv_python=tmp_path / "bin" / "python", lock_file=tmp_path / "pylock.toml", pip_args=[], verbose=False, ) assert ( f"needs pip>=26.1, but this environment runs {reported}." in " ".join(str(error.value).split()), run.call_count, ) == (True, 1) def test_pip_backend_install_lock_passes_lock_to_pip_with_lock_support(tmp_path: Path, mocker: MockerFixture) -> None: run: Final[MagicMock] = mocker.patch( "pipx.backends.pip.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout="pip 26.1 from /pip\n", stderr=""), ) PipBackend().install_lock( venv_root=tmp_path, venv_python=tmp_path / "bin" / "python", lock_file=tmp_path / "pylock.toml", pip_args=[], verbose=False, ) assert run.call_args.args[0] == [ str(tmp_path / "bin" / "python"), "-m", "pip", "--no-input", "install", "--requirement", str(tmp_path / "pylock.toml"), ] pipx-1.18.16/tests/test_backends_install.py000066400000000000000000000072241527355705200207430ustar00rootroot00000000000000from __future__ import annotations import shutil import sys from typing import TYPE_CHECKING, Final import pytest from helpers import run_pipx_cli from pipx import paths if TYPE_CHECKING: from pathlib import Path _REQUIRES_UV: Final[pytest.MarkDecorator] = pytest.mark.skipif( shutil.which("uv") is None, reason="uv binary not on PATH; skipping uv integration smoke" ) def test_install_pip_with_uv_backend_errors(capsys: pytest.CaptureFixture[str]) -> None: exit_code: Final[int] = run_pipx_cli(["install", "pip", "--backend", "uv"]) assert ( exit_code, "'pip' package cannot be installed or exposed via the uv backend" in capsys.readouterr().err, ) == (1, True) @_REQUIRES_UV @pytest.mark.usefixtures("pipx_temp_env") def test_uv_backend_install_index_url_beats_uv_default_index( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, pipx_local_pypiserver: str ) -> None: monkeypatch.setenv("UV_DEFAULT_INDEX", tmp_path.as_uri()) assert run_pipx_cli(["install", "pycowsay", "--backend", "uv", "--index-url", pipx_local_pypiserver]) == 0 @pytest.fixture def uv_installed_pycowsay( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing capsys: pytest.CaptureFixture[str], ) -> Path: exit_code: Final[int] = run_pipx_cli(["install", "pycowsay", "--backend", "uv", "--python", sys.executable]) assert exit_code == 0, capsys.readouterr().err return paths.CTX.venvs / "pycowsay" / "pipx_metadata.json" @_REQUIRES_UV def test_uv_backend_install_records_backend(uv_installed_pycowsay: Path) -> None: assert '"backend": "uv"' in uv_installed_pycowsay.read_text(encoding="utf-8") @_REQUIRES_UV @pytest.mark.usefixtures("uv_installed_pycowsay") def test_uv_backend_install_lists_package(capsys: pytest.CaptureFixture[str]) -> None: exit_code: Final[int] = run_pipx_cli(["list", "--short"]) assert (exit_code, "pycowsay" in capsys.readouterr().out) == (0, True) @_REQUIRES_UV def test_uv_backend_uninstall_removes_metadata(uv_installed_pycowsay: Path) -> None: assert (run_pipx_cli(["uninstall", "pycowsay"]), uv_installed_pycowsay.exists()) == (0, False) @_REQUIRES_UV @pytest.mark.usefixtures("pipx_temp_env") def test_uv_backend_force_install_existing() -> None: command: Final[list[str]] = ["install", "pycowsay", "--backend", "uv"] assert run_pipx_cli(command) == 0 assert run_pipx_cli([*command, "--force"]) == 0 @_REQUIRES_UV @pytest.mark.usefixtures("pipx_temp_env") def test_uv_backend_install_ignores_uv_toml_above_venv() -> None: # uv fails on a malformed config file it discovers, so success proves it never read the one above its cwd paths.CTX.venvs.mkdir(parents=True, exist_ok=True) (paths.CTX.venvs / "uv.toml").write_text("index-url = [\n") assert run_pipx_cli(["install", "pycowsay", "--backend", "uv"]) == 0 @_REQUIRES_UV @pytest.mark.usefixtures("pipx_temp_env") def test_uv_backend_install_reads_user_uv_toml( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: # uv fails on a malformed config file it reads, so the failure proves it still loads the user's own config user_config: Final[Path] = tmp_path / "xdg" / "uv" / "uv.toml" user_config.parent.mkdir(parents=True) user_config.write_text("index-url = [\n") # uv's user config directory is XDG_CONFIG_HOME on Unix and APPDATA on Windows for variable in ("XDG_CONFIG_HOME", "APPDATA"): monkeypatch.setenv(variable, str(user_config.parent.parent)) assert (run_pipx_cli(["install", "pycowsay", "--backend", "uv"]), str(user_config) in capsys.readouterr().err) == ( 1, True, ) pipx-1.18.16/tests/test_backends_pip.py000066400000000000000000000030311527355705200200550ustar00rootroot00000000000000from __future__ import annotations import stat import subprocess from functools import partial from itertools import groupby from typing import TYPE_CHECKING, Final import pytest from helpers import SKIP_IF_WINDOWS from pipx import paths from pipx.backends.pip import subprocess_post_check_handle_pip_error from pipx.fanout import fan_out if TYPE_CHECKING: from pathlib import Path def test_pip_error_logs_of_concurrent_failures_stay_whole(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(paths.CTX, "log_file", tmp_path / "cmd.log") tags: Final[str] = "ABCDEFGH" def fail(tag: str) -> None: output: Final[str] = f"{tag * 79}\n" * 2000 subprocess_post_check_handle_pip_error(subprocess.CompletedProcess([], 1, output, output), streamed=False) fan_out([partial(fail, tag) for tag in tags], sequential=False) lines: Final[list[str]] = (tmp_path / "cmd_pip_errors.log").read_text(encoding="utf-8").splitlines() blocks: Final[list[str]] = [tag for tag, _ in groupby(line[0] for line in lines if line[:1] in set(tags))] assert sorted(blocks) == sorted(tags) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("permissive_umask") def test_pip_error_log_is_owner_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(paths.CTX, "log_file", tmp_path / "cmd.log") subprocess_post_check_handle_pip_error(subprocess.CompletedProcess([], 1, "out", "err"), streamed=False) assert stat.S_IMODE((tmp_path / "cmd_pip_errors.log").stat().st_mode) == 0o600 pipx-1.18.16/tests/test_cache.py000066400000000000000000000044041527355705200165030ustar00rootroot00000000000000from __future__ import annotations import json from typing import TYPE_CHECKING, Final import pytest from helpers import run_pipx_cli from pipx import paths if TYPE_CHECKING: from _pytest.capture import CaptureResult @pytest.mark.usefixtures("pipx_temp_env") def test_cache_dir(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["cache", "dir"]) assert capsys.readouterr().out.strip() == str(paths.CTX.venv_cache) @pytest.mark.parametrize( ("count", "expected"), [ pytest.param(0, "Removed 0 cached environments.\n", id="empty"), pytest.param(1, "Removed 1 cached environment.\n", id="one"), pytest.param(2, "Removed 2 cached environments.\n", id="multiple"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_cache_purge_removes_run_environments( capsys: pytest.CaptureFixture[str], count: int, expected: str, ) -> None: paths.CTX.venv_cache.mkdir(parents=True) for index in range(count): (paths.CTX.venv_cache / f"cache-{index}").mkdir() assert not run_pipx_cli(["cache", "purge"]) assert ( capsys.readouterr().out, tuple(path for path in paths.CTX.venv_cache.iterdir() if path.is_dir()), ) == (expected, ()) @pytest.mark.parametrize("subcommand", [pytest.param("dir", id="dir"), pytest.param("purge", id="purge")]) @pytest.mark.usefixtures("pipx_temp_env") def test_cache_json_reports_the_directory( capsys: pytest.CaptureFixture[str], subcommand: str, ) -> None: assert not run_pipx_cli(["cache", subcommand, "--output", "json"]) assert json.loads(capsys.readouterr().out) == { "command": ["cache", subcommand], "data": {"directory": str(paths.CTX.venv_cache), "removed": []}, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", } @pytest.mark.parametrize("subcommand", [pytest.param("dir", id="dir"), pytest.param("purge", id="purge")]) @pytest.mark.usefixtures("pipx_temp_env") def test_cache_quiet_says_nothing( capsys: pytest.CaptureFixture[str], subcommand: str, ) -> None: assert not run_pipx_cli(["cache", subcommand, "--quiet"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err) == ("", "") pipx-1.18.16/tests/test_common.py000066400000000000000000000743351527355705200167420ustar00rootroot00000000000000from __future__ import annotations import os import subprocess import sys import zipfile from dataclasses import replace from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from helpers import SKIP_IF_WINDOWS, WIN, run_pipx_cli from pipx import paths from pipx.commands import common from pipx.commands.common import ( add_suffix, expose_resources_globally, get_exposed_paths_for_package, package_name_from_spec, remove_stale_venv_resources, reuse_resource_dir_scans, ) from pipx.constants import MACOS from pipx.options import DEFAULT_BACKEND_OPTIONS, BackendOptions from pipx.pipx_metadata_file import PipxMetadata from pipx.util import PipxError from pipx.venv import Venv if TYPE_CHECKING: from collections.abc import Callable from unittest.mock import MagicMock from _pytest.mark.structures import ParameterSet from pytest_mock import MockerFixture @SKIP_IF_WINDOWS def test_get_exposed_paths_ignores_recursive_symlink(tmp_path: Path) -> None: (venv_resource_path := tmp_path / "venv_bin").mkdir() (local_resource_dir := tmp_path / "bin").mkdir() (loop := local_resource_dir / "recursiveexample").symlink_to("recursiveexample") assert loop not in get_exposed_paths_for_package(venv_resource_path, local_resource_dir) @SKIP_IF_WINDOWS def test_expose_app_scripts_ignores_pythonpath(tmp_path: Path) -> None: (shadow_path := tmp_path / "shadow").mkdir() (app_path := tmp_path / "venv_bin" / "demo").parent.mkdir() app_path.write_text( f"#!{sys.executable}\n" "import sys\n" f"if {str(shadow_path)!r} in sys.path:\n" " raise SystemExit('PYTHONPATH leaked into app script')\n" "print('ok')\n" ) app_path.chmod(0o755) expose_resources_globally("app", tmp_path / "bin", [app_path], force=False) result: Final = subprocess.run( [app_path], check=True, capture_output=True, env={**os.environ, "PYTHONPATH": str(shadow_path)}, text=True, ) assert (app_path.read_text().splitlines()[0], result.stdout) == (f"#!{sys.executable} -E", "ok\n") @SKIP_IF_WINDOWS @pytest.mark.parametrize( ("overflow", "separator", "flag"), [ pytest.param(0, b"\n", b" -E", id="flag-fits"), pytest.param(1, b"\n", b"", id="flag-one-byte-over"), pytest.param(0, b"", b" -E", id="flag-fits-without-newline"), ], ) def test_expose_app_scripts_keep_shebang_within_kernel_limit( tmp_path: Path, overflow: int, separator: bytes, flag: bytes ) -> None: shebang: Final[bytes] = ( b"#!/" + b"x" * ((512 if MACOS else 127) + overflow - len(b"#!/python -E") - len(separator)) + b"python" ) (app_path := tmp_path / "demo").write_bytes(shebang + separator) expose_resources_globally("app", tmp_path / "bin", [app_path], force=False) assert app_path.read_bytes() == shebang + flag + separator @SKIP_IF_WINDOWS @pytest.mark.parametrize( "link", [pytest.param(Path.hardlink_to, id="hard-link"), pytest.param(Path.symlink_to, id="symlink")] ) def test_expose_app_scripts_leave_installer_cache_intact(tmp_path: Path, link: Callable[[Path, Path], None]) -> None: (cached_path := tmp_path / "cache" / "demo").parent.mkdir() cached_path.write_bytes(b"#!/usr/bin/python3\n") (app_path := tmp_path / "venv_bin" / "demo").parent.mkdir() link(app_path, cached_path) expose_resources_globally("app", tmp_path / "bin", [app_path], force=False) assert (cached_path.read_bytes(), app_path.read_bytes()) == (b"#!/usr/bin/python3\n", b"#!/usr/bin/python3 -E\n") @SKIP_IF_WINDOWS def test_expose_app_scripts_keep_mode_on_shebang_rewrite(tmp_path: Path) -> None: (app_path := tmp_path / "demo").write_bytes(b"#!/usr/bin/python3\n") app_path.chmod(0o750) expose_resources_globally("app", tmp_path / "bin", [app_path], force=False) assert (app_path.read_bytes(), app_path.stat().st_mode & 0o777) == (b"#!/usr/bin/python3 -E\n", 0o750) @pytest.mark.parametrize( ("resource_type", "resource", "suffix"), [ pytest.param("app", "bin/..", "", id="app-parent-directory"), pytest.param("man", "share/man/man1/..", "", id="man-parent-directory"), pytest.param("app", "bin/demo", "/../../state/demo", id="suffix-with-separator"), ], ) def test_expose_refuses_name_leading_out_of_resource_dir( tmp_path: Path, resource_type: str, resource: str, suffix: str ) -> None: (resource_path := tmp_path / "venv" / resource).parent.mkdir(parents=True) (local_resource_dir := tmp_path / "local" / "resources").mkdir(parents=True) (unrelated := tmp_path / "local" / "state" / "history").parent.mkdir() unrelated.touch() with pytest.raises(PipxError, match=r"^Refusing to expose"): expose_resources_globally(resource_type, local_resource_dir, [resource_path], force=True, suffix=suffix) assert (unrelated.exists(), list(local_resource_dir.iterdir()), (unrelated.parent / "demo").exists()) == ( True, [], False, ) @pytest.mark.parametrize( ("resource", "suffix", "module"), [ pytest.param("bin/enum.py", "", "enum", id="source-module"), pytest.param("bin/re.pyc", "", "re", id="bytecode-module"), pytest.param("bin/collections/__init__.py", "", "collections", id="package-directory"), pytest.param("bin/re", ".py", "re", id="suffix-makes-module"), pytest.param("bin/pipx.py", "", "pipx", id="pipx-module"), pytest.param("bin/pipx/__init__.py", "", "pipx", id="pipx-package"), ], ) def test_expose_refuses_app_named_like_shadowed_module( tmp_path: Path, caplog: pytest.LogCaptureFixture, resource: str, suffix: str, module: str ) -> None: (resource_path := tmp_path / "venv" / resource).parent.mkdir(parents=True) resource_path.write_text("") (tmp_path / "local").mkdir() app_path: Final[Path] = tmp_path / "venv" / "bin" / Path(resource).relative_to("bin").parts[0] expose_resources_globally("app", tmp_path / "local", [app_path], force=False, suffix=suffix) assert (list((tmp_path / "local").iterdir()), f"in place of the {module} module." in caplog.text) == ([], True) @pytest.mark.parametrize( ("resource_type", "resource", "source", "exposed"), [ pytest.param("app", "bin/enum", "bin/enum", "enum", id="app-without-module-suffix"), pytest.param("app", "bin/enum_tool.py", "bin/enum_tool.py", "enum_tool.py", id="app-not-a-stdlib-module"), pytest.param("app", "bin/tools/__init__.py", "bin/tools", "tools", id="directory-not-a-stdlib-module"), pytest.param("man", "share/man/man1/re.py", "share/man/man1/re.py", "man1/re.py", id="man-page"), ], ) def test_expose_keeps_names_that_shadow_no_stdlib_module( tmp_path: Path, resource_type: str, resource: str, source: str, exposed: str ) -> None: (resource_path := tmp_path / "venv" / resource).parent.mkdir(parents=True) resource_path.write_text("") expose_resources_globally(resource_type, tmp_path / "local", [tmp_path / "venv" / source], force=False) assert (tmp_path / "local" / exposed).exists() @SKIP_IF_WINDOWS def test_expose_force_keeps_directory_in_place_of_app(tmp_path: Path) -> None: (app_path := tmp_path / "venv" / "demo").parent.mkdir() app_path.write_text("#!/bin/sh\n") (kept := tmp_path / "bin" / "demo" / "keep").parent.mkdir(parents=True) kept.touch() assert (expose_resources_globally("app", tmp_path / "bin", [app_path], force=True), kept.exists()) == ( [kept.parent], True, ) @SKIP_IF_WINDOWS @pytest.mark.parametrize( ("resource_type", "resource", "exposed"), [ pytest.param("app", "bin/tool", "tool", id="app"), pytest.param("man", "share/man/man1/tool.1", "man1/tool.1", id="man"), pytest.param( "completion", "share/bash-completion/completions/tool", "bash-completion/completions/tool", id="completion" ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_expose_keeps_link_to_resource_another_venv_is_replacing( tmp_path: Path, resource_type: str, resource: str, exposed: str ) -> None: (paths.CTX.venvs / "holder").mkdir(parents=True) (path := paths.CTX.venvs / "other" / resource).parent.mkdir(parents=True) path.touch() (link := tmp_path / exposed).parent.mkdir(parents=True, exist_ok=True) link.symlink_to(held := paths.CTX.venvs / "holder" / resource) assert (expose_resources_globally(resource_type, tmp_path, [path], force=False), link.readlink()) == ([link], held) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_expose_force_replaces_link_to_resource_another_venv_is_replacing(tmp_path: Path) -> None: (paths.CTX.venvs / "holder").mkdir(parents=True) (path := paths.CTX.venvs / "other" / "bin" / "tool").parent.mkdir(parents=True) path.touch() (link := tmp_path / "tool").symlink_to(paths.CTX.venvs / "holder" / "bin" / "tool") assert (expose_resources_globally("app", tmp_path, [path], force=True), link.readlink()) == ([], path) _HELD_RESOURCES: Final[list[ParameterSet]] = [ pytest.param("app", "bin/tool", "tool", "app_paths", id="app"), pytest.param("man", "share/man/man1/tool.1", "man1/tool.1", "man_paths", id="man"), pytest.param( "completion", "share/bash-completion/completions/tool", "bash-completion/completions/tool", "completion_paths", id="completion", ), ] @SKIP_IF_WINDOWS @pytest.mark.parametrize(("resource_type", "resource", "exposed", "field"), _HELD_RESOURCES) @pytest.mark.usefixtures("pipx_temp_env") def test_expose_keeps_link_to_resource_its_venv_still_records( tmp_path: Path, resource_type: str, resource: str, exposed: str, field: str ) -> None: (held := paths.CTX.venvs / "holder" / resource).parent.mkdir(parents=True) _write_holder_metadata({field: [held]}) (path := paths.CTX.venvs / "other" / resource).parent.mkdir(parents=True) path.touch() (link := tmp_path / exposed).parent.mkdir(parents=True, exist_ok=True) link.symlink_to(held) assert (expose_resources_globally(resource_type, tmp_path, [path], force=False), link.readlink()) == ([link], held) def _write_holder_metadata(recorded: dict[str, list[Path]], *, injected: bool = False) -> None: metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "holder", read=False) main_package: Final = replace(metadata.main_package, package="holder", package_or_url="holder") if injected: metadata.main_package = main_package metadata.injected_packages = {"extra": replace(main_package, package="extra", **recorded)} else: metadata.main_package = replace(main_package, **recorded) metadata.write() @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_expose_keeps_link_to_app_an_injected_package_still_records(tmp_path: Path) -> None: (held := paths.CTX.venvs / "holder" / "bin" / "tool").parent.mkdir(parents=True) _write_holder_metadata({"app_paths": [held]}, injected=True) (path := paths.CTX.venvs / "other" / "bin" / "tool").parent.mkdir(parents=True) path.touch() (link := tmp_path / "tool").symlink_to(held) assert (expose_resources_globally("app", tmp_path, [path], force=False), link.readlink()) == ([link], held) @SKIP_IF_WINDOWS @pytest.mark.parametrize(("resource_type", "resource", "exposed", "field"), _HELD_RESOURCES) @pytest.mark.usefixtures("pipx_temp_env") def test_expose_replaces_link_to_resource_its_venv_dropped( tmp_path: Path, resource_type: str, resource: str, exposed: str, field: str ) -> None: (paths.CTX.venvs / "holder" / "bin").mkdir(parents=True) _write_holder_metadata({field: [paths.CTX.venvs / "holder" / "bin" / "holder"]}) (path := paths.CTX.venvs / "other" / resource).parent.mkdir(parents=True) path.touch() (link := tmp_path / exposed).parent.mkdir(parents=True, exist_ok=True) link.symlink_to(paths.CTX.venvs / "holder" / resource) assert (expose_resources_globally(resource_type, tmp_path, [path], force=False), link.readlink()) == ([], path) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_expose_warns_that_held_link_target_is_missing(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: (paths.CTX.venvs / "holder").mkdir(parents=True) (path := paths.CTX.venvs / "other" / "bin" / "tool").parent.mkdir(parents=True) path.touch() (tmp_path / "tool").symlink_to(paths.CTX.venvs / "holder" / "bin" / "tool") expose_resources_globally("app", tmp_path, [path], force=False) assert "which is missing" in " ".join(caplog.text.split()) @SKIP_IF_WINDOWS @pytest.mark.parametrize( "target", [ pytest.param("venvs/removed/bin/tool", id="removed-venv"), pytest.param("venvs/other/bin/renamed", id="own-venv"), pytest.param("venvs/removed", id="venv-dir"), pytest.param("trash/tool", id="outside-venvs"), pytest.param("../../tool", id="loop"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_expose_replaces_left_over_link(tmp_path: Path, target: str) -> None: (path := paths.CTX.venvs / "other" / "bin" / "tool").parent.mkdir(parents=True) path.touch() (link := tmp_path / "tool").symlink_to(paths.CTX.venvs.parent / target) assert (expose_resources_globally("app", tmp_path, [path], force=False), link.readlink()) == ([], path) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_remove_stale_venv_resources_keeps_files_pipx_does_not_own(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["install", "pycowsay"]) == 0 capsys.readouterr() venv: Final[Venv] = Venv(paths.CTX.venvs / "pycowsay") (owned := paths.CTX.bin_dir / "stale-owned").symlink_to(venv.pipx_metadata.main_package.app_paths[0]) (replaced := paths.CTX.bin_dir / "stale-replaced").write_text("belongs to the user") remove_stale_venv_resources({owned, replaced}, venv, paths.CTX.bin_dir, paths.CTX.man_dir) assert (owned.exists(), replaced.read_text()) == (False, "belongs to the user") # pipx parses a binary it did not create as if it were a launcher, so the bytes trailing a stray "#!" reach # os.fsdecode and stat as an interpreter path. Windows rejects the undecodable one, every platform the NUL. @pytest.mark.parametrize( "payload", [ pytest.param(b"MZ\x90\x00\x03#!\xff\xfe\x80\x81 more binary\n", id="undecodable-interpreter"), pytest.param(b"MZ\x90\x00\x03#!/opt/no\x00pe/bin/python\n", id="nul-in-interpreter"), ], ) def test_get_exposed_paths_ignores_foreign_binary(tmp_path: Path, mocker: MockerFixture, payload: bytes) -> None: # Only copy-based environments read a shebang to establish ownership, so without this the symlink branch # short-circuits the scan and the parse under test never runs off Windows. mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) (venv_resource_path := tmp_path / "venv_bin").mkdir() (local_resource_dir := tmp_path / "bin").mkdir() (foreign := local_resource_dir / "foreign.exe").write_bytes(payload) assert foreign not in get_exposed_paths_for_package(venv_resource_path, local_resource_dir) @pytest.mark.parametrize( "owned", [pytest.param(True, id="shebang-into-venv"), pytest.param(False, id="shebang-elsewhere")], ) def test_get_exposed_paths_matches_copied_launcher(tmp_path: Path, mocker: MockerFixture, owned: bool) -> None: mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) (venv_resource_path := tmp_path / "venv_bin").mkdir() (local_resource_dir := tmp_path / "bin").mkdir() (elsewhere := tmp_path / "other").mkdir() (launcher := local_resource_dir / "myapp").write_text( f"#!{(venv_resource_path if owned else elsewhere) / 'python'}\n" ) assert (launcher in get_exposed_paths_for_package(venv_resource_path, local_resource_dir)) is owned @SKIP_IF_WINDOWS def test_get_exposed_paths_matches_venv_reached_through_symlinked_dir(tmp_path: Path) -> None: (venv_resource_path := tmp_path / "venv_bin").mkdir() (alias := tmp_path / "alias").symlink_to(venv_resource_path) (local_resource_dir := tmp_path / "bin").mkdir() (exposed_app := local_resource_dir / "myapp").symlink_to(venv_resource_path / "myapp") assert get_exposed_paths_for_package(alias, local_resource_dir) == {exposed_app} @SKIP_IF_WINDOWS def test_get_exposed_paths_ignores_symlink_into_removed_dir(tmp_path: Path) -> None: (venv_resource_path := tmp_path / "venv_bin").mkdir() (local_resource_dir := tmp_path / "bin").mkdir() (local_resource_dir / "myapp").symlink_to(tmp_path / "removed" / "myapp") assert get_exposed_paths_for_package(venv_resource_path, local_resource_dir) == set() def test_get_exposed_paths_matches_copied_launcher_with_quoted_interpreter( tmp_path: Path, mocker: MockerFixture ) -> None: mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) (venv_resource_path := tmp_path / "venv bin").mkdir() (local_resource_dir := tmp_path / "bin").mkdir() (launcher := local_resource_dir / "myapp").write_text(f'#!"{venv_resource_path / "python"}" -E\n') assert get_exposed_paths_for_package(venv_resource_path, local_resource_dir) == {launcher} # pip and uv start a launcher this way when the interpreter path is too long for a shebang or holds a space @pytest.mark.parametrize( ("exec_line", "owned"), [ pytest.param( "'''exec' {python} \"$0\" \"$@\"", True, id="pip-unquoted", # sh reads a backslash outside quotes as an escape, so an unquoted Windows path cannot round-trip marks=pytest.mark.skipif(WIN, reason="pip and uv write the sh trampoline only on POSIX"), ), pytest.param('\'\'\'exec\' "{python}" "$0" "$@"', True, id="pip-double-quoted"), pytest.param("'''exec' '{python}' \"$0\" \"$@\"", True, id="uv-single-quoted"), pytest.param("'''exec' '{python}", False, id="unbalanced-quote"), pytest.param("'''exec' ", False, id="no-interpreter"), ], ) def test_get_exposed_paths_matches_copied_launcher_with_sh_trampoline( tmp_path: Path, mocker: MockerFixture, exec_line: str, owned: bool ) -> None: mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) (venv_resource_path := tmp_path / "venv_bin").mkdir() (local_resource_dir := tmp_path / "bin").mkdir() (launcher := local_resource_dir / "myapp").write_text( f"#!/bin/sh\n{exec_line.format(python=venv_resource_path / 'python')}\n' '''\n" ) assert (launcher in get_exposed_paths_for_package(venv_resource_path, local_resource_dir)) is owned def test_get_exposed_paths_ignores_unreadable_copy(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) (venv_resource_path := tmp_path / "venv_bin").mkdir() (local_resource_dir := tmp_path / "bin").mkdir() (local_resource_dir / "myapp").mkdir() assert get_exposed_paths_for_package(venv_resource_path, local_resource_dir) == set() def test_get_exposed_paths_matches_copy_by_recorded_source(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) (source := tmp_path / "source" / "myapp.1").parent.mkdir() source.write_text("page") (local_resource_dir := tmp_path / "man1").mkdir() (copy := local_resource_dir / "myapp.1").write_text("page") assert get_exposed_paths_for_package(tmp_path / "missing", local_resource_dir, {"myapp.1": [source]}) == {copy} def test_reuse_resource_dir_scans_skips_copy_removed_after_scan(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) (venv_resource_path := tmp_path / "venv_man1").mkdir() (venv_resource_path / "myapp.1").write_text("page") (local_resource_dir := tmp_path / "man1").mkdir() (copy := local_resource_dir / "myapp.1").write_text("page") with reuse_resource_dir_scans(): get_exposed_paths_for_package(tmp_path / "other_venv_man1", local_resource_dir) copy.unlink() exposed: Final[set[Path]] = get_exposed_paths_for_package(venv_resource_path, local_resource_dir) assert exposed == set() @pytest.mark.parametrize( ("name", "windows", "expected"), [ pytest.param("pycowsay", False, "pycowsay@1", id="posix-plain"), pytest.param("pip3.14", False, "pip3.14@1", id="posix-dotted"), pytest.param("tool.exe", False, "tool.exe@1", id="posix-exe-is-part-of-name"), pytest.param("pip3.14.exe", True, "pip3.14@1.exe", id="windows-exe"), pytest.param("tool.CMD", True, "tool@1.CMD", id="windows-cmd-any-case"), pytest.param("pip3.14", True, "pip3.14@1", id="windows-dotted-without-extension"), pytest.param("hello.py", True, "hello@1.py", id="windows-script-extension"), pytest.param("hello.ps1", True, "hello@1.ps1", id="windows-extension-outside-pathext"), pytest.param("hello.py", False, "hello.py@1", id="posix-script-extension"), ], ) def test_add_suffix(mocker: MockerFixture, name: str, windows: bool, expected: str) -> None: mocker.patch.object(common, "WINDOWS", windows) assert add_suffix(name, "@1") == expected _NAME_BY_BACKEND: Final[list[ParameterSet]] = [ pytest.param("pip", None, "", "Foo_Bar.X", id="pip-keeps-declared-name"), pytest.param("pip", None, "[extra]", "Foo_Bar.X", id="pip-with-extras"), pytest.param("uv", None, "[extra]", "foo-bar-x", id="uv-normalizes-name"), pytest.param(None, "uv", "", "foo-bar-x", id="uv-from-environment"), ] @pytest.mark.parametrize(("backend", "env_backend", "extras", "expected"), _NAME_BY_BACKEND) def test_package_name_from_spec_reads_static_name_without_building( tmp_path: Path, backend: str | None, env_backend: str | None, extras: str, expected: str ) -> None: # the build requirement does not exist, so any attempt to build the project would fail (tmp_path / "pyproject.toml").write_text( '[build-system]\nrequires = ["no-such-build-backend"]\nbuild-backend = "hatchling.build"\n' '[project]\nname = "Foo_Bar.X"\n', encoding="utf-8", ) assert ( package_name_from_spec( f"{tmp_path}{extras}", sys.executable, pip_args=[], verbose=False, backend=BackendOptions(cli=backend, env=env_backend), ) == expected ) @pytest.mark.parametrize(("backend", "env_backend", "extras", "expected"), _NAME_BY_BACKEND) def test_package_name_from_spec_reads_wheel_metadata_without_installing( make_wheel: Callable[[dict[str, str]], Path], backend: str | None, env_backend: str | None, extras: str, expected: str, ) -> None: # without a WHEEL file no installer accepts the archive, so installing it to learn the name would fail wheel: Final[Path] = make_wheel({"foo_bar_x-1.dist-info/METADATA": "Name: Foo_Bar.X\n"}) assert ( package_name_from_spec( f"{wheel}{extras}", sys.executable, pip_args=[], verbose=False, backend=BackendOptions(cli=backend, env=env_backend), ) == expected ) @SKIP_IF_WINDOWS @pytest.mark.parametrize( "package_spec", [ pytest.param("https://files.example/acme_tool-1.0-py3-none-any.whl", id="wheel-url"), pytest.param("acme-tool @ https://files.example/acme_tool-1.0-py3-none-any.whl", id="pep508-wheel-url"), pytest.param("git+https://git.example/acme-tool.git", id="vcs-url"), ], ) @pytest.mark.usefixtures("lookalikes_below_cwd", "probe_venv") def test_package_name_from_spec_ignores_files_below_cwd_named_like_url(package_spec: str) -> None: assert ( package_name_from_spec( package_spec, sys.executable, pip_args=[], verbose=False, backend=DEFAULT_BACKEND_OPTIONS ) == "acme-tool" ) @pytest.fixture def lookalikes_below_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # Path() collapses the URL's // so each file sits at the relative path one of the URLs below spells monkeypatch.chdir(tmp_path) for url_prefix in ("https:/files.example", "acme-tool @ https:/files.example"): (directory := tmp_path / url_prefix).mkdir(parents=True) with zipfile.ZipFile(directory / "acme_tool-1.0-py3-none-any.whl", "w") as archive: archive.writestr("x-0.dist-info/METADATA", "Name: other-app\n") (project := tmp_path / "git+https:/git.example/acme-tool.git").mkdir(parents=True) (project / "pyproject.toml").write_text( '[build-system]\nrequires = ["hatchling"]\nbuild-backend = "hatchling.build"\n[project]\nname = "other-app"\n', encoding="utf-8", ) @pytest.fixture def probe_venv(mocker: MockerFixture) -> MagicMock: venv: Final[MagicMock] = mocker.patch.object(common, "Venv", autospec=True) # Python 3.11's create_autospec drops a return value passed as a keyword for a child method venv.return_value.install_package_no_deps.return_value = "acme-tool" return venv def test_package_name_from_spec_creates_probe_venv_with_pip_args(probe_venv: MagicMock) -> None: package_name_from_spec( "https://files.example/acme_tool-1.0-py3-none-any.whl", sys.executable, pip_args=["--index-url", "https://mirror.example/simple"], verbose=False, backend=DEFAULT_BACKEND_OPTIONS, ) probe_venv.return_value.create_venv.assert_called_once_with( venv_args=[], pip_args=["--index-url", "https://mirror.example/simple"] ) @pytest.mark.usefixtures("pipx_temp_env") def test_package_name_from_spec_builds_setup_py_project(local_extras_project: Path) -> None: assert ( package_name_from_spec( f"{local_extras_project}[cow]", sys.executable, pip_args=[], verbose=False, backend=DEFAULT_BACKEND_OPTIONS ) == "repeatme" ) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("app_named_before_last_dot") def test_list_accepts_app_named_before_last_dot(capsys: pytest.CaptureFixture[str]) -> None: capsys.readouterr() assert not run_pipx_cli(["list"]) assert "unexpected location" not in capsys.readouterr().out @pytest.fixture def app_named_before_last_dot( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing dotted_app_wheel: Path, ) -> None: assert not run_pipx_cli(["install", "--suffix=@1", str(dotted_app_wheel)]) # the name pipx 1.17.14 gave the app, with the suffix before the last dot (paths.CTX.bin_dir / "tool.12@1").rename(paths.CTX.bin_dir / "tool@1.12") @SKIP_IF_WINDOWS @pytest.mark.usefixtures("app_named_before_last_dot") def test_expose_warns_when_renaming_app_named_before_last_dot(caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["upgrade", "foo-bar-x@1"]) assert "Renamed tool@1.12 to tool.12@1" in caplog.text @pytest.mark.parametrize( "command", [ pytest.param(["uninstall", "foo-bar-x@1"], id="uninstall"), pytest.param(["uninstall-all"], id="uninstall-all"), pytest.param(["reinstall-all"], id="reinstall-all"), pytest.param(["upgrade-all"], id="upgrade-all"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_copied_app_named_before_last_dot_is_removed( mocker: MockerFixture, dotted_app_wheel: Path, command: list[str] ) -> None: mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) assert not run_pipx_cli(["install", "--suffix=@1", str(dotted_app_wheel)]) legacy: Final[Path] = (paths.CTX.bin_dir / "tool.12@1").rename(paths.CTX.bin_dir / "tool@1.12") assert not run_pipx_cli(command) assert not legacy.exists() @pytest.mark.parametrize( "command", [ pytest.param(["uninstall", "foo-bar-x@2"], id="uninstall"), pytest.param(["reinstall", "foo-bar-x@2"], id="reinstall"), pytest.param(["unexpose", "foo-bar-x@2"], id="unexpose"), ], ) def test_copied_static_app_of_unsuffixed_venv_survives_suffixed_removal( static_scripts_wheel: Path, command: list[str] ) -> None: assert not run_pipx_cli(["install", str(static_scripts_wheel)]) assert not run_pipx_cli(["install", "--suffix=@2", str(static_scripts_wheel)]) assert not run_pipx_cli(command) assert (paths.CTX.bin_dir / "run.cmd").read_text(encoding="utf-8") == "echo static\n" @pytest.fixture def static_scripts_wheel( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing mocker: MockerFixture, make_wheel: Callable[[dict[str, str]], Path], ) -> Path: # scripts without a shebang copy byte for byte, so nothing in a copy says which venv exposed it mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) return make_wheel({ "foo_bar_x-1.data/scripts/run": "echo static\n", "foo_bar_x-1.data/scripts/run.2": "echo static\n", "foo_bar_x-1.data/scripts/run.cmd": "echo static\n", "foo_bar_x-1.dist-info/METADATA": "Metadata-Version: 2.1\nName: foo-bar-x\nVersion: 1\n", "foo_bar_x-1.dist-info/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", "foo_bar_x-1.dist-info/RECORD": "", }) def test_copied_static_app_of_unsuffixed_venv_survives_suffixed_uninject( static_scripts_wheel: Path, empty_project_wheel: Path ) -> None: assert not run_pipx_cli(["install", str(static_scripts_wheel)]) assert not run_pipx_cli(["install", "--suffix=@2", str(empty_project_wheel)]) assert not run_pipx_cli(["inject", "empty-project@2", str(static_scripts_wheel), "--include-apps", "--with-suffix"]) assert not run_pipx_cli(["uninject", "empty-project@2", "foo-bar-x"]) assert (paths.CTX.bin_dir / "run.cmd").read_text(encoding="utf-8") == "echo static\n" @pytest.mark.parametrize( "command", [ pytest.param(["uninstall", "foo-bar-xb"], id="uninstall"), pytest.param(["reinstall", "foo-bar-xb"], id="reinstall"), ], ) def test_copied_static_app_named_like_another_venvs_legacy_name_survives( static_scripts_wheel: Path, command: list[str] ) -> None: # runb.2 is both the name pipx 1.17.14 gave run.2 under suffix b and the name run gets under suffix b.2 assert not run_pipx_cli(["install", "--suffix=b", str(static_scripts_wheel)]) assert not run_pipx_cli(["install", "--suffix=b.2", str(static_scripts_wheel)]) assert not run_pipx_cli(command) assert (paths.CTX.bin_dir / "runb.2").read_text(encoding="utf-8") == "echo static\n" pipx-1.18.16/tests/test_completions.py000066400000000000000000000022441527355705200177740ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Final from helpers import run_pipx_cli from pipx.main import InstalledVenvsCompleter from pipx.venv import VenvContainer if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock import pytest from pytest_mock import MockerFixture def test_cli(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["completions"]) assert "Add the command for your shell" in capsys.readouterr().out def test_installed_venvs_noncanonical_prefix(tmp_path: Path) -> None: (tmp_path / "my-pkg").mkdir() assert InstalledVenvsCompleter(VenvContainer(tmp_path)).use("my__p") == ["my__pkg"] def test_installed_venvs_defers_listing(tmp_path: Path, mocker: MockerFixture) -> None: venv_container: Final[VenvContainer] = VenvContainer(tmp_path) iter_venv_dirs: Final[MagicMock] = mocker.spy(venv_container, "iter_venv_dirs") completer: Final[InstalledVenvsCompleter] = InstalledVenvsCompleter(venv_container) iter_venv_dirs.assert_not_called() assert (completer.use(""), completer.use("")) == ([], []) iter_venv_dirs.assert_called_once_with() pipx-1.18.16/tests/test_completions_expose.py000066400000000000000000000110571527355705200213610ustar00rootroot00000000000000from __future__ import annotations import shutil import sys from typing import TYPE_CHECKING, Final import pytest from flit_core.wheel import make_wheel_in from helpers import run_pipx_cli from pipx import paths from pipx.pipx_metadata_file import PipxMetadata if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path _SECTIONS: Final[dict[str, str]] = { "bash-completion/completions": "local-completion", "zsh/site-functions": "_local-completion", "fish/vendor_completions.d": "local-completion.fish", } @pytest.mark.parametrize( "backend", [ pytest.param("pip", id="pip"), pytest.param( "uv", id="uv", marks=pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_selects_completion_only_dependency( copy_local_package: Callable[[str], Path], make_project_with_dependency: Callable[[str], Path], tmp_path: Path, backend: str, ) -> None: pyproject: Final[Path] = copy_local_package("local_completion") / "pyproject.toml" pyproject.write_text( pyproject.read_text(encoding="utf-8").replace('scripts.local-completion = "local_completion.main:main"\n', ""), encoding="utf-8", ) dependency: Final[Path] = make_wheel_in(pyproject, tmp_path).file parent: Final[Path] = make_project_with_dependency(f"local-completion @ {dependency.as_uri()}") assert not run_pipx_cli([ "install", str(make_wheel_in(parent / "pyproject.toml", tmp_path).file), "--backend", backend, "--include-resources-from", "Local_Completion", ]) metadata: Final = PipxMetadata(paths.CTX.venvs / "empty-project").main_package assert ( metadata.include_resources_from, bool(metadata.app_paths_of_dependencies), bool(metadata.man_paths_of_dependencies), [(paths.CTX.completion_dir / section / name).is_file() for section, name in _SECTIONS.items()], ) == (["local-completion"], False, False, [True] * len(_SECTIONS)) @pytest.mark.parametrize("section", list(_SECTIONS), ids=["bash", "zsh", "fish"]) @pytest.mark.usefixtures("pipx_temp_env") def test_install_exposes_the_completion_script( completion_wheel: str, section: str, ) -> None: assert not run_pipx_cli(["install", completion_wheel]) exposed: Final[Path] = paths.CTX.completion_dir / section / _SECTIONS[section] assert exposed.is_symlink() or exposed.is_file() @pytest.fixture def completion_wheel(local_completion_wheel: Path) -> str: return str(local_completion_wheel) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_removes_the_completion_scripts(completion_wheel: str) -> None: assert not run_pipx_cli(["install", completion_wheel]) assert not run_pipx_cli(["uninstall", "local-completion"]) assert not list(paths.CTX.completion_dir.rglob("*local-completion*")) @pytest.mark.usefixtures("pipx_temp_env") def test_unexpose_removes_the_completion_scripts(completion_wheel: str) -> None: assert not run_pipx_cli(["install", completion_wheel]) assert not run_pipx_cli(["unexpose", "local-completion"]) assert not list(paths.CTX.completion_dir.rglob("*local-completion*")) @pytest.mark.usefixtures("pipx_temp_env") def test_expose_restores_the_completion_scripts(completion_wheel: str) -> None: assert not run_pipx_cli(["install", completion_wheel]) assert not run_pipx_cli(["unexpose", "local-completion"]) assert not run_pipx_cli(["expose", "local-completion"]) assert sorted(path.name for path in paths.CTX.completion_dir.rglob("*local-completion*")) == sorted( _SECTIONS.values() ) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_removes_a_stale_completion_link(completion_wheel: str) -> None: assert not run_pipx_cli(["install", completion_wheel]) source: Final[Path] = paths.CTX.venvs / "local-completion" / "share" / "bash-completion" / "completions" / "orphan" source.write_text("# orphan completion\n", encoding="utf-8") (orphan := paths.CTX.completion_dir / "bash-completion" / "completions" / "orphan").symlink_to(source) assert not run_pipx_cli(["reinstall", "--python", sys.executable, "local-completion"]) assert not orphan.exists() @pytest.mark.usefixtures("pipx_temp_env") def test_environment_reports_the_completion_dir( capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["environment", "--value", "PIPX_COMPLETION_DIR"]) assert capsys.readouterr().out.strip() == str(paths.CTX.completion_dir) pipx-1.18.16/tests/test_docs_urls.py000066400000000000000000000022001527355705200174250ustar00rootroot00000000000000from __future__ import annotations import re from typing import TYPE_CHECKING, Final if TYPE_CHECKING: from pathlib import Path def test_documentation_links_do_not_use_mkdocs_page_urls(root: Path) -> None: # MkDocs published every page as a directory, so links to one ended in a slash. Sphinx publishes .html and # Read the Docs serves the built tree as is, so the old shape returns 404. The terminator set covers a bare URL, a # markdown link and a quoted value, because the Release Notes link lives in a TOML string. mkdocs_page_url: Final[re.Pattern[str]] = re.compile( r"""https://pipx\.pypa\.io/(?:latest|stable)/[^\s<>)\]"']*/(?=[\s<>)\]"']|$)""" ) stale: Final[list[str]] = [ f"{path.relative_to(root).as_posix()}: {url}" # pyproject.toml's urls table feeds the PyPI sidebar, where a user reported the stale Release Notes link for glob in ("*.md", "*.rst", "pyproject.toml", ".github/*.md", "docs/**/*.md", "docs/**/*.rst") for path in sorted(root.glob(glob)) for url in mkdocs_page_url.findall(path.read_text(encoding="utf-8")) ] assert stale == [] pipx-1.18.16/tests/test_emojis.py000066400000000000000000000057121527355705200167310ustar00rootroot00000000000000from __future__ import annotations import subprocess import sys from io import BytesIO, TextIOWrapper from typing import TYPE_CHECKING import pytest from pipx.emojis import use_emojis if TYPE_CHECKING: from pytest_mock import MockerFixture @pytest.mark.parametrize( "stderr_expression", [ pytest.param("None", id="missing-stream"), pytest.param("io.StringIO()", id="missing-encoding"), ], ) def test_import_without_stderr_encoding(stderr_expression: str) -> None: process = subprocess.run( [sys.executable, "-c", f"import io, sys; sys.stderr = {stderr_expression}; import pipx.emojis"], capture_output=True, check=False, text=True, ) assert process.returncode == 0, process.stderr @pytest.mark.parametrize( ("environment", "expected"), [ pytest.param({}, False, id="no-override"), pytest.param({"USE_EMOJI": "1"}, True, id="legacy-override"), ], ) def test_use_emojis_without_stderr( no_emoji_override: pytest.MonkeyPatch, mocker: MockerFixture, environment: dict[str, str], expected: bool ) -> None: mocker.patch.object(sys, "stderr", None) for name, value in environment.items(): no_emoji_override.setenv(name, value) assert use_emojis() is expected @pytest.fixture def no_emoji_override(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch: monkeypatch.delenv("PIPX_USE_EMOJI", raising=False) monkeypatch.delenv("USE_EMOJI", raising=False) return monkeypatch @pytest.mark.parametrize( ("encoding", "expected"), [ pytest.param("utf-8", True, id="utf-8"), pytest.param("latin_1", False, id="latin-1"), pytest.param("cp1252", False, id="cp1252"), ], ) @pytest.mark.usefixtures("no_emoji_override") def test_use_emojis_follows_stderr_encoding(mocker: MockerFixture, encoding: str, expected: bool) -> None: mocker.patch.object(sys, "stderr", TextIOWrapper(BytesIO(), encoding=encoding)) assert use_emojis() is expected @pytest.mark.parametrize( "encoding", [ pytest.param("utf-8", id="utf-8"), pytest.param("latin_1", id="latin-1"), pytest.param("cp1252", id="cp1252"), ], ) @pytest.mark.parametrize( ("env_value", "expected"), [ pytest.param("", False, id="empty"), pytest.param("0", False, id="zero"), pytest.param("1", True, id="one"), pytest.param("true", True, id="true"), pytest.param("tru", False, id="misspelled"), # codespell:ignore tru pytest.param("True", True, id="capitalized"), pytest.param("false", False, id="false"), ], ) def test_use_emojis_override_ignores_stderr_encoding( no_emoji_override: pytest.MonkeyPatch, mocker: MockerFixture, encoding: str, env_value: str, expected: bool, ) -> None: mocker.patch.object(sys, "stderr", TextIOWrapper(BytesIO(), encoding=encoding)) no_emoji_override.setenv("PIPX_USE_EMOJI", env_value) assert use_emojis() is expected pipx-1.18.16/tests/test_ensurepath.py000066400000000000000000000275721527355705200176310ustar00rootroot00000000000000from __future__ import annotations import os import stat from pathlib import Path from typing import TYPE_CHECKING, Final import pytest import userpath from helpers import run_pipx_cli from pipx import paths from pipx.commands import ensure_path as ensure_path_module from pipx.constants import EXIT_CODE_OK, WINDOWS if TYPE_CHECKING: from unittest.mock import MagicMock from pytest_mock import MockerFixture _SKIP_GLOBAL_ON_WINDOWS: Final = pytest.mark.skipif(WINDOWS, reason="System-wide ensurepath is unavailable on Windows") @pytest.fixture def mock_userpath(mocker: MockerFixture) -> MagicMock: # Python 3.11 ignores dotted-key return values passed to create_autospec for module children. mocked: Final = mocker.create_autospec(userpath) mocked.need_shell_restart.return_value = False mocked.in_current_path.return_value = False mocked.append.return_value = True mocked.prepend.return_value = True mocker.patch.object(ensure_path_module, "userpath", mocked) return mocked def test_ensure_path_dry_run_does_not_append(mock_userpath: MagicMock, capsys: pytest.CaptureFixture[str]) -> None: location: Final[Path] = Path("/some/bin") path_added, _ = ensure_path_module.ensure_path(location, force=True, prepend=False, all_shells=False, dry_run=True) assert (path_added, mock_userpath.append.called, mock_userpath.prepend.called, capsys.readouterr().out) == ( False, False, False, f"Would append {location} to the PATH environment variable.\n", ) def test_ensure_path_dry_run_reports_prepend(mock_userpath: MagicMock, capsys: pytest.CaptureFixture[str]) -> None: ensure_path_module.ensure_path(Path("/some/bin"), force=True, prepend=True, all_shells=False, dry_run=True) assert (mock_userpath.prepend.called, "Would prepend" in capsys.readouterr().out) == (False, True) def test_ensure_path_non_dry_run_still_appends( mock_userpath: MagicMock, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] ) -> None: location: Final[Path] = Path("/some/bin") ensure_path_module.ensure_path(location, force=True, prepend=False, all_shells=False, dry_run=False) assert (mock_userpath.append.call_args_list, "Success!" in capsys.readouterr().out) == ( [mocker.call(str(location), "pipx", all_shells=False)], True, ) def test_ensure_pipx_paths_dry_run_footer(mock_userpath: MagicMock, capsys: pytest.CaptureFixture[str]) -> None: exit_code: Final = ensure_path_module.ensure_pipx_paths( force=True, prepend=False, all_shells=False, dry_run=True, is_global=False ) out: Final[str] = capsys.readouterr().out.lower() assert (exit_code, mock_userpath.append.called, "dry run" in out, "no changes were made" in out) == ( EXIT_CODE_OK, False, True, True, ) @pytest.mark.parametrize("prepend", [pytest.param(False, id="append"), pytest.param(True, id="prepend")]) @pytest.mark.parametrize("in_path", [pytest.param(True, id="already-present"), pytest.param(False, id="failed-add")]) @pytest.mark.usefixtures("pipx_temp_env") def test_ensure_pipx_paths_reports_existing_paths( mock_userpath: MagicMock, capsys: pytest.CaptureFixture[str], *, prepend: bool, in_path: bool ) -> None: mock_userpath.in_current_path.return_value = in_path mock_userpath.append.return_value = False mock_userpath.prepend.return_value = False assert run_pipx_cli(["ensurepath", *["--prepend"] * prepend]) == EXIT_CODE_OK captured: Final = capsys.readouterr() assert ( "is already in PATH" in " ".join(captured.out.split()), "are already in PATH" in " ".join(captured.err.split()), mock_userpath.append.called, mock_userpath.prepend.called, ) == (in_path, in_path, not in_path and not prepend, not in_path and prepend) @pytest.mark.parametrize( ("macos", "prepend", "expected"), [ pytest.param( False, False, 'case ":$PATH:" in *:/opt/pipx/bin:*) ;; *) export PATH="$PATH":/opt/pipx/bin ;; esac\n', id="linux-append", ), pytest.param( False, True, 'case ":$PATH:" in *:/opt/pipx/bin:*) ;; *) export PATH=/opt/pipx/bin:"$PATH" ;; esac\n', id="linux-prepend", ), pytest.param(True, False, "/opt/pipx/bin\n", id="macos"), ], ) @_SKIP_GLOBAL_ON_WINDOWS def test_ensure_pipx_paths_global_updates_system_profile( mocker: MockerFixture, tmp_path: Path, macos: bool, prepend: bool, expected: str ) -> None: (config_file := tmp_path / "etc/profile.d/pipx.sh").parent.mkdir(parents=True) mocker.patch.object(type(paths.CTX), "bin_dir", mocker.PropertyMock(return_value=Path("/opt/pipx/bin"))) mocker.patch.object(ensure_path_module, "_GLOBAL_PATH_FILE", config_file) mocker.patch.object(ensure_path_module, "MACOS", macos) ensure_path_module.ensure_pipx_paths(force=False, prepend=prepend, all_shells=False, dry_run=False, is_global=True) assert config_file.read_text() == expected @pytest.mark.parametrize( ("macos", "existing"), [ pytest.param(False, "# site settings\nexport PIPX_GLOBAL_HOME=/srv/pipx\n", id="linux-foreign"), pytest.param( False, 'case ":$PATH:" in *:/site/bin:*) ;; *) export PATH="$PATH":/site/bin:/site/sbin ;; esac\n', id="linux-similar-foreign-line", ), pytest.param(True, "/site/bin\n/other/bin\n", id="macos-foreign"), ], ) @_SKIP_GLOBAL_ON_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_ensurepath_cli_global_keeps_foreign_file( mocker: MockerFixture, tmp_path: Path, capsys: pytest.CaptureFixture[str], macos: bool, existing: str ) -> None: (config_file := tmp_path / "pipx.sh").write_text(existing, encoding="utf-8") config_file.chmod(0o640) mocker.patch.object(ensure_path_module, "_GLOBAL_PATH_FILE", config_file) mocker.patch.object(ensure_path_module, "MACOS", macos) assert ( run_pipx_cli(["ensurepath", "--global"]), config_file.read_text(encoding="utf-8"), stat.S_IMODE(config_file.stat().st_mode), "pass --force to replace the file" in " ".join(capsys.readouterr().err.split()), ) == (1, existing, 0o640, True) @pytest.fixture(params=[pytest.param(False, id="linux"), pytest.param(True, id="macos")]) def undecodable_config_file(request: pytest.FixtureRequest, mocker: MockerFixture, tmp_path: Path) -> Path: (config_file := tmp_path / "pipx.sh").write_bytes(b"/site/\xff\n") mocker.patch.object(ensure_path_module, "_GLOBAL_PATH_FILE", config_file) mocker.patch.object(ensure_path_module, "MACOS", request.param) return config_file @_SKIP_GLOBAL_ON_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_ensurepath_cli_global_keeps_undecodable_file( undecodable_config_file: Path, capsys: pytest.CaptureFixture[str] ) -> None: assert ( run_pipx_cli(["ensurepath", "--global"]), undecodable_config_file.read_bytes(), "pass --force to replace the file" in " ".join(capsys.readouterr().err.split()), ) == (1, b"/site/\xff\n", True) @_SKIP_GLOBAL_ON_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_ensurepath_cli_global_force_replaces_undecodable_file(undecodable_config_file: Path) -> None: assert ( run_pipx_cli(["ensurepath", "--global", "--force"]), str(paths.CTX.bin_dir) in undecodable_config_file.read_text(encoding="utf-8"), ) == (EXIT_CODE_OK, True) @_SKIP_GLOBAL_ON_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_ensurepath_cli_global_force_replaces_foreign_file(mocker: MockerFixture, tmp_path: Path) -> None: (config_file := tmp_path / "pipx.sh").write_text("/site/bin\n/other/bin\n", encoding="utf-8") mocker.patch.object(ensure_path_module, "_GLOBAL_PATH_FILE", config_file) mocker.patch.object(ensure_path_module, "MACOS", True) assert (run_pipx_cli(["ensurepath", "--global", "--force"]), config_file.read_text(encoding="utf-8")) == ( EXIT_CODE_OK, f"{paths.CTX.bin_dir}\n", ) @pytest.mark.parametrize( ("macos", "prepend", "existing", "expected"), [ pytest.param( False, True, """case ":$PATH:" in *:'/old dir/bin':*) ;; *) export PATH="$PATH":'/old dir/bin' ;; esac\n""", 'case ":$PATH:" in *:/srv/pipx/bin:*) ;; *) export PATH=/srv/pipx/bin:"$PATH" ;; esac\n', id="linux", ), pytest.param(True, False, "/old/bin\n", "/srv/pipx/bin\n", id="macos"), ], ) @_SKIP_GLOBAL_ON_WINDOWS def test_ensure_pipx_paths_global_replaces_own_entry( mocker: MockerFixture, tmp_path: Path, *, macos: bool, prepend: bool, existing: str, expected: str ) -> None: (config_file := tmp_path / "pipx.sh").write_text(existing, encoding="utf-8") mocker.patch.object(type(paths.CTX), "bin_dir", mocker.PropertyMock(return_value=Path("/srv/pipx/bin"))) mocker.patch.object(ensure_path_module, "_GLOBAL_PATH_FILE", config_file) mocker.patch.object(ensure_path_module, "MACOS", macos) ensure_path_module.ensure_pipx_paths(force=False, prepend=prepend, all_shells=False, dry_run=False, is_global=True) assert config_file.read_text(encoding="utf-8") == expected @_SKIP_GLOBAL_ON_WINDOWS def test_ensure_pipx_paths_global_skips_userpath( mock_userpath: MagicMock, mocker: MockerFixture, tmp_path: Path ) -> None: config_file: Final[Path] = tmp_path / "pipx.sh" mocker.patch.object(ensure_path_module, "_GLOBAL_PATH_FILE", config_file) ensure_path_module.ensure_pipx_paths(force=False, prepend=False, all_shells=False, dry_run=False, is_global=True) assert (mock_userpath.append.called, config_file.is_file()) == (False, True) @_SKIP_GLOBAL_ON_WINDOWS def test_ensure_pipx_paths_global_dry_run_does_not_write( mocker: MockerFixture, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: config_file: Final[Path] = tmp_path / "pipx.sh" mocker.patch.object(ensure_path_module, "_GLOBAL_PATH_FILE", config_file) ensure_path_module.ensure_pipx_paths(force=False, prepend=False, all_shells=False, dry_run=True, is_global=True) # pipx wraps the message, so a long temp path moves the line break into the phrase assert ( config_file.exists(), "no changes were made to the system PATH" in " ".join(capsys.readouterr().out.split()), ) == (False, True) @_SKIP_GLOBAL_ON_WINDOWS def test_ensure_pipx_paths_global_configuration_is_world_readable(mocker: MockerFixture, tmp_path: Path) -> None: config_file: Final[Path] = tmp_path / "pipx" mocker.patch.object(ensure_path_module, "_GLOBAL_PATH_FILE", config_file) previous_umask: Final[int] = os.umask(0o077) try: ensure_path_module.ensure_pipx_paths( force=False, prepend=False, all_shells=False, dry_run=False, is_global=True ) finally: os.umask(previous_umask) assert stat.S_IMODE(config_file.stat().st_mode) == 0o644 @_SKIP_GLOBAL_ON_WINDOWS def test_ensure_pipx_paths_global_reports_existing_configuration( mocker: MockerFixture, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: (config_file := tmp_path / "pipx.sh").write_text(f"{paths.CTX.bin_dir}\n") mocker.patch.object(ensure_path_module, "_GLOBAL_PATH_FILE", config_file) mocker.patch.object(ensure_path_module, "MACOS", True) ensure_path_module.ensure_pipx_paths(force=False, prepend=False, all_shells=False, dry_run=False, is_global=True) # pipx wraps the message, so a long temp path moves the line break into the phrase assert "already in the system PATH" in " ".join(capsys.readouterr().out.split()) @_SKIP_GLOBAL_ON_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_ensurepath_cli_global_writes_system_configuration(mocker: MockerFixture, tmp_path: Path) -> None: config_file: Final[Path] = tmp_path / "pipx" mocker.patch.object(ensure_path_module, "_GLOBAL_PATH_FILE", config_file) assert (run_pipx_cli(["ensurepath", "--global"]), config_file.exists()) == (EXIT_CODE_OK, True) pipx-1.18.16/tests/test_environment.py000066400000000000000000000201221527355705200177770ustar00rootroot00000000000000from __future__ import annotations import fnmatch import importlib import re from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from helpers import SKIP_IF_WINDOWS, run_pipx_cli from pipx import paths from pipx.commands.environment import DERIVED_ENVIRONMENT_VARIABLES, ENVIRONMENT_VARIABLES from pipx.paths import get_expanded_environ if TYPE_CHECKING: from unittest.mock import MagicMock from pytest_mock import MockerFixture _ENVIRONMENT_VARIABLES_DOC: Final[Path] = Path(__file__).parents[1] / "docs" / "reference" / "environment-variables.rst" # docstrfmt indents RST section titles by one space, and that indent is what keeps these off the prose naming # the same sections further up the page. _SETTABLE_HEADING: Final[str] = " Settable variables" _DERIVED_HEADING: Final[str] = " Derived (read-only) values" _DOC_TABLE_NAME: Final[re.Pattern[str]] = re.compile( r""" ^\ \ \ \ -\ -\ `` # first cell of a list-table row, at the indent docstrfmt writes ([A-Z0-9_]+) # the variable name, which the prose around the table spells the same way `` """, re.VERBOSE | re.MULTILINE, ) @pytest.mark.usefixtures("pipx_temp_env") def test_cli_value_skips_unrelated_discovery( mocker: MockerFixture, capsys: pytest.CaptureFixture[str], ) -> None: environment_module: Final = importlib.import_module("pipx.commands.environment") resolve_backend_name: Final[MagicMock] = mocker.patch.object( environment_module, "resolve_backend_name", autospec=True, return_value=("pip", "auto-pip"), ) find_uv_binary: Final[MagicMock] = mocker.patch.object( environment_module, "find_uv_binary", autospec=True, return_value=(None, "missing"), ) get_default_python: Final[MagicMock] = mocker.patch.object(environment_module, "get_default_python", autospec=True) assert not run_pipx_cli(["environment", "--value", "PIPX_HOME"]) assert ( capsys.readouterr().out.strip(), resolve_backend_name.call_count, find_uv_binary.call_count, get_default_python.call_count, ) == (str(paths.CTX.home), 0, 0, 0) @pytest.mark.parametrize( ("args", "home", "other"), [ pytest.param([], "subdir/pipxhome", "otherdir", id="local"), pytest.param(["--global"], "global/pipxhome", "global_otherdir", id="global", marks=SKIP_IF_WINDOWS), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_cli(capsys: pytest.CaptureFixture[str], args: list[str], home: str, other: str) -> None: assert not run_pipx_cli(["environment", *args]) output: Final[str] = capsys.readouterr().out unmatched: Final[list[str]] = [ pattern for pattern in ( f"*PIPX_HOME=*{home}*", f"*PIPX_BIN_DIR=*{other}/pipxbindir*", f"*PIPX_MAN_DIR=*{other}/pipxmandir*", f"*PIPX_LOCAL_VENVS=*{home}/venvs*", f"*PIPX_LOG_DIR=*{home}/logs*", f"*PIPX_TRASH_DIR=*{home}/.trash*", f"*PIPX_VENV_CACHEDIR=*{home}/.cache*", ) if not fnmatch.fnmatch(output, pattern) ] assert (unmatched, [variable for variable in ENVIRONMENT_VARIABLES if variable not in output]) == ([], []) @pytest.mark.parametrize( "variable", [ pytest.param("PIPX_HOME", id="PIPX_HOME"), pytest.param("PIPX_BIN_DIR", id="PIPX_BIN_DIR"), pytest.param("PIPX_MAN_DIR", id="PIPX_MAN_DIR"), pytest.param("PIPX_SHARED_LIBS", id="PIPX_SHARED_LIBS"), pytest.param("PIPX_LOCAL_VENVS", id="PIPX_LOCAL_VENVS"), pytest.param("PIPX_LOG_DIR", id="PIPX_LOG_DIR"), pytest.param("PIPX_TRASH_DIR", id="PIPX_TRASH_DIR"), pytest.param("PIPX_VENV_CACHEDIR", id="PIPX_VENV_CACHEDIR"), pytest.param("PIPX_DEFAULT_PYTHON", id="PIPX_DEFAULT_PYTHON"), pytest.param("PIPX_DISABLE_SHARED_LIBS_AUTO_UPGRADE", id="PIPX_DISABLE_SHARED_LIBS_AUTO_UPGRADE"), pytest.param("PIPX_USE_EMOJI", id="PIPX_USE_EMOJI"), ], ) def test_cli_with_args(variable: str) -> None: assert not run_pipx_cli(["environment", "--value", variable]) def test_cli_rejects_unknown_value(capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit) as excinfo: run_pipx_cli(["environment", "--value", "SSS"]) assert (excinfo.value.code, "invalid choice" in capsys.readouterr().err) == (2, True) @pytest.mark.parametrize( ("variable", "value"), [ pytest.param("PIPX_GLOBAL_HOME", "global-home", id="PIPX_GLOBAL_HOME-global-home"), pytest.param("PIPX_GLOBAL_BIN_DIR", "global-bin", id="PIPX_GLOBAL_BIN_DIR-global-bin"), pytest.param("PIPX_GLOBAL_MAN_DIR", "global-man", id="PIPX_GLOBAL_MAN_DIR-global-man"), pytest.param("PIPX_DEFAULT_BACKEND", "pip", id="PIPX_DEFAULT_BACKEND-pip"), pytest.param("PIPX_FETCH_MISSING_PYTHON", "1", id="PIPX_FETCH_MISSING_PYTHON-1"), pytest.param("PIPX_FETCH_PYTHON", "missing", id="PIPX_FETCH_PYTHON-missing"), pytest.param("PIPX_MAX_LOGS", "3", id="PIPX_MAX_LOGS-3"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_cli_with_user_environment_value( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], variable: str, value: str, ) -> None: monkeypatch.setenv(variable, value) assert not run_pipx_cli(["environment", "--value", variable]) assert capsys.readouterr().out == f"{value}\n" @pytest.mark.parametrize( ("start", "end", "reported"), [ pytest.param(_SETTABLE_HEADING, _DERIVED_HEADING, ENVIRONMENT_VARIABLES, id="settable"), pytest.param(_DERIVED_HEADING, None, DERIVED_ENVIRONMENT_VARIABLES, id="derived"), ], ) def test_reported_variables_match_the_documentation(start: str, end: str | None, reported: list[str]) -> None: # test_cli checks the list environment() prints from, so it cannot see a name missing from that list; the # documentation table is the user-facing contract documented: Final[list[str]] = _documented_variables(start, end) assert documented, f"no variables parsed out of the {start.strip()!r} table" assert sorted(documented) == sorted(reported) def _documented_variables(start: str, end: str | None) -> list[str]: text: Final[str] = _ENVIRONMENT_VARIABLES_DOC.read_text(encoding="utf-8") section = text[text.index(start) :] if end is not None: section = section[: section.index(end)] return _DOC_TABLE_NAME.findall(section) def test_resolve_user_dir_in_env_paths(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TEST_DIR", "~/test") assert get_expanded_environ("TEST_DIR") == Path.home() / "test" def test_resolve_missing_env_path() -> None: assert get_expanded_environ("THIS_SHOULD_NOT_EXIST") is None @pytest.mark.parametrize( "env_name", [ pytest.param("PIPX_HOME", id="PIPX_HOME"), pytest.param("PIPX_GLOBAL_HOME", id="PIPX_GLOBAL_HOME"), pytest.param("PIPX_BIN_DIR", id="PIPX_BIN_DIR"), pytest.param("PIPX_GLOBAL_BIN_DIR", id="PIPX_GLOBAL_BIN_DIR"), pytest.param("PIPX_MAN_DIR", id="PIPX_MAN_DIR"), pytest.param("PIPX_GLOBAL_MAN_DIR", id="PIPX_GLOBAL_MAN_DIR"), pytest.param("PIPX_SHARED_LIBS", id="PIPX_SHARED_LIBS"), ], ) def test_resolve_empty_env_paths(monkeypatch: pytest.MonkeyPatch, env_name: str) -> None: monkeypatch.setenv(env_name, "") assert get_expanded_environ(env_name) is None @pytest.mark.usefixtures("pipx_temp_env") def test_cli_logs_fallback_home( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path, ) -> None: fallback_home: Final[Path] = tmp_path / "fallback" fallback_home.mkdir() monkeypatch.setattr(paths, "FALLBACK_PIPX_HOMES", [fallback_home]) paths.CTX.make_local() monkeypatch.setenv("COLUMNS", "1000") assert not run_pipx_cli(["environment", "--verbose"]) assert ( f"Both a specific pipx home folder ({paths.CTX.home}) and the fallback pipx home folder ({fallback_home}) " "exist. If you are done migrating from the fallback to the new location, it is safe to delete the fallback " "location.\n" ) in capsys.readouterr().err pipx-1.18.16/tests/test_execute.py000066400000000000000000000062011527355705200170770ustar00rootroot00000000000000from __future__ import annotations import importlib import os from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from helpers import app_name, run_pipx_cli from pipx import paths from pipx.util import get_venv_paths if TYPE_CHECKING: from unittest.mock import MagicMock from pytest_mock import MockerFixture @pytest.mark.parametrize( ("setup_commands", "environment_name", "application"), [ pytest.param([["install", "requests==2.31.0", "--include-deps"]], "requests", "normalizer", id="dependency"), pytest.param( [["install", "pycowsay"], ["inject", "pycowsay", "black"]], "pycowsay", "black", id="injected", ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_execute_runs_recorded_app( mocker: MockerFixture, setup_commands: list[list[str]], environment_name: str, application: str, ) -> None: for setup_command in setup_commands: assert not run_pipx_cli(setup_command) boundary: Final[MagicMock] = mocker.patch.object( importlib.import_module("pipx.commands.execute"), "exec_app", autospec=True, side_effect=RuntimeError("exec"), ) with pytest.raises(RuntimeError, match="exec"): run_pipx_cli(["exec", environment_name, application, "--version"]) command: Final[list[str | Path]] = boundary.call_args.args[0] environment: Final[dict[str, str]] = boundary.call_args.kwargs["env"] venv_dir: Final[Path] = paths.CTX.venvs / environment_name bin_dir: Final[Path] = get_venv_paths(venv_dir)[0] assert ( Path(command[0]), command[1:], environment["VIRTUAL_ENV"], environment["PATH"].split(os.pathsep)[0], ) == (bin_dir / app_name(application), ["--version"], str(venv_dir), str(bin_dir)) @pytest.mark.usefixtures("pipx_temp_env") def test_execute_rejects_unknown_app( capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() assert run_pipx_cli(["exec", "pycowsay", "missing"]) == 1 assert " ".join(capsys.readouterr().err.split()) == ( "Application 'missing' was not found in environment 'pycowsay'. Available applications: pycowsay" ) @pytest.mark.usefixtures("pipx_temp_env") def test_execute_rejects_unknown_environment( capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["exec", "missing", "app"]) == 1 assert capsys.readouterr().err == "pipx does not manage environment 'missing'.\n" @pytest.mark.usefixtures("pipx_temp_env") def test_execute_rejects_missing_recorded_app( capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) app_path: Final[Path] = get_venv_paths(paths.CTX.venvs / "pycowsay")[0] / app_name("pycowsay") app_path.unlink() capsys.readouterr() assert run_pipx_cli(["exec", "pycowsay", "pycowsay"]) == 1 assert " ".join(capsys.readouterr().err.split()) == ( f"Application 'pycowsay' is missing from environment 'pycowsay' at {app_path}. " "Reinstall it with `pipx reinstall pycowsay`." ) pipx-1.18.16/tests/test_expose.py000066400000000000000000000215541527355705200167500ustar00rootroot00000000000000from __future__ import annotations import json import sys from typing import TYPE_CHECKING, Final import pytest from helpers import app_name, mock_legacy_venv, run_pipx_cli from package_info import PKG from pipx import paths from pipx.pipx_metadata_file import PipxMetadata if TYPE_CHECKING: from pathlib import Path def test_unexpose_hides_resources(installed_pycowsay: Path, capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["unexpose", "pycowsay"]) == 0 assert ( (paths.CTX.bin_dir / app_name("pycowsay")).exists(), (paths.CTX.man_dir / "man6" / "pycowsay.6").exists(), installed_pycowsay.exists(), PipxMetadata(installed_pycowsay).exposure_enabled, capsys.readouterr().out, ) == (False, False, True, False, "pycowsay: unexposed\n") def test_expose_restores_resources(unexposed_pycowsay: Path, capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["expose", "pycowsay"]) == 0 assert ( (paths.CTX.bin_dir / app_name("pycowsay")).exists(), (paths.CTX.man_dir / "man6" / "pycowsay.6").exists(), PipxMetadata(unexposed_pycowsay).exposure_enabled, capsys.readouterr().out, ) == (True, True, True, "pycowsay: exposed\n") @pytest.fixture def unexposed_pycowsay(installed_pycowsay: Path, capsys: pytest.CaptureFixture[str]) -> Path: assert run_pipx_cli(["unexpose", "pycowsay"]) == 0 capsys.readouterr() return installed_pycowsay @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_preserves_unexposed_state(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["install", "isort==5.6.4"]) == 0 assert run_pipx_cli(["unexpose", "isort"]) == 0 venv_dir: Final[Path] = paths.CTX.venvs / "isort" previous_version: Final[str] = PipxMetadata(venv_dir).main_package.package_version capsys.readouterr() assert run_pipx_cli(["upgrade", "isort"]) == 0 capsys.readouterr() metadata: Final[PipxMetadata] = PipxMetadata(venv_dir) assert ( metadata.main_package.package_version != previous_version, (paths.CTX.bin_dir / app_name("isort")).exists(), metadata.exposure_enabled, ) == (True, False, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_upgrade_preserves_unexposed_state(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["install", PKG["black"]["spec"]]) == 0 assert run_pipx_cli(["unexpose", "black"]) == 0 capsys.readouterr() assert run_pipx_cli(["install", "--upgrade", "black==22.10.0"]) == 0 assert ( "upgraded package black from 22.8.0 to 22.10.0" in capsys.readouterr().out, (paths.CTX.bin_dir / app_name("black")).exists(), PipxMetadata(paths.CTX.venvs / "black").exposure_enabled, ) == (True, False, False) def test_force_install_preserves_unexposed_state( unexposed_pycowsay: Path, capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["install", "--force", "pycowsay"]) == 0 assert ( "installed package pycowsay" in capsys.readouterr().out, (paths.CTX.bin_dir / app_name("pycowsay")).exists(), PipxMetadata(unexposed_pycowsay).exposure_enabled, ) == (True, False, False) def test_reinstall_preserves_unexposed_state( unexposed_pycowsay: Path, capsys: pytest.CaptureFixture[str], ) -> None: (marker := unexposed_pycowsay / "marker").touch() assert run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) == 0 capsys.readouterr() assert ( marker.exists(), (paths.CTX.bin_dir / app_name("pycowsay")).exists(), PipxMetadata(unexposed_pycowsay).exposure_enabled, ) == (False, False, False) def test_unexposed_environment_does_not_expose_injected_apps( unexposed_pycowsay: Path, capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["inject", "--include-apps", "pycowsay", "isort"]) == 0 capsys.readouterr() metadata: Final[PipxMetadata] = PipxMetadata(unexposed_pycowsay) assert ( "isort" in metadata.injected_packages, (paths.CTX.bin_dir / app_name("isort")).exists(), metadata.exposure_enabled, ) == (True, False, False) def test_install_all_preserves_unexposed_state( unexposed_pycowsay: Path, capsys: pytest.CaptureFixture[str], tmp_path: Path, ) -> None: assert run_pipx_cli(["list", "--json"]) == 0 (spec_path := tmp_path / "pipx.json").write_text(capsys.readouterr().out, encoding="utf-8") assert run_pipx_cli(["uninstall", "pycowsay"]) == 0 capsys.readouterr() assert run_pipx_cli(["install-all", str(spec_path)]) == 0 capsys.readouterr() assert ( (paths.CTX.bin_dir / app_name("pycowsay")).exists(), PipxMetadata(unexposed_pycowsay).exposure_enabled, ) == (False, False) @pytest.mark.usefixtures("unexposed_pycowsay") def test_list_reports_unexposed_environment(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["list"]) == 0 output: Final[str] = capsys.readouterr().out assert ("apps and manual pages are not exposed" in output, "symlink missing" in output) == (True, False) @pytest.mark.usefixtures("installed_pycowsay") def test_unexpose_json(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["unexpose", "pycowsay", "--output", "json"]) == 0 assert json.loads(capsys.readouterr().out) == { "command": ["unexpose"], "data": { "environments": [{"environment": "pycowsay", "status": "unexposed"}], }, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", } @pytest.mark.usefixtures("unexposed_pycowsay") def test_expose_reports_collisions_as_partial(capsys: pytest.CaptureFixture[str]) -> None: (blocker := paths.CTX.bin_dir / app_name("pycowsay")).write_text("not managed by pipx", encoding="utf-8") assert run_pipx_cli(["expose", "pycowsay", "--output", "json"]) == 1 envelope: Final = json.loads(capsys.readouterr().out) assert ( envelope["status"], envelope["command"], [error["code"] for error in envelope["errors"]], (paths.CTX.man_dir / "man6" / "pycowsay.6").exists(), blocker.read_text(encoding="utf-8"), ) == ("partial", ["expose"], ["environment_expose_conflict"], True, "not managed by pipx") @pytest.mark.parametrize( "missing_app", [ pytest.param("app_removed_after_cleared_collision", id="after-cleared-collision"), pytest.param("app_removed_by_hand", id="after-manual-delete"), ], ) def test_expose_relinks_missing_app( request: pytest.FixtureRequest, capsys: pytest.CaptureFixture[str], missing_app: str ) -> None: app: Final[Path] = request.getfixturevalue(missing_app) assert (run_pipx_cli(["expose", "pycowsay"]), app.exists(), capsys.readouterr().out) == ( 0, True, "pycowsay: exposed\n", ) @pytest.fixture def app_removed_after_cleared_collision(unexposed_pycowsay: Path, capsys: pytest.CaptureFixture[str]) -> Path: (app := paths.CTX.bin_dir / app_name(unexposed_pycowsay.name)).write_text("not managed by pipx", encoding="utf-8") assert run_pipx_cli(["expose", "pycowsay"]) == 1 capsys.readouterr() app.unlink() return app @pytest.fixture def app_removed_by_hand(installed_pycowsay: Path) -> Path: (app := paths.CTX.bin_dir / app_name(installed_pycowsay.name)).unlink() return app @pytest.mark.parametrize( ("command", "state", "message"), [ pytest.param("expose", "installed_pycowsay", "pycowsay: already exposed\n", id="expose"), pytest.param("unexpose", "unexposed_pycowsay", "pycowsay: already unexposed\n", id="unexpose"), ], ) def test_exposure_command_is_idempotent( request: pytest.FixtureRequest, capsys: pytest.CaptureFixture[str], command: str, state: str, message: str, ) -> None: request.getfixturevalue(state) assert run_pipx_cli([command, "pycowsay"]) == 0 assert capsys.readouterr().out == message def test_legacy_environment_defaults_to_exposed(installed_pycowsay: Path) -> None: mock_legacy_venv("pycowsay", "0.3") assert PipxMetadata(installed_pycowsay).exposure_enabled is True @pytest.mark.usefixtures("pipx_temp_env") def test_exposure_command_reports_unreadable_metadata( capsys: pytest.CaptureFixture[str], ) -> None: (paths.CTX.venvs / "broken").mkdir(parents=True) assert run_pipx_cli(["expose", "broken"]) == 1 assert capsys.readouterr().err == "pipx cannot read metadata for package broken\n" @pytest.mark.parametrize("command", [pytest.param("expose", id="expose"), pytest.param("unexpose", id="unexpose")]) @pytest.mark.usefixtures("pipx_temp_env") def test_exposure_command_reports_missing_environment( capsys: pytest.CaptureFixture[str], command: str, ) -> None: assert run_pipx_cli([command, "missing"]) == 1 assert capsys.readouterr().err == "pipx does not manage package missing\n" pipx-1.18.16/tests/test_fanout.py000066400000000000000000000341651527355705200167430ustar00rootroot00000000000000from __future__ import annotations import io import logging import os import signal import sys import threading import time import weakref from functools import partial from typing import TYPE_CHECKING, Final import pytest from filelock import FileLock, Timeout from helpers import SKIP_IF_WINDOWS from pipx.fanout import ( fan_out, finished_on_interrupt, in_turn, locked_in_order, output_is_held, stopping_children_on_signals, ) from pipx.subprocess_runner import run_subprocess if TYPE_CHECKING: from pathlib import Path from pytest_mock import MockerFixture @pytest.fixture def second_done() -> threading.Event: return threading.Event() def test_fan_out_returns_results_in_task_order(second_done: threading.Event) -> None: def first() -> str: second_done.wait(timeout=5) return "first" def second() -> str: second_done.set() return "second" assert fan_out([first, second], sequential=False) == ["first", "second"] def test_fan_out_sequential_runs_tasks_in_calling_thread() -> None: assert fan_out([threading.get_ident, threading.get_ident], sequential=True) == [threading.get_ident()] * 2 def test_fan_out_prints_in_task_order(capsys: pytest.CaptureFixture[str], second_done: threading.Event) -> None: def first() -> None: second_done.wait(timeout=5) print("first") # ruff:ignore[print] # the output under test def second() -> None: print("second") # ruff:ignore[print] # the output under test second_done.set() fan_out([first, second], sequential=False) assert capsys.readouterr().out == "first\nsecond\n" def test_fan_out_logs_in_task_order(capsys: pytest.CaptureFixture[str], second_done: threading.Event) -> None: logging.getLogger("pipx").addHandler(logging.StreamHandler(sys.stderr)) logger: Final[logging.Logger] = logging.getLogger("pipx.test") def first() -> None: second_done.wait(timeout=5) logger.warning("first") def second() -> None: logger.warning("second") second_done.set() fan_out([first, second], sequential=False) assert capsys.readouterr().err == "first\nsecond\n" def test_fan_out_streams_report_the_terminal_of_the_real_stream() -> None: expected: Final[bool] = sys.stdout.isatty() assert fan_out([sys.stdout.isatty, sys.stdout.isatty], sequential=False) == [expected, expected] def test_fan_out_finishes_last_task_queued_after_the_others() -> None: assert fan_out([int, int], sequential=False) == [0, 0] def test_fan_out_raises_first_failure_in_task_order(second_done: threading.Event) -> None: def first() -> None: second_done.wait(timeout=5) msg = "first" raise ValueError(msg) def second() -> None: second_done.set() msg = "second" raise ValueError(msg) with pytest.raises(ValueError, match=r"^first$"): fan_out([first, second], sequential=False) @pytest.mark.parametrize( "error", [ pytest.param(BrokenPipeError(), id="reader-gone"), pytest.param(ValueError("I/O operation on closed file."), id="closed"), ], ) def test_fan_out_waits_for_tasks_when_held_output_cannot_be_written( mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, second_done: threading.Event, error: Exception ) -> None: unwritable: Final = mocker.create_autospec(io.TextIOWrapper, instance=True, **{"write.side_effect": error}) monkeypatch.setattr(sys, "stdout", unwritable) def second() -> str: print("held") # ruff:ignore[print] # the output under test second_done.set() return "second" assert fan_out([lambda: second_done.wait(timeout=5), second], sequential=False) == [True, "second"] def test_fan_out_leaves_stream_closed_at_startup_alone( monkeypatch: pytest.MonkeyPatch, second_done: threading.Event ) -> None: monkeypatch.setattr(sys, "stdout", None) def second() -> str: print("dropped") # ruff:ignore[print] # the output under test second_done.set() return "second" assert fan_out([lambda: second_done.wait(timeout=5), second], sequential=False) == [True, "second"] def test_output_is_held_only_for_task_waiting_its_turn(second_done: threading.Event) -> None: def first() -> bool: second_done.wait(timeout=5) return output_is_held() def second() -> bool: held: Final[bool] = output_is_held() second_done.set() return held assert fan_out([first, second], sequential=False) == [False, True] def test_in_turn_waits_for_earlier_tasks() -> None: events: Final[list[str]] = [] second_waiting: Final[threading.Event] = threading.Event() def first() -> None: second_waiting.wait(timeout=5) events.append("first") def not_done() -> bool: second_waiting.set() return False def second() -> None: with in_turn(not_done): events.append("second") fan_out([first, second], sequential=False) assert events == ["first", "second"] def test_in_turn_proceeds_once_done(second_done: threading.Event) -> None: def second() -> None: with in_turn(lambda: True): second_done.set() assert fan_out([lambda: second_done.wait(timeout=5), second], sequential=False) == [True, None] def test_in_turn_outside_fan_out_runs_immediately() -> None: events: Final[list[str]] = [] with in_turn(lambda: False): events.append("ran") assert events == ["ran"] @SKIP_IF_WINDOWS @pytest.mark.usefixtures("signal_handlers") def test_fan_out_waits_for_rollback_through_second_interrupt() -> None: rolled_back: Final[list[str]] = [] main_thread: Final[int] = threading.get_ident() def rolling_back() -> None: for _ in range(2): signal.pthread_kill(main_thread, signal.SIGINT) time.sleep(0.2) rolled_back.append("first") with pytest.raises(KeyboardInterrupt), stopping_children_on_signals(): fan_out([rolling_back, int], sequential=False) assert rolled_back == ["first"] @SKIP_IF_WINDOWS @pytest.mark.usefixtures("signal_handlers") def test_fan_out_interrupted_by_signal_another_thread_handles() -> None: def interrupted() -> None: # a signal sent to the whole process can land on any thread; the interpreter runs its handler on the main thread signal.pthread_kill(threading.get_ident(), signal.SIGINT) run_subprocess([sys.executable, "-c", "import time; time.sleep(30)"]) with pytest.raises(KeyboardInterrupt), stopping_children_on_signals(): fan_out([interrupted, int], sequential=False) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("signal_handlers") def test_fan_out_interrupted_by_signal_whose_interrupt_the_interpreter_dropped(monkeypatch: pytest.MonkeyPatch) -> None: dropped: Final[list[type[BaseException] | None]] = [] monkeypatch.setattr(sys, "unraisablehook", lambda unraisable: dropped.append(unraisable.exc_type)) with stopping_children_on_signals(): handler: Final = signal.getsignal(signal.SIGINT) assert callable(handler) target = {0} # the interpreter reports an exception raised in a weakref callback as unraisable instead of propagating it _reference = weakref.ref(target, lambda _: handler(signal.SIGINT, None)) del target with pytest.raises(KeyboardInterrupt): fan_out( [partial(run_subprocess, [sys.executable, "-c", "import time; time.sleep(30)"]), int], sequential=False ) assert dropped == [KeyboardInterrupt] def test_locked_in_order_leaves_no_lock_cycle_with_another_run(tmp_path: Path) -> None: first_lock: Final[Path] = tmp_path / "first.lock" second_lock: Final[Path] = tmp_path / "second.lock" other_run_holds_first: Final[threading.Event] = threading.Event() second_task_locked: Final[threading.Event] = threading.Event() other_run: Final[list[str]] = [] def take_locks_in_reverse() -> None: with FileLock(first_lock): other_run_holds_first.set() second_task_locked.wait(timeout=0.5) try: with FileLock(second_lock, timeout=2): other_run.append("took both") except Timeout: other_run.append("deadlocked") def first() -> bool: with locked_in_order(FileLock(first_lock)) as lock: return lock.is_locked def second() -> str: with locked_in_order(FileLock(second_lock)): second_task_locked.set() with in_turn(lambda: False): return "in turn" thread: Final[threading.Thread] = threading.Thread(target=take_locks_in_reverse) thread.start() other_run_holds_first.wait(timeout=5) fan_out([first, second], sequential=False) thread.join() assert other_run == ["took both"] @SKIP_IF_WINDOWS @pytest.mark.usefixtures("signal_handlers") def test_fan_out_stops_children_on_sigterm() -> None: def terminated() -> None: run_subprocess([ sys.executable, "-c", "import os, signal, time; os.kill(os.getppid(), signal.SIGTERM); time.sleep(30)", ]) with pytest.raises(KeyboardInterrupt), stopping_children_on_signals(): fan_out([terminated, int], sequential=False) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("signal_handlers") def test_signal_stops_the_grandchildren_of_a_child(tmp_path: Path) -> None: pid_file: Final[Path] = tmp_path / "grandchild.pid" # the grandchild stands in for a build backend that pip or uv started script: Final[str] = ( "import os, signal, subprocess, sys, time; " "grandchild = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)']); " f"open({str(pid_file)!r}, 'w').write(str(grandchild.pid)); " "os.kill(os.getppid(), signal.SIGTERM); time.sleep(30)" ) with pytest.raises(KeyboardInterrupt), stopping_children_on_signals(): run_subprocess([sys.executable, "-c", script]) assert _exits_within(int(pid_file.read_text()), seconds=5) def _exits_within(pid: int, *, seconds: float) -> bool: deadline: Final[float] = time.monotonic() + seconds while time.monotonic() < deadline: try: os.kill(pid, 0) except ProcessLookupError: return True time.sleep(0.05) return False @pytest.mark.usefixtures("signal_handlers") def test_signals_keep_their_handlers_after_an_unsignalled_block() -> None: previous: Final = signal.getsignal(signal.SIGTERM) with stopping_children_on_signals(): pass assert signal.getsignal(signal.SIGTERM) is previous def test_signals_keep_their_handlers_off_the_main_thread() -> None: signalled: Final[list[bool]] = [] def run() -> None: with stopping_children_on_signals() as event: signalled.append(event.is_set()) thread: Final[threading.Thread] = threading.Thread(target=run) thread.start() thread.join() assert signalled == [False] @SKIP_IF_WINDOWS @pytest.mark.usefixtures("signal_handlers") def test_signals_are_ignored_once_signalled() -> None: with pytest.raises(KeyboardInterrupt), stopping_children_on_signals(): signal.raise_signal(signal.SIGINT) assert signal.getsignal(signal.SIGINT) is signal.SIG_IGN @SKIP_IF_WINDOWS @pytest.mark.usefixtures("signal_handlers") def test_fan_out_finishes_started_work_after_first_interrupt(caplog: pytest.LogCaptureFixture) -> None: main_thread: Final[int] = threading.get_ident() def finishing() -> str: with finished_on_interrupt(): signal.pthread_kill(main_thread, signal.SIGINT) _wait_for_log(caplog, "Finishing the 1 environment(s)") run_subprocess([sys.executable, "-c", "pass"]) return "finished" def not_started() -> str: _wait_for_log(caplog, "Finishing the 1 environment(s)") with finished_on_interrupt(): return "started" with stopping_children_on_signals(): assert fan_out( [finishing, not_started], sequential=False, interrupted=lambda index: f"interrupted {index}" ) == ["finished", "interrupted 1"] @SKIP_IF_WINDOWS @pytest.mark.usefixtures("signal_handlers") def test_fan_out_stops_started_work_on_second_interrupt(caplog: pytest.LogCaptureFixture) -> None: main_thread: Final[int] = threading.get_ident() def finishing() -> str: with finished_on_interrupt(): signal.pthread_kill(main_thread, signal.SIGINT) _wait_for_log(caplog, "Finishing the 1 environment(s)") signal.pthread_kill(main_thread, signal.SIGINT) run_subprocess([sys.executable, "-c", "import time; time.sleep(30)"]) return "finished" with stopping_children_on_signals(): assert fan_out([finishing], sequential=False, interrupted=lambda index: f"interrupted {index}") == [ "interrupted 0" ] def _wait_for_log(caplog: pytest.LogCaptureFixture, text: str) -> None: deadline: Final[float] = time.monotonic() + 5 while not any(text in message for message in caplog.messages) and time.monotonic() < deadline: time.sleep(0.01) def test_fan_out_runs_off_the_main_thread() -> None: results: Final[list[list[int]]] = [] thread: Final[threading.Thread] = threading.Thread( target=lambda: results.append(fan_out([int, int], sequential=False)) ) thread.start() thread.join() assert results == [[0, 0]] @SKIP_IF_WINDOWS @pytest.mark.usefixtures("signal_handlers") def test_fan_out_interrupted_ends_unfinished_line_before_held_output( capsys: pytest.CaptureFixture[str], second_done: threading.Event ) -> None: main_thread: Final[int] = threading.get_ident() def interrupted() -> None: second_done.wait(timeout=5) sys.stderr.write("building first -") signal.pthread_kill(main_thread, signal.SIGINT) run_subprocess([sys.executable, "-c", "import time; time.sleep(30)"]) def held() -> None: sys.stderr.write("installing second\n") second_done.set() with pytest.raises(KeyboardInterrupt), stopping_children_on_signals(): fan_out([interrupted, held], sequential=False) assert capsys.readouterr().err == "building first -\ninstalling second\n" pipx-1.18.16/tests/test_health.py000066400000000000000000000236641527355705200167160ustar00rootroot00000000000000from __future__ import annotations import importlib import json import shutil import subprocess import sys from typing import TYPE_CHECKING, Final import pytest from helpers import remove_venv_interpreter, run_pipx_cli from pipx import paths, util if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path from types import ModuleType from pytest_mock import MockerFixture _HEALTH_MODULE: Final[ModuleType] = importlib.import_module("pipx.commands.health") @pytest.fixture def installed_pycowsay( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing capsys: pytest.CaptureFixture[str], ) -> Path: assert run_pipx_cli(["install", "pycowsay"]) == 0 capsys.readouterr() return paths.CTX.venvs / "pycowsay" @pytest.mark.parametrize( ("command", "message"), [ pytest.param("health", "pipx manages no packages.\n", id="health"), pytest.param("repair", "pipx found no environments to repair.\n", id="repair"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_environment_maintenance_no_packages( capsys: pytest.CaptureFixture[str], command: str, message: str, ) -> None: assert run_pipx_cli([command]) == 0 assert capsys.readouterr().out == message def test_health_json(installed_pycowsay: Path, capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["health", "pycowsay", "--output", "json"]) == 0 assert json.loads(capsys.readouterr().out) == { "command": ["health"], "data": { "environments": [ { "environment": "pycowsay", "error": None, "interpreter": str(util.get_venv_paths(installed_pycowsay)[1]), "status": "healthy", } ] }, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", } @pytest.mark.usefixtures("installed_pycowsay") def test_health_deduplicates_repeated_package(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["health", "pycowsay", "PyCowSay", "--output", "json"]) == 0 environments: Final = json.loads(capsys.readouterr().out)["data"]["environments"] assert [environment["environment"] for environment in environments] == ["pycowsay"] @pytest.mark.usefixtures("pipx_temp_env") def test_repair_json(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["repair", "--output", "json"]) == 0 assert json.loads(capsys.readouterr().out) == { "command": ["repair"], "data": {"repaired": [], "skipped": []}, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", } def test_health_reports_broken_interpreter(installed_pycowsay: Path, capsys: pytest.CaptureFixture[str]) -> None: remove_venv_interpreter("pycowsay") assert run_pipx_cli(["health"]) == 1 assert capsys.readouterr().out == ( f"pycowsay: interpreter is missing at {util.get_venv_paths(installed_pycowsay)[1]}\n" ) @pytest.mark.parametrize( ("outcome", "error"), [ pytest.param( [subprocess.CompletedProcess(["python", "--version"], 7, "", "failure")], "interpreter exited with status 7", id="exit-status", ), pytest.param(PermissionError("denied"), "interpreter could not start: denied", id="start-error"), ], ) def test_health_reports_interpreter_failure( installed_pycowsay: Path, capsys: pytest.CaptureFixture[str], mocker: MockerFixture, outcome: list[subprocess.CompletedProcess[str]] | OSError, error: str, ) -> None: mocker.patch.object(_HEALTH_MODULE, "run_subprocess", autospec=True, side_effect=outcome) assert run_pipx_cli(["health", "pycowsay"]) == 1 assert capsys.readouterr().out == (f"pycowsay: {error} at {util.get_venv_paths(installed_pycowsay)[1]}\n") @pytest.mark.parametrize( ("command", "output"), [ pytest.param("health", ("missing: pipx does not manage this environment\n", ""), id="health"), pytest.param("repair", ("", "missing: pipx does not manage this environment\n"), id="repair"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_environment_maintenance_reports_unmanaged_package( capsys: pytest.CaptureFixture[str], command: str, output: tuple[str, str], ) -> None: assert run_pipx_cli([command, "missing"]) == 1 captured: Final = capsys.readouterr() assert (captured.out, captured.err) == output @pytest.mark.usefixtures("installed_pycowsay") def test_repair_rebuilds_broken_interpreter(capsys: pytest.CaptureFixture[str]) -> None: remove_venv_interpreter("pycowsay") assert run_pipx_cli(["repair", "--python", sys.executable]) == 0 capsys.readouterr() assert run_pipx_cli(["health", "pycowsay"]) == 0 assert "pycowsay: healthy" in capsys.readouterr().out @pytest.mark.skipif(shutil.which("uv") is None, reason="uv is not on PATH") @pytest.mark.usefixtures("pipx_temp_env") def test_repair_preserves_uv_backend(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["install", "--backend", "uv", "--python", sys.executable, "pycowsay"]) == 0 remove_venv_interpreter("pycowsay") capsys.readouterr() assert run_pipx_cli(["repair", "--python", sys.executable, "pycowsay"]) == 0 capsys.readouterr() assert run_pipx_cli(["list", "--json"]) == 0 assert json.loads(capsys.readouterr().out)["venvs"]["pycowsay"]["metadata"]["backend"] == "uv" @pytest.mark.usefixtures("installed_pycowsay") def test_repair_skips_healthy_interpreter(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["repair", "pycowsay"]) == 0 assert capsys.readouterr().out == "pycowsay: healthy\n" def _kill_install(venv_dir: Path) -> None: (venv_dir / "pipx_metadata.json").unlink() (venv_dir / "pipx_unfinished").touch() def _corrupt_record(venv_dir: Path) -> None: (venv_dir / "pipx_metadata.json").write_text("{", encoding="utf-8") @pytest.mark.parametrize( "lose_record", [pytest.param(_kill_install, id="killed-install"), pytest.param(_corrupt_record, id="unreadable")] ) @pytest.mark.usefixtures("installed_pycowsay") def test_repair_refuses_lost_metadata(capsys: pytest.CaptureFixture[str], lose_record: Callable[[Path], None]) -> None: lose_record(paths.CTX.venvs / "pycowsay") assert run_pipx_cli(["repair", "pycowsay"]) == 1 captured: Final = capsys.readouterr() assert "pycowsay: Not reinstalling pycowsay" in captured.err assert "has no internal pipx metadata" in captured.err @pytest.mark.usefixtures("installed_pycowsay") def test_repair_skips_healthy_environment_without_record(capsys: pytest.CaptureFixture[str]) -> None: # pipx before 0.15.0.0 wrote no record; rebuilding it from the bare name would upgrade a working app (paths.CTX.venvs / "pycowsay" / "pipx_metadata.json").unlink() assert run_pipx_cli(["repair", "pycowsay"]) == 0 assert capsys.readouterr().out == "pycowsay: healthy\n" @pytest.mark.usefixtures("installed_pycowsay") def test_health_reports_healthy_for_lost_metadata(capsys: pytest.CaptureFixture[str]) -> None: # health checks interpreter startability only, so a lost record shows up in `pipx list` and `pipx repair` instead (paths.CTX.venvs / "pycowsay" / "pipx_metadata.json").unlink() assert run_pipx_cli(["health", "pycowsay"]) == 0 assert capsys.readouterr().out == "pycowsay: healthy\n" @pytest.mark.usefixtures("installed_pycowsay") def test_repair_respects_pin(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["pin", "pycowsay"]) == 0 remove_venv_interpreter("pycowsay") capsys.readouterr() assert run_pipx_cli(["repair", "pycowsay"]) == 1 assert "is pinned. Run `pipx unpin pycowsay` to unpin it first." in capsys.readouterr().err.replace("\n", " ") @pytest.mark.usefixtures("installed_pycowsay") def test_repair_verifies_rebuilt_interpreter( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, ) -> None: remove_venv_interpreter("pycowsay") capsys.readouterr() mocker.patch.object( _HEALTH_MODULE, "run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(["python", "--version"], 7, "", "failure"), ) assert run_pipx_cli(["repair", "--python", sys.executable, "pycowsay"]) == 1 assert "pycowsay: interpreter exited with status 7" in capsys.readouterr().err @pytest.mark.usefixtures("installed_pycowsay") def test_repair_json_stays_pure_when_it_reinstalls(capsys: pytest.CaptureFixture[str]) -> None: remove_venv_interpreter("pycowsay") assert not run_pipx_cli(["repair", "--python", sys.executable, "--output", "json"]) captured: Final = capsys.readouterr() document: Final = json.loads(captured.out) # the internal reinstall must not print human text before this assert (document["command"], document["status"], captured.err) == (["repair"], "success", "") @pytest.mark.parametrize( ("healthy_suffixes", "status"), [ pytest.param([], "error", id="all-broken"), pytest.param(["-other"], "partial", id="some-broken"), ], ) @pytest.mark.usefixtures("installed_pycowsay") def test_health_json_reports_broken_environment_as_error( capsys: pytest.CaptureFixture[str], healthy_suffixes: list[str], status: str, ) -> None: for suffix in healthy_suffixes: assert not run_pipx_cli(["install", "pycowsay", f"--suffix={suffix}"]) remove_venv_interpreter("pycowsay") capsys.readouterr() assert run_pipx_cli(["health", "--output", "json"]) == 1 document: Final = json.loads(capsys.readouterr().out) assert (document["status"], document["errors"]) == ( status, [ { "code": "environment_unhealthy", "environment": "pycowsay", "message": "interpreter is missing", "package": None, } ], ) pipx-1.18.16/tests/test_imports.py000066400000000000000000000073221527355705200171370ustar00rootroot00000000000000from __future__ import annotations import os import subprocess import sys from typing import TYPE_CHECKING, Final import pytest if TYPE_CHECKING: from pathlib import Path _LAZY_MODULES_HONORED: Final[pytest.MarkDecorator] = pytest.mark.skipif( sys.version_info < (3, 15), reason="__lazy_modules__ (PEP 810) is honored from Python 3.15" ) def test_importing_util_first_succeeds(root: Path, source_env: dict[str, str]) -> None: result: Final = subprocess.run( [sys.executable, "-c", "import pipx.util; print('Success!')"], check=False, capture_output=True, cwd=root, env=source_env, text=True, ) assert (result.returncode, result.stdout) == (0, "Success!\n"), result.stderr @pytest.fixture def source_env(root: Path) -> dict[str, str]: return {**os.environ, "PYTHONPATH": str(root / "src")} @pytest.mark.parametrize( "module", [ pytest.param("filelock", id="filelock"), pytest.param("logging.config", id="logging-config"), pytest.param("packaging.requirements", id="packaging-requirements"), pytest.param("importlib.metadata", id="importlib-metadata"), pytest.param("pipx.venv_inspect", id="venv-inspect"), pytest.param("zipfile", id="zipfile"), pytest.param("hashlib", id="hashlib"), pytest.param("urllib.request", id="urllib-request"), pytest.param("pipx.standalone_python", id="standalone-python"), pytest.param("pipx.commands.install", id="command-module"), pytest.param("json", id="json", marks=_LAZY_MODULES_HONORED), pytest.param("tomllib", id="tomllib", marks=_LAZY_MODULES_HONORED), pytest.param("concurrent.futures", id="concurrent-futures", marks=_LAZY_MODULES_HONORED), pytest.param("packaging.specifiers", id="packaging-specifiers", marks=_LAZY_MODULES_HONORED), pytest.param("pipx.package_specifier", id="package-specifier", marks=_LAZY_MODULES_HONORED), ], ) def test_importing_main_defers_heavy_module(root: Path, source_env: dict[str, str], module: str) -> None: result: Final = subprocess.run( [sys.executable, "-c", f"import sys, pipx.main; print({module!r} in sys.modules)"], check=False, capture_output=True, cwd=root, env=source_env, text=True, ) assert result.stdout == "False\n", result.stderr @_LAZY_MODULES_HONORED @pytest.mark.parametrize( ("module", "loaded"), [ pytest.param("argcomplete.shell_integration", False, id="third-party-import-deferred"), pytest.param("pipx.colors", True, id="terminal-snapshot-eager"), ], ) def test_cli_startup_loads_module(root: Path, source_env: dict[str, str], module: str, loaded: bool) -> None: assert (module in _startup_imports(root, [], source_env)) is loaded @_LAZY_MODULES_HONORED @pytest.mark.parametrize( ("options", "environment"), [ pytest.param(["-X", "lazy_imports=normal"], {}, id="x-option"), pytest.param([], {"PYTHON_LAZY_IMPORTS": "normal"}, id="environment-variable"), ], ) def test_cli_startup_keeps_lazy_imports_mode_set_by_user( root: Path, source_env: dict[str, str], options: list[str], environment: dict[str, str] ) -> None: assert "argcomplete.shell_integration" in _startup_imports(root, options, source_env | environment) def _startup_imports(root: Path, options: list[str], env: dict[str, str]) -> set[str]: result: Final = subprocess.run( [sys.executable, *options, "-X", "importtime", "-m", "pipx", "--version"], check=True, capture_output=True, cwd=root, env=env, text=True, ) return {line.rsplit("|", 1)[-1].strip() for line in result.stderr.splitlines() if line.startswith("import time:")} pipx-1.18.16/tests/test_inject.py000066400000000000000000000564671527355705200167340ustar00rootroot00000000000000from __future__ import annotations import json import logging import re import shutil import subprocess import textwrap from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from packaging.utils import canonicalize_name from helpers import ( PIPX_METADATA_LEGACY_VERSIONS, SKIP_IF_WINDOWS, app_name, mock_legacy_venv, module_version, run_pipx_cli, write_wheel, ) from package_info import PKG from pipx import paths from pipx.pipx_metadata_file import PackageInfo, PipxMetadata from pipx.redact import redact_url_credentials if TYPE_CHECKING: from collections.abc import Callable from _pytest.capture import CaptureResult @pytest.mark.usefixtures("installed_pycowsay") def test_inject_json(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["inject", "--output", "json", "pycowsay", PKG["black"]["spec"]]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["inject"], "data": { "packages": [ { "environment": "pycowsay", "location": str(paths.CTX.venvs / "pycowsay"), "package": "black", "status": "injected", "version": "22.8.0", } ], "skipped": [], }, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", }, "", ) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_json_reports_locked_environment( make_pylock: Callable[[str, str], Path], capsys: pytest.CaptureFixture[str], ) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert not run_pipx_cli(["install", "--lock", str(lock_file), "pycowsay"]) capsys.readouterr() assert run_pipx_cli(["inject", "--output", "json", "pycowsay", "black"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["inject"], "data": {"packages": [], "skipped": []}, "errors": [ { "code": "package_inject_failed", "environment": "pycowsay", "message": ( f"Cannot inject into locked environment pycowsay. Update {lock_file} " "and run `pipx reinstall pycowsay`." ), "package": "black", } ], "exit_code": 1, "pipx_result_version": "1", "status": "error", }, "", ) @pytest.mark.usefixtures("installed_pycowsay") def test_inject_json_reports_already_installed( capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["inject", "--output", "json", "pycowsay", PKG["black"]["spec"]]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["inject"], "data": { "packages": [], "skipped": [ { "environment": "pycowsay", "package": "black", "reason": "already-installed", } ], }, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", }, "", ) @pytest.mark.usefixtures("installed_pycowsay") def test_inject_json_reports_partial_failure( empty_project: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: missing_project: Final[Path] = tmp_path / "missing-project" assert run_pipx_cli(["inject", "--output", "json", "pycowsay", str(empty_project), str(missing_project)]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert ( captured.err, json.loads(captured.out), ) == ( "", { "command": ["inject"], "data": { "packages": [ { "environment": "pycowsay", "location": str(paths.CTX.venvs / "pycowsay"), "package": "empty-project", "status": "injected", "version": "0.1.0", } ], "skipped": [], }, "errors": [ { "code": "package_inject_failed", "environment": "pycowsay", "message": f"Unable to parse package spec: {missing_project}", "package": str(missing_project), } ], "exit_code": 1, "pipx_result_version": "1", "status": "partial", }, ) @pytest.mark.usefixtures("installed_pycowsay") def test_inject_json_reports_no_packages( capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["inject", "--output", "json", "pycowsay"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["inject"], "data": {"packages": [], "skipped": []}, "errors": [ { "code": "package_inject_failed", "environment": "pycowsay", "message": "No packages to inject; pass a package spec or --requirement.", "package": None, } ], "exit_code": 1, "pipx_result_version": "1", "status": "error", }, "", ) @pytest.mark.usefixtures("installed_pycowsay") def test_inject_quiet(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["inject", "-qq", "pycowsay", "black"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert ( "injected package" not in captured.out, "done!" not in captured.err, ) == (True, True) @pytest.mark.usefixtures("installed_pycowsay") def test_inject_warns_system_site_packages_is_ignored(caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"], "--system-site-packages"]) assert any( record.levelno == logging.WARNING and "Ignoring --system-site-packages" in record.getMessage() for record in caplog.records ) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_rejects_pylock( make_pylock: Callable[[str, str], Path], capsys: pytest.CaptureFixture[str], ) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert not run_pipx_cli(["install", "--lock", str(lock_file), "pycowsay"]) capsys.readouterr() assert run_pipx_cli(["inject", "pycowsay", "black"]) error: Final[str] = " ".join(capsys.readouterr().err.split()) assert ( "Cannot inject into locked environment pycowsay" in error and "`pipx reinstall pycowsay`" in error, PipxMetadata(paths.CTX.venvs / "pycowsay").injected_packages, ) == (True, {}) # also checks that the packages other tests inject install on their own @pytest.mark.parametrize( "pkg_spec", [ pytest.param(PKG["black"]["spec"], id="black"), pytest.param(PKG["nox"]["spec"], id="nox"), pytest.param(PKG["pylint"]["spec"], id="pylint"), pytest.param("jaraco.clipboard==2.0.1", id="dotted-name"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_single_package( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, pkg_spec: str, ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", pkg_spec]) assert ( f"Injecting packages: {[pkg_spec]!r}" in caplog.text, set(re.findall(r"injected package (.+?) into venv pycowsay", capsys.readouterr().out)), ) == (True, {pkg_spec.split("=", 1)[0].replace(".", "-")}) @pytest.mark.parametrize( ("backend", "suffix"), [ pytest.param("pip", "", id="pip"), pytest.param( "uv", "_x", id="uv", marks=pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH"), ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_main_package_extra( local_extras_wheel: Path, capsys: pytest.CaptureFixture[str], backend: str, suffix: str, ) -> None: assert not run_pipx_cli(["install", str(local_extras_wheel), "--backend", backend, f"--suffix={suffix}"]) venv_dir: Final[Path] = paths.CTX.venvs / canonicalize_name(f"repeatme{suffix}") environment: Final[str | None] = PipxMetadata(venv_dir).environment assert environment is not None assert not run_pipx_cli(["inject", environment, f"{local_extras_wheel}[cow]", "--backend", backend]) assert not run_pipx_cli(["reinstall", environment]) output: Final[str] = subprocess.run( [paths.CTX.bin_dir / app_name(f"repeatme{suffix}"), "hello"], capture_output=True, text=True, check=True, ).stdout capsys.readouterr() assert not run_pipx_cli(["list", "--include-injected"]) list_output: Final[str] = capsys.readouterr().out assert ( "In cow, you said:" in output, "injected in venv" in list_output, environment, ) == (True, False, venv_dir.name) @pytest.mark.parametrize( ("installed", "injected", "recorded"), [ pytest.param("{wheel}", "repeatme[cow]", "{wheel}[cow]", id="by-name"), pytest.param("{wheel}", "RepeatMe[cow]", "{wheel}[cow]", id="by-unnormalized-name"), pytest.param("{wheel}[tools]", "repeatme[cow]", "{wheel}[cow,tools]", id="merges-recorded-extras"), pytest.param("{wheel}[tools]", "{wheel}[cow]", "{wheel}[cow,tools]", id="by-recorded-path"), pytest.param("{uri}", "repeatme[cow] @ {uri}", "repeatme[cow] @ {uri}", id="by-recorded-url"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_main_package_extra_keeps_recorded_source( local_extras_wheel: Path, installed: str, injected: str, recorded: str ) -> None: sources: Final[dict[str, str]] = {"wheel": str(local_extras_wheel), "uri": local_extras_wheel.as_uri()} assert not run_pipx_cli(["install", installed.format_map(sources)]) assert not run_pipx_cli(["inject", "repeatme", injected.format_map(sources)]) assert PipxMetadata(paths.CTX.venvs / "repeatme").main_package.package_or_url == recorded.format_map(sources) @pytest.mark.parametrize( "injected", [pytest.param("repeatme[cow]==0.1", id="version"), pytest.param("repeatme[cow] @ {uri}", id="other-source")], ) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_main_package_extra_with_other_source_needs_force( local_extras_wheel: Path, capsys: pytest.CaptureFixture[str], injected: str ) -> None: assert not run_pipx_cli(["install", str(local_extras_wheel)]) capsys.readouterr() assert not run_pipx_cli(["inject", "repeatme", injected.format(uri=local_extras_wheel.as_uri())]) assert ( "Pass '--force' to force installation." in " ".join(capsys.readouterr().out.split()), PipxMetadata(paths.CTX.venvs / "repeatme").main_package.package_or_url, ) == (True, str(local_extras_wheel)) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_main_package_extra_missing_recorded_source( local_extras_wheel: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: wheel: Final[Path] = Path(shutil.copy(local_extras_wheel, tmp_path)) assert not run_pipx_cli(["install", str(wheel)]) wheel.unlink() capsys.readouterr() assert run_pipx_cli(["inject", "repeatme", "repeatme[cow]"]) assert f"Therecordedsource{wheel}of'repeatme'nolongerexists." in "".join(capsys.readouterr().err.split()) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env", "capsys") def test_inject_simple_global() -> None: assert not run_pipx_cli(["install", "--global", "pycowsay"]) assert not run_pipx_cli(["inject", "--global", "pycowsay", PKG["pbr"]["spec"]]) @pytest.mark.parametrize( "metadata_version", [version for version in PIPX_METADATA_LEGACY_VERSIONS if version is not None] ) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_simple_legacy_venv(metadata_version: str) -> None: assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=metadata_version) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_legacy_venv_without_metadata(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=None) assert run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) assert "Uninstall and install" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env", "capsys") def test_inject_include_apps() -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"], "--include-deps"]) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"], "--include-deps"]) @pytest.mark.usefixtures("pipx_temp_env", "capsys") def test_inject_include_apps_with_suffix() -> None: assert not run_pipx_cli(["install", "pycowsay", "--suffix=_x"]) assert not run_pipx_cli(["inject", "pycowsay_x", PKG["black"]["spec"], "--include-deps"]) assert run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"], "--include-deps"]) assert not run_pipx_cli(["inject", "pycowsay_x", PKG["black"]["spec"], "--include-deps"]) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_local_project_uses_built_name_when_backend_ignores_project_table( empty_project_wheel: Path, project_ignoring_project_table: Path ) -> None: assert not run_pipx_cli(["install", "--backend", "pip", str(empty_project_wheel)]) assert not run_pipx_cli(["inject", "--include-apps", "empty-project", str(project_ignoring_project_table)]) assert ( list(PipxMetadata(paths.CTX.venvs / "empty-project").injected_packages), (paths.CTX.bin_dir / app_name("realapp")).exists(), ) == (["real-pkg"], True) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_include_resources_from_dependency( local_extras_wheel: Path, empty_project_wheel: Path, ) -> None: assert not run_pipx_cli(["install", str(empty_project_wheel)]) assert not run_pipx_cli([ "inject", "empty-project", f"{local_extras_wheel}[tools]", "--include-resources-from", "PyCowsay", ]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "empty-project").injected_packages["repeatme"] assert ( metadata.include_apps, metadata.include_resources_from, (paths.CTX.bin_dir / app_name("repeatme")).exists(), (paths.CTX.bin_dir / app_name("pycowsay")).exists(), (paths.CTX.bin_dir / app_name("black")).exists(), ) == (True, ["pycowsay"], True, True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_main_package_preserves_included_dependency( local_extras_wheel: Path, ) -> None: assert not run_pipx_cli(["install", f"{local_extras_wheel}[tools]", "--include-resources-from", "pycowsay"]) assert not run_pipx_cli(["inject", "repeatme", f"{local_extras_wheel}[cow]"]) assert PipxMetadata(paths.CTX.venvs / "repeatme").main_package.include_resources_from == ["pycowsay"] @pytest.mark.usefixtures("pipx_temp_env") def test_inject_force_replaces_included_dependency_resources( local_extras_wheel: Path, empty_project_wheel: Path, ) -> None: package: Final[str] = f"{local_extras_wheel}[tools]" assert not run_pipx_cli(["install", str(empty_project_wheel)]) assert not run_pipx_cli(["inject", "empty-project", package, "--include-resources-from", "pycowsay"]) assert not run_pipx_cli(["inject", "empty-project", package, "--force", "--include-resources-from", "black"]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "empty-project").injected_packages["repeatme"] assert ( metadata.include_resources_from, (paths.CTX.bin_dir / app_name("pycowsay")).exists(), (paths.CTX.man_dir / "man6" / "pycowsay.6").exists(), (paths.CTX.bin_dir / app_name("black")).exists(), ) == (["black"], False, False, True) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_include_resources_from_missing_dependency_rolls_back( local_extras_wheel: Path, empty_project_wheel: Path, capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["install", str(empty_project_wheel)]) capsys.readouterr() assert run_pipx_cli(["inject", "empty-project", f"{local_extras_wheel}[cow]", "--include-resources-from", "black"]) error: Final[str] = " ".join(capsys.readouterr().err.split()) assert ( "Cannot expose resources from black for package repeatme. " "Dependencies with apps, manual pages, or shell completions: pycowsay." in error, bool(run_pipx_cli(["runpip", "empty-project", "show", "repeatme"])), PipxMetadata(paths.CTX.venvs / "empty-project").injected_packages, ) == (True, True, {}) @pytest.mark.parametrize( "requirements", [ pytest.param("", id="no-apps"), pytest.param("Requires-Dist: pycowsay==0.0.0.2\n", id="apps-only-in-dependencies"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_include_apps_without_apps_rolls_back( make_wheel: Callable[[dict[str, str]], Path], empty_project_wheel: Path, requirements: str ) -> None: library: Final[Path] = make_wheel({ "foo_bar_x.py": "", "foo_bar_x-1.dist-info/METADATA": f"Metadata-Version: 2.1\nName: foo-bar-x\nVersion: 1\n{requirements}", "foo_bar_x-1.dist-info/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", "foo_bar_x-1.dist-info/RECORD": "", }) assert not run_pipx_cli(["install", str(empty_project_wheel)]) assert run_pipx_cli(["inject", "empty-project", str(library), "--include-apps"]) assert ( run_pipx_cli(["runpip", "empty-project", "show", "foo-bar-x"]), PipxMetadata(paths.CTX.venvs / "empty-project").injected_packages, ) == (1, {}) @pytest.mark.parametrize( "with_packages", [ pytest.param((), id="no-extra-packages"), pytest.param(("black",), id="duplicate-from-requirements-file"), pytest.param(("pbr",), id="additional-package"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_inject_with_req_file( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, tmp_path: Path, with_packages: tuple[str, ...], ) -> None: caplog.set_level(logging.INFO) req_file: Final[Path] = tmp_path / "inject-requirements.txt" req_file.write_text( textwrap.dedent( f""" {PKG["black"]["spec"]} # a comment inline {PKG["nox"]["spec"]} {PKG["isort"]["spec"]} # comment on separate line """ ).strip() ) assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli([ "inject", "pycowsay", *(PKG[pkg]["spec"] for pkg in with_packages), "--requirement", str(req_file), ]) packages: Final[list[tuple[str, str]]] = sorted({ (pkg, PKG[pkg]["spec"]) for pkg in ("black", "nox", "isort", *with_packages) }) assert ( f"Injecting packages: {[spec for _, spec in packages]!r}" in caplog.text, set(re.findall(r"injected package (.+?) into venv pycowsay", capsys.readouterr().out)), ) == (True, {pkg for pkg, _ in packages}) @pytest.mark.usefixtures("installed_pycowsay") def test_force_inject_keeps_injected_pin() -> None: assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) assert not run_pipx_cli(["pin", "pycowsay", "--injected-only"]) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"], "--force"]) assert PipxMetadata(paths.CTX.venvs / "pycowsay").injected_packages["black"].pinned @pytest.mark.usefixtures("pipx_temp_env") def test_force_inject_reinstalls_without_storing_force(caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"], "--pip-args=--no-cache-dir"]) caplog.clear() assert not run_pipx_cli([ "inject", "pycowsay", PKG["black"]["spec"], "--force", "--verbose", "--pip-args=--no-cache-dir", ]) assert ( "--force-reinstall" in caplog.text, PipxMetadata(paths.CTX.venvs / "pycowsay").injected_packages["black"].pip_args, ) == (True, ["--no-cache-dir"]) @pytest.mark.parametrize( ("backend", "inject_flags"), [ pytest.param("pip", [["--force"]], id="pip-first-inject"), pytest.param("uv", [[], ["--force"]], id="uv-reinject"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_force_inject_keeps_main_package_a_plugin_depends_on( tmp_path: Path, backend: str, inject_flags: list[list[str]] ) -> None: plugin_index: Final[Path] = tmp_path / "plugin-index" write_wheel(plugin_index, "plugin", "1.0", requires=("tool",)) write_wheel(plugin_index, "tool", "99.0") assert not run_pipx_cli(["install", "--backend", backend, str(write_wheel(tmp_path / "local", "tool", "1.0"))]) inject: Final[list[str]] = ["inject", "tool", "plugin", f"--pip-args=--no-index --find-links={plugin_index}"] assert not any(run_pipx_cli([*inject, *flags]) for flags in inject_flags) assert module_version("tool") == "1.0" @pytest.mark.usefixtures("pipx_temp_env") def test_inject_inherits_cooldown( package_cache_dir: Path, empty_project_wheel: Path, caplog: pytest.LogCaptureFixture, ) -> None: pip_args: Final[str] = f"--pip-args=--no-index --find-links={package_cache_dir}" assert not run_pipx_cli(["install", "--cooldown", "7", pip_args, PKG["pycowsay"]["spec"]]) caplog.clear() assert not run_pipx_cli(["inject", "pycowsay", pip_args, str(empty_project_wheel)]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") assert ( "--uploaded-prior-to P7D" in caplog.text, metadata.injected_packages["empty-project"].cooldown_days, ) == (True, 7) _CREDENTIALED_SPEC: Final[str] = "plugin @ http://u:s3cr3ttok@127.0.0.1:9/plugin-1.0-py3-none-any.whl" @pytest.mark.usefixtures("pipx_temp_env") def test_inject_logs_packages_without_url_credentials(caplog: pytest.LogCaptureFixture) -> None: run_pipx_cli(["inject", "missing-venv", _CREDENTIALED_SPEC]) assert f"Injecting packages: {[redact_url_credentials(_CREDENTIALED_SPEC)]!r}" in caplog.text @pytest.mark.usefixtures("pipx_temp_env") def test_inject_json_failure_reports_package_without_url_credentials(capsys: pytest.CaptureFixture[str]) -> None: run_pipx_cli(["inject", "--output", "json", "missing-venv", _CREDENTIALED_SPEC]) assert json.loads(capsys.readouterr().out)["errors"][0]["package"] == redact_url_credentials(_CREDENTIALED_SPEC) pipx-1.18.16/tests/test_install.py000066400000000000000000002556751527355705200171300ustar00rootroot00000000000000from __future__ import annotations import json import os import re import shutil import subprocess import sys import threading from contextlib import contextmanager from functools import partial from http.server import SimpleHTTPRequestHandler from pathlib import Path from socketserver import ThreadingTCPServer from typing import TYPE_CHECKING, Final import pytest from helpers import ( SKIP_IF_WINDOWS, MakePylock, app_name, current_log_file, drop_extra_app, run_pipx_cli, unwrap_log_text, ) from package_info import PKG from pipx import main, paths, shared_libs from pipx.constants import EXIT_CODE_OK from pipx.pipx_metadata_file import PIPX_INFO_FILENAME, PackageInfo, PipxMetadata from pipx.redact import redact_url_credentials from pipx.venv import Venv if TYPE_CHECKING: from collections.abc import Callable, Generator, Sequence from unittest.mock import MagicMock from _pytest.capture import CaptureResult from pytest_mock import MockerFixture def test_help_text(capsys: pytest.CaptureFixture[str], mocker: MockerFixture) -> None: mocker.patch.object(sys, "exit", side_effect=ValueError("raised in test to exit early")) with pytest.raises(ValueError, match="raised in test to exit early"): run_pipx_cli(["install", "--help"]) assert "apps you can run from anywhere" in capsys.readouterr().out @pytest.mark.parametrize("backend", ["pip", "uv"]) @pytest.mark.usefixtures("pipx_temp_env") def test_install_wheel_from_file_url(dotted_app_wheel: Path, tmp_path: Path, backend: str) -> None: wheel_dir: Final[Path] = tmp_path / "wheels with spaces" wheel_dir.mkdir() package_spec: Final[str] = dotted_app_wheel.rename(wheel_dir / dotted_app_wheel.name).as_uri() assert not run_pipx_cli(["install", "--backend", backend, package_spec]) assert ( PipxMetadata(paths.CTX.venvs / "foo-bar-x").main_package.package_or_url, (paths.CTX.bin_dir / app_name("tool3.12")).is_file(), ) == (package_spec, True) @pytest.mark.parametrize( ("package_name", "package_spec"), [("pycowsay", "pycowsay"), ("black", PKG["black"]["spec"])], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_easy_packages( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, package_name: str, package_spec: str, ) -> None: install_packages(capsys, caplog, [package_spec], [package_name]) def install_packages( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, packages: list[str], package_names: Sequence[str], ) -> None: run_pipx_cli(["install", *packages, "--verbose"]) out: Final[str] = capsys.readouterr().out assert ( [f"installed package {package_name}" in out for package_name in package_names], # https://github.com/pypa/pipx/issues/217 tracks checking this warning on Windows too "symlink missing or pointing to unexpected location" in out and not sys.platform.startswith("win"), "not modifying" in out, "is not on your PATH environment variable" in out, "โš ๏ธ" in caplog.text, "WARNING" in caplog.text, ) == ([True] * len(package_names), False, False, False, False, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_easy_multiple_packages( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, ) -> None: install_packages( capsys, caplog, ["pycowsay", PKG["black"]["spec"]], ["pycowsay", "black"], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_multiple_packages_reports_success_before_failure(capsys: pytest.CaptureFixture[str]) -> None: missing_package: Final[str] = "pipx-package-does-not-exist" return_code: Final[int] = run_pipx_cli(["install", "pycowsay", missing_package]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert ( return_code, "installed package pycowsay" in captured.out, "Error installing pipx-package-does-not-exist" in captured.err, (paths.CTX.venvs / "pycowsay").exists(), (paths.CTX.venvs / missing_package).exists(), ) == (1, True, True, True, False) @pytest.mark.parametrize( "suffix", [ pytest.param("/../../owned", id="parent-escape"), pytest.param("nested/name", id="separator"), pytest.param("has space", id="whitespace"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_rejects_unsafe_suffix(capsys: pytest.CaptureFixture[str], suffix: str) -> None: return_code: Final[int] = run_pipx_cli(["install", "--suffix", suffix, "pycowsay"]) assert (return_code, "Invalid suffix" in capsys.readouterr().err, (paths.CTX.venvs / "pycowsay").exists()) == ( 1, True, False, ) @pytest.mark.parametrize( ("package_name", "package_spec"), [("pycowsay", "pycowsay"), ("black", PKG["black"]["spec"])], ) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_install_easy_packages_globally( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, package_name: str, package_spec: str, ) -> None: install_packages(capsys, caplog, [package_spec], [package_name]) @pytest.mark.skipif(bool(os.getenv("FAST")), reason="skipping slow tests") @pytest.mark.usefixtures("pipx_temp_env") def test_install_tricky_packages(capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture) -> None: install_packages(capsys, caplog, [PKG["shell-functools"]["spec"]], ["shell-functools"]) @pytest.mark.usefixtures("pipx_temp_env") def test_install_multiple_packages_when_some_already_installed(capsys: pytest.CaptureFixture[str]) -> None: run_pipx_cli(["install", "black", "pycowsay"]) first: Final[str] = capsys.readouterr().out assert ("installed package black" in first, "installed package pycowsay" in first) == (True, True) run_pipx_cli(["install", "black", "pycowsay", "isort"]) second: Final[str] = capsys.readouterr().out assert ( "black" in second, "pycowsay" in second, "is already installed" in second, "installed package isort" in second, ) == (True, True, True, True) @pytest.mark.skipif(bool(os.getenv("FAST")), reason="skipping slow tests") @pytest.mark.usefixtures("pipx_temp_env") def test_install_tricky_multiple_packages( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, ) -> None: packages: Final[list[str]] = ["pycowsay", "shell-functools"] install_packages(capsys, caplog, [PKG[package]["spec"] for package in packages], packages) @pytest.mark.usefixtures("pipx_temp_env") def test_install_package_spec_pinned( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, ) -> None: install_packages(capsys, caplog, [PKG["isort"]["spec"]], ["isort"]) @pytest.mark.usefixtures("pipx_temp_env") def test_install_package_spec_vcs_url( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, empty_project: Path, ) -> None: install_packages(capsys, caplog, [_local_git_spec(empty_project)], ["empty-project"]) def _local_git_spec(project: Path) -> str: git: Final[str | None] = shutil.which("git") assert git is not None config: Final[list[str]] = ["-c", "user.name=pipx tests", "-c", "user.email=pipx@example.invalid"] for command in ( ["init", "--quiet"], ["add", "."], [*config, "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "test fixture"], ): subprocess.run([git, *command], cwd=project, check=True) return f"git+{project.as_uri()}" @pytest.mark.usefixtures("pipx_temp_env") def test_install_package_spec_archive_url( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, empty_project: Path, ) -> None: with _served_archive_spec(empty_project) as package_spec: install_packages(capsys, caplog, [package_spec], ["empty-project"]) @contextmanager def _served_archive_spec(project: Path) -> Generator[str, None, None]: archive: Final[Path] = Path( shutil.make_archive(str(project.parent / "empty-project-0.1.0"), "zip", project.parent, project.name) ) handler: Final = partial(SimpleHTTPRequestHandler, directory=str(project.parent)) # not http.server.HTTPServer: its bind resolves the host name, which stalls for 30 s on macOS CI runners with ThreadingTCPServer(("127.0.0.1", 0), handler) as server: threading.Thread(target=server.serve_forever, daemon=True).start() try: yield f"http://127.0.0.1:{server.server_address[1]}/{archive.name}" finally: server.shutdown() @pytest.mark.usefixtures("pipx_temp_env") def test_force_install(capsys: pytest.CaptureFixture[str]) -> None: run_pipx_cli(["install", "pycowsay"]) assert "installed package" in capsys.readouterr().out run_pipx_cli(["install", "pycowsay"]) skipped: Final[str] = capsys.readouterr().out assert ( "installed package" in skipped, "pycowsay" in skipped, "is already installed" in skipped, ) == (False, True, True) run_pipx_cli(["install", "pycowsay", "--force"]) assert "Installing to existing venv" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_force_install_does_not_record_internal_pip_args() -> None: assert run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) == 0 assert ( run_pipx_cli(["install", PKG["pycowsay"]["spec"], "--force"]), PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.pip_args, ) == (0, []) @pytest.mark.parametrize( ("pip_args", "recorded"), [ pytest.param("-r requirements.txt", [], id="requirement"), pytest.param("--requirement=requirements.txt", [], id="requirement-equals"), pytest.param("-rrequirements.txt", [], id="requirement-attached"), pytest.param("-qr requirements.txt", ["-q"], id="requirement-clustered"), pytest.param("--log pip.log", [], id="log"), pytest.param("--log-f pip.log", [], id="log-abbreviated"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_does_not_record_one_shot_pip_args( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pip_args: str, recorded: list[str] ) -> None: (tmp_path / "requirements.txt").write_text("", encoding="utf-8") monkeypatch.chdir(tmp_path) assert not run_pipx_cli(["install", "--backend", "pip", f"--pip-args=--no-cache-dir {pip_args}", "pycowsay"]) assert PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.pip_args == ["--no-cache-dir", *recorded] @pytest.mark.usefixtures("pipx_temp_env") def test_install_applies_one_shot_pip_args_to_its_own_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) assert not run_pipx_cli(["install", "--backend", "pip", "--pip-args=--log pip.log", "pycowsay"]) assert (tmp_path / "pip.log").stat().st_size @pytest.mark.parametrize("backend", ["pip", "uv"]) @pytest.mark.usefixtures("pipx_temp_env") def test_failed_force_install_keeps_recorded_metadata( dotted_app_wheel: Path, make_wheel: Callable[[dict[str, str]], Path], backend: str ) -> None: assert not run_pipx_cli(["install", "--backend", backend, str(dotted_app_wheel)]) metadata_path: Final[Path] = paths.CTX.venvs / "foo-bar-x" / PIPX_INFO_FILENAME recorded: Final[str] = metadata_path.read_text(encoding="utf-8") # data files unpack into the venv root before the installer writes INSTALLER, here a directory, and fails make_wheel({ "foo_bar_x-1.data/data/pipx_metadata.json": "{}", "foo_bar_x-1.dist-info/INSTALLER/placeholder": "", "foo_bar_x-1.dist-info/METADATA": "Metadata-Version: 2.1\nName: foo-bar-x\nVersion: 1\n", "foo_bar_x-1.dist-info/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", "foo_bar_x-1.dist-info/RECORD": "", }) assert ( run_pipx_cli(["install", "--force", str(dotted_app_wheel)]), metadata_path.read_text(encoding="utf-8"), ) == (1, recorded) @pytest.mark.parametrize( ("package_name", "display_name", "install_args", "has_dependency_apps"), [ pytest.param( "pygdbmi", "pygdbmi_test", [PKG["pygdbmi"]["spec"], "--suffix=_test"], False, id="library-with-suffix", ), pytest.param("requests", "requests", ["requests==2.31.0"], True, id="dependency-apps"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_no_apps_guidance( capsys: pytest.CaptureFixture[str], package_name: str, display_name: str, install_args: list[str], has_dependency_apps: bool, ) -> None: return_code: Final[int] = run_pipx_cli(["install", *install_args]) # pipx wraps the message to the terminal width, which can split any of the phrases below across lines error: Final[str] = " ".join(capsys.readouterr().err.split()) assert ( return_code, f"No apps associated with package {display_name}" in error, f"pipx inject {package_name}" in error, f"--preinstall {package_name}" in error, "--include-resources-from PACKAGE" in error, ) == (1, True, True, True, has_dependency_apps) @pytest.mark.usefixtures("pipx_temp_env") def test_install_records_expected_app() -> None: assert not run_pipx_cli(["install", "--app", "pycowsay", PKG["pycowsay"]["spec"]]) assert PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.expected_apps == ["pycowsay"] # the grouped row mirrors a `pipx manifest lock` output: nab gates every package on the tool's group and the lock # names that group as its default @pytest.mark.parametrize("group", [pytest.param(None, id="flat"), pytest.param("pycowsay", id="grouped")]) @pytest.mark.parametrize( "backend", [ pytest.param("pip", id="pip"), pytest.param("uv", id="uv", marks=pytest.mark.skipif(shutil.which("uv") is None, reason="uv is not installed")), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_from_pylock(make_pylock: MakePylock, backend: str, group: str | None) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2", group) assert not run_pipx_cli(["install", "--backend", backend, "--lock", str(lock_file), "pycowsay>=0"]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "pycowsay").main_package assert ( metadata.package_version, metadata.lock_file, (paths.CTX.bin_dir / app_name("pycowsay")).exists(), ) == ("0.0.0.2", lock_file.resolve(), True) @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") @pytest.mark.parametrize( ("recorded", "other"), [pytest.param("pip", "uv", id="pip"), pytest.param("uv", "pip", id="uv")] ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_force_pylock_keeps_the_recorded_backend(make_pylock: MakePylock, recorded: str, other: str) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2", None) assert not run_pipx_cli(["install", "--backend", recorded, PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["install", "--force", "--backend", other, "--lock", str(lock_file), "pycowsay>=0"]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") assert (metadata.backend, metadata.main_package.lock_file) == (recorded, lock_file.resolve()) @pytest.mark.parametrize( ("backend", "backend_option"), [ pytest.param("pip", "--uploaded-prior-to P7D", id="pip"), pytest.param( "uv", "--exclude-newer P7D", id="uv", marks=pytest.mark.skipif(shutil.which("uv") is None, reason="uv is not installed"), ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_cooldown( package_cache_dir: Path, caplog: pytest.LogCaptureFixture, backend: str, backend_option: str ) -> None: assert not run_pipx_cli([ "install", "--backend", backend, "--cooldown", "7", f"--pip-args=--no-index --find-links={package_cache_dir}", PKG["pycowsay"]["spec"], ]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "pycowsay").main_package assert (backend_option in caplog.text, metadata.cooldown_days) == (True, 7) @pytest.mark.parametrize( ("cli_args", "expected_days"), [ pytest.param([], 7, id="env"), pytest.param(["--cooldown", "3"], 3, id="flag-wins"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_env_cooldown( package_cache_dir: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, cli_args: list[str], expected_days: int, ) -> None: monkeypatch.setattr(main, "_COOLDOWN", 7) assert not run_pipx_cli([ "install", "--backend", "pip", *cli_args, f"--pip-args=--no-index --find-links={package_cache_dir}", PKG["pycowsay"]["spec"], ]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "pycowsay").main_package assert (f"--uploaded-prior-to P{expected_days}D" in caplog.text, metadata.cooldown_days) == (True, expected_days) @pytest.mark.usefixtures("pipx_temp_env") def test_install_env_cooldown_opted_out_by_flag( package_cache_dir: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(main, "_COOLDOWN", 7) assert not run_pipx_cli([ "install", "--backend", "pip", "--cooldown", "0", f"--pip-args=--no-index --find-links={package_cache_dir}", PKG["pycowsay"]["spec"], ]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "pycowsay").main_package assert ("--uploaded-prior-to" in caplog.text, metadata.cooldown_days) == (False, 0) @pytest.mark.usefixtures("pipx_temp_env") def test_install_env_cooldown_outranks_recorded( package_cache_dir: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: pip_args: Final[str] = f"--pip-args=--no-index --find-links={package_cache_dir}" assert not run_pipx_cli(["install", "--backend", "pip", "--cooldown", "7", pip_args, PKG["pycowsay"]["spec"]]) caplog.clear() monkeypatch.setattr(main, "_COOLDOWN", 3) assert not run_pipx_cli(["install", "--backend", "pip", "--force", pip_args, PKG["pycowsay"]["spec"]]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "pycowsay").main_package assert ("--uploaded-prior-to P3D" in caplog.text, metadata.cooldown_days) == (True, 3) @pytest.mark.parametrize("value", [pytest.param("-1", id="negative"), pytest.param("invalid", id="not-an-integer")]) def test_install_rejects_invalid_cooldown(value: str, capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit, match="2"): run_pipx_cli(["install", "--cooldown", value, "pycowsay"]) assert "--cooldown must be a non-negative integer" in capsys.readouterr().err @pytest.mark.parametrize("editable", [pytest.param(False, id="wheel"), pytest.param(True, id="editable")]) @pytest.mark.usefixtures("pipx_temp_env") def test_install_source_from_pylock( make_pylock: Callable[[str, str], Path], make_project_with_dependency: Callable[[str], Path], editable: bool ) -> None: project: Final[Path] = make_project_with_dependency("pycowsay==0.0.0.2") lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert not run_pipx_cli(["install", *(["--editable"] if editable else []), "--lock", str(lock_file), str(project)]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "empty-project").main_package assert (metadata.package_version, metadata.lock_file, metadata.apps, metadata.pip_args) == ( "0.1.0", lock_file.resolve(), [app_name("empty-project")], ["--editable"] if editable else [], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_rejects_incomplete_pylock( make_pylock: Callable[[str, str], Path], make_project_with_dependency: Callable[[str], Path], capsys: pytest.CaptureFixture[str], ) -> None: project: Final[Path] = make_project_with_dependency("black==22.8.0") lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert run_pipx_cli(["install", "--lock", str(lock_file), str(project)]) assert ( "does not satisfy empty-project" in capsys.readouterr().err, (paths.CTX.venvs / "empty-project").exists(), ) == (True, False) @pytest.mark.parametrize( ("package_spec", "environment", "expected_error"), [ pytest.param("pycowsay==999", "pycowsay", r"does\s+not\s+satisfy\s+pycowsay==999", id="version"), pytest.param("black", "black", r"does\s+not\s+contain\s+black", id="package"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_rejects_target_outside_pylock( make_pylock: Callable[[str, str], Path], capsys: pytest.CaptureFixture[str], package_spec: str, environment: str, expected_error: str, ) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert run_pipx_cli(["install", "--lock", str(lock_file), package_spec]) error: Final[str] = unwrap_log_text(capsys.readouterr().err) assert ( re.search(expected_error, error) is not None, (paths.CTX.venvs / environment).exists(), ) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_rejects_invalid_pylock(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: (lock_file := tmp_path / "pylock.toml").write_text("invalid = true\n", encoding="utf-8") assert run_pipx_cli(["install", "--lock", str(lock_file), "pycowsay"]) assert ( "Error installing packages from" in unwrap_log_text(capsys.readouterr().err), (paths.CTX.venvs / "pycowsay").exists(), ) == (True, False) @pytest.mark.parametrize( "command", [ pytest.param(["install", "--force", "pycowsay"], id="force"), pytest.param(["reinstall", "pycowsay"], id="reinstall"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_reapplies_recorded_pylock(make_pylock: Callable[[str, str], Path], command: list[str]) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert not run_pipx_cli(["install", "--lock", str(lock_file), "pycowsay"]) (marker := paths.CTX.venvs / "pycowsay" / "marker").touch() assert not run_pipx_cli(command) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "pycowsay").main_package assert (marker.exists(), metadata.package_version, metadata.lock_file) == ( False, "0.0.0.2", lock_file.resolve(), ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_restores_environment_after_pylock_mismatch(make_pylock: Callable[[str, str], Path]) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert not run_pipx_cli(["install", "--lock", str(lock_file), "pycowsay"]) (marker := paths.CTX.venvs / "pycowsay" / "marker").touch() assert run_pipx_cli(["install", "--force", "--lock", str(lock_file), "pycowsay==999"]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "pycowsay").main_package assert (marker.exists(), metadata.package_version, metadata.lock_file) == ( True, "0.0.0.2", lock_file.resolve(), ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_upgrade_skips_pylock( make_pylock: Callable[[str, str], Path], capsys: pytest.CaptureFixture[str] ) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert not run_pipx_cli(["install", "--lock", str(lock_file), "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["install", "--upgrade", "pycowsay==999"]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "pycowsay").main_package assert ( "Not upgrading locked package pycowsay. Update its lock file and run `pipx reinstall pycowsay`." in unwrap_log_text(capsys.readouterr().out), metadata.package_version, metadata.lock_file, ) == (True, "0.0.0.2", lock_file.resolve()) @pytest.mark.usefixtures("pipx_temp_env") def test_install_rejects_cooldown_for_recorded_pylock( make_pylock: Callable[[str, str], Path], capsys: pytest.CaptureFixture[str] ) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert not run_pipx_cli(["install", "--lock", str(lock_file), "pycowsay"]) capsys.readouterr() assert run_pipx_cli(["install", "--force", "--cooldown", "7", "pycowsay"]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "pycowsay").main_package assert ( "--cooldown cannot modify a locked environment" in unwrap_log_text(capsys.readouterr().err), metadata.package_version, metadata.lock_file, ) == (True, "0.0.0.2", lock_file.resolve()) @pytest.mark.usefixtures("pipx_temp_env") def test_install_env_cooldown_ignored_for_pylock( make_pylock: Callable[[str, str], Path], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(main, "_COOLDOWN", 7) lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert not run_pipx_cli(["install", "--lock", str(lock_file), "pycowsay"]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "pycowsay").main_package assert (metadata.cooldown_days, metadata.lock_file) == (None, lock_file.resolve()) @pytest.mark.parametrize( ("package_args", "created_name", "lock_name", "expected_error"), [ pytest.param( ["pycowsay", "black"], "pylock.test.toml", "pylock.test.toml", "--lock accepts one package spec", id="packages", ), pytest.param( ["--preinstall", "black", "pycowsay"], "pylock.test.toml", "pylock.test.toml", "--lock cannot be combined with --preinstall", id="preinstall", ), pytest.param( ["--upgrade", "pycowsay"], "pylock.test.toml", "pylock.test.toml", "--lock cannot be combined with --upgrade", id="upgrade", ), pytest.param( ["--cooldown", "7", "pycowsay"], "pylock.test.toml", "pylock.test.toml", "--lock cannot be combined with --cooldown", id="cooldown", ), pytest.param(["pycowsay"], "lock.toml", "lock.toml", "Rename the lock file 'lock.toml'", id="name"), pytest.param(["pycowsay"], "pylock.test.toml", "pylock.missing.toml", "Lock file does not exist", id="missing"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_rejects_invalid_pylock_options( make_pylock: Callable[[str, str], Path], tmp_path: Path, capsys: pytest.CaptureFixture[str], *, package_args: list[str], created_name: str, lock_name: str, expected_error: str, ) -> None: make_pylock("pycowsay", "0.0.0.2").rename(tmp_path / created_name) assert run_pipx_cli(["install", "--lock", str(tmp_path / lock_name), *package_args]) assert expected_error in unwrap_log_text(capsys.readouterr().err) @pytest.mark.parametrize( ("app_args", "expected_error"), [ pytest.param(["--app", "missing"], "expected app missing", id="one"), pytest.param( ["--app", "missing", "--app", "absent"], "expected apps absent, missing", id="multiple", ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_missing_expected_app_rolls_back( capsys: pytest.CaptureFixture[str], app_args: list[str], expected_error: str ) -> None: return_code: Final[int] = run_pipx_cli(["install", *app_args, PKG["pycowsay"]["spec"]]) error: Final[str] = " ".join(capsys.readouterr().err.split()) assert ( return_code, f"Package pycowsay does not provide {expected_error}. Available apps: pycowsay." in error, (paths.CTX.venvs / "pycowsay").exists(), (paths.CTX.bin_dir / app_name("pycowsay")).exists(), ) == (1, True, False, False) @pytest.mark.parametrize( ("package_spec", "setup_commands"), [ pytest.param("pycowsay>=0", [], id="satisfied"), pytest.param("pycowsay==999", [["pin", "pycowsay"]], id="pinned"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_upgrade_records_expected_app_without_package_change( package_spec: str, setup_commands: list[list[str]] ) -> None: for command in [["install", PKG["pycowsay"]["spec"]], *setup_commands]: assert not run_pipx_cli(command) assert not run_pipx_cli(["install", "--upgrade", "--app", "pycowsay", package_spec]) assert PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.expected_apps == ["pycowsay"] @pytest.mark.usefixtures("pipx_temp_env") def test_install_accepts_expected_dependency_app(local_extras_wheel: Path) -> None: assert not run_pipx_cli(["install", "--include-deps", "--app", "pycowsay", f"{local_extras_wheel}[cow]"]) assert PipxMetadata(paths.CTX.venvs / "repeatme").main_package.expected_apps == ["pycowsay"] @pytest.mark.usefixtures("pipx_temp_env") def test_install_upgrade_reexposes_expected_dependency_resources( root: Path, tmp_path: Path, local_manpage_wheel: Path ) -> None: project: Final[Path] = tmp_path / "expected-dependency-app" shutil.copytree(root / "testdata/empty_project", project) pyproject: Final[Path] = project / "pyproject.toml" pyproject.write_text( pyproject.read_text(encoding="utf-8").replace( 'requires-python = ">=3.10"\n', f'dependencies = [\n "local-manpage @ {local_manpage_wheel.as_uri()}",\n]\nrequires-python = ">=3.10"\n', ), encoding="utf-8", ) assert not run_pipx_cli(["install", "--include-deps", "--app", "local-manpage", str(project)]) app_path: Final[Path] = paths.CTX.bin_dir / app_name("local-manpage") man_path: Final[Path] = paths.CTX.man_dir / "man1/local-manpage.1" app_path.unlink() man_path.unlink() pyproject.write_text( pyproject.read_text(encoding="utf-8").replace('version = "0.1.0"', 'version = "0.2.0"'), encoding="utf-8", ) assert not run_pipx_cli(["install", "--upgrade", str(project)]) assert (app_path.exists(), man_path.exists()) == (True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_install_upgrade_removes_dropped_app(two_app_project: Path, capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", str(two_app_project)]) drop_extra_app(two_app_project) assert not run_pipx_cli(["install", "--upgrade", str(two_app_project)]) capsys.readouterr() assert not run_pipx_cli(["list"]) assert ( sorted(path.name for path in paths.CTX.bin_dir.iterdir()), "empty-project-extra" in capsys.readouterr().out, ) == ([app_name("empty-project")], False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_expected_app_rejects_multiple_specs(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["install", "--app", "pycowsay", "pycowsay", PKG["black"]["spec"]]) assert ( "--app accepts one package spec" in capsys.readouterr().err, (paths.CTX.venvs / "pycowsay").exists(), (paths.CTX.venvs / "black").exists(), ) == (True, False, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_force_preserves_expected_app() -> None: assert not run_pipx_cli(["install", "--app", "pycowsay", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["install", "--force", PKG["pycowsay"]["spec"]]) assert ( PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.expected_apps, tuple(paths.CTX.venvs.glob(".pycowsay-*-pipx-backup")), ) == (["pycowsay"], ()) @pytest.mark.parametrize("locked", [pytest.param(False, id="plain"), pytest.param(True, id="lock")]) @pytest.mark.usefixtures("pipx_temp_env") def test_install_force_keeps_pin(make_pylock: MakePylock, locked: bool) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["pin", "pycowsay"]) lock_args: Final[list[str]] = ["--lock", str(make_pylock("pycowsay", "0.0.0.2"))] if locked else [] assert not run_pipx_cli(["install", "--force", *lock_args, PKG["pycowsay"]["spec"]]) assert PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.pinned @pytest.mark.usefixtures("pipx_temp_env") def test_install_backup_failure_preserves_existing_environment( capsys: pytest.CaptureFixture[str], mocker: MockerFixture ) -> None: assert not run_pipx_cli(["install", "--app", "pycowsay", PKG["pycowsay"]["spec"]]) mocker.patch("pipx.commands.transaction.copytree", autospec=True, side_effect=OSError("disk full")) assert run_pipx_cli(["install", "--force", PKG["pycowsay"]["spec"]]) assert ( "pipx could not back up environment pycowsay: disk full" in capsys.readouterr().err, PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.expected_apps, (paths.CTX.bin_dir / app_name("pycowsay")).exists(), ) == (True, ["pycowsay"], True) @pytest.mark.parametrize( "command", [ pytest.param(["install", "--force", "{project}"], id="install-force"), pytest.param(["install", "--upgrade", "{project}"], id="install-upgrade"), pytest.param(["upgrade", "empty-project"], id="upgrade"), pytest.param(["reinstall", "empty-project"], id="reinstall"), ], ) def test_install_missing_recorded_app_restores_existing_environment( missing_expected_app_project: Path, capsys: pytest.CaptureFixture[str], command: list[str], ) -> None: assert run_pipx_cli([part.format(project=missing_expected_app_project) for part in command]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "empty-project").main_package error: Final[str] = " ".join(capsys.readouterr().err.split()) assert ( "Package empty-project does not provide expected app empty-project. Available apps: none." in error, metadata.package_version, metadata.expected_apps, metadata.apps, (paths.CTX.bin_dir / app_name("empty-project")).exists(), ) == (True, "0.1.0", ["empty-project"], [app_name("empty-project")], True) @pytest.fixture def missing_expected_app_project( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # required so the temp env is active while the project is built capsys: pytest.CaptureFixture[str], root: Path, tmp_path: Path, ) -> Path: project: Final[Path] = tmp_path / "expected-app-project" shutil.copytree(root / "testdata/empty_project", project) assert not run_pipx_cli(["install", "--app", "empty-project", str(project)]) capsys.readouterr() pyproject: Final[Path] = project / "pyproject.toml" pyproject.write_text( pyproject .read_text(encoding="utf-8") .replace('version = "0.1.0"', 'version = "0.2.0"') .replace('scripts.empty-project = "empty_project.main:cli"\n', "") .replace('entry-points."pipx.run".empty-project = "empty_project.main:cli"\n', ""), encoding="utf-8", ) return project @pytest.mark.usefixtures("pipx_temp_env") def test_install_same_package_twice_no_force(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["install", "pycowsay"]) out: Final[str] = capsys.readouterr().out assert ("is already installed" in out, "pipx upgrade" in out, "0.0.0.2" in out) == (True, True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_install_upgrade_installs_missing_package(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "--upgrade", PKG["black"]["spec"]]) assert "installed package black 22.8.0" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_install_upgrade_reconciles_package_spec(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["black"]["spec"]]) assert not run_pipx_cli(["install", "--upgrade", "--upgrade-strategy=eager", "black==22.10.0"]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "black").main_package assert ( "upgraded package black from 22.8.0 to 22.10.0" in capsys.readouterr().out, metadata.package_version, metadata.pip_args, ) == (True, "22.10.0", []) assert not run_pipx_cli(["install", "--upgrade", "black<22.9"]) assert ( "upgraded package black from 22.10.0 to 22.8.0" in capsys.readouterr().out, PipxMetadata(paths.CTX.venvs / "black").main_package.package_version, ) == (True, "22.8.0") @pytest.mark.usefixtures("pipx_temp_env") def test_install_upgrade_multiple_existing(capsys: pytest.CaptureFixture[str]) -> None: package_specs: Final[list[str]] = [PKG["black"]["spec"], PKG["pycowsay"]["spec"]] assert run_pipx_cli(["install", *package_specs]) == EXIT_CODE_OK capsys.readouterr() assert run_pipx_cli(["install", "--upgrade", *package_specs]) == EXIT_CODE_OK output: Final[str] = capsys.readouterr().out assert ("black 22.8.0 already satisfies" in output, "pycowsay 0.0.0.2 already satisfies" in output) == ( True, True, ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_reuses_empty_environment_dir() -> None: (paths.CTX.venvs / "pycowsay").mkdir(parents=True) assert ( run_pipx_cli(["install", PKG["pycowsay"]["spec"]]), PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.package, ) == (EXIT_CODE_OK, "pycowsay") @pytest.mark.usefixtures("pipx_temp_env") def test_install_upgrade_satisfied_spec_is_offline(capsys: pytest.CaptureFixture[str], mocker: MockerFixture) -> None: assert not run_pipx_cli(["install", PKG["black"]["spec"]]) check_shared: Final = mocker.patch.object(Venv, "check_upgrade_shared_libs", autospec=True) upgrade_package: Final = mocker.patch.object(Venv, "upgrade_package", autospec=True) assert not run_pipx_cli(["install", "--upgrade", "black>=22,<23"]) assert ( "black 22.8.0 already satisfies black>=22,<23" in capsys.readouterr().out, check_shared.call_count, upgrade_package.call_count, ) == (True, 0, 0) @pytest.mark.usefixtures("pipx_temp_env") def test_install_upgrade_strategy_requires_upgrade(capsys: pytest.CaptureFixture[str]) -> None: assert ( run_pipx_cli(["install", "--upgrade-strategy=eager", PKG["black"]["spec"]]), "--upgrade-strategy requires --upgrade" in capsys.readouterr().err, ) == (1, True) @pytest.mark.parametrize( "index_args", [ pytest.param(["--index-url", ""], id="index-url"), pytest.param(["--pip-args=--index-url="], id="index-in-pip-args"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_rejects_empty_index(capsys: pytest.CaptureFixture[str], index_args: list[str]) -> None: assert run_pipx_cli(["install", *index_args, "pycowsay"]) assert ("--index-url is empty" in capsys.readouterr().err, (paths.CTX.venvs / "pycowsay").exists()) == ( True, False, ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_existing_package_skips_shared_lib_maintenance(mocker: MockerFixture) -> None: assert run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) == 0 mocker.patch.object(shared_libs.SHARED_LIBS, "has_been_updated_this_run", False) mocker.patch( "pipx.shared_libs._time", return_value=shared_libs.SHARED_LIBS.pip_path.stat().st_mtime + shared_libs.SHARED_LIBS_MAX_AGE_SEC + 1, ) run_subprocess: Final = mocker.patch( "pipx.shared_libs.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout="ModuleSpec", stderr=""), ) run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) run_subprocess.assert_not_called() @pytest.mark.usefixtures("pipx_temp_env") def test_include_deps() -> None: assert not run_pipx_cli(["install", "requests==2.31.0", "--include-deps"]) @pytest.mark.usefixtures("pipx_temp_env") def test_install_include_resources_from_dependency( local_extras_wheel: Path, capsys: pytest.CaptureFixture[str] ) -> None: package: Final[str] = f"{local_extras_wheel}[tools]" assert not run_pipx_cli(["install", package, "--include-resources-from", "PyCowsay"]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "repeatme").main_package assert ( metadata.include_resources_from, (paths.CTX.bin_dir / app_name("pycowsay")).exists(), (paths.CTX.bin_dir / app_name("black")).exists(), (paths.CTX.man_dir / "man6" / "pycowsay.6").exists(), ) == (["pycowsay"], True, False, True) capsys.readouterr() assert not run_pipx_cli(["list"]) output: Final[str] = capsys.readouterr().out assert (f" - {app_name('pycowsay')}" in output, f" - {app_name('black')}" in output) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_force_replaces_included_dependency_resources(local_extras_wheel: Path) -> None: package: Final[str] = f"{local_extras_wheel}[tools]" assert not run_pipx_cli(["install", package, "--include-resources-from", "pycowsay"]) assert not run_pipx_cli(["install", "--force", package, "--include-resources-from", "black"]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "repeatme").main_package assert ( metadata.include_resources_from, (paths.CTX.bin_dir / app_name("pycowsay")).exists(), (paths.CTX.man_dir / "man6" / "pycowsay.6").exists(), (paths.CTX.bin_dir / app_name("black")).exists(), ) == (["black"], False, False, True) def test_install_preserves_colliding_dependency_resource( copied_dependency_resource: tuple[Path, bytes], local_extras_wheel: Path, ) -> None: exposed_app, first_contents = copied_dependency_resource assert not run_pipx_cli(["install", f"{local_extras_wheel}[cow]", "--include-deps"]) assert exposed_app.read_bytes() == first_contents @pytest.mark.usefixtures("pipx_temp_env") def test_install_include_resources_from_missing_dependency_rolls_back( local_extras_wheel: Path, capsys: pytest.CaptureFixture[str] ) -> None: package: Final[str] = f"{local_extras_wheel}[cow]" assert run_pipx_cli(["install", package, "--include-resources-from", "black"]) error: Final[str] = " ".join(capsys.readouterr().err.split()) assert ( "Cannot expose resources from black for package repeatme. " "Dependencies with apps, manual pages, or shell completions: pycowsay." in error, (paths.CTX.venvs / "repeatme").exists(), ) == (True, False) @pytest.mark.parametrize( ("package_name", "package_spec"), [ pytest.param( "zest-releaser", PKG["zest-releaser"]["spec"], marks=[ pytest.mark.skipif( sys.platform == "darwin", reason="Skipping zest-releaser due to missing Python 3.13 wheel for cmarkgfm on macOS", ), pytest.mark.skipif( sys.version_info >= (3, 15), reason="Skipping zest-releaser due to missing Python 3.15 wheel for comrak", ), ], ), pytest.param("tox-ini-fmt", PKG["tox-ini-fmt"]["spec"]), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_name_tricky_characters( caplog: pytest.LogCaptureFixture, capsys: pytest.CaptureFixture[str], package_name: str, package_spec: str, ) -> None: install_packages(capsys, caplog, [package_spec], [package_name]) @pytest.mark.usefixtures("pipx_temp_env") def test_extra(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "nox[tox_to_nox]==2023.4.22", "--include-deps"]) assert f"- {app_name('tox')}\n" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_install_local_extra(capsys: pytest.CaptureFixture[str], local_extras_project: Path) -> None: assert not run_pipx_cli(["install", f"{local_extras_project}[cow]", "--include-deps"]) out: Final[str] = capsys.readouterr().out assert (f"- {app_name('pycowsay')}\n" in out, f"- {Path('man6/pycowsay.6')}\n" in out) == (True, True) @pytest.mark.parametrize( "backend", [ pytest.param("pip", id="pip"), pytest.param( "uv", id="uv", marks=pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_bare_local_wheel_extra( local_extras_wheel: Path, monkeypatch: pytest.MonkeyPatch, backend: str ) -> None: monkeypatch.chdir(local_extras_wheel.parent) assert not run_pipx_cli(["install", "--backend", backend, f"{local_extras_wheel.name}[cow]", "--include-deps"]) assert Venv(paths.CTX.venvs / "repeatme").has_package("pycowsay") @pytest.mark.usefixtures("pipx_temp_env") def test_path_warning(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert "is not on your PATH environment variable" not in unwrap_log_text(caplog.text) monkeypatch.setenv("PATH", "") assert not run_pipx_cli(["install", "pycowsay", "--force"]) assert "is not on your PATH environment variable" in unwrap_log_text(caplog.text) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_existing_symlink_points_to_existing_wrong_location_warning( caplog: pytest.LogCaptureFixture, capsys: pytest.CaptureFixture[str] ) -> None: paths.CTX.bin_dir.mkdir(exist_ok=True, parents=True) (paths.CTX.bin_dir / "pycowsay").symlink_to(os.devnull) assert not run_pipx_cli(["install", "pycowsay"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert ( "File exists at" in unwrap_log_text(caplog.text), "symlink missing or pointing to unexpected location" in captured.out, # the bin dir is on PATH, so a symlink to the wrong location must not trigger the PATH warning "is not on your PATH environment variable" in captured.err, ) == (True, True, False) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_existing_man_page_symlink_points_to_existing_wrong_location_warning( caplog: pytest.LogCaptureFixture, capsys: pytest.CaptureFixture[str] ) -> None: (paths.CTX.man_dir / "man6").mkdir(exist_ok=True, parents=True) (paths.CTX.man_dir / "man6" / "pycowsay.6").symlink_to(os.devnull) assert not run_pipx_cli(["install", "pycowsay"]) assert ( "File exists at" in unwrap_log_text(caplog.text), "symlink missing or pointing to unexpected location" in capsys.readouterr().out, ) == (True, True) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_existing_symlink_points_to_nothing(capsys: pytest.CaptureFixture[str]) -> None: paths.CTX.bin_dir.mkdir(exist_ok=True, parents=True) (paths.CTX.bin_dir / "pycowsay").symlink_to("/asdf/jkl") assert not run_pipx_cli(["install", "pycowsay"]) # pipx replaces a dangling symlink without a warning assert "symlink missing or pointing to unexpected location" not in capsys.readouterr().out @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_existing_man_page_symlink_points_to_nothing(capsys: pytest.CaptureFixture[str]) -> None: (paths.CTX.man_dir / "man6").mkdir(exist_ok=True, parents=True) (paths.CTX.man_dir / "man6" / "pycowsay.6").symlink_to("/asdf/jkl") assert not run_pipx_cli(["install", "pycowsay"]) # pipx replaces a dangling symlink without a warning assert "symlink missing or pointing to unexpected location" not in capsys.readouterr().out @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_pip_args_forwarded_to_shared_libs( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture ) -> None: # an empty pipx home makes the next command upgrade the shared libraries assert shared_libs.SHARED_LIBS.needs_upgrade return_code: Final[int] = run_pipx_cli(["install", "--verbose", "--pip-args=--no-index", "pycowsay"]) assert ( return_code != 0, "Upgrading shared libraries in" in caplog.text, "ERROR: Could not find a version that satisfies the requirement pip" in capsys.readouterr().err, "Failed to upgrade shared libraries" in caplog.text, ) == (True, True, True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_pip_args_forwarded_to_package_name_determination(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli([ "install", "https://github.com/psf/black/archive/22.8.0.zip", "--verbose", "--pip-args='--asdf'", ]) err: Final[str] = capsys.readouterr().err assert ("--asdf" in err, "Cannot determine package name from spec" in err) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_url_refuses_wheel_whose_data_files_rename_it( make_wheel: Callable[[dict[str, str]], Path], capsys: pytest.CaptureFixture[str] ) -> None: metadata: Final[str] = "Metadata-Version: 2.1\nName: {}\nVersion: 1\n" wheel: Final[Path] = make_wheel({ "foo_bar_x.py": "", "foo_bar_x-1.dist-info/METADATA": metadata.format("foo-bar-x"), "foo_bar_x-1.dist-info/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", "foo_bar_x-1.dist-info/RECORD": "", "foo_bar_x-1.data/purelib/foo_bar_x-1.dist-info/METADATA": metadata.format("pycowsay"), }) assert run_pipx_cli(["install", "--backend", "pip", wheel.as_uri()]) error: Final[str] = " ".join(capsys.readouterr().err.split()) assert ("declares the name 'pycowsay'" in error, list(paths.CTX.venvs.glob("*"))) == (True, []) @pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only") @pytest.mark.usefixtures("pipx_temp_env") def test_pip_args_with_windows_path(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli([ "install", "pycowsay", "--verbose", "--pip-args='--no-index --find-links=D:\\TEST\\DIR'", ]) assert r"D:\\TEST\\DIR" in capsys.readouterr().err @pytest.mark.parametrize("constraint_flag", ["-c ", "--constraint ", "--constraint="]) @pytest.mark.usefixtures("pipx_temp_env") def test_pip_args_with_constraint_relative_path( constraint_flag: str, tmp_path: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch ) -> None: (constraints_file := tmp_path / "constraints.txt").write_text("black!=22.10.0", encoding="utf-8") monkeypatch.chdir(tmp_path) assert not run_pipx_cli(["install", f"--pip-args='{constraint_flag}constraints.txt'", "black"]) assert ( f"{constraint_flag}{constraints_file}" in caplog.text, PipxMetadata(paths.CTX.venvs / "black").main_package.package_version, ) == (True, "22.8.0") @pytest.mark.usefixtures("pipx_temp_env") def test_pip_args_with_attached_constraint_records_absolute_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: (constraint_file := tmp_path / "constraints.txt").write_text("pycowsay==0.0.0.2", encoding="utf-8") monkeypatch.chdir(tmp_path) assert ( run_pipx_cli(["install", "--pip-args=-cconstraints.txt", PKG["pycowsay"]["spec"]]), PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.pip_args[:1], ) == (0, [f"-c{constraint_file}"]) @pytest.mark.parametrize("constraint_flag", ["-c ", "--constraint ", "--constraint="]) @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_pip_args_with_wrong_constraint_fail( constraint_flag: str, tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: constraint_file_name: Final[str] = "constraints.txt" monkeypatch.chdir(tmp_path) assert run_pipx_cli(["install", f"--pip-args='{constraint_flag}{constraint_file_name}'", "pycowsay"]) # pip phrases this as "requirements file" (<26) or "constraint file" (>=26), so match the common part assert f"[Errno 2] No such file or directory: {str(tmp_path / constraint_file_name)!r}" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_install_suffix(capsys: pytest.CaptureFixture[str]) -> None: outputs: Final[list[str]] = [] for suffix in ("_a", "_b"): assert not run_pipx_cli(["install", PKG["pbr"]["spec"], f"--suffix={suffix}"]) outputs.append(capsys.readouterr().out) name_a, name_b = app_name("pbr_a"), app_name("pbr_b") assert ( f"- {name_a}" in outputs[0], f"- {name_b}" in outputs[1], (paths.CTX.bin_dir / name_a).exists(), (paths.CTX.bin_dir / name_b).exists(), ) == (True, True, True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_man_page_install(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert ( f"- {Path('man6/pycowsay.6')}" in capsys.readouterr().out, (paths.CTX.man_dir / "man6" / "pycowsay.6").exists(), ) == (True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_editable_install_links_man_pages(capsys: pytest.CaptureFixture[str], local_manpage_project: Path) -> None: assert not run_pipx_cli(["install", "--editable", local_manpage_project.as_posix()]) out: Final[str] = capsys.readouterr().out exposed_man_page: Final[Path] = paths.CTX.man_dir / "man1" / "local-manpage.1" assert ( f"- {app_name('local-manpage')}\n" in out, f"- {Path('man1/local-manpage.1')}" in out, (paths.CTX.venvs / "local-manpage" / "share" / "man" / "man1" / "local-manpage.1").exists(), exposed_man_page.exists(), ) == (True, True, True, True) assert not run_pipx_cli(["uninstall", "local-manpage"]) assert not exposed_man_page.exists() @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") @pytest.mark.usefixtures("pipx_temp_env") def test_install_uv_failure_shows_uv_error( make_project_with_dependency: Callable[[str], Path], capsys: pytest.CaptureFixture[str] ) -> None: project: Final[Path] = make_project_with_dependency("no-such-dependency-for-pipx") assert run_pipx_cli(["install", "--backend", "uv", str(project)]) assert re.search( r"Errors from uv install:\n\s+error: .*\n.*no-such-dependency-for-pipx", capsys.readouterr().err, ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_in_terminal_keeps_installer_output_off_screen( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: # patch each module's copy of the flag so the test holds wherever the terminal check is read for module in [ module for name, module in sys.modules.items() if name.startswith("pipx") and hasattr(module, "STDERR_IS_TTY") ]: monkeypatch.setattr(module, "STDERR_IS_TTY", True) assert not run_pipx_cli(["install", "--backend", "pip", PKG["pycowsay"]["spec"]]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert ("installing pycowsay" in captured.err, "Collecting pycowsay" in captured.out + captured.err) == ( True, False, ) @pytest.mark.usefixtures("pipx_temp_env") @pytest.mark.parametrize("flags", [pytest.param([], id="plain"), pytest.param(["--verbose"], id="verbose")]) def test_install_pip_failure_prints_pip_error_once( make_project_with_dependency: Callable[[str], Path], capsys: pytest.CaptureFixture[str], flags: list[str] ) -> None: project: Final[Path] = make_project_with_dependency("no-such-dependency-for-pipx") assert run_pipx_cli(["install", "--backend", "pip", *flags, str(project)]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out + captured.err).count("No matching distribution found for no-such-dependency-for-pipx") == 1 @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") @pytest.mark.usefixtures("pipx_temp_env") @pytest.mark.parametrize("flags", [pytest.param([], id="plain"), pytest.param(["--verbose"], id="verbose")]) def test_install_uv_failure_prints_uv_error_once( make_project_with_dependency: Callable[[str], Path], capsys: pytest.CaptureFixture[str], flags: list[str] ) -> None: project: Final[Path] = make_project_with_dependency("no-such-dependency-for-pipx") assert run_pipx_cli(["install", "--backend", "uv", *flags, str(project)]) assert capsys.readouterr().err.count("No solution found when resolving dependencies") == 1 @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") @pytest.mark.usefixtures("pipx_temp_env") def test_install_verbose_masks_index_credentials( make_project_with_dependency: Callable[[str], Path], capsys: pytest.CaptureFixture[str] ) -> None: project: Final[Path] = make_project_with_dependency("no-such-dependency-for-pipx") index_args: Final[str] = "--no-index --index-url=https://alice:s3cr3t@127.0.0.1:9/simple/" assert run_pipx_cli(["install", "--verbose", "--backend", "uv", f"--pip-args={index_args}", str(project)]) output: Final[str] = capsys.readouterr().err + current_log_file().read_text(encoding="utf-8") assert ("****:****@127.0.0.1" in output, "s3cr3t" in output) == (True, False) @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") @pytest.mark.usefixtures("pipx_temp_env") @pytest.mark.parametrize( "spec", [ pytest.param("https://alice:s3cr3t@127.0.0.1:9/pkg-1.0-py3-none-any.whl", id="url"), pytest.param("pkg @ https://alice:s3cr3t@127.0.0.1:9/pkg-1.0-py3-none-any.whl", id="named-url"), ], ) @pytest.mark.parametrize("flags", [pytest.param([], id="quiet"), pytest.param(["--verbose"], id="verbose")]) def test_install_masks_spec_credentials( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, spec: str, flags: list[str] ) -> None: monkeypatch.setenv("UV_HTTP_RETRIES", "0") assert run_pipx_cli(["install", "--backend", "uv", *flags, spec]) captured: Final[CaptureResult[str]] = capsys.readouterr() log: Final[str] = current_log_file().read_text(encoding="utf-8") assert "s3cr3t" not in captured.out + captured.err + log @pytest.mark.usefixtures("pipx_temp_env") @pytest.mark.parametrize( ("backend", "flags"), [ pytest.param("pip", [], id="pip"), pytest.param("pip", ["--verbose"], id="pip-verbose"), # uv --verbose repeats the build error in its own debug log, so only its plain run isolates pipx's reporting pytest.param( "uv", [], id="uv", marks=pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") ), ], ) def test_install_name_probe_failure_prints_the_error_once( project_failing_to_build: Path, capsys: pytest.CaptureFixture[str], backend: str, flags: list[str] ) -> None: assert run_pipx_cli(["install", *flags, "--backend", backend, str(project_failing_to_build)]) assert capsys.readouterr().err.count("pipx-test-build-failure") == 1 @pytest.fixture def project_failing_to_build(tmp_path: Path) -> Path: # setup.py only, so pipx learns the name by installing it, and the build fails before anything installs (project := tmp_path / "fails-to-build").mkdir() (project / "setup.py").write_text("raise SystemExit('pipx-test-build-failure')\n", encoding="utf-8") return project @pytest.mark.usefixtures("pipx_temp_env") @pytest.mark.parametrize( "backend", [ pytest.param("pip", id="pip"), pytest.param( "uv", id="uv", marks=pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") ), ], ) @pytest.mark.parametrize("flags", [pytest.param([], id="plain"), pytest.param(["--verbose"], id="verbose")]) def test_install_json_failure_carries_the_installer_error( make_project_with_dependency: Callable[[str], Path], capsys: pytest.CaptureFixture[str], backend: str, flags: list[str], ) -> None: project: Final[Path] = make_project_with_dependency("no-such-dependency-for-pipx") assert run_pipx_cli(["install", "--output", "json", *flags, "--backend", backend, str(project)]) assert "no-such-dependency-for-pipx" in json.loads(capsys.readouterr().out)["errors"][0]["message"] @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") @pytest.mark.usefixtures("pipx_temp_env") def test_install_json_failure_keeps_what_the_terminal_showed( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: (project := tmp_path / "redraws").mkdir() (project / "setup.py").write_text( "import sys\nsys.stderr.write('pipx-redrawn-frame\\rpipx-final-frame\\n')\nraise SystemExit(1)\n", encoding="utf-8", ) assert run_pipx_cli(["install", "--output", "json", "--verbose", "--backend", "uv", str(project)]) message: Final[str] = json.loads(capsys.readouterr().out)["errors"][0]["message"] assert ("pipx-final-frame" in message, "pipx-redrawn-frame" in message) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_pip_failure(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["install", "weblate==4.3.1", "--verbose"]) err: Final[str] = capsys.readouterr().err pip_log_file_match: Final = re.search(r"Full pip output in file:\s+(\S.+)$", err, re.MULTILINE) assert pip_log_file_match assert ( "Fatal error from pip" in err, Path(pip_log_file_match[1]).exists(), re.search(r"pip failed (to build|while processing) package", err) is not None, ) == (True, True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_install_local_archive( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], local_extras_wheel: Path ) -> None: monkeypatch.chdir(local_extras_wheel.parent) assert not run_pipx_cli(["install", "repeatme-0.1-py3-none-any.whl"]) assert f"- {app_name('repeatme')}\n" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_install_local_project_uses_built_name_when_backend_ignores_project_table( project_ignoring_project_table: Path, ) -> None: assert not run_pipx_cli(["install", "--backend", "pip", str(project_ignoring_project_table)]) assert ( PipxMetadata(paths.CTX.venvs / "real-pkg").main_package.package, (paths.CTX.bin_dir / app_name("realapp")).exists(), ) == ("real-pkg", True) @pytest.mark.parametrize( ("backend", "package"), [ pytest.param("pip", "Foo_Bar.X", id="pip"), pytest.param( "uv", "foo-bar-x", id="uv", marks=pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH"), ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_local_wheel_records_installer_name( make_wheel: Callable[[dict[str, str]], Path], backend: str, package: str ) -> None: wheel: Final[Path] = make_wheel({ "foo_bar_x/__init__.py": "def main() -> None:\n pass\n", "foo_bar_x-1.dist-info/METADATA": "Metadata-Version: 2.1\nName: Foo_Bar.X\nVersion: 1\n", "foo_bar_x-1.dist-info/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", "foo_bar_x-1.dist-info/entry_points.txt": "[console_scripts]\nfoo-bar-x = foo_bar_x:main\n", "foo_bar_x-1.dist-info/RECORD": "", }) assert not run_pipx_cli(["install", "--backend", backend, str(wheel)]) assert ( PipxMetadata(paths.CTX.venvs / "foo-bar-x").main_package.package, (paths.CTX.bin_dir / app_name("foo-bar-x")).exists(), ) == (package, True) @pytest.mark.parametrize( "backend", [ pytest.param("pip", id="pip"), pytest.param("uv", id="uv", marks=pytest.mark.skipif(shutil.which("uv") is None, reason="uv is not installed")), ], ) @pytest.mark.parametrize( "directory", [ pytest.param("scripts", id="plain"), pytest.param("scripts#123", id="hash"), pytest.param("Project #123", id="space-and-hash"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_inline_script(inline_script: Path, backend: str, directory: str) -> None: (script_dir := inline_script.parent / directory).mkdir() script: Final[Path] = inline_script.rename(script_dir / inline_script.name) assert not run_pipx_cli(["install", "--backend", backend, str(script)]) app: Final[Path] = paths.CTX.bin_dir / app_name("hello") process: Final[subprocess.CompletedProcess[str]] = subprocess.run( [app], check=True, capture_output=True, text=True, ) assert (process.stdout, run_pipx_cli(["uninstall", "hello"]), app.exists()) == ("installed\n", 0, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_inline_script_by_bare_relative_name(inline_script: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(inline_script.parent) assert ( run_pipx_cli(["install", inline_script.name]), PipxMetadata(paths.CTX.venvs / "hello").main_package.package_or_url, ) == (0, str(inline_script.resolve())) @pytest.mark.parametrize( "directory", [pytest.param("[1]", id="whole-name"), pytest.param("a[x]b", id="inside-name")], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_inline_script_from_bracketed_directory(inline_script: Path, directory: str) -> None: (script_dir := inline_script.parent / directory).mkdir() script: Final[Path] = inline_script.rename(script_dir / inline_script.name) assert ( run_pipx_cli(["install", str(script)]), run_pipx_cli(["reinstall", "hello"]), PipxMetadata(paths.CTX.venvs / "hello").main_package.package_or_url, ) == (0, 0, str(script.resolve())) @pytest.mark.usefixtures("pipx_temp_env") def test_install_inline_script_from_unexpanded_home(inline_script: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("HOME", str(inline_script.parent)) monkeypatch.setenv("USERPROFILE", str(inline_script.parent)) assert not run_pipx_cli(["install", f"~/{inline_script.name}"]) @pytest.mark.usefixtures("pipx_temp_env") def test_install_inline_script_app_override(inline_script: Path) -> None: assert not run_pipx_cli(["install", "--app", "greet", str(inline_script)]) assert (paths.CTX.bin_dir / app_name("greet")).is_file() @pytest.mark.usefixtures("pipx_temp_env") def test_install_inline_script_url(inline_script: Path, mocker: MockerFixture) -> None: response: Final[MagicMock] = mocker.MagicMock( length=0, **{ "read.return_value": inline_script.read_bytes(), "headers.get_content_charset.return_value": "utf-8", }, ) response.__enter__.return_value = response mocker.patch("urllib.request.urlopen", autospec=True, return_value=response) assert not run_pipx_cli(["install", "https://example.invalid/hello.py"]) assert (paths.CTX.bin_dir / app_name("hello")).is_file() @pytest.mark.usefixtures("pipx_temp_env") def test_install_inline_script_url_error(mocker: MockerFixture, capsys: pytest.CaptureFixture[str]) -> None: mocker.patch("urllib.request.urlopen", autospec=True, side_effect=OSError("unavailable")) assert run_pipx_cli(["install", "--output", "json", "https://example.invalid/hello.py"]) captured: Final[CaptureResult[str]] = capsys.readouterr() failure: Final[str] = json.loads(captured.out)["errors"][0]["message"] assert ("Unable to read script" in " ".join(failure.split()), (paths.CTX.venvs / "hello").exists()) == ( True, False, ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_inline_script_rolls_back_without_metadata(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: script: Final[Path] = tmp_path / "plain.py" script.write_text("print('hello')\n", encoding="utf-8") assert run_pipx_cli(["install", "--output", "json", str(script)]) captured: Final[CaptureResult[str]] = capsys.readouterr() failure: Final[str] = json.loads(captured.out)["errors"][0]["message"] assert ("has no PEP 723" in " ".join(failure.split()), (paths.CTX.venvs / "plain").exists()) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_inline_script_rolls_back_obsolete_metadata(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: script: Final[Path] = tmp_path / "obsolete.py" script.write_text("# /// pyproject\n# dependencies = []\n# ///\n", encoding="utf-8") assert run_pipx_cli(["install", "--output", "json", str(script)]) captured: Final[CaptureResult[str]] = capsys.readouterr() failure: Final[str] = json.loads(captured.out)["errors"][0]["message"] assert ("obsolete" in failure, (paths.CTX.venvs / "obsolete").exists()) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_inline_script_from_pylock(inline_script: Path, make_pylock: Callable[[str, str], Path]) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert not run_pipx_cli(["install", "--lock", str(lock_file), str(inline_script)]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "hello").main_package assert (metadata.lock_file, (paths.CTX.bin_dir / app_name("hello")).is_file()) == (lock_file.resolve(), True) @pytest.mark.usefixtures("pipx_temp_env") def test_force_install_changes(capsys: pytest.CaptureFixture[str], empty_project: Path, tmp_path: Path) -> None: with _served_archive_spec(empty_project) as package_spec: assert not run_pipx_cli(["install", package_spec]) assert "0.1.0" in capsys.readouterr().out bumped_project: Final[Path] = tmp_path / "bumped_project" shutil.copytree(empty_project, bumped_project) pyproject: Final[Path] = bumped_project / "pyproject.toml" pyproject.write_text( pyproject.read_text(encoding="utf-8").replace('version = "0.1.0"', 'version = "0.2.0"'), encoding="utf-8" ) assert not run_pipx_cli(["install", str(bumped_project), "--force"]) assert "0.1.0" not in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_force_install_changes_editable(empty_project: Path, capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "--editable", empty_project.as_posix()]) assert "empty-project" in capsys.readouterr().out assert not run_pipx_cli(["install", "--editable", empty_project.as_posix(), "--force"]) assert "Installing to existing venv 'empty-project'" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_install_multiple_packages_preserves_editable_for_local_package(empty_project: Path) -> None: assert ( run_pipx_cli(["install", PKG["pycowsay"]["spec"], empty_project.as_posix(), "--editable"]), PipxMetadata(paths.CTX.venvs / "empty-project").main_package.pip_args, ) == (0, ["--editable"]) @pytest.mark.usefixtures("pipx_temp_env") def test_preinstall(caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["install", "--preinstall", "black", "nox"]) assert "black" in caplog.text @pytest.mark.usefixtures("pipx_temp_env") def test_preinstall_cooldown(package_cache_dir: Path, caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli([ "install", "--preinstall", PKG["black"]["spec"], "--cooldown", "7", f"--pip-args=--no-index --find-links={package_cache_dir}", PKG["nox"]["spec"], ]) assert ( caplog.text.count("--uploaded-prior-to P7D"), PipxMetadata(paths.CTX.venvs / "nox").main_package.cooldown_days, ) == (2, 7) @pytest.mark.usefixtures("pipx_temp_env") def test_preinstall_multiple(caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["install", "--preinstall", "chardet", "--preinstall", "colorama", PKG["pycowsay"]["spec"]]) assert ("chardet" in caplog.text, "colorama" in caplog.text) == (True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_preinstall_specific_version(caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["install", "--preinstall", "black==22.8.0", "nox"]) assert "black==22.8.0" in caplog.text @pytest.mark.usefixtures("pipx_temp_env", "pipx_session_shared_dir") def test_do_not_wait_for_input(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PIP_INDEX_URL", "http://127.0.0.1:8080/simple") # pip otherwise retries the refused connection five times with backoff monkeypatch.setenv("PIP_RETRIES", "0") assert run_pipx_cli(["install", "pycowsay"]) @pytest.mark.usefixtures("pipx_temp_env") def test_passed_python_and_force_flag_warning(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["install", "--python", sys.executable, "--force", PKG["pycowsay"]["spec"]]) assert "pipx ignores --python when you pass --force." in capsys.readouterr().out @pytest.mark.parametrize( "setup_commands", [ pytest.param([["install", PKG["pycowsay"]["spec"]]], id="existing"), pytest.param([], id="new"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_force_flag_without_python_does_not_warn( capsys: pytest.CaptureFixture[str], setup_commands: list[list[str]] ) -> None: for command in setup_commands: assert not run_pipx_cli(command) assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"], "--force"]) assert "pipx ignores --python when you pass --force." not in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_force_install_python_warning_suggests_reinstall_of_environment(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["install", "--force", "--python", sys.executable, PKG["pycowsay"]["spec"]]) assert f"run `pipx reinstall pycowsay --python {sys.executable}` instead" in " ".join( capsys.readouterr().out.split() ) @SKIP_IF_WINDOWS @pytest.mark.parametrize("locked", [pytest.param(False, id="plain"), pytest.param(True, id="locked")]) @pytest.mark.usefixtures("pipx_temp_env") def test_force_install_keeps_existing_interpreter( tmp_path: Path, make_pylock: Callable[[str, str], Path], locked: bool ) -> None: lock_args: Final[list[str]] = ["--lock", str(make_pylock("pycowsay", "0.0.0.2"))] if locked else [] assert not run_pipx_cli(["install", *lock_args, PKG["pycowsay"]["spec"]]) venv_dir: Final[Path] = paths.CTX.venvs / "pycowsay" recorded: Final[Path | None] = PipxMetadata(venv_dir).source_interpreter (other_python := tmp_path / "python-other").symlink_to(sys.executable) assert not run_pipx_cli(["install", "--force", "--python", str(other_python), PKG["pycowsay"]["spec"]]) assert PipxMetadata(venv_dir).source_interpreter == recorded @pytest.mark.parametrize( ("python_version", "fetch_flag", "expect_deprecation"), [ pytest.param("3.0", "--fetch-python=missing", False, id="3.0-fetch-python-missing"), pytest.param("3.1", "--fetch-python=missing", False, id="3.1-fetch-python-missing"), pytest.param("3.0", "--fetch-missing-python", True, id="3.0-deprecated-flag"), ], ) @pytest.mark.usefixtures("mocked_github_api") def test_install_fetch_missing_python_invalid( capsys: pytest.CaptureFixture[str], python_version: str, fetch_flag: str, expect_deprecation: bool ) -> None: assert run_pipx_cli(["install", "--python", python_version, fetch_flag, "pycowsay"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert ( f"No executable for the provided Python version '{python_version}' found" in captured.out, "--fetch-missing-python is deprecated" in captured.err, ) == (True, expect_deprecation) @pytest.mark.usefixtures("pipx_temp_env") def test_install_run_in_separate_directory( caplog: pytest.LogCaptureFixture, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.chdir(tmp_path) Path("argparse.py").touch() install_packages(capsys, caplog, ["pycowsay"], ["pycowsay"]) @SKIP_IF_WINDOWS @pytest.mark.parametrize( "python_version", [ str(sys.version_info.major), f"{sys.version_info.major}.{sys.version_info.minor}", f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_python_command_version( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], python_version: str ) -> None: monkeypatch.setenv("PATH", os.environ["PATH_ORIG"]) assert not run_pipx_cli(["install", "--python", python_version, "--verbose", "pycowsay"]) assert python_version in capsys.readouterr().out @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_install_python_command_version_invalid(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["install", "--python", "3.x", "--verbose", "pycowsay"]) assert "Invalid Python version: 3.x" in capsys.readouterr().err @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_install_python_command_version_unsupported(capsys: pytest.CaptureFixture[str]) -> None: python_version: Final[str] = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}.dev" assert run_pipx_cli(["install", "--python", python_version, "--verbose", "pycowsay"]) assert f"Unsupported Python version: {python_version}" in capsys.readouterr().err @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_install_python_command_version_missing( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setenv("PATH", os.environ["PATH_ORIG"]) python_version: Final[str] = f"{sys.version_info.major + 99}.{sys.version_info.minor}" assert run_pipx_cli(["install", "--python", python_version, "--verbose", "pycowsay"]) assert f"Command `python{python_version}` was not found" in capsys.readouterr().err @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_install_python_command_version_micro_mismatch( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setenv("PATH", os.environ["PATH_ORIG"]) python_version: Final[str] = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro + 1}" assert not run_pipx_cli(["install", "--python", python_version, "--verbose", "pycowsay"]) assert ( f"It may not match the specified version {python_version} at the micro/patch level" in capsys.readouterr().err ) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_global_flag_before_subcommand_rejected(capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit) as exc_info: run_pipx_cli(["--global", "install", "pycowsay"]) assert (exc_info.value.code, "unrecognized arguments: --global" in capsys.readouterr().err) == (2, True) @pytest.mark.parametrize( "flag", [ pytest.param("--global", marks=SKIP_IF_WINDOWS, id="global"), pytest.param("--skip-maintenance", id="skip-maintenance"), pytest.param("--verbose", id="verbose"), pytest.param("--quiet", id="quiet"), ], ) @pytest.mark.parametrize( ("group", "subcommand"), [ pytest.param("manifest", ["sync", "pipx.toml"], id="manifest"), pytest.param("interpreter", ["prune"], id="interpreter"), pytest.param("cache", ["purge"], id="cache"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_shared_flag_before_nested_subcommand_rejected( capsys: pytest.CaptureFixture[str], group: str, subcommand: list[str], flag: str ) -> None: # the subcommand's namespace used to overwrite the flag parsed by the group parser, silently dropping it with pytest.raises(SystemExit) as exc_info: run_pipx_cli([group, flag, *subcommand]) assert (exc_info.value.code, f"unrecognized arguments: {flag}" in capsys.readouterr().err) == (2, True) @pytest.mark.usefixtures("pipx_temp_env") def test_install_quiet_flag(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "--quiet", "--quiet", "pycowsay"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert ( "installed package" in captured.out, "These apps are now" in captured.out, "done!" in captured.out, "done!" in captured.err, ) == (False, False, False, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_json(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "--output", "json", "pycowsay"]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["install"], "data": { "packages": [ { "environment": "pycowsay", "location": str(paths.CTX.venvs / "pycowsay"), "package": "pycowsay", "version": "0.0.0.2", "interpreter": metadata.python_version, "backend": metadata.backend, } ], "skipped": [], }, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", }, "", ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_json_reports_existing(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["install", "--output", "json", "pycowsay"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["install"], "data": { "packages": [], "skipped": [ { "environment": "pycowsay", "package": "pycowsay", "reason": "already-installed", } ], }, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", }, "", ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_json_reports_invalid_option(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["install", "--output", "json", "--upgrade-strategy", "eager", "pycowsay"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["install"], "data": {"packages": [], "skipped": []}, "errors": [ { "code": "package_install_failed", "environment": "pycowsay", "message": "--upgrade-strategy requires --upgrade", "package": None, } ], "exit_code": 1, "pipx_result_version": "1", "status": "error", }, "", ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_json_attributes_name_resolution_failure( empty_project: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: missing: Final[Path] = tmp_path / "missing.whl" assert run_pipx_cli(["install", "--output", "json", str(empty_project), str(missing)]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out)["errors"][0]["environment"], captured.err) == (str(missing), "") @pytest.mark.usefixtures("pipx_temp_env") def test_install_json_reports_missing_app(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["install", "--output", "json", "--app", "missing", "pycowsay"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert ( json.loads(captured.out), captured.err, (paths.CTX.venvs / "pycowsay").exists(), ) == ( { "command": ["install"], "data": {"packages": [], "skipped": []}, "errors": [ { "code": "package_install_failed", "environment": "pycowsay", "message": "Package pycowsay does not provide expected app missing. Available apps:\npycowsay.", "package": None, } ], "exit_code": 1, "pipx_result_version": "1", "status": "error", }, "", False, ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_json_reports_existing_upgrade_failure(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "--suffix=-x", "pycowsay"]) capsys.readouterr() assert run_pipx_cli(["install", "--output", "json", "--upgrade", "--app", "missing", "--suffix=-x", "pycowsay"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out)["errors"], captured.err) == ( [ { "code": "package_install_failed", "environment": "pycowsay-x", "message": "Package pycowsay does not provide expected app missing. Available apps:\npycowsay.", "package": None, } ], "", ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_keyboard_interrupt_removes_environment(mocker: MockerFixture) -> None: mocker.patch.object(Venv, "install_package", autospec=True, side_effect=KeyboardInterrupt) assert ( run_pipx_cli(["install", "pycowsay"]), (paths.CTX.venvs / "pycowsay").exists(), ) == (1, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_error_removes_environment(mocker: MockerFixture) -> None: mocker.patch.object(Venv, "install_package", autospec=True, side_effect=RuntimeError("failed")) with pytest.raises(RuntimeError, match="failed"): run_pipx_cli(["install", "pycowsay"]) assert not (paths.CTX.venvs / "pycowsay").exists() @pytest.mark.usefixtures("pipx_temp_env") def test_install_killed_before_recording_leaves_environment_unfinished(mocker: MockerFixture) -> None: # no handler rolls a kill back, which SystemExit stands in for mocker.patch.object(Venv, "install_package", autospec=True, side_effect=SystemExit(1)) with pytest.raises(SystemExit): run_pipx_cli(["install", "pycowsay"]) assert (paths.CTX.venvs / "pycowsay" / "pipx_unfinished").is_file() @pytest.mark.usefixtures("pipx_temp_env") def test_install_leaves_recorded_environment_finished() -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not (paths.CTX.venvs / "pycowsay" / "pipx_unfinished").exists() @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_install_skip_maintenance_without_index( capsys: pytest.CaptureFixture[str], package_cache_dir: Path, tmp_path: Path ) -> None: (wheelhouse := tmp_path / "wheelhouse").mkdir() wheel: Final = shutil.copy2(next(package_cache_dir.glob("pycowsay-*.whl")), wheelhouse) assert not run_pipx_cli([ "install", "--skip-maintenance", str(wheel), f"--pip-args=--no-index --find-links={wheelhouse}", ]) assert ( "installed package pycowsay" in capsys.readouterr().out, shared_libs.shared_libs_auto_upgrade_disabled(), ) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_json_failure_reports_environment_without_url_credentials(capsys: pytest.CaptureFixture[str]) -> None: spec: Final[str] = "http://u:s3cr3ttok@127.0.0.1:9/tool-1.0-py3-none-any.whl" assert run_pipx_cli(["install", "--output", "json", "--upgrade-strategy", "eager", spec]) assert json.loads(capsys.readouterr().out)["errors"][0]["environment"] == redact_url_credentials(spec) pipx-1.18.16/tests/test_install_all.py000066400000000000000000000611271527355705200177430ustar00rootroot00000000000000from __future__ import annotations import json import os import re import shutil import sys import time from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from packaging.utils import canonicalize_name from helpers import SKIP_IF_WINDOWS, current_log_file, run_pipx_cli from package_info import PKG from pipx import paths from pipx.pipx_metadata_file import PipxMetadata from pipx.wrap import pipx_wrap if TYPE_CHECKING: from collections.abc import Callable from pytest_mock import MockerFixture @pytest.mark.parametrize( ("install_all_args", "expected_cooldown"), [ pytest.param([], 7, id="stored"), pytest.param(["--cooldown", "0"], 0, id="override"), ], ) def test_install_all( *, package_cache_dir: Path, capsys: pytest.CaptureFixture[str], make_pylock: Callable[[str, str], Path], make_snapshot: Callable[[list[list[str]]], Path], install_all_args: list[str], expected_cooldown: int, ) -> None: pip_args: Final[str] = f"--pip-args=--no-index --find-links={package_cache_dir}" lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") spec_file: Final[Path] = make_snapshot([ ["install", "--app", "pycowsay", "--lock", str(lock_file), "pycowsay"], ["install", "--cooldown", "7", pip_args, PKG["black"]["spec"]], ["inject", "black", pip_args, "pycowsay"], ]) assert not run_pipx_cli(["install-all", *install_all_args, pip_args, str(spec_file)]) capsys.readouterr() assert not run_pipx_cli(["list", "--json"]) installed_names: Final[list[str]] = sorted(json.loads(capsys.readouterr().out)["venvs"]) black_metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "black") pycowsay_metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") assert ( installed_names, sorted(black_metadata.injected_packages), pycowsay_metadata.main_package.expected_apps, pycowsay_metadata.main_package.lock_file, black_metadata.main_package.cooldown_days, black_metadata.injected_packages["pycowsay"].cooldown_days, ) == ( ["black", "pycowsay"], ["pycowsay"], ["pycowsay"], lock_file.resolve(), expected_cooldown, expected_cooldown, ) @pytest.fixture def make_snapshot( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> Callable[[list[list[str]]], Path]: def create(commands: list[list[str]]) -> Path: for command in commands: assert not run_pipx_cli(command) capsys.readouterr() assert not run_pipx_cli(["list", "--json"]) (spec_file := tmp_path / "pipx.json").write_text(capsys.readouterr().out, encoding="utf-8") assert not run_pipx_cli(["uninstall-all"]) capsys.readouterr() return spec_file return create @pytest.mark.parametrize( ("pin_args", "expected_pins"), [ pytest.param([], (True, True), id="all"), pytest.param(["--injected-only"], (False, True), id="injected-only"), ], ) def test_install_all_restores_pins( make_snapshot: Callable[[list[list[str]]], Path], pin_args: list[str], expected_pins: tuple[bool, bool] ) -> None: spec_file: Final[Path] = make_snapshot([ ["install", "pycowsay"], ["inject", "pycowsay", PKG["black"]["spec"]], ["pin", "pycowsay", *pin_args], ]) assert not run_pipx_cli(["install-all", str(spec_file)]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") assert (metadata.main_package.pinned, metadata.injected_packages["black"].pinned) == expected_pins @pytest.mark.parametrize( ("python_arg", "use_recorded"), [ pytest.param([], True, id="recorded"), pytest.param(["--python", sys.executable], False, id="explicit"), ], ) def test_install_all_selects_interpreter( recorded_snapshot: tuple[Path, Path], mocker: MockerFixture, python_arg: list[str], use_recorded: bool, ) -> None: spec_file, recorded = recorded_snapshot install: Final = mocker.patch("pipx.commands.install.install", autospec=True) assert not run_pipx_cli(["install-all", str(spec_file), *python_arg]) assert Path(install.call_args.args[3].python).resolve() == ( recorded if use_recorded else Path(sys.executable).resolve() ) @pytest.fixture def recorded_snapshot(record_interpreter: Callable[[Path], Path], tmp_path: Path) -> tuple[Path, Path]: (recorded := tmp_path / "python-recorded").touch() return record_interpreter(recorded), recorded @pytest.fixture def record_interpreter(make_snapshot: Callable[[list[list[str]]], Path]) -> Callable[[Path], Path]: spec_file: Final[Path] = make_snapshot([["install", "pycowsay"]]) def record(recorded: Path) -> Path: snapshot: Final = json.loads(spec_file.read_text(encoding="utf-8")) snapshot["venvs"]["pycowsay"]["metadata"]["source_interpreter"] = { "__type__": "Path", "__Path__": str(recorded), } spec_file.write_text(json.dumps(snapshot), encoding="utf-8") return spec_file return record def test_install_all_reports_missing_recorded_interpreter( recorded_snapshot: tuple[Path, Path], capsys: pytest.CaptureFixture[str] ) -> None: spec_file, recorded = recorded_snapshot recorded.unlink() assert not run_pipx_cli(["install-all", str(spec_file)]) printed: Final[str] = " ".join(capsys.readouterr().out.split()) assert ("The exported python interpreter" in printed, "is ignored as not found" in printed) == (True, True) def _inside_venv(tmp_path: Path) -> Path: (bootstrap := tmp_path / "bootstrap" / "bin").mkdir(parents=True) (bootstrap.parent / "pyvenv.cfg").touch() (python := bootstrap / "python3").touch() return python def _in_shared_directory(tmp_path: Path) -> Path: (shared := tmp_path / "shared").mkdir() shared.chmod(0o777) (python := shared / "python3").touch() return python def _linked_into_shared_directory(tmp_path: Path) -> Path: (link := tmp_path / "python3").symlink_to(_in_shared_directory(tmp_path)) return link @pytest.mark.parametrize( ("place", "reason"), [ pytest.param(_inside_venv, "it is inside a virtual environment", id="inside-venv"), pytest.param(_in_shared_directory, "another account can change", id="shared-directory", marks=SKIP_IF_WINDOWS), pytest.param( _linked_into_shared_directory, "another account can change", id="linked-into-shared-directory", marks=SKIP_IF_WINDOWS, ), ], ) def test_install_all_replaces_distrusted_recorded_interpreter_with_default( *, record_interpreter: Callable[[Path], Path], tmp_path: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str], place: Callable[[Path], Path], reason: str, ) -> None: install: Final = mocker.patch("pipx.commands.install.install", autospec=True) assert not run_pipx_cli(["install-all", str(record_interpreter(place(tmp_path)))]) assert ( Path(install.call_args.args[3].python).resolve(), f"is ignored as {reason}" in " ".join(capsys.readouterr().out.split()), ) == (Path(sys.executable).resolve(), True) @pytest.mark.usefixtures("pipx_temp_env") def test_install_all_force_ignores_explicit_python(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["list", "--json"]) (spec_file := tmp_path / "pipx.json").write_text(capsys.readouterr().out, encoding="utf-8") assert not run_pipx_cli(["install-all", str(spec_file), "--force", "--python", sys.executable]) assert "pipx ignores --python when you pass --force" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_install_all_multiple_errors(root: Path, capsys: pytest.CaptureFixture[str]) -> None: pipx_metadata_path: Final[Path] = root / "testdata" / "pipx_metadata_multiple_errors.json" assert run_pipx_cli(["install-all", str(pipx_metadata_path)]) captured: Final = capsys.readouterr() log_file: Final[Path] = current_log_file() pip_errors: Final[str] = log_file.with_name(f"{log_file.stem}_pip_errors.log").read_text(encoding="utf-8") assert ( "The following package(s) failed to install: dotenv, weblate" in captured.err, f"No packages installed after running 'pipx install-all {pipx_metadata_path}'" in captured.out, "dotenv" in pip_errors, "weblate" in pip_errors, ) == (True, True, True, True) def test_install_all_reports_injected_failure( tmp_path: Path, capsys: pytest.CaptureFixture[str], make_snapshot: Callable[[list[list[str]]], Path] ) -> None: spec_file: Final[Path] = make_snapshot([["install", "black"], ["inject", "black", "pycowsay"]]) missing_spec: Final[str] = (tmp_path / "missing").as_posix() spec_file.write_text( spec_file.read_text(encoding="utf-8").replace( '"package_or_url": "pycowsay"', f'"package_or_url": "{missing_spec}"' ), encoding="utf-8", ) result: Final[int] = run_pipx_cli(["install-all", str(spec_file)]) error: Final[str] = " ".join(capsys.readouterr().err.split()) expected_error: Final[str] = " ".join(pipx_wrap(f"Unable to parse package spec: {missing_spec}").split()) assert (result, expected_error in error) == (1, True) @pytest.mark.parametrize( ("install_all_args", "expected_pip_args"), [ pytest.param([], ["--no-cache-dir"], id="recorded"), pytest.param(["--pip-args=--no-compile"], ["--no-compile"], id="override"), ], ) def test_install_all_restores_recorded_args( make_snapshot: Callable[[list[list[str]]], Path], install_all_args: list[str], expected_pip_args: list[str] ) -> None: spec_file: Final[Path] = make_snapshot([ ["install", "--system-site-packages", "--pip-args=--no-cache-dir", "black"], ["inject", "black", "--pip-args=--no-cache-dir", "pycowsay"], ]) assert not run_pipx_cli(["install-all", *install_all_args, str(spec_file)]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "black") assert ( metadata.venv_args, metadata.main_package.pip_args, metadata.injected_packages["pycowsay"].pip_args, ) == (["--system-site-packages"], expected_pip_args, expected_pip_args) def test_install_all_leaves_directory_named_in_recorded_venv_args( tmp_path: Path, make_snapshot: Callable[[list[list[str]]], Path] ) -> None: spec_file: Final[Path] = make_snapshot([["install", "--backend", "pip", "pycowsay"]]) spec: Final = json.loads(spec_file.read_text(encoding="utf-8")) (other_dir := tmp_path / "documents").mkdir() (other_dir / "notes.txt").write_text("keep", encoding="utf-8") spec["venvs"]["pycowsay"]["metadata"]["venv_args"] = ["--clear", str(other_dir)] spec_file.write_text(json.dumps(spec), encoding="utf-8") assert not run_pipx_cli(["install-all", str(spec_file)]) assert sorted(path.name for path in other_dir.iterdir()) == ["notes.txt"] @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") def test_install_all_drops_recorded_pip_args_for_another_backend( make_snapshot: Callable[[list[list[str]]], Path], ) -> None: spec_file: Final[Path] = make_snapshot([ ["install", "--backend", "pip", "--pip-args=--progress-bar off", "pycowsay"], ["inject", "pycowsay", "--pip-args=--progress-bar off", PKG["black"]["spec"]], ]) assert not run_pipx_cli(["install-all", "--backend", "uv", str(spec_file)]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") assert (metadata.backend, metadata.main_package.pip_args, metadata.injected_packages["black"].pip_args) == ( "uv", [], [], ) @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") def test_install_all_warns_about_dropped_pip_args( make_snapshot: Callable[[list[list[str]]], Path], caplog: pytest.LogCaptureFixture ) -> None: spec_file: Final[Path] = make_snapshot([ ["install", "--backend", "pip", "--pip-args=--progress-bar off", "pycowsay"] ]) assert not run_pipx_cli(["install-all", "--backend", "uv", str(spec_file)]) assert "Ignoring pip args recorded for the pip backend of pycowsay: --progress-bar off\n" in caplog.text @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") def test_install_all_keeps_ambiguous_pip_abbreviation_as_typed( make_snapshot: Callable[[list[list[str]]], Path], caplog: pytest.LogCaptureFixture ) -> None: # pip rejects --pr, a prefix of --pre, --prefix and --progress-bar, so it must not replay as uv's --prerelease spec_file: Final[Path] = make_snapshot([["install", "--backend", "pip", "--pip-args=--pre", "pycowsay"]]) spec_file.write_text(spec_file.read_text(encoding="utf-8").replace('"--pre"', '"--pr"'), encoding="utf-8") assert not run_pipx_cli(["install-all", "--backend", "uv", str(spec_file)]) assert ( "Ignoring pip args recorded for the pip backend of pycowsay: --pr\n" in caplog.text, PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.pip_args, ) == (True, []) @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") def test_install_all_warning_redacts_index_credentials( make_snapshot: Callable[[list[list[str]]], Path], credentialed_index_url: str, caplog: pytest.LogCaptureFixture ) -> None: spec_file: Final[Path] = make_snapshot([ [ "install", "--backend", "pip", f"--pip-args=--progress-bar off --index-url {credentialed_index_url}", "pycowsay", ] ]) assert not run_pipx_cli(["install-all", "--backend", "uv", str(spec_file)]) redacted: Final[str] = credentialed_index_url.replace("user:s3cret", "****:****") assert ( "Ignoring pip args recorded for the pip backend of pycowsay: --progress-bar off; translated for uv: " f"--default-index {redacted}\n" ) in caplog.text @pytest.fixture def credentialed_index_url(pipx_temp_env: None) -> str: # ruff:ignore[unused-function-argument] # it sets the index scheme, _, location = os.environ["PIP_INDEX_URL"].partition("://") return f"{scheme}://user:s3cret@{location}" @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") @pytest.mark.parametrize( ("recorded_backend", "recorded_args", "expected_args"), [ pytest.param("pip", "--index-url {url}", "--default-index {url}", id="pip-index-url"), pytest.param("pip", "--index-url={url}", "--default-index {url}", id="pip-joined-value"), pytest.param("pip", "-i{url}", "--default-index {url}", id="pip-short-joined-value"), pytest.param("pip", "--index-u {url}", "--default-index {url}", id="pip-abbreviated-index-url"), pytest.param("pip", "--index={url}", "--default-index {url}", id="pip-index-abbreviates-index-url"), pytest.param( "pip", "--extra-i {url} --find-l {links} --no-ind --tr localhost", "--index {url} --find-links {links} --no-index --allow-insecure-host localhost", id="pip-abbreviated-options", ), pytest.param( "pip", "--extra-index-url {url} --find-links {links} --no-index", "--index {url} --find-links {links} --no-index", id="pip-extra-index-and-find-links", ), pytest.param( "pip", "--pre --trusted-host localhost -c {constraints} --keyring-provider disabled", "--prerelease allow --allow-insecure-host localhost --constraints {constraints} " "--keyring-provider disabled", id="pip-resolver-options", ), pytest.param("uv", "--default-index {url}", "--index-url {url}", id="uv-default-index"), pytest.param("uv", "--index=private={url}", "--extra-index-url {url}", id="uv-named-index"), pytest.param( "uv", "--no-index -f {links},{links}", "--no-index --find-links {links} --find-links {links}", id="uv-find-links-list", ), pytest.param( "uv", "--prerelease=allow --allow-insecure-host localhost --constraints {constraints}", "--pre --trusted-host localhost --constraint {constraints}", id="uv-resolver-options", ), pytest.param("uv", "--index-strategy unsafe-best-match", "", id="uv-pip-index-strategy"), pytest.param("uv", "--prerelease if-necessary", "", id="uv-prerelease-without-pip-equivalent"), ], ) def test_install_all_translates_recorded_pip_args_for_another_backend( *, make_snapshot: Callable[[list[list[str]]], Path], package_cache_dir: Path, tmp_path: Path, credentialed_index_url: str, recorded_backend: str, recorded_args: str, expected_args: str, ) -> None: (constraints := tmp_path / "constraints.txt").write_text("pycowsay\n", encoding="utf-8") values: Final[dict[str, str]] = { "url": credentialed_index_url, "links": str(package_cache_dir), "constraints": str(constraints), } spec_file: Final[Path] = make_snapshot([ ["install", "--backend", recorded_backend, f"--pip-args={recorded_args.format(**values)}", "pycowsay"] ]) assert not run_pipx_cli(["install-all", "--backend", "uv" if recorded_backend == "pip" else "pip", str(spec_file)]) assert PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.pip_args == expected_args.format(**values).split() @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") @pytest.mark.parametrize( ("recorded_backend", "recorded_args", "untranslatable"), [ pytest.param("uv", "--index-strategy first-index", "--index-strategy first-index", id="uv-index-strategy"), pytest.param( "uv", "--allow-insecure-host http://localhost", "--allow-insecure-host http://localhost", id="uv-insecure-host-url", ), pytest.param("pip", "--keyring-provider import", "--keyring-provider import", id="pip-keyring-provider"), pytest.param("pip", "--keyring-p import", "--keyring-provider import", id="pip-abbreviated-keyring-provider"), ], ) def test_install_all_refuses_untranslatable_index_args( make_snapshot: Callable[[list[list[str]]], Path], capsys: pytest.CaptureFixture[str], recorded_backend: str, recorded_args: str, untranslatable: str, ) -> None: spec_file: Final[Path] = make_snapshot([ ["install", "--backend", recorded_backend, f"--pip-args={recorded_args}", "pycowsay"] ]) other_backend: Final[str] = "uv" if recorded_backend == "pip" else "pip" result: Final[int] = run_pipx_cli(["install-all", "--backend", other_backend, str(spec_file)]) expected: Final[str] = ( f"Cannot install pycowsay with the {other_backend} backend: its recorded index option {untranslatable} has " f"no {other_backend} equivalent, and dropping it could install from a different index." ) assert (result, expected in " ".join(capsys.readouterr().err.split()), (paths.CTX.venvs / "pycowsay").exists()) == ( 1, True, False, ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_all_rejects_missing_spec_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: spec_file: Final[Path] = tmp_path / "pipx.json" result: Final[int] = run_pipx_cli(["install-all", str(spec_file)]) expected: Final[str] = " ".join( pipx_wrap(f"Unable to read the spec metadata file {spec_file}: No such file or directory").split() ) assert (result, expected in " ".join(capsys.readouterr().err.split())) == (1, True) @pytest.mark.parametrize( ("content", "expected_error"), [ pytest.param(b"\xff", "The spec metadata file {file} is not valid JSON.", id="binary"), pytest.param(b"{", "The spec metadata file {file} is not valid JSON.", id="truncated"), pytest.param(b"[]", "The spec metadata file {file} is invalid.", id="not-an-object"), pytest.param(b'{"venvs": []}', "The spec metadata file {file} is invalid.", id="venvs-not-an-object"), pytest.param(b'{"venvs": {}}', "No packages found in the spec metadata file {file}.", id="no-venvs"), pytest.param( b'{"venvs": {"x": {}}}', "Entry 'x' in the spec metadata file {file} is missing 'metadata'.", id="no-metadata", ), pytest.param( b'{"venvs": {"x": {"metadata": {"pipx_metadata_version": "0.12"}}}}', "Entry 'x' in the spec metadata file {file} is missing 'main_package'.", id="no-main-package", ), pytest.param( b'{"venvs": {"x": {"metadata": "x"}}}', "Entry 'x' in the spec metadata file {file} is malformed: string indices must be integers, not 'str'", id="metadata-not-an-object", ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_all_rejects_bad_spec_file( tmp_path: Path, capsys: pytest.CaptureFixture[str], content: bytes, expected_error: str ) -> None: (spec_file := tmp_path / "pipx.json").write_bytes(content) result: Final[int] = run_pipx_cli(["install-all", str(spec_file)]) error: Final[str] = " ".join(capsys.readouterr().err.split()) expected: Final[str] = " ".join(pipx_wrap(expected_error.format(file=spec_file)).split()) assert (result, expected in error) == (1, True) def test_install_all_prints_progress_in_spec_order( capsys: pytest.CaptureFixture[str], before_venv_subprocess: Callable[[Path, Callable[[], None]], None], spec_file: Path, ) -> None: venv_names: Final[list[str]] = list(json.loads(spec_file.read_text(encoding="utf-8"))["venvs"]) before_venv_subprocess(paths.CTX.venvs / venv_names[0], lambda: time.sleep(0.3)) assert not run_pipx_cli(["install-all", str(spec_file)]) assert re.findall(r"^installing ([\w-]+)", capsys.readouterr().err, re.MULTILINE) == venv_names @pytest.fixture def spec_file(make_snapshot: Callable[[list[list[str]]], Path]) -> Path: return make_snapshot([["install", spec] for spec in ("pycowsay", PKG["pbr"]["spec"], PKG["black"]["spec"])]) def test_install_all_refuses_an_environment_name_outside_the_store(spec_file: Path, tmp_path: Path) -> None: spec: Final = json.loads(spec_file.read_text(encoding="utf-8")) spec["venvs"] = {"pycowsay": spec["venvs"]["pycowsay"]} spec["venvs"]["pycowsay"]["metadata"]["main_package"]["package"] = str(tmp_path / "outside") spec_file.write_text(json.dumps(spec), encoding="utf-8") # pipx canonicalizes the name before it becomes a directory outside: Final[Path] = Path(canonicalize_name(str(tmp_path / "outside"))) assert (run_pipx_cli(["install-all", str(spec_file)]), outside.exists()) == (1, False) @pytest.mark.usefixtures("pipx_temp_env") def test_install_all_leaves_injected_packages_of_a_skipped_environment( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", PKG["pbr"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["list", "--json"]) (spec_file := tmp_path / "pipx.json").write_text(capsys.readouterr().out, encoding="utf-8") assert not run_pipx_cli(["uninject", "pycowsay", "pbr"]) assert not run_pipx_cli(["install-all", str(spec_file)]) assert list(PipxMetadata(paths.CTX.venvs / "pycowsay").injected_packages) == [] def test_install_all_installs_entries_before_a_malformed_one(spec_file: Path) -> None: spec: Final = json.loads(spec_file.read_text(encoding="utf-8")) spec["venvs"] = {"pycowsay": spec["venvs"]["pycowsay"], "malformed": {}} spec_file.write_text(json.dumps(spec), encoding="utf-8") exit_code: Final[int] = run_pipx_cli(["install-all", str(spec_file)]) assert (exit_code, sorted(venv_dir.name for venv_dir in paths.CTX.venvs.iterdir() if venv_dir.is_dir())) == ( 1, ["pycowsay"], ) def test_install_all_gives_shared_man_page_to_the_first_venv( make_snapshot: Callable[[list[list[str]]], Path], before_venv_subprocess: Callable[[Path, Callable[[], None]], None], ) -> None: spec_file: Final[Path] = make_snapshot([["install", "pycowsay"], ["install", "pycowsay", "--suffix=_x"]]) first: Final[Path] = paths.CTX.venvs / next(iter(json.loads(spec_file.read_text(encoding="utf-8"))["venvs"])) before_venv_subprocess(first, lambda: time.sleep(0.5)) assert not run_pipx_cli(["install-all", str(spec_file)]) assert (paths.CTX.man_dir / "man6" / "pycowsay.6").resolve().is_relative_to(first) pipx-1.18.16/tests/test_interpreter.py000066400000000000000000000514111527355705200200030ustar00rootroot00000000000000from __future__ import annotations import json import os import shutil import subprocess import sys from pathlib import Path from typing import TYPE_CHECKING, Final import pytest import pipx.interpreter import pipx.paths import pipx.standalone_python from helpers import run_pipx_cli from pipx.constants import WINDOWS, FetchPythonOptions from pipx.interpreter import InterpreterResolutionError, find_python_interpreter, recordable_interpreter from pipx.util import PipxError if TYPE_CHECKING: from collections.abc import Iterator from unittest.mock import MagicMock from pytest_mock import MockerFixture def test_import_defers_default_python_resolution() -> None: result: Final = subprocess.run( [sys.executable, "-c", "import pipx.interpreter; print('imported')"], env={**os.environ, "PIPX_DEFAULT_PYTHON": "missing-pipx-python"}, capture_output=True, text=True, check=False, ) assert (result.returncode, result.stdout.strip()) == (0, "imported") @pytest.mark.usefixtures("uncached_default_python") def test_default_python_compatibility_attribute() -> None: assert pipx.interpreter.get_default_python() == pipx.interpreter.DEFAULT_PYTHON @pytest.fixture def uncached_default_python() -> Iterator[None]: pipx.interpreter.get_default_python.cache_clear() yield pipx.interpreter.get_default_python.cache_clear() @pytest.mark.usefixtures("uncached_default_python") def test_get_default_python_resolves_configured_value(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PIPX_DEFAULT_PYTHON", sys.executable) assert pipx.interpreter.get_default_python() == str(Path(sys.executable).resolve()) def test_has_venv_leaves_venv_unimported() -> None: result: Final = subprocess.run( [ sys.executable, "-c", "import sys, pipx.interpreter; print(pipx.interpreter.has_venv(), 'venv' in sys.modules)", ], capture_output=True, text=True, check=False, ) assert (result.returncode, result.stdout.strip()) == (0, "True False") def test_has_venv_without_venv_module(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setitem(sys.modules, "venv", None) assert pipx.interpreter.has_venv() is False @pytest.mark.skipif(not sys.platform.startswith("win"), reason="Looks for Python.exe") @pytest.mark.parametrize("venv", [pytest.param(True, id="venv"), pytest.param(False, id="no-venv")]) @pytest.mark.parametrize("prefix", [pytest.param("", id="version"), pytest.param("python", id="python-and-version")]) @pytest.mark.usefixtures("py_launcher_on_path") def test_windows_python_with_version(monkeypatch: pytest.MonkeyPatch, venv: bool, prefix: str) -> None: major, minor = sys.version_info[:2] monkeypatch.setattr(pipx.interpreter, "has_venv", lambda: venv) python_path: Final[str] = find_python_interpreter(f"{prefix}{major}.{minor}", FetchPythonOptions.NEVER) assert f"{major}.{minor}" in python_path or f"{major}{minor}" in python_path assert python_path.endswith("python.exe") @pytest.fixture def py_launcher_on_path(monkeypatch: pytest.MonkeyPatch) -> None: which: Final = shutil.which monkeypatch.setattr(shutil, "which", lambda name: "py" if name == "py" else which(name)) @pytest.mark.skipif(not sys.platform.startswith("win"), reason="Looks for Python.exe") @pytest.mark.parametrize("venv", [pytest.param(True, id="venv"), pytest.param(False, id="no-venv")]) @pytest.mark.usefixtures("py_launcher_on_path") def test_windows_python_with_python_and_unavailable_version(monkeypatch: pytest.MonkeyPatch, venv: bool) -> None: monkeypatch.setattr(pipx.interpreter, "has_venv", lambda: venv) with pytest.raises(InterpreterResolutionError) as error: find_python_interpreter( f"python{sys.version_info.major + 99}.{sys.version_info.minor}", FetchPythonOptions.NEVER ) assert "py --list" in error.value.raw_message @pytest.mark.usefixtures("windows_default_python") def test_windows_python_no_version_with_venv(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(pipx.interpreter, "has_venv", lambda: True) assert pipx.interpreter.get_default_python() == sys.executable @pytest.fixture def windows_default_python(monkeypatch: pytest.MonkeyPatch, uncached_default_python: None) -> None: # ruff:ignore[unused-function-argument] # the cache must be clear before and after monkeypatch.setattr(pipx.interpreter, "WINDOWS", True) monkeypatch.delenv("PIPX_DEFAULT_PYTHON", raising=False) @pytest.mark.parametrize( ("found", "expected"), [ pytest.param({"py": "py", "python": "python"}, "py", id="py-launcher"), pytest.param({"python": "python"}, "python", id="python-present"), ], ) @pytest.mark.usefixtures("windows_default_python") def test_windows_python_no_version_no_venv( monkeypatch: pytest.MonkeyPatch, found: dict[str, str], expected: str ) -> None: monkeypatch.setattr(pipx.interpreter, "has_venv", lambda: False) monkeypatch.setattr(shutil, "which", found.get) assert pipx.interpreter.get_default_python() == expected @pytest.mark.usefixtures("windows_default_python") def test_windows_python_no_version_no_venv_no_python(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(pipx.interpreter, "has_venv", lambda: False) monkeypatch.setattr(shutil, "which", lambda _name: None) with pytest.raises(PipxError): pipx.interpreter.get_default_python() @pytest.mark.parametrize( ("returncode", "stdout"), [ pytest.param(9009, b"", id="stub-exit-code"), pytest.param(0, b"", id="no-version-printed"), ], ) @pytest.mark.usefixtures("windows_store_python") def test_windows_python_no_venv_store_stub_rejected(mocker: MockerFixture, returncode: int, stdout: bytes) -> None: mocker.patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], returncode, stdout=stdout)) with pytest.raises(PipxError): pipx.interpreter.get_default_python() @pytest.fixture def windows_store_python(monkeypatch: pytest.MonkeyPatch, windows_default_python: None) -> None: # ruff:ignore[unused-function-argument] # the store Python is looked up as the Windows default monkeypatch.setattr(pipx.interpreter, "has_venv", lambda: False) monkeypatch.setattr(shutil, "which", {"python": "WindowsApps"}.get) @pytest.mark.usefixtures("windows_store_python") def test_windows_python_no_venv_store_python_that_prints_its_version(mocker: MockerFixture) -> None: mocker.patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout=b"3.8")) assert pipx.interpreter.get_default_python() == "WindowsApps" @pytest.mark.usefixtures("uncached_default_python") def test_get_default_python_resolves_executable_name(monkeypatch: pytest.MonkeyPatch) -> None: candidates: Final[list[str]] = [ f"python{sys.version_info.major}.{sys.version_info.minor}", f"python{sys.version_info.major}", "python", ] name: Final[str | None] = next((c for c in candidates if shutil.which(c)), None) assert name is not None, "no python executable on PATH" monkeypatch.setenv("PIPX_DEFAULT_PYTHON", name) result: Final[Path] = Path(pipx.interpreter.get_default_python()) assert (result.is_absolute(), result.is_file()) == (True, True) @pytest.mark.skipif(WINDOWS, reason="Unix command resolution") @pytest.mark.usefixtures("uncached_default_python") def test_get_default_python_resolves_bare_version(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PIPX_DEFAULT_PYTHON", f"{sys.version_info.major}.{sys.version_info.minor}") result: Final[Path] = Path(pipx.interpreter.get_default_python()) assert (result.is_absolute(), result.is_file()) == (True, True) @pytest.mark.usefixtures("uncached_default_python") def test_get_default_python_resolves_path_to_absolute(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: (fake_python := tmp_path / "mypython").touch(mode=0o755) monkeypatch.setenv("PIPX_DEFAULT_PYTHON", str(fake_python)) assert Path(pipx.interpreter.get_default_python()).is_absolute() @pytest.mark.skipif(WINDOWS, reason="Windows searches the working directory for a bare command name by design") @pytest.mark.usefixtures("uncached_default_python") def test_get_default_python_ignores_bare_name_in_working_directory( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: (tmp_path / "python3").touch(mode=0o755) monkeypatch.chdir(tmp_path) monkeypatch.setenv("PIPX_DEFAULT_PYTHON", "python3") assert pipx.interpreter.get_default_python() == shutil.which("python3") @pytest.mark.usefixtures("uncached_default_python") def test_get_default_python_resolves_relative_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: (tmp_path / "mypython").touch(mode=0o755) monkeypatch.chdir(tmp_path) monkeypatch.setenv("PIPX_DEFAULT_PYTHON", f".{os.sep}mypython") assert pipx.interpreter.get_default_python() == str((tmp_path / "mypython").resolve()) @pytest.mark.parametrize( "python", [ pytest.param("no_such_python_99.99", id="missing-name"), pytest.param("99.99", id="missing-version"), ], ) @pytest.mark.usefixtures("uncached_default_python") def test_get_default_python_invalid_raises(monkeypatch: pytest.MonkeyPatch, python: str) -> None: monkeypatch.setenv("PIPX_DEFAULT_PYTHON", python) with pytest.raises(InterpreterResolutionError, match="PIPX_DEFAULT_PYTHON"): pipx.interpreter.get_default_python() def test_find_python_interpreter_by_path() -> None: assert find_python_interpreter(sys.executable, FetchPythonOptions.NEVER) == sys.executable def test_find_python_interpreter_by_version() -> None: major, minor = sys.version_info[:2] python_path: Final[str] = find_python_interpreter(f"python{major}.{minor}", FetchPythonOptions.NEVER) result: Final = subprocess.run( [python_path, "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"], capture_output=True, text=True, check=True, ) assert result.stdout.strip() == f"{major}.{minor}" def test_find_python_interpreter_by_name_returns_path_lookup_result(python_on_path: Path) -> None: assert find_python_interpreter(python_on_path.name, FetchPythonOptions.NEVER) == str(python_on_path) @pytest.fixture def python_on_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: (python := tmp_path / "mypython.exe").touch(mode=0o755) monkeypatch.setenv("PATH", str(tmp_path)) return python @pytest.mark.skipif(WINDOWS, reason="Windows searches the working directory for a bare command name by design") def test_find_python_interpreter_by_name_skips_working_directory_file( python_on_path: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: (cwd := tmp_path / "cwd").mkdir() (cwd / python_on_path.name).touch(mode=0o755) monkeypatch.chdir(cwd) assert find_python_interpreter(python_on_path.name, FetchPythonOptions.NEVER) == str(python_on_path) @pytest.mark.parametrize( "python_version", [ pytest.param(f"{sys.executable}99", id="wrong-path"), pytest.param("1.1", id="missing-on-path"), ], ) def test_find_python_interpreter_unresolvable_raises(python_version: str) -> None: with pytest.raises(InterpreterResolutionError): find_python_interpreter(python_version, FetchPythonOptions.NEVER) @pytest.mark.parametrize( "fetch_python", [ pytest.param(FetchPythonOptions.MISSING, id="missing"), pytest.param(FetchPythonOptions.ALWAYS, id="always"), ], ) @pytest.mark.usefixtures("pipx_temp_env", "mocked_github_api") def test_fetch_missing_python(monkeypatch: pytest.MonkeyPatch, fetch_python: FetchPythonOptions) -> None: monkeypatch.setattr(shutil, "which", lambda _name: None) target_python: Final[str] = f"{sys.version_info.major}.{sys.version_info.minor}" python_path: Final[str] = find_python_interpreter(target_python, fetch_python=fetch_python) assert target_python in python_path assert str(pipx.paths.CTX.standalone_python_cachedir) in python_path assert python_path.endswith("python.exe" if WINDOWS else "python3") subprocess.run([python_path, "-c", "import sys; print(sys.executable)"], check=True) @pytest.mark.usefixtures("mocked_github_api") def test_fetch_python_always_invalid_version_raises() -> None: with pytest.raises(InterpreterResolutionError, match="python-build-standalone"): find_python_interpreter("3.0", fetch_python=FetchPythonOptions.ALWAYS) @pytest.mark.parametrize( ("system", "machine", "libc", "expected_platform"), [ pytest.param("Plan9", "riscv64", "", "Plan9 on riscv64", id="system"), pytest.param("Linux", "aarch64", "musl", "Linux on aarch64 with musl", id="linux-libc"), ], ) def test_fetch_python_unsupported_platform_raises( mocker: MockerFixture, system: str, machine: str, libc: str, expected_platform: str, ) -> None: mocker.patch.object(pipx.standalone_python.platform, "system", return_value=system) mocker.patch.object(pipx.standalone_python.platform, "machine", return_value=machine) mocker.patch.object(pipx.standalone_python.platform, "libc_ver", return_value=(libc, "")) with pytest.raises(InterpreterResolutionError, match="python-build-standalone") as error: find_python_interpreter("3.99", fetch_python=FetchPythonOptions.ALWAYS) assert expected_platform in str(error.value.__cause__) def test_fetch_python_retries_incomplete_install( mocker: MockerFixture, caplog: pytest.LogCaptureFixture, ) -> None: mocker.patch.object(pipx.standalone_python.platform, "system", return_value="Plan9") mocker.patch.object(pipx.standalone_python.platform, "machine", return_value="riscv64") (install_dir := pipx.paths.CTX.standalone_python_cachedir / "3.98").mkdir(parents=True) with pytest.raises(InterpreterResolutionError): find_python_interpreter("3.98", fetch_python=FetchPythonOptions.ALWAYS) assert not install_dir.exists() assert "A previous attempt to install python 3.98 failed. Retrying." in caplog.text @pytest.mark.parametrize( "python_version", [ pytest.param("/usr/bin/python3.13", id="absolute-path"), pytest.param("relative/python3.13", id="relative-path"), pytest.param(__file__, id="existing-file"), ], ) def test_fetch_python_always_rejects_paths(python_version: str) -> None: with pytest.raises(PipxError, match="requires a Python version"): find_python_interpreter(python_version, fetch_python=FetchPythonOptions.ALWAYS) @pytest.mark.usefixtures("mocked_github_api") @pytest.mark.parametrize( ("python_version", "fetch_python"), [ pytest.param("..", FetchPythonOptions.ALWAYS, id="parent-always"), pytest.param("..", FetchPythonOptions.MISSING, id="parent-missing"), pytest.param(".", FetchPythonOptions.ALWAYS, id="cache-dir"), pytest.param("python", FetchPythonOptions.ALWAYS, id="python-stripped-to-empty"), pytest.param("cpython", FetchPythonOptions.MISSING, id="cpython-stripped-to-empty"), pytest.param(f"..{os.sep}venvs{os.sep}app", FetchPythonOptions.MISSING, id="sibling-venv"), ], ) def test_fetch_python_non_version_keeps_pipx_home( monkeypatch: pytest.MonkeyPatch, python_version: str, fetch_python: FetchPythonOptions ) -> None: monkeypatch.setattr(shutil, "which", lambda _name: None) cache_dir: Final[Path] = pipx.paths.CTX.standalone_python_cachedir kept: Final[list[Path]] = [cache_dir / "3.98", cache_dir.parent / "venvs" / "app"] for directory in kept: directory.mkdir(parents=True) with pytest.raises(InterpreterResolutionError, match="python-build-standalone"): find_python_interpreter(python_version, fetch_python=fetch_python) assert [directory.is_dir() for directory in kept] == [True, True] @pytest.mark.usefixtures("mocked_github_api") def test_fetch_python_missing_keeps_directory_named_by_path( monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory ) -> None: monkeypatch.setattr(shutil, "which", lambda _name: None) project: Final[Path] = tmp_path_factory.mktemp("project") with pytest.raises(InterpreterResolutionError, match="python-build-standalone"): find_python_interpreter(str(project), fetch_python=FetchPythonOptions.MISSING) assert project.is_dir() def test_find_python_interpreter_py_launcher_failure_without_fetch_raises( mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(shutil, "which", lambda _name: None) mocker.patch.object( pipx.interpreter, "find_py_launcher_python", autospec=True, side_effect=subprocess.CalledProcessError(1, ["py"]) ) with pytest.raises(InterpreterResolutionError, match="py launcher"): find_python_interpreter("3.13", fetch_python=FetchPythonOptions.NEVER) def test_find_python_interpreter_py_launcher_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(shutil, "which", lambda _name: None) monkeypatch.setattr(pipx.interpreter, "find_py_launcher_python", lambda version: f"/fake/python{version}") assert find_python_interpreter("3.13", fetch_python=FetchPythonOptions.NEVER) == "/fake/python3.13" @pytest.mark.skipif(WINDOWS, reason="Python Launcher for Unix") @pytest.mark.usefixtures("unix_py_launcher_on_path") @pytest.mark.parametrize( "python_version", [ pytest.param("3.14.8", id="micro"), pytest.param("python3.14.8", id="python-prefix-micro"), pytest.param("3.13t", id="free-threaded"), ], ) def test_find_python_interpreter_skips_unix_py_launcher_for_non_selector( mocker: MockerFixture, python_version: str ) -> None: launcher: Final[MagicMock] = mocker.patch.object(subprocess, "run", autospec=True) with pytest.raises(InterpreterResolutionError, match="found in PATH"): find_python_interpreter(python_version, FetchPythonOptions.NEVER) launcher.assert_not_called() @pytest.fixture def unix_py_launcher_on_path(mocker: MockerFixture) -> None: mocker.patch.object(shutil, "which", side_effect=lambda name: "/usr/bin/py" if name == "py" else None) @pytest.mark.skipif(WINDOWS, reason="Python Launcher for Unix") @pytest.mark.usefixtures("unix_py_launcher_on_path") def test_find_python_interpreter_asks_unix_py_launcher_for_minor_version(mocker: MockerFixture) -> None: mocker.patch.object( subprocess, "run", autospec=True, return_value=subprocess.CompletedProcess([], 0, stdout="/opt/python3.99/bin/python3.99\n"), ) assert find_python_interpreter("3.99", FetchPythonOptions.NEVER) == "/opt/python3.99/bin/python3.99" @pytest.mark.parametrize( ("subcommand", "command"), [ pytest.param("list", ["interpreter", "list"], id="list"), pytest.param("prune", ["interpreter", "prune"], id="prune"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_interpreter_json_reports_an_empty_cache( capsys: pytest.CaptureFixture[str], subcommand: str, command: str, ) -> None: assert not run_pipx_cli(["interpreter", subcommand, "--output", "json"]) assert json.loads(capsys.readouterr().out) == { "command": command, "data": {"interpreters": [], "removed": [], "upgraded": []}, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", } @pytest.mark.parametrize("subcommand", [pytest.param("list", id="list"), pytest.param("prune", id="prune")]) @pytest.mark.usefixtures("pipx_temp_env") def test_interpreter_quiet_says_nothing( capsys: pytest.CaptureFixture[str], subcommand: str, ) -> None: assert not run_pipx_cli(["interpreter", subcommand, "--quiet"]) captured: Final = capsys.readouterr() assert (captured.out, captured.err) == ("", "") @pytest.mark.parametrize( ("pyvenv_cfg", "expected"), [ pytest.param(b"home = {base_dir}\n", "python3", id="home"), pytest.param(b"home = {missing}\nexecutable = {base_dir}/python3.14\n", "python3.14", id="executable"), pytest.param(b"home = {missing}\n", None, id="home-without-interpreter"), pytest.param(b"version = 3.14.0\n", None, id="no-home"), pytest.param(b"home = \xff\n", None, id="undecodable"), ], ) def test_recordable_interpreter_inside_venv(tmp_path: Path, pyvenv_cfg: bytes, expected: str | None) -> None: (base_dir := tmp_path / "base").mkdir() for name in ("python3", "python3.14"): (base_dir / name).touch() (bootstrap := tmp_path / "bootstrap").mkdir() (bootstrap / "pyvenv.cfg").write_bytes( pyvenv_cfg.replace(b"{base_dir}", str(base_dir).encode()).replace( b"{missing}", str(tmp_path / "missing").encode() ) ) (bin_dir := bootstrap / "bin").mkdir() (python := bin_dir / "python3").touch() assert recordable_interpreter(python) == (None if expected is None else base_dir / expected) pipx-1.18.16/tests/test_list.py000066400000000000000000000556221527355705200164230ustar00rootroot00000000000000from __future__ import annotations import importlib import json import os import re import shutil import subprocess import sys import time import zipfile from contextlib import contextmanager from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from filelock import BaseFileLock from helpers import ( SKIP_IF_WINDOWS, app_name, assert_package_metadata, create_package_info_ref, mock_legacy_venv, pipx_subprocess_env, remove_venv_interpreter, run_pipx_cli, write_wheel, ) from package_info import PKG from pipx import constants, paths, shared_libs, venv from pipx.commands import common from pipx.commands.list_packages import list_packages from pipx.options import ListOptions from pipx.pipx_metadata_file import PIPX_INFO_FILENAME, PackageInfo, PipxMetadata, load_spec_file from pipx.util import PipxError if TYPE_CHECKING: from collections.abc import Callable, Generator from pytest_mock import MockerFixture from pytest_subprocess import FakeProcess @pytest.mark.usefixtures("pipx_temp_env") def test_cli(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["list"]) assert "nothing has been installed with pipx" in capsys.readouterr().err @SKIP_IF_WINDOWS @pytest.mark.skipif(not constants.WINDOWS and os.geteuid() == 0, reason="root reads files whatever their mode") @pytest.mark.parametrize( ("flags", "listed"), [ pytest.param([], "package black", id="text"), pytest.param(["--short"], "black ", id="short"), pytest.param(["--json"], '"black"', id="json"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_list_reports_unreadable_metadata_per_environment( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, flags: list[str], listed: str ) -> None: assert not run_pipx_cli(["install", "pycowsay", PKG["black"]["spec"]]) (paths.CTX.venvs / "pycowsay" / PIPX_INFO_FILENAME).chmod(0) capsys.readouterr() caplog.clear() assert ( run_pipx_cli(["list", *flags]), listed in capsys.readouterr().out, "package pycowsay has pipx metadata this user cannot read" in caplog.text, "has no internal pipx metadata" in caplog.text, ) == (constants.EXIT_CODE_LIST_PROBLEM, True, True, False) def test_list_allows_read_only_venv_lock(tmp_path: Path, mocker: MockerFixture) -> None: (venv_dir := tmp_path / "venvs" / "example").mkdir(parents=True) venv_container: Final[venv.VenvContainer] = venv.VenvContainer(venv_dir.parent) lock: Final = mocker.create_autospec(BaseFileLock, instance=True) # Python before 3.14 ignores a keyword argument naming a method of an autospec lock.acquire.side_effect = PermissionError("read-only lock file") mocker.patch.object(venv_container, "venv_lock", return_value=lock) mocker.patch.object( importlib.import_module("pipx.commands.list_packages"), "get_venv_summary", return_value=("package example 1", common.VenvProblems()), ) assert not list_packages( venv_container, [venv_dir], ListOptions(include_injected=False, json_format=False, short_format=False, pinned_only=False), ) lock.release.assert_not_called() @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_cli_global(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert "installed package" in capsys.readouterr().out assert not run_pipx_cli(["list", "--global"]) assert "nothing has been installed with pipx" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_missing_interpreter(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["list"]) assert "package pycowsay has invalid interpreter" not in capsys.readouterr().err remove_venv_interpreter("pycowsay") assert run_pipx_cli(["list"]) assert "package pycowsay has invalid interpreter" in capsys.readouterr().err @pytest.mark.usefixtures("uv_symlinked_install") def test_list_counts_link_to_uv_symlinked_man_page_as_exposed(capsys: pytest.CaptureFixture[str]) -> None: capsys.readouterr() assert not run_pipx_cli(["list"]) assert f" - {Path('man1', 'tool.1')}\n" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_list_suffix(capsys: pytest.CaptureFixture[str]) -> None: suffix: Final[str] = "_x" assert not run_pipx_cli(["install", "pycowsay", f"--suffix={suffix}"]) assert not run_pipx_cli(["list"]) assert f"package pycowsay 0.0.0.2 (pycowsay{suffix})," in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_list_legacy_venv_without_metadata(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=None) assert run_pipx_cli(["list"]) assert "package pycowsay has no internal pipx metadata" in capsys.readouterr().err @pytest.mark.parametrize("metadata_version", ["0.1", "0.2", "0.3"]) @pytest.mark.usefixtures("pipx_temp_env") def test_list_legacy_venv(capsys: pytest.CaptureFixture[str], metadata_version: str) -> None: assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=metadata_version) assert not run_pipx_cli(["list"]) assert "package pycowsay 0.0.0.2," in capsys.readouterr().out @pytest.mark.parametrize( "metadata", [ pytest.param("{", id="invalid-json"), pytest.param("{}", id="missing-version"), pytest.param( '{"pipx_metadata_version":"0.6","main_package":{"unexpected":true}}', id="unexpected-package-field", ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_list_returns_problem_for_corrupt_metadata(metadata: str) -> None: (venv_dir := paths.CTX.venvs / "broken").mkdir(parents=True) (venv_dir / PIPX_INFO_FILENAME).write_text(metadata, encoding="utf-8") assert run_pipx_cli(["list"]) == constants.EXIT_CODE_LIST_PROBLEM @pytest.mark.parametrize("metadata_version", ["0.1"]) @pytest.mark.usefixtures("pipx_temp_env") def test_list_suffix_legacy_venv(capsys: pytest.CaptureFixture[str], metadata_version: str) -> None: suffix: Final[str] = "_x" assert not run_pipx_cli(["install", "pycowsay", f"--suffix={suffix}"]) mock_legacy_venv(f"pycowsay{suffix}", metadata_version=metadata_version) assert not run_pipx_cli(["list"]) captured: Final = capsys.readouterr() assert f"package pycowsay 0.0.0.2 (pycowsay{suffix})," in captured.out assert f"shell completions are exposed at {paths.CTX.completion_dir}" in captured.out @pytest.mark.usefixtures("pipx_temp_env") def test_list_json(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: pipx_venvs_dir: Final[Path] = paths.CTX.home / "venvs" venv_bin_dir: Final[str] = "Scripts" if constants.WINDOWS else "bin" assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["install", PKG["pylint"]["spec"]]) assert not run_pipx_cli(["inject", "pylint", PKG["black"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["list", "--output", "json"]) captured: Final = capsys.readouterr() assert not re.search(r"\S", captured.err) (listed := tmp_path / "list.json").write_text(captured.out, encoding="utf-8") json_parsed: Final = load_spec_file(listed) assert sorted(json_parsed["venvs"].keys()) == ["pycowsay", "pylint"] assert_package_metadata( PackageInfo(**json_parsed["venvs"]["pycowsay"]["metadata"]["main_package"]), create_package_info_ref("pycowsay", "pycowsay", pipx_venvs_dir), ) assert json_parsed["venvs"]["pycowsay"]["metadata"]["injected_packages"] == {} pylint_package_ref: Final[PackageInfo] = create_package_info_ref( "pylint", "pylint", pipx_venvs_dir, app_paths_of_dependencies={ "dill": [ pipx_venvs_dir / "pylint" / venv_bin_dir / "get_gprof", pipx_venvs_dir / "pylint" / venv_bin_dir / "get_objgraph", pipx_venvs_dir / "pylint" / venv_bin_dir / "undill", ], "isort": [ pipx_venvs_dir / "pylint" / venv_bin_dir / app_name("isort"), pipx_venvs_dir / "pylint" / venv_bin_dir / app_name("isort-identify-imports"), ], }, ) assert_package_metadata( PackageInfo(**json_parsed["venvs"]["pylint"]["metadata"]["main_package"]), pylint_package_ref, ) assert sorted(json_parsed["venvs"]["pylint"]["metadata"]["injected_packages"].keys()) == ["black"] assert_package_metadata( PackageInfo(**json_parsed["venvs"]["pylint"]["metadata"]["injected_packages"]["black"]), create_package_info_ref("pylint", "black", pipx_venvs_dir, include_apps=False), ) @pytest.fixture def self_managed_pipx( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing tmp_path: Path, ) -> PipxMetadata: assert not run_pipx_cli(["install", str(write_wheel(tmp_path / "wheels", "pipx", "1.0"))]) return PipxMetadata(paths.CTX.venvs / "pipx") @pytest.fixture def exported_self_managed_interpreter( self_managed_pipx: PipxMetadata, capsys: pytest.CaptureFixture[str] ) -> Callable[[Path], dict[str, str] | None]: def export(recorded: Path) -> dict[str, str] | None: self_managed_pipx.source_interpreter = recorded self_managed_pipx.write() capsys.readouterr() assert not run_pipx_cli(["list", "--json"]) return json.loads(capsys.readouterr().out)["venvs"]["pipx"]["metadata"]["source_interpreter"] return export @SKIP_IF_WINDOWS @pytest.mark.parametrize( "recorded", [ pytest.param(Path("bootstrap", "bin", "python3"), id="removed-bootstrap"), pytest.param(Path("/bin/sh"), id="other-file"), ], ) def test_list_json_drops_self_managed_interpreter_not_backing_the_venv( exported_self_managed_interpreter: Callable[[Path], dict[str, str] | None], tmp_path: Path, recorded: Path ) -> None: assert exported_self_managed_interpreter(tmp_path / recorded) is None @SKIP_IF_WINDOWS def test_list_json_keeps_self_managed_interpreter_backing_the_venv( exported_self_managed_interpreter: Callable[[Path], dict[str, str] | None], ) -> None: backing: Final[Path] = (paths.CTX.venvs / "pipx" / "bin" / "python").resolve() assert exported_self_managed_interpreter(backing) == {"__type__": "Path", "__Path__": str(backing)} @pytest.mark.usefixtures("pipx_temp_env") def test_list_short(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["install", PKG["isort"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["list", "--short"]) captured: Final = capsys.readouterr() assert "pycowsay 0.0.0.2" in captured.out assert "isort 5.6.4" in captured.out @pytest.mark.parametrize( ("extra_args", "expected"), [ pytest.param(["--include-injected"], "isort 5.6.4\nblack 22.8.0 (injected in venv isort)\n", id="injected"), pytest.param([], "isort 5.6.4\n", id="main-only"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_list_short_injected(capsys: pytest.CaptureFixture[str], extra_args: list[str], expected: str) -> None: assert not run_pipx_cli(["install", PKG["isort"]["spec"]]) assert not run_pipx_cli(["inject", "isort", PKG["black"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["list", "--short", *extra_args]) assert capsys.readouterr().out == expected @pytest.mark.parametrize("option", [pytest.param("--short", id="short"), pytest.param("--pinned", id="pinned")]) @pytest.mark.usefixtures("pipx_temp_env") def test_list_json_rejects_human_filter( capsys: pytest.CaptureFixture[str], option: str, ) -> None: assert run_pipx_cli(["list", "--output", "json", option]) == 1 document: Final = json.loads(capsys.readouterr().out) assert document["status"] == "error" assert "--output json cannot be combined with --short or --pinned" in document["errors"][0]["message"] @pytest.mark.usefixtures("pipx_temp_env") def test_list_selected_package_text(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["install", PKG["isort"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["list", "pycowsay"]) captured: Final = capsys.readouterr() assert ("package pycowsay 0.0.0.2," in captured.out, "package isort" in captured.out) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_list_selected_packages_json(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["install", PKG["isort"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["list", "pycowsay", "isort", "--json"]) assert sorted(json.loads(capsys.readouterr().out)["venvs"]) == ["isort", "pycowsay"] @pytest.mark.parametrize( "packages", [ pytest.param(["isort"], id="single"), pytest.param(["isort", "isort"], id="repeated"), pytest.param(["isort", "ISort"], id="mixed-case"), pytest.param(["isort", "isort==5.6.4"], id="with-specifier"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_list_selected_package_short( capsys: pytest.CaptureFixture[str], packages: list[str], ) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["install", PKG["isort"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["list", *packages, "--short"]) assert capsys.readouterr().out == "isort 5.6.4\n" @pytest.mark.usefixtures("pipx_temp_env") def test_list_selected_package_with_suffix(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"], "--suffix=@1"]) capsys.readouterr() assert not run_pipx_cli(["list", "pycowsay@1", "--short"]) assert capsys.readouterr().out == "pycowsay 0.0.0.2\n" @pytest.mark.usefixtures("pipx_temp_env") def test_list_selected_package_pinned(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["install", PKG["isort"]["spec"]]) assert not run_pipx_cli(["pin", "isort"]) capsys.readouterr() assert not run_pipx_cli(["list", "isort", "--pinned"]) assert capsys.readouterr().out == "isort 5.6.4\n" @pytest.mark.usefixtures("pipx_temp_env") def test_list_rejects_missing_selected_package( capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["list", "missing"]) assert capsys.readouterr().err.endswith( "pipx has no venv for 'missing'; run 'pipx list' to see the installed packages\n" ) @pytest.mark.usefixtures("pipx_temp_env") def test_list_injected_apps_without_symlinks( mocker: MockerFixture, capsys: pytest.CaptureFixture[str], ) -> None: mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["inject", "pycowsay", PKG["isort"]["spec"], "--include-apps"]) capsys.readouterr() assert not run_pipx_cli(["list", "--include-injected"]) assert f" - {PKG['isort']['apps'][0]}" in capsys.readouterr().out.splitlines() @pytest.mark.usefixtures("pipx_temp_env") def test_list_waits_for_install(tmp_path: Path) -> None: release: Final[Path] = tmp_path / "release" with _install_paused_under_lock(tmp_path, "slow-app", release) as install: listed: Final[subprocess.Popen[str]] = _start_pipx("list", "--short") # the install cannot progress until release, so a list that honours the lock is still running with pytest.raises(subprocess.TimeoutExpired): listed.wait(timeout=1) release.touch() install_stdout, install_stderr = install.communicate() listed_stdout, listed_stderr = listed.communicate() assert (install.returncode, listed.returncode, listed_stdout.strip()) == (0, 0, "slow-app 1"), ( install_stdout, install_stderr, listed_stdout, listed_stderr, ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_does_not_wait_for_other_environment(tmp_path: Path) -> None: with _install_paused_under_lock(tmp_path, "blocked-app", tmp_path / "release") as blocked_install: installed: Final[subprocess.CompletedProcess[str]] = subprocess.run( [ sys.executable, "-m", "pipx", "install", str(_create_wheel(tmp_path, "other-app")), "--skip-maintenance", ], env=pipx_subprocess_env(), capture_output=True, text=True, check=False, ) blocked_returncode: Final[int | None] = blocked_install.poll() assert (installed.returncode, blocked_returncode, (paths.CTX.venvs / "other-app").is_dir()) == (0, None, True), ( installed.stdout, installed.stderr, ) @pytest.mark.usefixtures("pipx_temp_env", "mocked_github_api") def test_list_standalone_interpreter(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setattr(shutil, "which", lambda _name: None) assert not run_pipx_cli([ "install", "--fetch-python=missing", "--python", f"{sys.version_info.major}.{sys.version_info.minor}", PKG["pycowsay"]["spec"], ]) capsys.readouterr() assert not run_pipx_cli(["list"]) assert "standalone" in capsys.readouterr().out @pytest.mark.parametrize( "flags", [ pytest.param([], id="default"), pytest.param(["--skip-maintenance"], id="skip-maintenance"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_list_does_not_trigger_maintenance(flags: list[str]) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["install", PKG["isort"]["spec"]]) now: Final[float] = time.time() shared_libs.SHARED_LIBS.create(verbose=True, pip_args=[]) shared_libs.SHARED_LIBS.has_been_updated_this_run = False os.utime(shared_libs.SHARED_LIBS.pip_path, (now, -shared_libs.SHARED_LIBS_MAX_AGE_SEC - 5 * 60 + now)) assert shared_libs.SHARED_LIBS.needs_upgrade run_pipx_cli(["list", *flags]) assert (shared_libs.SHARED_LIBS.has_been_updated_this_run, shared_libs.SHARED_LIBS.needs_upgrade) == (False, True) @pytest.mark.usefixtures("pipx_temp_env") def test_list_pinned_packages(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["install", PKG["black"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["pin", "black"]) assert not run_pipx_cli(["list", "--pinned"]) captured: Final = capsys.readouterr() assert "black 22.8.0" in captured.out assert "pycowsay 0.0.0.2" not in captured.out @pytest.mark.usefixtures("pipx_temp_env") def test_list_pinned_packages_include_injected(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["isort"]["spec"], PKG["nox"]["spec"]]) assert not run_pipx_cli(["inject", "isort", PKG["black"]["spec"]]) assert not run_pipx_cli(["pin", "isort"]) assert not run_pipx_cli(["pin", "nox"]) capsys.readouterr() assert not run_pipx_cli(["list", "--pinned", "--include-injected"]) captured: Final = capsys.readouterr() assert "nox 2023.4.22" in captured.out assert "isort 5.6.4" in captured.out assert "black 22.8.0 (injected in venv isort)" in captured.out @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_list_installed_packages_error(tmp_path: Path, fake_process: FakeProcess) -> None: (tmp_path / "fake_venv").mkdir() (tmp_path / "fake_venv" / "pyvenv.cfg").touch() fake_venv: Final[venv.Venv] = venv.Venv(tmp_path / "fake_venv") shared_python: Final[Path] = shared_libs.SHARED_LIBS.python_path shared_python.parent.mkdir(parents=True) shared_python.touch() fake_process.register( [str(fake_venv.python_path), "-m", "pip", "list", "--format=json"], returncode=1, stderr="unit test stderr" ) fake_process.register([str(shared_python), "-c", "from pip._internal.cli.main import main"]) with pytest.raises(PipxError) as excinfo: fake_venv.list_installed_packages() # pipx_wrap breaks the message into lines on render rendered: Final[str] = " ".join(str(excinfo.value).split()) assert "Failed to execute" in rendered assert "Process exited with return code 1" in rendered assert "unit test stderr" in rendered @contextmanager def _install_paused_under_lock( tmp_path: Path, name: str, release: Path ) -> Generator[subprocess.Popen[str], None, None]: entered: Final[Path] = tmp_path / f"{name}-entered" # the wheel's .pth imports its module whenever the environment's python starts, and pipx starts it to inspect the # new environment while holding the lock pause: Final[str] = ( "from pathlib import Path\n" "from time import sleep\n" f"Path({str(entered)!r}).touch()\n" f"while not Path({str(release)!r}).exists():\n" " sleep(0.01)\n" ) install: Final[subprocess.Popen[str]] = _start_pipx( "install", str(_create_wheel(tmp_path, name, pause)), "--skip-maintenance" ) try: while not entered.exists(): if install.poll() is not None: pytest.fail(f"install exited before pausing under its lock: {install.communicate()!r}") time.sleep(0.01) yield install finally: release.touch() if install.returncode is None: install.communicate() def _create_wheel(tmp_path: Path, name: str, module_prefix: str = "") -> Path: # a prebuilt wheel skips the isolated build a project directory needs, which takes seconds on Windows module: Final[str] = name.replace("-", "_") dist_info: Final[str] = f"{module}-1.dist-info" with zipfile.ZipFile(wheel := tmp_path / f"{module}-1-py3-none-any.whl", "w") as archive: for path, content in { f"{module}.py": f"{module_prefix}def main() -> None:\n print({name!r})\n", f"{module}.pth": f"import {module}\n", f"{dist_info}/METADATA": f"Metadata-Version: 2.1\nName: {name}\nVersion: 1\n", f"{dist_info}/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", f"{dist_info}/entry_points.txt": f"[console_scripts]\n{name} = {module}:main\n", f"{dist_info}/RECORD": "", }.items(): archive.writestr(path, content) return wheel def _start_pipx(*args: str) -> subprocess.Popen[str]: return subprocess.Popen( [sys.executable, "-m", "pipx", *args], env=pipx_subprocess_env(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) pipx-1.18.16/tests/test_main.py000066400000000000000000000625071527355705200163740ustar00rootroot00000000000000from __future__ import annotations import argparse import json import os import re import shutil import stat import subprocess import sys from pathlib import Path from typing import TYPE_CHECKING, Final, Protocol, TypedDict import pytest from docutils.core import publish_string from docutils.writers.manpage import Writer from helpers import SKIP_IF_WINDOWS, current_log_file, run_pipx_cli from package_info import PKG from pipx import main, paths from pipx.backends import registry from pipx.main import ( _validate_backend_available, # ruff:ignore[import-private-name] # test exercises private helper, no public API build_parser, get_command_parser, get_pip_args, package_is_path, parse_pipx_args, prog_name, ) from pipx.options import BackendOptions from pipx.redact import redact_url_credentials from pipx.util import PipxError from pipx.version import version if TYPE_CHECKING: from collections.abc import Iterator from unittest.mock import MagicMock from pytest_mock import MockerFixture _ROOT: Final = Path(__file__).parents[1] _MANPAGE_RST: Final = _ROOT / "docs" / "man" / "pipx.1.rst" _SKIP_WITHOUT_PERMISSIONS: Final[pytest.MarkDecorator] = pytest.mark.skipif( sys.platform == "win32" or os.geteuid() == 0, reason="needs POSIX permissions that bind the current user" ) _ENVIRONMENT_VALUE: Final[list[str]] = ["environment", "--value", "PIPX_HOME"] @pytest.fixture def exit_early(mocker: MockerFixture) -> MagicMock: # the real sys.exit would end the test run, so raise instead and let each test catch it return mocker.patch.object(sys, "exit", side_effect=ValueError("raised in test to exit early")) @pytest.mark.parametrize( ("argv", "expected"), [ pytest.param(["--help"], "usage: pipx", id="help-flag"), pytest.param(["help"], "usage: pipx", id="help-command"), pytest.param(["help", "install"], "usage: pipx install", id="help-for-subcommand"), ], ) def test_help_text(exit_early: MagicMock, capsys: pytest.CaptureFixture[str], argv: list[str], expected: str) -> None: with pytest.raises(ValueError, match="raised in test to exit early"): run_pipx_cli(argv) exit_early.assert_called_with(0) assert expected in capsys.readouterr().out def test_version(exit_early: MagicMock, capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(ValueError, match="raised in test to exit early"): run_pipx_cli(["--version"]) exit_early.assert_called_with(0) assert version in capsys.readouterr().out.strip() @pytest.mark.parametrize( ("argv", "executable", "expected"), [ pytest.param("/usr/bin/pipx", "", "pipx", id="installed-script"), pytest.param("__main__.py", "/usr/bin/python", "/usr/bin/python -m pipx", id="module-invocation"), ], ) def test_prog_name(mocker: MockerFixture, argv: str, executable: str, expected: str) -> None: mocker.patch.object(sys, "argv", [argv]) mocker.patch.object(sys, "executable", executable) assert prog_name() == expected @pytest.mark.parametrize( ("start", "pipx_copies", "expected"), [ pytest.param(["launcher"], [], (0, ""), id="console-script"), pytest.param(["-m", "pipx"], [], (0, ""), id="module"), pytest.param(["-m", "pipx"], ["pipx"], (1, "shadowed"), id="module-found-in-working-directory"), ], ) def test_startup_drops_search_path_entry_python_adds( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, start: list[str], pipx_copies: list[str], expected: tuple[int, str] ) -> None: monkeypatch.delenv("PYTHONSAFEPATH", raising=False) (tmp_path / "launcher").write_text("import sys\nfrom pipx.__main__ import cli\nsys.exit(cli())\n") (tmp_path / "platformdirs.py").write_text("raise SystemExit('shadowed')\n") for copy in pipx_copies: shutil.copytree(Path(main.__file__).parent, tmp_path / copy) result = subprocess.run( [sys.executable, *start, "environment", "--value", "PIPX_BIN_DIR"], check=False, capture_output=True, cwd=tmp_path, text=True, ) assert (result.returncode, result.stderr.strip()) == expected @SKIP_IF_WINDOWS def test_module_starts_in_removed_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PYTHONSAFEPATH", raising=False) start: Final[str] = "os.execv(sys.executable, [sys.executable, '-m', 'pipx', '--version'])" result = subprocess.run( [sys.executable, "-c", f"import os, sys; os.rmdir(os.getcwd()); {start}"], check=False, capture_output=True, cwd=tmp_path, text=True, ) assert (result.returncode, result.stdout.strip()) == (0, version) def test_build_parser_uses_pipx_in_subcommand_help(mocker: MockerFixture, capsys: pytest.CaptureFixture[str]) -> None: mocker.patch.object(sys, "argv", ["sphinx-build"]) parser: Final[argparse.ArgumentParser] = build_parser() with pytest.raises(SystemExit) as sys_exit: parser.parse_args(["run", "--help"]) assert sys_exit.value.code == 0 assert capsys.readouterr().out.startswith("usage: pipx run ") @pytest.mark.usefixtures("exit_early") def test_run_help_prints_usage_prefix_once_in_color( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("FORCE_COLOR", "1") monkeypatch.delenv("NO_COLOR", raising=False) monkeypatch.delenv("PYTHON_COLORS", raising=False) with pytest.raises(ValueError, match="raised in test to exit early"): run_pipx_cli(["run", "--help"]) assert re.sub(r"\x1b\[[0-9;]*m", "", capsys.readouterr().out).count("usage:") == 1 @pytest.mark.parametrize("flag", [pytest.param("-qqq", id="quiet"), pytest.param("-vvvv", id="verbose")]) @pytest.mark.usefixtures("pipx_temp_env") def test_limit_verbosity(flag: str) -> None: assert not run_pipx_cli(["list", flag]) def test_all_subcommands_have_func_registered() -> None: assert [ name for name, subparser in _subparsers(get_command_parser()) if not callable(subparser.get_default("func")) ] == [] def _subparsers(parser: argparse.ArgumentParser) -> Iterator[tuple[str, argparse.ArgumentParser]]: for action in parser._actions: # ruff:ignore[private-member-access] # argparse has no public subparser API if isinstance(action, argparse._SubParsersAction): # ruff:ignore[private-member-access] # argparse has no public subparser API for name, subparser in action.choices.items(): yield name, subparser yield from _subparsers(subparser) @pytest.mark.parametrize( "argv", [ pytest.param(["install", "pycowsay"], id="install"), pytest.param(["inject", "pycowsay", "black"], id="inject"), pytest.param(["uninject", "pycowsay", "black"], id="uninject"), pytest.param(["expose", "pycowsay"], id="expose"), pytest.param(["unexpose", "pycowsay"], id="unexpose"), pytest.param(["pin", "pycowsay"], id="pin"), pytest.param(["unpin", "pycowsay"], id="unpin"), pytest.param(["upgrade", "pycowsay"], id="upgrade"), pytest.param(["upgrade-all"], id="upgrade-all"), pytest.param(["uninstall", "pycowsay"], id="uninstall"), pytest.param(["uninstall-all"], id="uninstall-all"), pytest.param(["health"], id="health"), pytest.param(["repair"], id="repair"), pytest.param(["list"], id="list"), ], ) def test_operation_commands_accept_output_format(argv: list[str]) -> None: parser: Final[argparse.ArgumentParser] = get_command_parser() args: Final[argparse.Namespace] = parse_pipx_args(parser, [*argv, "--output", "json"]) assert args.output == "json" def test_manpage_matches_parser(tmp_path: Path) -> None: generated: Final[Path] = tmp_path / "pipx.1.rst" subprocess.run([sys.executable, str(_ROOT / "scripts" / "generate_man.py"), str(generated)], check=True) assert _MANPAGE_RST.read_bytes() == generated.read_bytes() def test_manpage_sections(manpage_troff: bytes) -> None: assert re.findall(r"^\.SH (.+)$", manpage_troff.decode(), re.MULTILINE) == [ "Name", "SYNOPSIS", "DESCRIPTION", "COMMANDS", "GLOBAL OPTIONS", "ENVIRONMENT VARIABLES", "SEE ALSO", "AUTHORS", ] def test_manpage_header(manpage_troff: bytes) -> None: assert re.search(r'^\.TH "pipx" "1" .* "User Commands"$', manpage_troff.decode(), re.MULTILINE) @pytest.mark.timeout(60) # man printed nothing for 5 s on a busy Linux CI runner def test_manpage_renders(man: str, manpage_troff: bytes, tmp_path: Path) -> None: (manpage := tmp_path / "pipx.1").write_bytes(manpage_troff) result = subprocess.run( [man, str(manpage)], capture_output=True, text=True, env=os.environ | {"COLUMNS": "200", "MANPAGER": "cat", "PAGER": "cat"}, stdin=subprocess.DEVNULL, check=False, timeout=30, ) rendered: Final[str] = re.sub(r".\x08", "", result.stdout) assert (result.returncode, "pipx" in rendered.lower(), "SYNOPSIS" in rendered) == (0, True, True) @pytest.fixture def man() -> str: if (executable := shutil.which("man")) is None: pytest.skip("man is not installed") return executable @pytest.fixture def manpage_troff() -> bytes: source: Final[str] = "\n".join( line for line in _MANPAGE_RST.read_text(encoding="utf-8").splitlines() if line.strip() != ":orphan:" ) return publish_string(source, writer=Writer(), settings_overrides={"report_level": 5}) @pytest.mark.parametrize( "argv", [ pytest.param(["inject", "ansible-core", "--force", "ansible"], id="flag-before-dependency"), pytest.param(["inject", "ansible-core", "ansible", "--force"], id="flag-after-dependency"), ], ) def test_inject_accepts_force_before_or_after_dependency(argv: list[str]) -> None: args: Final[argparse.Namespace] = parse_pipx_args(get_command_parser(), argv) assert (args.package, args.dependencies, args.force) == ("ansible-core", ["ansible"], True) def test_package_is_path_ignores_existing_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) (tmp_path / "commit-check").mkdir() # regression for #1778: a directory sharing the package name is not a path spec, so this must not raise package_is_path("commit-check") @pytest.mark.usefixtures("pipx_temp_env") def test_package_is_path_rejects_forward_slash( capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["uninstall", "namespace/package"]) assert "'namespace/package' looks like a path" in capsys.readouterr().err @pytest.mark.parametrize( ("env", "exit_code", "expected"), [ pytest.param((True, "garbage", None), 1, "PIPX_FETCH_PYTHON must be unset", id="invalid-value"), pytest.param((False, "missing", "1"), 1, "Setting both", id="both-env-vars"), pytest.param((False, None, "1"), 0, "PIPX_FETCH_MISSING_PYTHON is deprecated", id="deprecated-env-warning"), ], ) def test_cli_reports_fetch_python_env( fetch_python_env: _FetchPythonEnv, capsys: pytest.CaptureFixture[str], env: tuple[bool, str | None, str | None], exit_code: int, expected: str, ) -> None: fetch_python_env(*env) assert (run_pipx_cli(_ENVIRONMENT_VALUE), expected in capsys.readouterr().err) == (exit_code, True) def test_cli_accepts_unset_fetch_python_env(fetch_python_env: _FetchPythonEnv) -> None: fetch_python_env(False, None, None) assert run_pipx_cli(_ENVIRONMENT_VALUE) == 0 class _FetchPythonEnv(Protocol): def __call__(self, invalid: bool, fetch_python_raw: str | None, missing_raw: str | None) -> None: ... @pytest.fixture def fetch_python_env(monkeypatch: pytest.MonkeyPatch) -> _FetchPythonEnv: # pipx reads these variables at import, so patch the values main parsed from them def apply(invalid: bool, fetch_python_raw: str | None, missing_raw: str | None) -> None: monkeypatch.setattr(main, "_FETCH_PYTHON_INVALID", invalid) monkeypatch.setattr(main, "_FETCH_PYTHON_RAW", fetch_python_raw) monkeypatch.setattr(main, "_FETCH_MISSING_PYTHON_RAW", missing_raw) return apply def test_cli_rejects_invalid_env_cooldown(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setattr(main, "_COOLDOWN_INVALID", True) monkeypatch.setattr(main, "_COOLDOWN_RAW", "garbage") assert run_pipx_cli(["list"]) assert "PIPX_COOLDOWN must be unset or a non-negative integer, got 'garbage'." in capsys.readouterr().err @pytest.mark.usefixtures("exit_early") def test_deprecated_fetch_missing_python_silent_under_help(capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(ValueError, match="raised in test to exit early"): run_pipx_cli(["install", "--fetch-missing-python", "--help"]) assert "--fetch-missing-python is deprecated" not in capsys.readouterr().err @pytest.mark.parametrize("windows", [pytest.param(True, id="windows"), pytest.param(False, id="posix")]) @pytest.mark.parametrize( ("pip_args", "expected"), [ pytest.param("--no-deps", ["--no-deps"], id="bare"), pytest.param("'--no-deps --pre'", ["--no-deps", "--pre"], id="wrapped-value"), pytest.param("--find-links '/opt/foo bar'", ["--find-links", "/opt/foo bar"], id="quoted-path"), pytest.param('--find-links "/opt/foo bar"', ["--find-links", "/opt/foo bar"], id="double-quoted-path"), pytest.param( "'--find-links=/opt/foo bar' '--constraint=/opt/c d.txt'", ["--find-links=/opt/foo bar", "--constraint=/opt/c d.txt"], id="two-quoted-arguments", ), ], ) def test_get_pip_args_quoting(mocker: MockerFixture, windows: bool, pip_args: str, expected: list[str]) -> None: mocker.patch.object(main, "WINDOWS", windows) assert get_pip_args(argparse.Namespace(pip_args=pip_args)) == expected def test_get_pip_args_keeps_windows_path_separator(mocker: MockerFixture) -> None: mocker.patch.object(main, "WINDOWS", True) assert get_pip_args(argparse.Namespace(pip_args=r"'--no-index --find-links=D:\TEST\DIR'")) == [ "--no-index", r"--find-links=D:\TEST\DIR", ] def test_get_pip_args_honours_posix_escape(mocker: MockerFixture) -> None: mocker.patch.object(main, "WINDOWS", False) assert get_pip_args(argparse.Namespace(pip_args="--find-links /opt/foo\\ bar")) == ["--find-links", "/opt/foo bar"] def test_get_pip_args_rejects_unclosed_quote() -> None: with pytest.raises(PipxError, match="invalid pip arguments: No closing quotation"): get_pip_args(argparse.Namespace(pip_args="--find-links '/opt/foo bar")) def test_get_pip_args_forwards_index_url() -> None: assert get_pip_args(argparse.Namespace(index_url="https://example.com/simple")) == [ "--index-url", "https://example.com/simple", ] @pytest.mark.parametrize("index_url", [pytest.param("", id="empty"), pytest.param(" ", id="whitespace")]) def test_get_pip_args_rejects_empty_index_url(index_url: str) -> None: with pytest.raises(PipxError, match="--index-url is empty"): get_pip_args(argparse.Namespace(index_url=index_url)) @pytest.mark.parametrize( ("pip_args", "option"), [ pytest.param("--index-url=", "--index-url", id="index-url-attached"), pytest.param("--index-url ''", "--index-url", id="index-url-separate"), pytest.param("-i=", "-i", id="short-attached"), pytest.param("-i ' '", "-i", id="short-whitespace"), pytest.param("--default-index=", "--default-index", id="default-index"), pytest.param("--index=", "--index", id="index"), pytest.param("--pre --extra-index-url=", "--extra-index-url", id="extra-index-url"), ], ) def test_get_pip_args_rejects_empty_index_in_pip_args(pip_args: str, option: str) -> None: with pytest.raises(PipxError, match=f"^{option} is empty"): get_pip_args(argparse.Namespace(pip_args=pip_args)) @pytest.mark.parametrize( "pip_args", [ pytest.param("--index-url", id="missing-value"), pytest.param("-ihttps://example.com/simple", id="short-joined"), pytest.param("--index-url https://example.com/simple", id="separate"), ], ) def test_get_pip_args_keeps_non_empty_index_in_pip_args(pip_args: str) -> None: assert get_pip_args(argparse.Namespace(pip_args=pip_args)) == pip_args.split() _LOCAL: Final = pytest.param("OVERRIDE_PIPX_HOME", [], id="local") _GLOBAL: Final = pytest.param("OVERRIDE_PIPX_GLOBAL_HOME", ["--global"], id="global") @_SKIP_WITHOUT_PERMISSIONS @pytest.mark.parametrize("command", [pytest.param("environment", id="environment"), pytest.param("list", id="list")]) @pytest.mark.parametrize(("locked_home", "flags"), [_LOCAL, _GLOBAL], indirect=["locked_home"]) def test_read_only_command_runs_without_writable_home(locked_home: Path, flags: list[str], command: str) -> None: del locked_home assert run_pipx_cli([command, *flags]) == 0 @_SKIP_WITHOUT_PERMISSIONS @pytest.mark.parametrize( ("locked_home", "flags", "fix"), [ pytest.param( *_LOCAL.values, "point PIPX_HOME, PIPX_BIN_DIR and PIPX_MAN_DIR at writable directories", id="local" ), pytest.param( *_GLOBAL.values, "run pipx with sudo, or point PIPX_GLOBAL_HOME, PIPX_GLOBAL_BIN_DIR and PIPX_GLOBAL_MAN_DIR at writable" " directories", id="global", ), ], indirect=["locked_home"], ) def test_unwritable_home_reports_error( locked_home: Path, capsys: pytest.CaptureFixture[str], flags: list[str], fix: str ) -> None: assert (run_pipx_cli(["install", *flags, "pycowsay"]), capsys.readouterr().err) == ( 1, f"Cannot create {locked_home}: permission denied; {fix}.\n", ) @pytest.fixture def locked_home( request: pytest.FixtureRequest, pipx_temp_env: None, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, ) -> Iterator[Path]: del pipx_temp_env # the log file falls back to the user log directory, keep it inside the test mocker.patch("platformdirs.user_log_path", autospec=True, return_value=tmp_path / "logs") (locked := tmp_path / "locked").mkdir(mode=0o500) monkeypatch.setattr(paths, request.param, home := locked / "pipx") paths.CTX.make_local() yield home locked.chmod(0o700) @pytest.fixture def json_install_verbose(pipx_temp_env: None, capsys: pytest.CaptureFixture[str]) -> pytest.CaptureFixture[str]: del pipx_temp_env assert not run_pipx_cli(["install", "--output", "json", "--verbose", PKG["pycowsay"]["spec"]]) return capsys def test_json_output_keeps_streamed_progress_off_stdout( json_install_verbose: pytest.CaptureFixture[str], ) -> None: assert json.loads(json_install_verbose.readouterr().out)["status"] == "success" def test_json_output_streams_progress_to_stderr(json_install_verbose: pytest.CaptureFixture[str]) -> None: assert "Collecting pycowsay" in json_install_verbose.readouterr().err class _Envelope(TypedDict): command: list[str] status: str exit_code: int errors: list[dict[str, str | None]] def _envelope_failure(stdout: str) -> tuple[list[str], str, int, list[str | None]]: document: Final[_Envelope] = json.loads(stdout) return ( document["command"], document["status"], document["exit_code"], [error["code"] for error in document["errors"]], ) @pytest.mark.parametrize( "patches", [ pytest.param({"_COOLDOWN_INVALID": True, "_COOLDOWN_RAW": "abc"}, id="cooldown"), pytest.param({"_MAX_LOGS_INVALID": True, "_MAX_LOGS_RAW": "x"}, id="max-logs"), pytest.param({"_FETCH_PYTHON_INVALID": True}, id="fetch-python"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_json_output_reports_invalid_environment_variable_in_envelope( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], patches: dict[str, bool | str], ) -> None: for name, value in patches.items(): monkeypatch.setattr(main, name, value) assert run_pipx_cli(["cache", "dir", "--output", "json"]) == 1 assert _envelope_failure(capsys.readouterr().out) == (["cache", "dir"], "error", 1, ["pipx_error"]) @pytest.mark.parametrize( "argv", [ pytest.param(["install", "--python", "/nonexistent/python3", "pycowsay"], id="unresolvable-python"), pytest.param(["uninstall", "some/path"], id="package-path"), pytest.param(["uninstall", "https://example.com/pycowsay"], id="package-url"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_json_output_reports_rejected_arguments_in_envelope( capsys: pytest.CaptureFixture[str], argv: list[str], ) -> None: assert run_pipx_cli([*argv, "--output", "json"]) == 1 assert _envelope_failure(capsys.readouterr().out) == ([argv[0]], "error", 1, ["pipx_error"]) @pytest.mark.usefixtures("pipx_temp_env") def test_json_output_reports_missing_uv_in_envelope(capsys: pytest.CaptureFixture[str], mocker: MockerFixture) -> None: mocker.patch("pipx.backends.registry.find_uv_binary", return_value=(None, "missing")) mocker.patch("pipx.backends.uv.find_uv_binary", return_value=(None, "missing")) assert run_pipx_cli(["install", "--backend", "uv", "--output", "json", "pycowsay"]) == 1 assert _envelope_failure(capsys.readouterr().out) == (["install"], "error", 1, ["pipx_error"]) @pytest.mark.usefixtures("pipx_temp_env") def test_cli_skips_uv_detection_without_backend_choice(monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture) -> None: monkeypatch.delenv("PIPX_DEFAULT_BACKEND") find_uv_binary: Final[MagicMock] = mocker.patch( "pipx.backends.registry.find_uv_binary", autospec=True, return_value=(Path("/usr/local/bin/uv"), "path") ) assert not run_pipx_cli(["list"]) find_uv_binary.assert_not_called() def test_validate_backend_silent_on_env_uv_missing( monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, caplog: pytest.LogCaptureFixture, ) -> None: # diagnostic commands (`pipx environment`, `pipx list`) must keep working without a startup warning that the later # UvBackend.__init__ error would contradict; patch both the source and the registry lookup, as either one left # unpatched would find uv mocker.patch.object(registry, "find_uv_binary", return_value=(None, "missing")) mocker.patch("pipx.backends.uv.find_uv_binary", return_value=(None, "missing")) monkeypatch.setenv("PIPX_DEFAULT_BACKEND", "uv") caplog.set_level("WARNING", logger="pipx.main") _validate_backend_available(BackendOptions(env="uv")) assert not any("uv" in rec.message.lower() for rec in caplog.records) def test_validate_backend_raises_on_cli_uv_missing(mocker: MockerFixture) -> None: mocker.patch.object(registry, "find_uv_binary", return_value=(None, "missing")) mocker.patch("pipx.backends.uv.find_uv_binary", return_value=(None, "missing")) with pytest.raises(PipxError, match="uv' executable could not be found"): _validate_backend_available(BackendOptions(cli="uv")) @pytest.mark.usefixtures("pipx_temp_env") def test_cli_without_command_prints_help(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli([]) == 1 assert capsys.readouterr().out.startswith("usage: pipx") @pytest.mark.usefixtures("pipx_temp_env") def test_package_is_path_hides_url_credentials(capsys: pytest.CaptureFixture[str]) -> None: spec: Final[str] = "tool @ https://u:s3cr3ttok@host/tool.whl" assert run_pipx_cli(["upgrade", spec]) assert f"'{redact_url_credentials(spec)}' looks like a path" in " ".join(capsys.readouterr().err.split()) @pytest.mark.usefixtures("pipx_temp_env") def test_log_masks_command_line_password_with_space() -> None: run_pipx_cli(["upgrade", "--index-url", "https://alice:correct horse@host/simple", "nosuchpackage"]) log: Final[str] = current_log_file().read_text(encoding="utf-8") assert "upgrade --index-url https://****:****@host/simple nosuchpackage" in log @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env", "permissive_umask") def test_log_dir_is_created_owner_only() -> None: assert not run_pipx_cli(["list"]) assert stat.S_IMODE(paths.CTX.logs.stat().st_mode) == 0o700 @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env", "permissive_umask") def test_log_dir_left_world_readable_is_tightened() -> None: paths.CTX.logs.mkdir(mode=0o755, parents=True) assert not run_pipx_cli(["list"]) assert stat.S_IMODE(paths.CTX.logs.stat().st_mode) == 0o700 @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env", "permissive_umask") def test_log_file_is_owner_only() -> None: assert not run_pipx_cli(["list"]) assert stat.S_IMODE(current_log_file().stat().st_mode) == 0o600 @pytest.mark.usefixtures("pipx_temp_env") def test_log_dir_that_cannot_change_mode_still_logs(mocker: MockerFixture) -> None: mocker.patch.object(Path, "chmod", autospec=True, side_effect=PermissionError) assert not run_pipx_cli(["list"]) assert current_log_file().parent == paths.CTX.logs @pytest.mark.parametrize( "argv", [ pytest.param(["install", "--preinstall=-rrequirements.txt", "pycowsay"], id="install-preinstall"), pytest.param(["run", "--with=--find-links=elsewhere", "script.py"], id="run-with"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_option_shaped_package_is_refused(capsys: pytest.CaptureFixture[str], argv: list[str]) -> None: with pytest.raises(SystemExit): run_pipx_cli(argv) assert "is an installer option; pass it with --pip-args" in capsys.readouterr().err pipx-1.18.16/tests/test_main_env_parsing.py000066400000000000000000000054521527355705200207630ustar00rootroot00000000000000from __future__ import annotations import pytest from pipx import main from pipx.constants import FetchPythonOptions @pytest.mark.parametrize( ("missing_raw", "python_raw", "expected_option", "expected_invalid"), [ pytest.param(None, None, FetchPythonOptions.NEVER, False, id="unset"), pytest.param("1", None, FetchPythonOptions.MISSING, False, id="legacy-truthy"), pytest.param("0", None, FetchPythonOptions.NEVER, False, id="legacy-falsy"), pytest.param(" False ", None, FetchPythonOptions.NEVER, False, id="legacy-whitespace-falsy"), pytest.param("n", None, FetchPythonOptions.NEVER, False, id="legacy-falsy-n"), pytest.param(None, "always", FetchPythonOptions.ALWAYS, False, id="explicit-always"), pytest.param(None, "garbage", FetchPythonOptions.NEVER, True, id="invalid-value"), ], ) def test_compute_fetch_python( missing_raw: str | None, python_raw: str | None, expected_option: FetchPythonOptions, expected_invalid: bool, ) -> None: option, invalid = main._compute_fetch_python(missing_raw, python_raw) # ruff:ignore[private-member-access] # parsed once at import, so per-test env changes cannot reach it assert option is expected_option assert invalid is expected_invalid @pytest.mark.parametrize( ("raw", "expected_days", "expected_invalid"), [ pytest.param(None, None, False, id="unset"), pytest.param(" ", None, False, id="blank"), pytest.param("7", 7, False, id="days"), pytest.param(" 7 ", 7, False, id="whitespace-padded"), pytest.param("0", 0, False, id="zero"), pytest.param("-1", None, True, id="negative"), pytest.param("garbage", None, True, id="not-an-integer"), ], ) def test_compute_cooldown(raw: str | None, expected_days: int | None, expected_invalid: bool) -> None: assert main._compute_cooldown(raw) == (expected_days, expected_invalid) # ruff:ignore[private-member-access] # parsed once at import, so per-test env changes cannot reach it @pytest.mark.parametrize( ("raw", "expected_logs", "expected_invalid"), [ pytest.param(None, 10, False, id="unset"), pytest.param("", 10, False, id="empty"), pytest.param(" ", 10, False, id="whitespace-only"), pytest.param("10", 10, False, id="default"), pytest.param(" 5 ", 5, False, id="whitespace-padded"), pytest.param("0", 0, False, id="zero"), pytest.param("-1", 10, True, id="negative"), pytest.param("garbage", 10, True, id="not-an-integer"), ], ) def test_compute_max_logs(raw: str | None, expected_logs: int, expected_invalid: bool) -> None: assert main._compute_max_logs(raw) == (expected_logs, expected_invalid) # ruff:ignore[private-member-access] # parsed once at import, so per-test env changes cannot reach it pipx-1.18.16/tests/test_manifest.py000066400000000000000000000641651527355705200172600ustar00rootroot00000000000000from __future__ import annotations import subprocess from pathlib import Path from typing import TYPE_CHECKING, Final, Protocol import pytest from helpers import app_name, current_log_file, run_pipx_cli from pipx import paths from pipx.commands import manifest as manifest_module from pipx.pipx_metadata_file import PackageInfo, PipxMetadata from pipx.redact import redact_url_credentials if TYPE_CHECKING: from collections.abc import Callable from pytest_mock import MockerFixture _TWO_LOCKS_MANIFEST: Final[str] = """[project] name = "pipx-tools" version = "1" dependencies = [] [dependency-groups] black = ["black>=22,<23"] pycowsay = ["pycowsay"] [tool.pipx] version = "1.0" [tool.pipx.tools.black] lock = "pylock.black.toml" [tool.pipx.tools.pycowsay] lock = "pylock.pycowsay.toml" """ @pytest.fixture def write_manifest(tmp_path: Path) -> Callable[[str], Path]: def write(content: str) -> Path: (manifest := tmp_path / "pipx.toml").write_text(content, encoding="utf-8") return manifest return write def _manifest(package: str = "black", environment: str = "black", tool: str = "") -> str: tool_table: Final[str] = f"\n[tool.pipx.tools.{environment}]\n{tool}" if tool else "" return f"""[project] name = "pipx-tools" version = "1" dependencies = [] requires-python = ">=3.10" [dependency-groups] {environment} = [{package!r}] [tool.pipx] version = "1.0" {tool_table} """ @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_installs_declared_tool( write_manifest: Callable[[str], Path], ) -> None: manifest: Final[Path] = write_manifest( _manifest( "pycowsay==0.0.0.2", "pycowsay-managed", 'suffix = "-managed"\napps = ["pycowsay"]\ninclude-dependencies = false\nexpose = true\n', ) ) assert not run_pipx_cli(["manifest", "sync", str(manifest)]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay-managed") assert ( metadata.main_package.package_or_url, metadata.main_package.suffix, metadata.main_package.expected_apps, (paths.CTX.bin_dir / app_name("pycowsay-managed")).is_file(), ) == ("pycowsay==0.0.0.2", "-managed", ["pycowsay"], True) @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_again_keeps_a_file_it_did_not_expose(write_manifest: Callable[[str], Path]) -> None: manifest: Final[Path] = write_manifest(_manifest("pycowsay==0.0.0.2", "pycowsay")) paths.CTX.bin_dir.mkdir(parents=True, exist_ok=True) (user_file := paths.CTX.bin_dir / app_name("pycowsay")).write_text("mine", encoding="utf-8") assert not run_pipx_cli(["manifest", "sync", str(manifest)]) assert not run_pipx_cli(["manifest", "sync", str(manifest)]) assert (user_file.is_symlink(), user_file.read_text(encoding="utf-8")) == (False, "mine") @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_selects_dependency_apps( write_manifest: Callable[[str], Path], local_extras_wheel: Path, ) -> None: package: Final[str] = f"repeatme[tools] @ {local_extras_wheel.as_uri()}" manifest: Final[Path] = write_manifest(_manifest(package, "repeatme", 'include-resources-from = ["PyCowsay"]\n')) assert not run_pipx_cli(["manifest", "sync", str(manifest)]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "repeatme").main_package assert ( metadata.include_resources_from, (paths.CTX.bin_dir / app_name("pycowsay")).exists(), (paths.CTX.bin_dir / app_name("black")).exists(), ) == (["pycowsay"], True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_prunes_undeclared_tool( write_manifest: Callable[[str], Path], ) -> None: assert not run_pipx_cli(["install", "black"]) manifest: Final[Path] = write_manifest(_manifest("pycowsay", "pycowsay")) assert not run_pipx_cli(["manifest", "sync", "--prune", str(manifest)]) assert not (paths.CTX.venvs / "black").exists() @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_hides_resources( write_manifest: Callable[[str], Path], ) -> None: manifest: Final[Path] = write_manifest(_manifest("pycowsay", "pycowsay", "expose = false\n")) assert not run_pipx_cli(["manifest", "sync", str(manifest)]) assert ( PipxMetadata(paths.CTX.venvs / "pycowsay").exposure_enabled, (paths.CTX.bin_dir / app_name("pycowsay")).exists(), ) == (False, False) @pytest.mark.parametrize( "setup", [ pytest.param([], id="exposed"), pytest.param([["unexpose", "pycowsay"]], id="unexposed"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_keeps_injected_apps(write_manifest: Callable[[str], Path], setup: list[list[str]]) -> None: for command in [["install", "pycowsay"], ["inject", "--include-apps", "pycowsay", "isort"], *setup]: assert not run_pipx_cli(command) manifest: Final[Path] = write_manifest(_manifest("pycowsay", "pycowsay")) assert not run_pipx_cli(["manifest", "sync", str(manifest)]) assert [(paths.CTX.bin_dir / app_name(app)).is_file() for app in ("pycowsay", "isort")] == [True, True] @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_reexposes_environment_after_interrupt( write_manifest: Callable[[str], Path], mocker: MockerFixture ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) manifest: Final[Path] = write_manifest(_manifest("pycowsay", "pycowsay")) mocker.patch("pipx.venv.Venv.install_package", autospec=True, side_effect=KeyboardInterrupt) assert run_pipx_cli(["manifest", "sync", str(manifest)]) assert ( PipxMetadata(paths.CTX.venvs / "pycowsay").exposure_enabled, (paths.CTX.bin_dir / app_name("pycowsay")).is_file(), ) == (True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_restores_environment_after_missing_app( write_manifest: Callable[[str], Path], capsys: pytest.CaptureFixture[str], ) -> None: manifest: Final[Path] = write_manifest(_manifest("pycowsay", "pycowsay", 'apps = ["pycowsay"]\n')) assert not run_pipx_cli(["manifest", "sync", str(manifest)]) (marker := paths.CTX.venvs / "pycowsay" / "marker").touch() write_manifest(_manifest("pycowsay", "pycowsay", 'apps = ["missing"]\n')) capsys.readouterr() assert run_pipx_cli(["manifest", "sync", str(manifest)]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") assert ( "Package pycowsay does not provide expected app missing" in capsys.readouterr().err, marker.exists(), metadata.main_package.expected_apps, metadata.exposure_enabled, (paths.CTX.bin_dir / app_name("pycowsay")).is_file(), ) == (True, True, ["pycowsay"], True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_reapplies_lock( write_manifest: Callable[[str], Path], make_pylock: Callable[[str, str], Path], ) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") manifest: Final[Path] = write_manifest( _manifest("pycowsay>=0", "pycowsay", f'apps = ["pycowsay"]\nlock = "{lock_file.name}"\n') ) assert not run_pipx_cli(["manifest", "sync", str(manifest)]) (marker := paths.CTX.venvs / "pycowsay" / "marker").touch() assert not run_pipx_cli(["manifest", "sync", str(manifest)]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "pycowsay").main_package assert (metadata.package_version, metadata.lock_file, marker.exists()) == ( "0.0.0.2", lock_file.resolve(), False, ) @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_clears_app_and_lock_state( write_manifest: Callable[[str], Path], make_pylock: Callable[[str, str], Path], ) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") manifest: Final[Path] = write_manifest( _manifest("pycowsay", "pycowsay", f'apps = ["pycowsay"]\nlock = "{lock_file.name}"\n') ) assert not run_pipx_cli(["manifest", "sync", str(manifest)]) (marker := paths.CTX.venvs / "pycowsay" / "marker").touch() write_manifest(_manifest("pycowsay", "pycowsay")) assert not run_pipx_cli(["manifest", "sync", str(manifest)]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "pycowsay").main_package assert (metadata.expected_apps, metadata.lock_file, marker.exists()) == ([], None, False) @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_replays_recorded_install_options( write_manifest: Callable[[str], Path], package_cache_dir: Path, caplog: pytest.LogCaptureFixture, ) -> None: pip_args: Final[list[str]] = ["--no-index", f"--find-links={package_cache_dir}"] assert not run_pipx_cli(["install", f"--pip-args={' '.join(pip_args)}", "--system-site-packages", "pycowsay"]) manifest: Final[Path] = write_manifest(_manifest("pycowsay", "pycowsay")) caplog.clear() assert not run_pipx_cli(["manifest", "sync", str(manifest)]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") assert ( f"--force-reinstall {' '.join(pip_args)} pycowsay" in caplog.text, metadata.main_package.pip_args, metadata.venv_args, ) == (True, pip_args, ["--system-site-packages"]) @pytest.mark.parametrize( ("content", "expected_error"), [ pytest.param("invalid", "Cannot read manifest", id="toml"), pytest.param(_manifest() + "\n[extra]\n", "Unknown manifest table", id="root"), pytest.param("project = []\n", "Manifest project must be a table", id="project-type"), pytest.param( _manifest().replace("dependencies = []", "dependencies = []\nextra = true"), "Unknown manifest project key", id="project-key", ), pytest.param( _manifest().replace('name = "pipx-tools"', "name = 1"), "requires string name and version", id="project-name", ), pytest.param( _manifest().replace("dependencies = []", 'dependencies = ["black"]'), "project dependencies must be empty", id="project-dependencies", ), pytest.param( _manifest().replace('requires-python = ">=3.10"', "requires-python = true"), "requires-python must be a string", id="requires-python", ), pytest.param( _manifest().replace("black = ['black']", ""), "dependency-groups must be a non-empty table", id="groups-empty", ), pytest.param( _manifest().replace("black = ['black']", 'black = "black"'), "must contain one package requirement", id="group-type", ), pytest.param( _manifest().replace("black = ['black']", "black = ['black', 'ruff']"), "must contain one package requirement", id="group-size", ), pytest.param( _manifest().replace("black = ['black']", "black = [1]"), "must contain one package requirement", id="group-package", ), pytest.param( _manifest().replace('[tool.pipx]\nversion = "1.0"', "tool = []"), "Manifest tool must be a table", id="tool-type", ), pytest.param(_manifest() + "\n[tool.other]\n", "Unknown manifest tool table", id="tool-table"), pytest.param(_manifest() + "\n[tool]\nnab = true\n", "tool.nab must be a table", id="nab"), pytest.param( _manifest().replace('[tool.pipx]\nversion = "1.0"', "[tool.nab]"), "tool.pipx must be a table", id="pipx-missing", ), pytest.param( _manifest().replace('[tool.pipx]\nversion = "1.0"', "[tool]\npipx = true"), "tool.pipx must be a table", id="pipx-type", ), pytest.param(_manifest() + "unknown = true\n", "Unknown tool.pipx key", id="pipx-key"), pytest.param(_manifest().replace('version = "1.0"', 'version = "2.0"'), "Manifest version", id="version"), pytest.param(_manifest() + "tools = []\n", "tool.pipx.tools must be a table", id="tools-type"), pytest.param( _manifest() + "\n[tool.pipx.tools.ruff]\n", "Missing dependency groups for manifest tools", id="tool-group", ), pytest.param( _manifest() + '\n[tool.pipx.tools]\nblack = "black"\n', "Manifest tool black must be a table", id="tool-value", ), pytest.param(_manifest("black", "Black"), "must be normalized", id="name"), pytest.param( _manifest("black", "black", "unknown = true\n"), "Unknown key for manifest tool black", id="tool-key", ), pytest.param(_manifest("["), "invalid package requirement", id="package"), pytest.param( _manifest('black; python_version > "3.10"'), "cannot use an environment marker", id="marker", ), pytest.param( _manifest("black", "black-other", 'suffix = "-one"\n'), "must match package black", id="environment", ), pytest.param(_manifest("black", "black", "suffix = true\n"), "suffix must be a string", id="suffix"), pytest.param( _manifest("black", "black", 'apps = "black"\n'), "apps must be non-empty strings", id="apps-type", ), pytest.param( _manifest("black", "black", 'apps = [""]\n'), "apps must be non-empty strings", id="app-empty", ), pytest.param( _manifest("black", "black", 'apps = ["black", "black"]\n'), "apps must be unique", id="apps-duplicate", ), pytest.param( _manifest("black", "black", 'include-dependencies = "yes"\n'), "include-dependencies must be a boolean", id="include-dependencies", ), pytest.param( _manifest("black", "black", 'include-resources-from = "pycowsay"\n'), "include-resources-from must be non-empty strings", id="include-resources-from-type", ), pytest.param( _manifest("black", "black", 'include-resources-from = [""]\n'), "include-resources-from must be non-empty strings", id="include-resources-from-empty", ), pytest.param( _manifest("black", "black", 'include-resources-from = ["PyCowsay", "pycowsay"]\n'), "include-resources-from must be unique", id="include-resources-from-duplicate", ), pytest.param( _manifest( "black", "black", 'include-dependencies = true\ninclude-resources-from = ["pycowsay"]\n', ), "cannot combine include-dependencies", id="include-resources-from-all", ), pytest.param( _manifest("black", "black", 'expose = "yes"\n'), "expose must be a boolean", id="expose", ), pytest.param(_manifest("black", "black", "lock = 1\n"), "lock must be a path string", id="lock-type"), pytest.param( _manifest("black", "black", 'lock = "black.lock"\n'), "lock must be named", id="lock-name", ), pytest.param( _manifest("black", "black", 'lock = "pylock.black.toml"\n'), "Lock file does not exist", id="lock-missing", ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_rejects_invalid_input( write_manifest: Callable[[str], Path], capsys: pytest.CaptureFixture[str], content: str, expected_error: str, ) -> None: assert run_pipx_cli(["manifest", "sync", str(write_manifest(content))]) assert expected_error in capsys.readouterr().err @pytest.mark.parametrize( ("create", "expected_error"), [ pytest.param(lambda _: None, "does not exist", id="missing"), pytest.param(Path.mkdir, "Cannot read manifest", id="directory"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_rejects_unreadable_path( tmp_path: Path, capsys: pytest.CaptureFixture[str], create: Callable[[Path], None], expected_error: str, ) -> None: create(manifest := tmp_path / "missing.toml") assert run_pipx_cli(["manifest", "sync", str(manifest)]) assert expected_error in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_rejects_duplicate_locks( write_manifest: Callable[[str], Path], tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: (tmp_path / "pylock.tools.toml").touch() manifest: Final[Path] = write_manifest( """[project] name = "pipx-tools" version = "1" dependencies = [] [dependency-groups] black = ["black"] pycowsay = ["pycowsay"] [tool.pipx] version = "1.0" [tool.pipx.tools.black] lock = "pylock.tools.toml" [tool.pipx.tools.pycowsay] lock = "pylock.tools.toml" """ ) assert run_pipx_cli(["manifest", "sync", str(manifest)]) assert "Lock files must be unique per tool" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_lock_manifest_generates_named_locks( write_manifest: Callable[[str], Path], tmp_path: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str], ) -> None: manifest: Final[Path] = write_manifest( """[project] name = "pipx-tools" version = "1" dependencies = [] [dependency-groups] black = ["black>=22,<23"] pycowsay = ["pycowsay"] [tool.pipx] version = "1.0" [tool.pipx.tools.black] lock = "locks/pylock.toml" [tool.nab] offline = false """ ) (lock_file := tmp_path / "locks" / "pylock.toml").parent.mkdir() lock_file.write_text("old\n", encoding="utf-8") observed: Final[list[tuple[str, list[str], str]]] = [] def run_nab(command: list[str], *, check: bool, cwd: Path) -> subprocess.CompletedProcess[str]: del check, cwd generated_lock: Final[Path] = Path(command[command.index("--output") + 1]) observed.append(( Path(command[2]).read_text(encoding="utf-8"), command[3 : command.index("--output")], generated_lock.read_text(encoding="utf-8"), )) generated_lock.write_text('lock-version = "1.0"\ncreated-by = "nab"\n', encoding="utf-8") return subprocess.CompletedProcess(command, 0) mocker.patch.object(manifest_module, "which", autospec=True, return_value="/usr/bin/nab") run: Final = mocker.patch.object(manifest_module.subprocess, "run", autospec=True, side_effect=run_nab) assert not run_pipx_cli(["manifest", "lock", str(manifest)]) assert ( run.call_count, observed, lock_file.read_text(encoding="utf-8"), "locked" in capsys.readouterr().out, ) == ( 1, [ ( manifest.read_text(encoding="utf-8"), ["--groups", "black", "--project-default-group", "black"], "old\n", ) ], 'lock-version = "1.0"\ncreated-by = "nab"\n', True, ) class _NabRun(Protocol): def __call__(self, command: list[str], *, check: bool, cwd: Path) -> subprocess.CompletedProcess[str]: ... def _nab_fails(command: list[str], *, check: bool, cwd: Path) -> subprocess.CompletedProcess[str]: del check, cwd return subprocess.CompletedProcess(command, 1) def _nab_writes_nothing(command: list[str], *, check: bool, cwd: Path) -> subprocess.CompletedProcess[str]: del check, cwd Path(command[command.index("--output") + 1]).unlink() return subprocess.CompletedProcess(command, 0) @pytest.mark.parametrize( ("run_nab", "expected_error"), [ pytest.param(_nab_fails, "nab failed to lock black", id="failure"), pytest.param(_nab_writes_nothing, "nab did not create pylock.toml", id="missing-output"), pytest.param(OSError("unavailable"), "Cannot write manifest locks: unavailable", id="os-error"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_lock_manifest_preserves_existing_lock_after_failure( write_manifest: Callable[[str], Path], tmp_path: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str], *, run_nab: _NabRun | OSError, expected_error: str, ) -> None: manifest: Final[Path] = write_manifest(_manifest("black", "black", 'lock = "pylock.toml"\n')) (lock_file := tmp_path / "pylock.toml").write_text("old\n", encoding="utf-8") mocker.patch.object(manifest_module, "which", autospec=True, return_value="/usr/bin/nab") mocker.patch.object(manifest_module.subprocess, "run", autospec=True, side_effect=run_nab) assert run_pipx_cli(["manifest", "lock", str(manifest)]) assert (expected_error in capsys.readouterr().err, lock_file.read_text(encoding="utf-8")) == (True, "old\n") @pytest.mark.usefixtures("pipx_temp_env", "nab_writes_new_lock") def test_lock_manifest_rolls_back_every_lock_when_one_replacement_fails( write_manifest: Callable[[str], Path], tmp_path: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str], ) -> None: manifest: Final[Path] = write_manifest(f"{_TWO_LOCKS_MANIFEST}\n[tool.nab]\noffline = false\n") (tmp_path / "pylock.black.toml").write_text("old-black\n", encoding="utf-8") (tmp_path / "pylock.pycowsay.toml").write_text("old-pycowsay\n", encoding="utf-8") real_replace: Final = Path.replace swap_ins: Final[list[Path]] = [] def replace(self: Path, target: Path) -> Path: source, destination = Path(self), Path(target) if destination.name.startswith("pylock") and ".previous" not in destination.parts + source.parts: swap_ins.append(destination) if len(swap_ins) == 2: msg = "replace failed" raise OSError(msg) return real_replace(self, target) mocker.patch.object(Path, "replace", autospec=True, side_effect=replace) assert run_pipx_cli(["manifest", "lock", str(manifest)]) assert ( "Cannot write manifest locks" in capsys.readouterr().err, (tmp_path / "pylock.black.toml").read_text(encoding="utf-8"), (tmp_path / "pylock.pycowsay.toml").read_text(encoding="utf-8"), ) == (True, "old-black\n", "old-pycowsay\n") @pytest.fixture def nab_writes_new_lock(mocker: MockerFixture) -> None: def run_nab(command: list[str], *, check: bool, cwd: Path) -> subprocess.CompletedProcess[str]: del check, cwd Path(command[command.index("--output") + 1]).write_text("new\n", encoding="utf-8") return subprocess.CompletedProcess(command, 0) mocker.patch.object(manifest_module, "which", autospec=True, return_value="/usr/bin/nab") mocker.patch.object(manifest_module.subprocess, "run", autospec=True, side_effect=run_nab) @pytest.mark.usefixtures("pipx_temp_env", "nab_writes_new_lock") def test_lock_manifest_rolls_back_earlier_locks_when_a_target_cannot_be_undone( write_manifest: Callable[[str], Path], tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: manifest: Final[Path] = write_manifest(_TWO_LOCKS_MANIFEST) (tmp_path / "pylock.black.toml").write_text("old-black\n", encoding="utf-8") # a directory refuses both the swap-in and the unlink that would undo it (tmp_path / "pylock.pycowsay.toml").mkdir() assert run_pipx_cli(["manifest", "lock", str(manifest)]) assert ( "Cannot write manifest locks" in capsys.readouterr().err, (tmp_path / "pylock.black.toml").read_text(encoding="utf-8"), (tmp_path / "pylock.pycowsay.toml").is_dir(), ) == (True, "old-black\n", True) @pytest.mark.parametrize( "lock", [ pytest.param("../outside/pylock.toml", id="parent"), pytest.param("{outside}/pylock.toml", id="absolute"), pytest.param("link/pylock.toml", id="symlink"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_lock_manifest_refuses_lock_outside_manifest_directory( tmp_path: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str], lock: str, ) -> None: (manifest_dir := tmp_path / "manifest").mkdir() (outside := tmp_path / "outside").mkdir() (outside_lock := outside / "pylock.toml").write_text("old\n", encoding="utf-8") (manifest_dir / "link").symlink_to(outside, target_is_directory=True) (manifest := manifest_dir / "pipx.toml").write_text( _manifest(tool=f'lock = "{lock.format(outside=outside.as_posix())}"\n'), encoding="utf-8" ) mocker.patch.object(manifest_module, "which", autospec=True, return_value="/usr/bin/nab") run: Final = mocker.patch.object(manifest_module.subprocess, "run", autospec=True) assert run_pipx_cli(["manifest", "lock", str(manifest)]) assert ( "must be inside the manifest directory" in capsys.readouterr().err, run.called, outside_lock.read_text(encoding="utf-8"), ) == (True, False, "old\n") @pytest.mark.parametrize( ("nab", "lock", "expected_error"), [ pytest.param(None, 'lock = "pylock.toml"\n', "nab command is required", id="nab"), pytest.param("/usr/bin/nab", "", "does not declare any lock files", id="lock"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_lock_manifest_requires_nab_and_lock( write_manifest: Callable[[str], Path], mocker: MockerFixture, capsys: pytest.CaptureFixture[str], *, nab: str | None, lock: str, expected_error: str, ) -> None: manifest: Final[Path] = write_manifest(_manifest("black", "black", lock)) mocker.patch.object(manifest_module, "which", autospec=True, return_value=nab) assert run_pipx_cli(["manifest", "lock", str(manifest)]) assert expected_error in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_sync_manifest_invalid_requirement_hides_url_credentials( write_manifest: Callable[[str], Path], capsys: pytest.CaptureFixture[str] ) -> None: package: Final[str] = "internal-tool git+https://bot:s3cr3ttok@host/repo.git" assert run_pipx_cli(["manifest", "sync", str(write_manifest(_manifest(package, "internal")))]) err: Final[str] = capsys.readouterr().err output: Final[str] = err + current_log_file().read_text(encoding="utf-8") assert (redact_url_credentials(package) in " ".join(err.split()), "s3cr3ttok" in output) == (True, False) pipx-1.18.16/tests/test_max_logs.py000066400000000000000000000144361527355705200172570ustar00rootroot00000000000000from __future__ import annotations import errno import os from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from helpers import current_log_file, run_pipx_cli from pipx import constants, main, paths if TYPE_CHECKING: from pytest_mock import MockerFixture @pytest.mark.parametrize( ("environment", "existing", "expected"), [ pytest.param({}, 15, 10, id="default"), pytest.param({"PIPX_MAX_LOGS": "5"}, 15, 5, id="env-var"), pytest.param({"PIPX_MAX_LOGS": "100"}, 5, 6, id="under-limit"), pytest.param({"PIPX_MAX_LOGS": "1"}, 15, 1, id="one"), pytest.param({"PIPX_MAX_LOGS": "0"}, 15, 1, id="zero-keeps-current-run"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_max_logs_counts_the_new_log( log_dir: Path, monkeypatch: pytest.MonkeyPatch, environment: dict[str, str], existing: int, expected: int ) -> None: monkeypatch.delenv("PIPX_MAX_LOGS", raising=False) for name, value in environment.items(): monkeypatch.setenv(name, value) for index in range(existing): (log_dir / f"cmd_2024-01-01_00.00.{index:02d}.log").touch() assert not run_pipx_cli(["list"]) assert len(list(log_dir.glob("cmd_*[0-9].log"))) == expected @pytest.fixture def log_dir() -> Path: paths.CTX.logs.mkdir(parents=True) return paths.CTX.logs @pytest.mark.usefixtures("pipx_temp_env") def test_max_logs_rotates_every_backend_error_log(log_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PIPX_MAX_LOGS", "2") for index, backend in enumerate(["uv", "pip", "uv", "pip"]): (log_dir / f"cmd_2024-01-01_00.00.{index:02d}_{backend}_errors.log").touch() assert not run_pipx_cli(["list"]) assert sorted(path.name for path in log_dir.glob("cmd_*_errors.log")) == ["cmd_2024-01-01_00.00.03_pip_errors.log"] @pytest.mark.usefixtures("pipx_temp_env") def test_max_logs_orders_same_second_logs_by_counter(log_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PIPX_MAX_LOGS", "2") for counter in ("", "_1"): (log_dir / f"cmd_2024-01-01_00.00.00{counter}_uv_errors.log").touch() assert not run_pipx_cli(["list"]) assert [path.name for path in log_dir.glob("cmd_*_errors.log")] == ["cmd_2024-01-01_00.00.00_1_uv_errors.log"] @pytest.mark.usefixtures("pipx_temp_env") def test_max_logs_keeps_logs_of_running_command(log_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PIPX_MAX_LOGS", "1") assert not run_pipx_cli(["list"]) # the first command's log handler stays open until the next command replaces it, like a concurrent pipx process log: Final[Path] = current_log_file() (error_log := log.with_name(f"{log.stem}_uv_errors.log")).touch() assert not run_pipx_cli(["list"]) assert {log, error_log} <= set(log_dir.iterdir()) def test_cli_rejects_invalid_env_max_logs(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setattr(main, "_MAX_LOGS_INVALID", True) monkeypatch.setattr(main, "_MAX_LOGS_RAW", "garbage") assert run_pipx_cli(["list"]) assert "PIPX_MAX_LOGS must be unset or a non-negative integer, got 'garbage'." in capsys.readouterr().err @pytest.mark.parametrize("raw", [pytest.param("", id="empty"), pytest.param(" ", id="whitespace-only")]) @pytest.mark.usefixtures("pipx_temp_env") def test_cli_blank_env_max_logs_falls_back_to_default( raw: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: # blank values pass validation as unset, so the log setup must not parse them with int() again monkeypatch.setenv("PIPX_MAX_LOGS", raw) assert not run_pipx_cli(["list"]) assert "ValueError" not in capsys.readouterr().err @pytest.mark.skipif(constants.WINDOWS, reason="file locks only guard logs on POSIX") @pytest.mark.usefixtures("pipx_temp_env") def test_max_logs_rotates_without_lock_support( log_dir: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture ) -> None: monkeypatch.setenv("PIPX_MAX_LOGS", "1") (log_dir / "cmd_2024-01-01_00.00.00.log").touch() mocker.patch("fcntl.flock", side_effect=OSError(errno.ENOLCK, "No locks available")) assert not run_pipx_cli(["list"]) assert [path.name for path in log_dir.glob("cmd_*.log")] == [current_log_file().name] @pytest.mark.skipif(constants.WINDOWS, reason="POSIX permission bits decide whether the log can be opened") @pytest.mark.skipif(not constants.WINDOWS and os.geteuid() == 0, reason="root reads any file") @pytest.mark.usefixtures("pipx_temp_env") def test_max_logs_rotates_an_unreadable_log(log_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PIPX_MAX_LOGS", "1") (unreadable := log_dir / "cmd_2024-01-01_00.00.00.log").touch() unreadable.chmod(0) assert not run_pipx_cli(["list"]) assert [path.name for path in log_dir.glob("cmd_*.log")] == [current_log_file().name] @pytest.mark.skipif(constants.WINDOWS, reason="creating symlinks needs developer mode on Windows") @pytest.mark.parametrize( "target", [pytest.param("cmd_2024-01-01_00.00.00.log", id="symlink-loop"), pytest.param(".", id="symlink-to-dir")], ) @pytest.mark.usefixtures("pipx_temp_env") def test_max_logs_rotates_a_log_that_cannot_open(log_dir: Path, monkeypatch: pytest.MonkeyPatch, target: str) -> None: monkeypatch.setenv("PIPX_MAX_LOGS", "1") (log_dir / "cmd_2024-01-01_00.00.00.log").symlink_to(target) assert not run_pipx_cli(["list"]) assert [path.name for path in log_dir.glob("cmd_*.log")] == [current_log_file().name] @pytest.mark.parametrize( "name", [ pytest.param("cmd_2024-01-01_00.00.00.log", id="command-log"), pytest.param("cmd_2024-01-01_00.00.00_uv_errors.log", id="error-log"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_max_logs_keeps_logging_beside_a_log_it_cannot_remove( log_dir: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, name: str ) -> None: monkeypatch.setenv("PIPX_MAX_LOGS", "1") (log_dir / name).touch() # a sticky shared directory lets this user create a log but not remove another user's mocker.patch.object(Path, "unlink", autospec=True, side_effect=PermissionError(errno.EPERM, "Not permitted")) assert not run_pipx_cli(["list"]) assert current_log_file().parent == log_dir pipx-1.18.16/tests/test_outdated.py000066400000000000000000000461341527355705200172570ustar00rootroot00000000000000from __future__ import annotations import json import shutil import subprocess from contextlib import nullcontext from dataclasses import replace from typing import TYPE_CHECKING, Final, Literal, TypeAlias import pytest from packaging.utils import canonicalize_name from helpers import SKIP_IF_WINDOWS, mock_legacy_venv, remove_venv_interpreter, run_pipx_cli from pipx import paths, util from pipx.pipx_metadata_file import PIPX_INFO_FILENAME, PipxMetadata if TYPE_CHECKING: from contextlib import AbstractContextManager from pathlib import Path from unittest.mock import MagicMock from _pytest.capture import CaptureResult from pytest_mock import MockerFixture from pipx.venv import VenvContainer _JsonValue: TypeAlias = bool | int | float | str | list["_JsonValue"] | dict[str, "_JsonValue"] | None _OUTDATED_PROCESS: Final[subprocess.CompletedProcess[str]] = subprocess.CompletedProcess( args=[], returncode=0, stdout=( '[{"name":"pycowsay","version":"0.0.0.2","latest_version":"1.0","latest_filetype":"wheel"},' '{"name":"packaging","version":"20","latest_version":"26","latest_filetype":"wheel"}]' ), stderr="", ) @pytest.mark.parametrize("backend", [pytest.param("pip", id="pip"), pytest.param("uv", id="uv")]) @pytest.mark.usefixtures("pipx_temp_env") def test_list_outdated_backend( capsys: pytest.CaptureFixture[str], backend: str, ) -> None: assert not run_pipx_cli(["install", "pycowsay", "--backend", backend]) capsys.readouterr() assert not run_pipx_cli(["list", "--outdated"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err) == ("pipx found no available upgrades.\n", "") @pytest.mark.usefixtures("pipx_temp_env") def test_list_outdated_selected_package( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["install", "isort"]) capsys.readouterr() list_outdated: Final[MagicMock] = mocker.patch( "pipx.backends.pip.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout="[]", stderr=""), ) assert not run_pipx_cli(["list", "pycowsay", "--outdated"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err, list_outdated.call_count) == ("pipx found no available upgrades.\n", "", 1) @pytest.mark.usefixtures("pipx_temp_env") def test_list_outdated_skips_removed_environment( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() def remove_environment(_container: VenvContainer, venv_dir: Path) -> AbstractContextManager[None]: shutil.rmtree(venv_dir) return nullcontext() lock: Final[MagicMock] = mocker.patch( "pipx.venv.VenvContainer.venv_lock", autospec=True, side_effect=remove_environment ) assert not run_pipx_cli(["list", "--outdated"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err, lock.call_count) == ("pipx found no index packages to check.\n", "", 1) @pytest.mark.parametrize( ("outdated_environment", "expected"), [ pytest.param(_OUTDATED_PROCESS, "pycowsay: 0.0.0.2 -> 1.0\n", id="outdated"), pytest.param( subprocess.CompletedProcess(args=[], returncode=0, stdout="[]", stderr=""), "pipx found no available upgrades.\n", id="current", ), ], indirect=["outdated_environment"], ) def test_list_outdated_text( outdated_environment: MagicMock, capsys: pytest.CaptureFixture[str], expected: str, ) -> None: assert not run_pipx_cli(["list", "--outdated"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err, outdated_environment.call_count) == (expected, "", 1) @pytest.fixture def outdated_environment( request: pytest.FixtureRequest, pipx_temp_env: None, # ruff:ignore[unused-function-argument] # required so the temp env is active while the environment is built capsys: pytest.CaptureFixture[str], mocker: MockerFixture, ) -> MagicMock: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() process: Final[subprocess.CompletedProcess[str]] = getattr(request, "param", _OUTDATED_PROCESS) return mocker.patch("pipx.backends.pip.run_subprocess", autospec=True, return_value=process) @pytest.mark.usefixtures("outdated_environment") def test_list_outdated_json(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["list", "--outdated", "--output", "json"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( _outdated_result( packages_checked=1, packages=( { "environment": "pycowsay", "injected": False, "latest_version": "1.0", "package": "pycowsay", "pinned": False, "version": "0.0.0.2", }, ), ), "", ) def _outdated_result( *, packages_checked: int = 0, packages: tuple[dict[str, _JsonValue], ...] = (), skipped: tuple[dict[str, _JsonValue], ...] = (), failures: tuple[dict[str, _JsonValue], ...] = (), status: Literal["success", "partial", "error"] = "success", ) -> dict[str, _JsonValue]: return { "command": ["list"], "data": { "packages_checked": packages_checked, "packages": list(packages), "skipped": list(skipped), }, "errors": [ { "code": "environment_outdated_check_failed", "environment": failure["environment"], "message": failure["error"], "package": None, } for failure in failures ], "exit_code": 1 if failures else 0, "pipx_result_version": "1", "status": status, } def test_list_outdated_reports_pinned( outdated_environment: MagicMock, capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["pin", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["list", "--outdated"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err, outdated_environment.call_count) == ( "pycowsay [pinned]: 0.0.0.2 -> 1.0\n", "", 1, ) @pytest.mark.parametrize( ("options", "expected"), [ pytest.param([], "pycowsay: 0.0.0.2 -> 1.0\n", id="main-only"), pytest.param( ["--include-injected"], "black (injected in pycowsay): 23 -> 24\npycowsay: 0.0.0.2 -> 1.0\n", id="include-injected", ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_list_outdated_filters_injected( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, options: list[str], expected: str, ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", "black"]) capsys.readouterr() mocker.patch( "pipx.backends.pip.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess( args=[], returncode=0, stdout=( '[{"name":"pycowsay","version":"0.0.0.2","latest_version":"1.0"},' '{"name":"black","version":"23","latest_version":"24"}]' ), stderr="", ), ) assert not run_pipx_cli(["list", "--outdated", *options]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err) == (expected, "") @pytest.mark.parametrize("backend", [pytest.param("pip", id="pip"), pytest.param("uv", id="uv")]) @pytest.mark.parametrize( ("options", "managed"), [ pytest.param([], {"pycowsay"}, id="main-only"), pytest.param(["--include-injected"], {"pycowsay", "black"}, id="include-injected"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_list_outdated_excludes_unmanaged_distributions( capfd: pytest.CaptureFixture[str], mocker: MockerFixture, backend: str, options: list[str], managed: set[str], ) -> None: assert not run_pipx_cli(["install", "pycowsay", "--backend", backend]) assert not run_pipx_cli(["inject", "pycowsay", "black"]) capfd.readouterr() assert not run_pipx_cli(["runpip", "pycowsay", "list", "--format=json"]) installed: Final[set[str]] = {canonicalize_name(entry["name"]) for entry in json.loads(capfd.readouterr().out)} run_subprocess: Final[MagicMock] = mocker.patch( f"pipx.backends.{backend}.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout="[]", stderr=""), ) assert not run_pipx_cli(["list", "--outdated", *options]) assert [ arg.removeprefix("--exclude=") for arg in map(str, run_subprocess.call_args.args[0]) if arg.startswith("--exclude=") ] == sorted(installed - managed) @pytest.mark.usefixtures("pipx_temp_env") def test_list_outdated_uv_skips_invalid_distribution_names(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay", "--backend", "uv"]) # pip renames a distribution being replaced to ``~`` and leaves it behind when interrupted next((paths.CTX.venvs / "pycowsay").rglob("pycowsay-*.dist-info")).with_name("~arkdown-3.4.dist-info").mkdir() capsys.readouterr() assert not run_pipx_cli(["list", "--outdated"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err) == ("pipx found no available upgrades.\n", "") @pytest.mark.parametrize( ("outdated_environment", "expected_error"), [ pytest.param( subprocess.CompletedProcess(args=["pip", "list"], returncode=1, stdout="", stderr="index unavailable"), "Package backend exited with code 1.\nstderr: index unavailable", id="exit-code", ), pytest.param( subprocess.CompletedProcess(args=["pip", "list"], returncode=0, stdout="not json", stderr=""), "Package backend returned invalid JSON for an outdated query.", id="invalid-json", ), pytest.param( subprocess.CompletedProcess(args=["pip", "list"], returncode=0, stdout='[{"name":"demo"}]', stderr=""), "Package backend returned invalid JSON for an outdated query.", id="missing-key", ), pytest.param( subprocess.CompletedProcess(args=["pip", "list"], returncode=0, stdout="null", stderr=""), "Package backend returned invalid JSON for an outdated query.", id="null", ), ], indirect=["outdated_environment"], ) def test_list_outdated_json_reports_backend_failure( outdated_environment: MagicMock, capsys: pytest.CaptureFixture[str], expected_error: str, ) -> None: assert run_pipx_cli(["list", "--outdated", "--json"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err, outdated_environment.call_count) == ( _outdated_result( packages_checked=1, failures=({"environment": "pycowsay", "error": expected_error},), status="error", ), "", 1, ) @pytest.mark.parametrize( "outdated_environment", [ pytest.param( subprocess.CompletedProcess(args=["pip", "list"], returncode=1, stdout="", stderr="index unavailable"), id="exit-code", ) ], indirect=True, ) def test_list_outdated_reports_backend_failure( outdated_environment: MagicMock, capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["list", "--outdated"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err, outdated_environment.call_count) == ( "", "pycowsay: Package backend exited with code 1.\nstderr: index unavailable\n", 1, ) def test_list_outdated_json_reports_non_index_skip( non_index_environment: MagicMock, capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["list", "--outdated", "--json"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err, non_index_environment.call_count) == ( _outdated_result(skipped=({"environment": "pycowsay", "package": "pycowsay", "reason": "non-index"},)), "", 0, ) @pytest.fixture def non_index_environment( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # required so the temp env is active while the environment is built capsys: pytest.CaptureFixture[str], mocker: MockerFixture, ) -> MagicMock: assert not run_pipx_cli(["install", "pycowsay"]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") metadata.main_package = replace(metadata.main_package, package_or_url="git+https://example.com/pycowsay.git") metadata.write() capsys.readouterr() return mocker.patch("pipx.backends.pip.run_subprocess", autospec=True) def test_list_outdated_reports_no_index_packages( non_index_environment: MagicMock, capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["list", "--outdated"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err, non_index_environment.call_count) == ( "pipx found no index packages to check.\n", "", 0, ) @pytest.mark.usefixtures("pipx_temp_env") def test_list_outdated_json_reports_editable_skip( capsys: pytest.CaptureFixture[str], empty_project: Path, ) -> None: assert not run_pipx_cli(["install", "--editable", str(empty_project)]) capsys.readouterr() assert not run_pipx_cli(["list", "--outdated", "--json"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( _outdated_result(skipped=({"environment": "empty-project", "package": "empty-project", "reason": "editable"},)), "", ) def test_list_outdated_json_reports_corrupt_package( corrupt_environment: MagicMock, capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["list", "--outdated", "--json"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err, corrupt_environment.call_count) == ( _outdated_result( failures=({"environment": "pycowsay", "error": "Package pycowsay has corrupt pipx metadata."},), status="error", ), "", 0, ) @pytest.fixture def corrupt_environment(outdated_environment: MagicMock) -> MagicMock: metadata_path: Final[Path] = paths.CTX.venvs / "pycowsay" / PIPX_INFO_FILENAME metadata: Final = json.loads(metadata_path.read_text(encoding="utf-8")) metadata["main_package"]["package_or_url"] = None metadata_path.write_text(json.dumps(metadata), encoding="utf-8") return outdated_environment def test_list_outdated_reports_corrupt_package( corrupt_environment: MagicMock, capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["list", "--outdated"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err, corrupt_environment.call_count) == ( "", "pycowsay: Package pycowsay has corrupt pipx metadata.\n", 0, ) @pytest.mark.usefixtures("missing_metadata_environment") def test_list_outdated_json_reports_missing_metadata( capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["list", "--outdated", "--json"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( _outdated_result( failures=({"environment": "pycowsay", "error": "Missing internal pipx metadata."},), status="error", ), "", ) @pytest.fixture def missing_metadata_environment( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # required so the temp env is active while the environment is built capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay") capsys.readouterr() @pytest.mark.usefixtures("missing_metadata_environment") def test_list_outdated_reports_missing_metadata( capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["list", "--outdated"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err) == ("", "pycowsay: Missing internal pipx metadata.\n") @pytest.mark.usefixtures("pipx_temp_env") def test_list_outdated_reports_missing_interpreter(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) remove_venv_interpreter("pycowsay") capsys.readouterr() assert run_pipx_cli(["list", "--outdated"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err) == ( "", ( f"pycowsay: Invalid python interpreter {util.get_venv_paths(paths.CTX.venvs / 'pycowsay')[1]}. " "To fix, execute: pipx reinstall-all\n" ), ) @pytest.mark.parametrize( "option", [ pytest.param("--pinned", id="pinned"), pytest.param("--short", id="short"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_list_outdated_rejects_other_filters( capsys: pytest.CaptureFixture[str], option: str, ) -> None: assert run_pipx_cli(["list", "--outdated", option]) assert capsys.readouterr().err == "--outdated cannot be combined with --short or --pinned.\n" @SKIP_IF_WINDOWS def test_list_outdated_refuses_find_links_others_can_change( outdated_environment: MagicMock, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: (shared := tmp_path.resolve() / "shared").mkdir() shared.chmod(0o777) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") metadata.main_package = replace(metadata.main_package, pip_args=["--find-links", str(shared)]) metadata.write() assert run_pipx_cli(["list", "--outdated"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err, outdated_environment.call_count) == ( "", ( f"pycowsay: refusing to check pycowsay for updates through the recorded --find-links {shared}: another " f"account can change {shared}, and with it the versions reported. Move the file or directory to a place " "only you and root can change.\n" ), 0, ) @pytest.mark.usefixtures("outdated_environment") def test_upgrade_all_missing_index_value(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["upgrade-all", "--pip-args=--index-url"]) == 1 assert "Missing value for '--index-url' in --pip-args." in capsys.readouterr().err pipx-1.18.16/tests/test_package_specifier.py000066400000000000000000000741661527355705200211000ustar00rootroot00000000000000from __future__ import annotations import re from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from pipx.package_specifier import ( add_extras, extract_index_options, fix_package_name, get_extras, package_spec_satisfied, parse_specifier_for_install, parse_specifier_for_metadata, parse_specifier_for_upgrade, replace_index_options, source_without_extras, static_project_name, valid_pypi_name, wheel_project_name, ) from pipx.util import PipxError if TYPE_CHECKING: from collections.abc import Callable _TEST_DATA_PATH: Final[str] = "./testdata/test_package_specifier" _ROOT: Final[Path] = Path(__file__).parents[1] @pytest.mark.parametrize( ("pip_args", "expected"), [ pytest.param( ["--no-cache-dir", "--index-url", "https://index.example/simple", "--pre"], ["--index-url", "https://index.example/simple"], id="separate-value", ), pytest.param( ["-ihttps://index.example/simple", "-f", "https://files.example", "--no-index"], ["-ihttps://index.example/simple", "-f", "https://files.example", "--no-index"], id="short-options", ), pytest.param( ["--extra-index-url=https://extra.example/simple", "--trusted-host", "extra.example"], ["--extra-index-url=https://extra.example/simple", "--trusted-host", "extra.example"], id="attached-value", ), pytest.param( [ "--default-index", "https://index.example/simple", "--index=https://extra.example/simple", "--allow-insecure-host", "extra.example", "--index-strategy", "unsafe-best-match", "--keyring-provider", "subprocess", "--compile-bytecode", ], [ "--default-index", "https://index.example/simple", "--index=https://extra.example/simple", "--allow-insecure-host", "extra.example", "--index-strategy", "unsafe-best-match", "--keyring-provider", "subprocess", ], id="uv-spellings", ), ], ) def test_extract_index_options(pip_args: list[str], expected: list[str]) -> None: assert extract_index_options(pip_args) == expected @pytest.mark.parametrize( "option", [ "-f", "-i", "--allow-insecure-host", "--default-index", "--extra-index-url", "--find-links", "--index", "--index-strategy", "--index-url", "--keyring-provider", "--trusted-host", ], ) def test_extract_index_options_missing_value(option: str) -> None: with pytest.raises(PipxError, match=re.escape(f"Missing value for {option!r} in --pip-args.")): extract_index_options(["--no-cache-dir", option]) @pytest.mark.parametrize( ("package_spec", "expected"), [ pytest.param("Black[d]==22.8.0", "black==22.8.0", id="requirement"), pytest.param( "black[d] @ https://files.example/black.whl", "black @ https://files.example/black.whl", id="pep508" ), pytest.param("https://files.example/black.whl", "https://files.example/black.whl", id="url"), ], ) def test_source_without_extras(package_spec: str, expected: str) -> None: assert source_without_extras(package_spec) == expected def test_source_without_extras_local_path(tmp_path: Path) -> None: assert source_without_extras(f"{tmp_path}[d]") == str(tmp_path.resolve()) @pytest.mark.parametrize( ("package_or_url", "expected"), [ pytest.param("black[jupyter]==22.8.0", "black[d,jupyter]==22.8.0", id="requirement"), pytest.param("https://files.example/black.whl", "black[d] @ https://files.example/black.whl", id="url"), ], ) def test_add_extras(package_or_url: str, expected: str) -> None: assert add_extras(package_or_url, "black", {"d"}) == expected def test_add_extras_local_path(tmp_path: Path) -> None: assert add_extras(f"{tmp_path}[jupyter]", "black", {"d"}) == f"{tmp_path.resolve()}[d,jupyter]" @pytest.mark.parametrize( ("pip_args", "index_args", "expected"), [ pytest.param( ["--no-cache-dir", "--index-url", "https://old.example/simple", "--pre"], ["--find-links", "/wheels"], ["--no-cache-dir", "--pre", "--find-links", "/wheels"], id="pip-spellings", ), pytest.param( [ "--default-index", "https://old.example/simple", "--index=https://extra.example/simple", "--compile-bytecode", ], ["--default-index", "https://new.example/simple"], ["--compile-bytecode", "--default-index", "https://new.example/simple"], id="uv-spellings", ), ], ) def test_replace_index_options(pip_args: list[str], index_args: list[str], expected: list[str]) -> None: assert replace_index_options(pip_args, index_args) == expected @pytest.mark.parametrize( ("package_spec_in", "package_name_out"), [ pytest.param("Black", "black", id="name"), pytest.param("https://github.com/ambv/black/archive/18.9b0.zip", None, id="url"), pytest.param("black @ https://github.com/ambv/black/archive/18.9b0.zip", None, id="pep508-url"), pytest.param("black-18.9b0-py36-none-any.whl", None, id="whl"), pytest.param("black-18.9b0.tar.gz", None, id="tar-gz"), pytest.param("black-18.9b0.zip", None, id="zip"), pytest.param("black-18.9b0.tar", None, id="tar"), pytest.param("black-18.9b0.tgz", None, id="tgz"), pytest.param("black-18.9b0.tar.bz2", None, id="tar-bz2"), pytest.param("black-18.9b0.tbz", None, id="tbz"), pytest.param("black-18.9b0.tar.xz", None, id="tar-xz"), pytest.param("black-18.9b0.txz", None, id="txz"), pytest.param("black-18.9b0.tlz", None, id="tlz"), pytest.param("black-18.9b0.tar.lz", None, id="tar-lz"), pytest.param("black-18.9b0.tar.lzma", None, id="tar-lzma"), pytest.param("black-18.9b0.TAR.GZ", None, id="uppercase-tar-gz"), ], ) def test_valid_pypi_name(package_spec_in: str, package_name_out: str | None) -> None: assert valid_pypi_name(package_spec_in) == package_name_out @pytest.mark.parametrize( "name", [pytest.param("pkg-1.0.tar.xz", id="lowercase"), pytest.param("Pkg-1.0.TAR.XZ", id="uppercase")] ) def test_parse_specifier_for_install_bare_archive(name: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: (archive := tmp_path / name).touch() monkeypatch.chdir(tmp_path) assert parse_specifier_for_install(name, []) == (str(archive.resolve()), []) def test_parse_specifier_for_install_missing_bare_archive(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) with pytest.raises(PipxError, match=r"pkg-1\.0\.tar\.xz does not exist"): parse_specifier_for_install("pkg-1.0.tar.xz", []) @pytest.mark.parametrize( ("package_spec", "installed_spec", "expected"), [ pytest.param("black>=22,<23", "black==22.8.0", True, id="within-range"), pytest.param("black<22", "black==22.8.0", False, id="outside-range"), pytest.param("Black", "black==22.8.0", True, id="name-case"), pytest.param("black[colorama]", "black==22.8.0", False, id="extra-missing"), pytest.param("black[colorama]", "black[colorama]==22.8.0", True, id="extra-installed"), pytest.param("black @ https://example.com/black.whl", "black==22.8.0", False, id="pep508-url"), pytest.param("https://example.com/black.whl", "black==22.8.0", False, id="url"), ], ) def test_package_spec_satisfied(package_spec: str, installed_spec: str, expected: bool) -> None: assert package_spec_satisfied(package_spec, "black", "22.8.0", installed_spec) is expected @pytest.mark.parametrize( ("package_spec_in", "package_name", "package_spec_out"), [ pytest.param( "https://github.com/ambv/black/archive/18.9b0.zip", "black", "https://github.com/ambv/black/archive/18.9b0.zip", id="url", ), pytest.param( "nox@https://github.com/ambv/black/archive/18.9b0.zip", "black", "black @ https://github.com/ambv/black/archive/18.9b0.zip", id="pep508-url-renamed", ), pytest.param( "nox[extra]@https://github.com/ambv/black/archive/18.9b0.zip", "black", "black[extra] @ https://github.com/ambv/black/archive/18.9b0.zip", id="pep508-url-extra-renamed", ), pytest.param("black-18.9b0.tar.xz", "black", "black-18.9b0.tar.xz", id="archive"), pytest.param("Black-18.9b0.TAR.XZ", "black", "Black-18.9b0.TAR.XZ", id="uppercase-archive"), ], ) def test_fix_package_name(package_spec_in: str, package_name: str, package_spec_out: str) -> None: assert fix_package_name(package_spec_in, package_name) == package_spec_out @pytest.fixture def bare_archive(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: (archive := tmp_path / "repeatme-0.1-py3-none-any.whl").touch() monkeypatch.chdir(tmp_path) return archive @pytest.mark.parametrize( "extras", [ pytest.param("", id="none"), pytest.param("[cow]", id="single"), pytest.param("[tools,cow]", id="unsorted"), pytest.param(" [tools, cow]", id="spaced"), ], ) def test_parse_specifier_bare_archive_matches_explicit_path(bare_archive: Path, extras: str) -> None: assert parse_specifier_for_metadata(f"{bare_archive.name}{extras}") == parse_specifier_for_metadata( f"./{bare_archive.name}{extras.lstrip()}" ) def test_parse_specifier_bare_archive_extras_before_trailing_space(bare_archive: Path) -> None: assert parse_specifier_for_metadata(f"{bare_archive.name}[cow] ") == f"{bare_archive}[cow]" def test_get_extras_bare_archive(bare_archive: Path) -> None: assert get_extras(f"{bare_archive.name}[tools,cow]") == {"cow", "tools"} @pytest.mark.parametrize( "suffix", [ pytest.param("==0.1", id="specifier"), pytest.param("; python_version > '3'", id="marker"), pytest.param(" @ https://example.com/repeatme-0.1-py3-none-any.whl", id="url"), ], ) def test_parse_specifier_bare_archive_rejects_non_extras(bare_archive: Path, suffix: str) -> None: with pytest.raises(PipxError, match=r"^Only extras can follow the local archive"): parse_specifier_for_metadata(f"{bare_archive.name}[cow]{suffix}") @pytest.mark.parametrize( ("package_spec", "expected"), [ pytest.param("pipx", "pipx", id="name"), pytest.param("PiPx_stylized.name", "pipx-stylized-name", id="normalized-name"), pytest.param("pipx==0.15.0", "pipx==0.15.0", id="pinned"), pytest.param("pipx>=0.15.0", "pipx>=0.15.0", id="lower-bound"), pytest.param("pipx<=0.15.0", "pipx<=0.15.0", id="upper-bound"), pytest.param('pipx;python_version>="3.6"', "pipx", id="marker"), pytest.param('pipx==0.15.0;python_version>="3.6"', "pipx==0.15.0", id="pinned-marker"), pytest.param("pipx[extra1]", "pipx[extra1]", id="extra"), pytest.param("pipx[extra1, extra2]", "pipx[extra1,extra2]", id="extras"), pytest.param("src/pipx", str((_ROOT / "src" / "pipx").resolve()), id="local-path"), pytest.param( "git+https://github.com/cs01/nox.git@5ea70723e9e6", "git+https://github.com/cs01/nox.git@5ea70723e9e6", id="vcs-url", ), pytest.param( "nox@git+https://github.com/cs01/nox.git@5ea70723e9e6", "nox @ git+https://github.com/cs01/nox.git@5ea70723e9e6", id="pep508-vcs-url", ), pytest.param( "https://github.com/ambv/black/archive/18.9b0.zip", "https://github.com/ambv/black/archive/18.9b0.zip", id="url", ), pytest.param( "black@https://github.com/ambv/black/archive/18.9b0.zip", "black @ https://github.com/ambv/black/archive/18.9b0.zip", id="pep508-url", ), pytest.param( "black @ https://github.com/ambv/black/archive/18.9b0.zip", "black @ https://github.com/ambv/black/archive/18.9b0.zip", id="pep508-url-spaced", ), pytest.param( "black[extra] @ https://github.com/ambv/black/archive/18.9b0.zip", "black[extra] @ https://github.com/ambv/black/archive/18.9b0.zip", id="pep508-url-extra", ), pytest.param( 'my-project[cli] @ git+ssh://git@bitbucket.org/my-company/myproject.git ; python_version<"3.8"', "my-project[cli] @ git+ssh://git@bitbucket.org/my-company/myproject.git", id="pep508-ssh-url-marker", ), ], ) def test_parse_specifier_for_metadata( package_spec: str, expected: str, monkeypatch: pytest.MonkeyPatch, root: Path ) -> None: monkeypatch.chdir(root) assert parse_specifier_for_metadata(package_spec) == expected @pytest.mark.parametrize( ("package_spec", "expected"), [ pytest.param("pipx", "pipx", id="name"), pytest.param("PiPx_stylized.name", "pipx-stylized-name", id="normalized-name"), pytest.param("pipx==0.15.0", "pipx", id="pinned"), pytest.param("pipx>=0.15.0", "pipx", id="lower-bound"), pytest.param("pipx<=0.15.0", "pipx", id="upper-bound"), pytest.param('pipx;python_version>="3.6"', "pipx", id="marker"), pytest.param('pipx==0.15.0;python_version>="3.6"', "pipx", id="pinned-marker"), pytest.param("pipx[extra1]", "pipx[extra1]", id="extra"), pytest.param("pipx[extra1, extra2]", "pipx[extra1,extra2]", id="extras"), pytest.param("src/pipx", str((_ROOT / "src" / "pipx").resolve()), id="local-path"), pytest.param( "git+https://github.com/cs01/nox.git@5ea70723e9e6", "git+https://github.com/cs01/nox.git@5ea70723e9e6", id="vcs-url", ), pytest.param( "nox@git+https://github.com/cs01/nox.git@5ea70723e9e6", "nox @ git+https://github.com/cs01/nox.git@5ea70723e9e6", id="pep508-vcs-url", ), pytest.param( "https://github.com/ambv/black/archive/18.9b0.zip", "https://github.com/ambv/black/archive/18.9b0.zip", id="url", ), pytest.param( "black@https://github.com/ambv/black/archive/18.9b0.zip", "black @ https://github.com/ambv/black/archive/18.9b0.zip", id="pep508-url", ), pytest.param( "black @ https://github.com/ambv/black/archive/18.9b0.zip", "black @ https://github.com/ambv/black/archive/18.9b0.zip", id="pep508-url-spaced", ), pytest.param( "black[extra] @ https://github.com/ambv/black/archive/18.9b0.zip", "black[extra] @ https://github.com/ambv/black/archive/18.9b0.zip", id="pep508-url-extra", ), pytest.param( 'my-project[cli] @ git+ssh://git@bitbucket.org/my-company/myproject.git ; python_version<"3.8"', "my-project[cli] @ git+ssh://git@bitbucket.org/my-company/myproject.git", id="pep508-ssh-url-marker", ), ], ) def test_parse_specifier_for_upgrade( package_spec: str, expected: str, monkeypatch: pytest.MonkeyPatch, root: Path ) -> None: monkeypatch.chdir(root) assert parse_specifier_for_upgrade(package_spec) == expected @pytest.mark.parametrize( "parse", [ pytest.param(parse_specifier_for_metadata, id="metadata"), pytest.param(parse_specifier_for_upgrade, id="upgrade"), ], ) @pytest.mark.parametrize( "package_spec", [ pytest.param("path/doesnt/exist", id="missing-path"), pytest.param("https:/github.com/ambv/black/archive/18.9b0.zip", id="url-single-slash"), ], ) def test_parse_specifier_rejects_unparsable_spec( parse: Callable[[str], str], package_spec: str, monkeypatch: pytest.MonkeyPatch, root: Path ) -> None: monkeypatch.chdir(root) with pytest.raises(PipxError, match=r"^Unable to parse package spec"): parse(package_spec) @pytest.mark.parametrize( ("package_spec", "pip_args", "expected", "warning"), [ pytest.param( "pipx==0.15.0", ["--editable"], ("pipx==0.15.0", []), "Ignoring --editable", id="editable-requirement" ), pytest.param( 'pipx==0.15.0;python_version>="3.6"', [], ("pipx==0.15.0", []), 'Ignoring environment markers (python_version >= "3.6") in package', id="marker", ), pytest.param( "pipx==0.15.0", ["--no-cache-dir", "--editable"], ("pipx==0.15.0", ["--no-cache-dir"]), "Ignoring --editable", id="editable-among-other-args", ), pytest.param( "git+https://github.com/cs01/nox.git@5ea70723e9e6", ["--editable"], ("git+https://github.com/cs01/nox.git@5ea70723e9e6", []), "Ignoring --editable", id="editable-vcs-url", ), pytest.param( "https://github.com/ambv/black/archive/18.9b0.zip", ["--editable"], ("https://github.com/ambv/black/archive/18.9b0.zip", []), "Ignoring --editable", id="editable-url", ), ], ) def test_parse_specifier_for_install_warns( package_spec: str, pip_args: list[str], expected: tuple[str, list[str]], warning: str, caplog: pytest.LogCaptureFixture, ) -> None: assert (parse_specifier_for_install(package_spec, pip_args), warning in caplog.text) == (expected, True) @pytest.mark.parametrize( ("package_path", "extras", "pip_args"), [ pytest.param("src/pipx", "", ["--editable"], id="editable-path"), pytest.param(f"{_TEST_DATA_PATH}/local_extras", "", [], id="path"), pytest.param(f"{_TEST_DATA_PATH}/local_extras", "[cow]", [], id="path-extras"), pytest.param(f"{_TEST_DATA_PATH}/local_extras", "", ["--editable"], id="editable-dot-path"), pytest.param(f"{_TEST_DATA_PATH}/local_extras", "[cow]", ["--editable"], id="editable-path-extras"), ], ) def test_parse_specifier_for_install_local_path( package_path: str, extras: str, pip_args: list[str], monkeypatch: pytest.MonkeyPatch, root: Path ) -> None: monkeypatch.chdir(root) assert parse_specifier_for_install(f"{package_path}{extras}", pip_args) == ( f"{(root / package_path).resolve()}{extras}", pip_args, ) @pytest.mark.parametrize( ("pip_args_in", "pip_args_expected"), [ pytest.param( ["-c", "https://example.com/constraints.txt"], ["-c", "https://example.com/constraints.txt"], id="short-separated-url", ), pytest.param( ["-chttps://example.com/constraints.txt"], ["-chttps://example.com/constraints.txt"], id="short-attached-url", ), pytest.param( ["--constraint", "https://example.com/constraints.txt"], ["--constraint", "https://example.com/constraints.txt"], id="long-separated-url", ), pytest.param( ["--constraint=https://example.com/constraints.txt"], ["--constraint=https://example.com/constraints.txt"], id="long-equals-url", ), pytest.param( ["-c", "constraints.txt"], ["-c", str(Path("constraints.txt").resolve())], id="short-separated-path" ), pytest.param(["-cconstraints.txt"], [f"-c{Path('constraints.txt').resolve()}"], id="short-attached-path"), pytest.param( ["--constraint=constraints.txt"], [f"--constraint={Path('constraints.txt').resolve()}"], id="long-equals-path", ), ], ) def test_parse_specifier_for_install_constraint_args(pip_args_in: list[str], pip_args_expected: list[str]) -> None: assert parse_specifier_for_install("pipx", pip_args_in) == ("pipx", pip_args_expected) @pytest.mark.parametrize( "package_spec", [ pytest.param("git+file:///tmp/project@main", id="git"), pytest.param("hg+file:///tmp/project@default", id="mercurial"), pytest.param("file:///opt/wheels/demo-1-py3-none-any.whl", id="file"), pytest.param("file:///opt/wheels%20with%20spaces/demo-1.tar.gz#sha256=abc", id="file-quoted-space"), pytest.param("file:///C:/wheels/demo-1-py3-none-any.whl", id="file-windows-drive"), ], ) def test_parse_specifier_for_install_accepts_local_url(package_spec: str) -> None: assert parse_specifier_for_install(package_spec, []) == (package_spec, []) @pytest.mark.parametrize( "package_spec", [ pytest.param("file:relative.whl", id="file"), pytest.param("git+file:relative", id="git"), pytest.param("https:/example.com/demo.whl", id="https-single-slash"), ], ) def test_parse_specifier_for_install_rejects_hostless_relative_url(package_spec: str) -> None: with pytest.raises(PipxError, match="Unable to parse package spec"): parse_specifier_for_install(package_spec, []) @pytest.mark.parametrize( ("pip_args_in", "pip_args_expected"), [ pytest.param( ["-f", "https://example.com/wheels"], ["-f", "https://example.com/wheels"], id="short-separated-url" ), pytest.param(["-fhttps://example.com/wheels"], ["-fhttps://example.com/wheels"], id="short-attached-url"), pytest.param( ["--find-links", "https://example.com/wheels"], ["--find-links", "https://example.com/wheels"], id="long-separated-url", ), pytest.param( ["--find-links=https://example.com/wheels"], ["--find-links=https://example.com/wheels"], id="long-equals-url", ), pytest.param(["-f", "wheels"], ["-f", str(Path("wheels").resolve())], id="short-separated-path"), pytest.param(["-fwheels"], [f"-f{Path('wheels').resolve()}"], id="short-attached-path"), pytest.param(["--find-links=wheels"], [f"--find-links={Path('wheels').resolve()}"], id="long-equals-path"), ], ) def test_parse_specifier_for_install_find_links_args(pip_args_in: list[str], pip_args_expected: list[str]) -> None: assert parse_specifier_for_install("pipx", pip_args_in) == ("pipx", pip_args_expected) @pytest.mark.parametrize( "option", [ "--build-constraint", "-r", "--requirement", "--requirements-from-script", "-t", "--target", "--root", "--prefix", "--src", "--source", "--source-dir", "--source-directory", "--report", "--log", "--log-file", "--local-log", "--cert", "--client-cert", "--cache-dir", ], ) def test_parse_specifier_for_install_path_option_separated(option: str) -> None: assert parse_specifier_for_install("pipx", [option, "file"]) == ("pipx", [option, str(Path("file").resolve())]) @pytest.mark.parametrize("option", ["--requirement", "--cert", "--cache-dir"]) def test_parse_specifier_for_install_path_option_equals(option: str) -> None: assert parse_specifier_for_install("pipx", [f"{option}=file"]) == ("pipx", [f"{option}={Path('file').resolve()}"]) @pytest.mark.parametrize("option", ["-r", "-t"]) def test_parse_specifier_for_install_path_option_attached(option: str) -> None: assert parse_specifier_for_install("pipx", [f"{option}file"]) == ("pipx", [f"{option}{Path('file').resolve()}"]) @pytest.mark.parametrize( "kept", [ pytest.param(["--report", "-"], id="report-stdout"), pytest.param(["--report=-"], id="report-stdout-equals"), pytest.param(["-r", "https://example.com/requirements.txt"], id="requirement-url"), pytest.param(["-rhttps://example.com/requirements.txt"], id="requirement-url-attached"), ], ) def test_parse_specifier_for_install_path_option_keeps_value(kept: list[str]) -> None: assert parse_specifier_for_install("pipx", [*kept, "--log", "file"]) == ( "pipx", [*kept, "--log", str(Path("file").resolve())], ) @pytest.mark.parametrize( ("pyproject", "expected"), [ pytest.param(b'[project]\nname = "Foo_Bar.X"\n', "Foo_Bar.X", id="static"), pytest.param(b'[project]\nname = "demo"\ndynamic = ["version"]\n', "demo", id="other-field-dynamic"), pytest.param(b"", None, id="no-project-table"), pytest.param(b'[project]\nversion = "1"\n', None, id="no-name"), pytest.param(b"[project]\nname = 1\n", None, id="name-not-string"), pytest.param(b'[project]\nname = "-bad-"\n', None, id="name-invalid"), pytest.param(b'[project]\nname = "demo"\ndynamic = ["name"]\n', None, id="name-dynamic"), pytest.param(b'[project]\nname = "demo"\ndynamic = "name"\n', None, id="dynamic-not-list"), pytest.param(b"[project\n", None, id="invalid-toml"), pytest.param(b'[project]\nname = "\xff"\n', None, id="not-utf8"), ], ) def test_static_project_name(tmp_path: Path, pyproject: bytes, expected: str | None) -> None: (tmp_path / "pyproject.toml").write_bytes( b'[build-system]\nrequires = ["hatchling"]\nbuild-backend = "hatchling.build"\n' + pyproject ) assert static_project_name(f"{tmp_path}[extra]") == expected def test_static_project_name_without_pyproject(tmp_path: Path) -> None: assert static_project_name(f"{tmp_path}[extra]") is None @pytest.mark.parametrize( ("build_system", "expected"), [ pytest.param('requires = ["hatchling"]\nbuild-backend = "hatchling.build"', "demo", id="hatchling"), pytest.param('requires = ["flit_core>=3.2,<4"]\nbuild-backend = "flit_core.buildapi"', "demo", id="flit-core"), pytest.param('requires = ["pdm-backend"]\nbuild-backend = "pdm.backend"', "demo", id="pdm-backend"), pytest.param('requires = ["uv_build>=0.8"]\nbuild-backend = "uv_build"', "demo", id="uv-build"), pytest.param('requires = ["setuptools"]\nbuild-backend = "setuptools.build_meta"', "demo", id="setuptools"), pytest.param( 'requires = ["setuptools>=40,<70"]\nbuild-backend = "setuptools.build_meta"', "demo", id="setuptools-61-allowed", ), pytest.param( 'requires = ["poetry-core==2.1.*"]\nbuild-backend = "poetry.core.masonry.api"', "demo", id="poetry-core-2" ), pytest.param( 'requires = ["poetry-core>=2.1"]\nbuild-backend = "poetry.core.masonry.api"', "demo", id="poetry-core-above-2", ), pytest.param( 'requires = ["poetry-core>=1,<2"]\nbuild-backend = "poetry.core.masonry.api"', None, id="poetry-core-1" ), pytest.param( 'requires = ["setuptools<61"]\nbuild-backend = "setuptools.build_meta"', None, id="setuptools-before-61" ), pytest.param( 'requires = ["flit_core==3.1.*"]\nbuild-backend = "flit_core.buildapi"', None, id="flit-core-before-3.2" ), pytest.param( 'requires = ["setuptools===custom"]\nbuild-backend = "setuptools.build_meta"', None, id="arbitrary-equality" ), pytest.param('requires = ["wheel"]\nbuild-backend = "setuptools.build_meta"', None, id="backend-not-required"), pytest.param('build-backend = "setuptools.build_meta"', None, id="no-requires"), pytest.param( 'requires = ["setuptools"]\nbuild-backend = "setuptools.build_meta:__legacy__"', None, id="legacy" ), pytest.param('requires = ["maturin"]\nbuild-backend = "maturin"', None, id="unknown-backend"), pytest.param('requires = ["hatchling"]\nbuild-backend = ["hatchling.build"]', None, id="backend-not-string"), pytest.param( 'requires = []\nbuild-backend = "hatchling.build"\nbackend-path = ["."]', None, id="in-tree-backend" ), pytest.param('requires = ["setuptools"]', None, id="no-backend"), ], ) def test_static_project_name_build_backend(tmp_path: Path, build_system: str, expected: str | None) -> None: (tmp_path / "pyproject.toml").write_text( f'[build-system]\n{build_system}\n[project]\nname = "demo"\n', encoding="utf-8" ) assert static_project_name(str(tmp_path)) == expected def test_static_project_name_unreadable_pyproject(tmp_path: Path) -> None: (tmp_path / "pyproject.toml").mkdir() assert static_project_name(str(tmp_path)) is None @pytest.mark.parametrize( ("entries", "expected"), [ pytest.param({"foo_bar_x-1.dist-info/METADATA": "Name: Foo_Bar.X\n"}, "Foo_Bar.X", id="declared"), pytest.param({"foo_bar_x/__init__.py": ""}, None, id="no-dist-info"), pytest.param({"nested/foo_bar_x-1.dist-info/METADATA": "Name: Foo_Bar.X\n"}, None, id="nested-dist-info"), pytest.param( {"foo-1.dist-info/METADATA": "Name: foo\n", "bar-1.dist-info/METADATA": "Name: bar\n"}, None, id="multiple-dist-info", ), pytest.param({"foo_bar_x-1.dist-info/METADATA": "Version: 1\n"}, None, id="no-name"), pytest.param({"foo_bar_x-1.dist-info/METADATA": "Name: -bad-\n"}, None, id="name-invalid"), pytest.param({"foo_bar_x-1.dist-info/METADATA": "Name: f\u00e9\n"}, None, id="name-not-ascii"), ], ) def test_wheel_project_name( make_wheel: Callable[[dict[str, str]], Path], entries: dict[str, str], expected: str | None ) -> None: assert wheel_project_name(f"{make_wheel(entries)}[extra]") == expected def test_wheel_project_name_not_a_zip(tmp_path: Path) -> None: (wheel := tmp_path / "foo-1-py3-none-any.whl").write_bytes(b"not a zip") assert wheel_project_name(str(wheel)) is None def test_wheel_project_name_missing(tmp_path: Path) -> None: assert wheel_project_name(str(tmp_path / "foo-1-py3-none-any.whl")) is None def test_wheel_project_name_ignores_other_archives( make_wheel: Callable[[dict[str, str]], Path], tmp_path: Path ) -> None: archive: Final[Path] = make_wheel({"foo_bar_x-1.dist-info/METADATA": "Name: Foo_Bar.X\n"}).rename( tmp_path / "foo_bar_x-1.zip" ) assert wheel_project_name(str(archive)) is None pipx-1.18.16/tests/test_paths.py000066400000000000000000000075021527355705200165610ustar00rootroot00000000000000from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from platformdirs import user_cache_path, user_log_path from helpers import SKIP_IF_WINDOWS from pipx import paths if TYPE_CHECKING: from collections.abc import Iterator from unittest.mock import MagicMock from pytest_mock import MockerFixture @pytest.mark.parametrize( ("attribute", "override_name"), [ pytest.param("bin_dir", "OVERRIDE_PIPX_BIN_DIR", id="bin"), pytest.param("man_dir", "OVERRIDE_PIPX_MAN_DIR", id="man"), pytest.param("shared_libs", "OVERRIDE_PIPX_SHARED_LIBS", id="shared"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_context_resolves_base_path_once( mocker: MockerFixture, attribute: str, override_name: str, ) -> None: resolve: Final[MagicMock] = mocker.spy(Path, "resolve") paths.CTX.make_local() expected: Final[Path | None] = getattr(paths, override_name) assert expected is not None assert ( getattr(paths.CTX, attribute), getattr(paths.CTX, attribute), sum(call.args[0] == expected for call in resolve.call_args_list), ) == (expected, expected, 1) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_context_preserves_home_symlink(without_pipx_home: pytest.MonkeyPatch, tmp_path: Path) -> None: target: Final[Path] = tmp_path / "target" target.mkdir() home: Final[Path] = tmp_path / "home" home.symlink_to(target, target_is_directory=True) without_pipx_home.setenv("PIPX_HOME", str(home)) paths.CTX.make_local() assert (paths.CTX.home, paths.CTX.venvs) == (home, home / "venvs") @pytest.fixture def without_pipx_home(monkeypatch: pytest.MonkeyPatch) -> Iterator[pytest.MonkeyPatch]: with monkeypatch.context() as scoped: scoped.setattr(paths, "OVERRIDE_PIPX_HOME", None) scoped.delenv("PIPX_HOME", raising=False) yield scoped paths.CTX.make_local() @pytest.mark.usefixtures("pipx_temp_env") def test_context_keeps_cache_and_logs_out_of_fallback_home( without_pipx_home: pytest.MonkeyPatch, tmp_path: Path ) -> None: fallback: Final[Path] = tmp_path / "legacy" fallback.mkdir() without_pipx_home.setattr(paths, "FALLBACK_PIPX_HOMES", [fallback]) paths.CTX.make_local() assert (paths.CTX.home, paths.CTX.venv_cache, paths.CTX.logs, paths.CTX.trash) == ( fallback, user_cache_path("pipx"), user_log_path("pipx"), fallback / ".trash", ) @pytest.mark.usefixtures("pipx_temp_env") def test_context_keeps_cache_and_logs_inside_explicit_home( without_pipx_home: pytest.MonkeyPatch, tmp_path: Path ) -> None: home: Final[Path] = tmp_path / "home" without_pipx_home.setenv("PIPX_HOME", str(home)) paths.CTX.make_local() assert (paths.CTX.venv_cache, paths.CTX.logs) == (home / ".cache", home / "logs") @pytest.mark.usefixtures("pipx_temp_env") def test_context_uses_platform_dirs_without_any_home(without_pipx_home: pytest.MonkeyPatch, tmp_path: Path) -> None: default: Final[Path] = tmp_path / "default" without_pipx_home.setattr(paths, "FALLBACK_PIPX_HOMES", [tmp_path / "absent"]) without_pipx_home.setattr(paths, "DEFAULT_PIPX_HOME", default) paths.CTX.make_local() assert (paths.CTX.home, paths.CTX.venv_cache, paths.CTX.logs) == ( default, user_cache_path("pipx"), user_log_path("pipx"), ) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_context_honors_xdg_cache_home(without_pipx_home: pytest.MonkeyPatch, tmp_path: Path) -> None: cache: Final[Path] = tmp_path / "xdg-cache" without_pipx_home.setattr(paths, "FALLBACK_PIPX_HOMES", [tmp_path / "absent"]) without_pipx_home.setenv("XDG_CACHE_HOME", str(cache)) paths.CTX.make_local() assert paths.CTX.venv_cache == cache / "pipx" pipx-1.18.16/tests/test_pin.py000066400000000000000000000334501527355705200162310ustar00rootroot00000000000000from __future__ import annotations import json from typing import TYPE_CHECKING, Final import pytest from packaging.utils import canonicalize_name from helpers import run_pipx_cli, write_wheel from package_info import PKG from pipx import paths from pipx.pipx_metadata_file import PipxMetadata if TYPE_CHECKING: from pathlib import Path from _pytest.capture import CaptureResult @pytest.mark.usefixtures("pipx_temp_env") def test_pin(caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["pin", "pycowsay"]) assert not run_pipx_cli(["upgrade", "pycowsay"]) assert "Not upgrading pinned package pycowsay" in caplog.text @pytest.mark.usefixtures("pipx_temp_env") def test_pin_survives_main_package_extra_injection(local_extras_wheel: Path) -> None: assert not run_pipx_cli(["install", str(local_extras_wheel)]) assert not run_pipx_cli(["pin", "repeatme"]) assert not run_pipx_cli(["inject", "repeatme", f"{local_extras_wheel}[cow]"]) assert PipxMetadata(paths.CTX.venvs / "repeatme").main_package.pinned @pytest.mark.usefixtures("pipx_temp_env") def test_pin_json(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["pin", "pycowsay", "--output", "json"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["pin"], "data": { "packages": [ { "environment": "pycowsay", "injected": False, "location": str(paths.CTX.venvs / "pycowsay"), "package": "pycowsay", "status": "pinned", "version": "0.0.0.2", } ], "skipped": [], }, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", }, "", ) @pytest.mark.usefixtures("pipx_temp_env") def test_pin_json_reports_missing(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["pin", "missing", "--output", "json"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["pin"], "data": {"packages": [], "skipped": []}, "errors": [ { "code": "package_pin_failed", "environment": "missing", "message": "pipx does not manage package missing", "package": None, } ], "exit_code": 1, "pipx_result_version": "1", "status": "error", }, "", ) @pytest.mark.parametrize( ("options", "expected_output"), [ pytest.param((), "pipx pinned 1 package in venv pycowsay\n - pycowsay 0.0.0.2\n", id="default"), pytest.param(("--quiet",), "", id="quiet"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_pin_output(capsys: pytest.CaptureFixture[str], options: tuple[str, ...], expected_output: str) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["pin", "pycowsay", *options]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err) == (expected_output, "") @pytest.mark.usefixtures("pipx_temp_env") def test_pin_injected_quiet(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", "black"]) capsys.readouterr() assert not run_pipx_cli(["pin", "pycowsay", "--injected-only", "--quiet"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err) == ("", "") @pytest.mark.usefixtures("pinned_injected_environment") def test_pin_json_reports_already_pinned_injected( capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["pin", "pycowsay", "--injected-only", "--output", "json"]) assert json.loads(capsys.readouterr().out)["data"] == { "packages": [], "skipped": [{"environment": "pycowsay", "package": "black", "reason": "already-pinned"}], } @pytest.fixture def pinned_injected_environment( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # required so the temp env is active while the environment is built capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", "black"]) assert not run_pipx_cli(["pin", "pycowsay", "--injected-only"]) capsys.readouterr() @pytest.mark.usefixtures("pinned_injected_environment") def test_pin_json_pins_main_after_injected( capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["pin", "pycowsay", "--output", "json"]) assert json.loads(capsys.readouterr().out)["data"] == { "packages": [ { "environment": "pycowsay", "injected": False, "location": str(paths.CTX.venvs / "pycowsay"), "package": "pycowsay", "status": "pinned", "version": "0.0.0.2", } ], "skipped": [{"environment": "pycowsay", "package": "black", "reason": "already-pinned"}], } @pytest.mark.usefixtures("pipx_temp_env") def test_pin_json_pins_injected_after_main(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["pin", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["pin", "pycowsay", "--output", "json"]) assert json.loads(capsys.readouterr().out)["data"] == { "packages": [ { "environment": "pycowsay", "injected": True, "location": str(paths.CTX.venvs / "pycowsay"), "package": "black", "status": "pinned", "version": "22.8.0", } ], "skipped": [{"environment": "pycowsay", "package": "pycowsay", "reason": "already-pinned"}], } @pytest.mark.usefixtures("pipx_temp_env") def test_pin_with_suffix(caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["install", PKG["black"]["spec"], "--suffix", "@1"]) assert not run_pipx_cli(["pin", "black@1"]) assert not run_pipx_cli(["upgrade", "black@1"]) assert "Not upgrading pinned package black@1" in caplog.text @pytest.mark.usefixtures("pipx_temp_env") def test_pin_warning(caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["install", PKG["nox"]["spec"]]) assert not run_pipx_cli(["pin", "nox"]) assert not run_pipx_cli(["pin", "nox"]) assert "pipx already pins package nox ๐Ÿ˜ด" in caplog.text @pytest.mark.usefixtures("pipx_temp_env") def test_pin_not_installed_package(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["pin", "abc"]) assert "pipx does not manage package abc" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_pin_injected_packages_only( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", "black", PKG["isort"]["spec"]]) assert not run_pipx_cli(["pin", "pycowsay", "--injected-only"]) out: Final[str] = capsys.readouterr().out assert not run_pipx_cli(["upgrade", "pycowsay", "--include-injected"]) assert ( "pipx pinned 2 packages in venv pycowsay" in out, "black" in out, "isort" in out, "Not upgrading pinned package black in venv pycowsay" in caplog.text, "Not upgrading pinned package isort in venv pycowsay" in caplog.text, ) == (True, True, True, True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_pin_injected_packages_only_when_main_package_pinned( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["pin", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", "black", PKG["isort"]["spec"]]) capsys.readouterr() caplog.clear() assert not run_pipx_cli(["pin", "pycowsay", "--injected-only"]) out: Final[str] = capsys.readouterr().out pin_log: Final[str] = caplog.text assert not run_pipx_cli(["upgrade", "pycowsay", "--include-injected"]) assert ( "pipx pinned 2 packages in venv pycowsay" in out, "black" in out, "isort" in out, "pipx already pins package pycowsay" in pin_log, "Not upgrading pinned package pycowsay" in caplog.text, "Not upgrading pinned package black in venv pycowsay" in caplog.text, "Not upgrading pinned package isort in venv pycowsay" in caplog.text, ) == (True, True, True, False, True, True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_pin_injected_packages_with_skip( capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["install", "black"]) assert not run_pipx_cli(["inject", "black", PKG["pbr"]["spec"], PKG["isort"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["pin", "black", "--injected-only", "--skip", "isort"]) out: Final[str] = capsys.readouterr().out assert ("pbr" in out, "isort" in out) == (True, False) _SKIPPED: Final[list[dict[str, str]]] = [ {"environment": "pycowsay@1", "package": "empty-project", "reason": "requested"} ] @pytest.mark.parametrize( ("suffix", "inject_flags", "skip", "pinned", "reported", "skipped"), [ pytest.param("@1", ["--with-suffix"], "empty-project", False, ["black"], _SKIPPED, id="bare"), pytest.param("@1", ["--with-suffix"], "Empty_Project", False, ["black"], _SKIPPED, id="normalized-bare"), pytest.param("@1", ["--with-suffix"], "empty-project@1", False, ["black"], _SKIPPED, id="suffixed"), pytest.param("@1", ["--with-suffix"], "Empty_Project@1", False, ["black"], _SKIPPED, id="normalized-suffixed"), pytest.param( "_X", ["--with-suffix"], "Empty.Project_x", False, ["black"], [{"environment": "pycowsay_X", "package": "empty-project", "reason": "requested"}], id="normalized-suffix", ), pytest.param( "@1", ["--with-suffix"], "empty-project@2", True, ["black", "empty-project@1"], [], id="wrong-suffix" ), pytest.param( "@1", ["--with-suffix"], "missing@1", True, ["black", "empty-project@1"], [], id="unknown-package" ), pytest.param("@1", [], "empty-project", False, ["black"], _SKIPPED, id="unsuffixed-injection"), pytest.param("@1", [], "empty-project@1", True, ["black", "empty-project"], [], id="suffix-not-applied"), pytest.param( "", [], "Empty_Project", False, ["black"], [{"environment": "pycowsay", "package": "empty-project", "reason": "requested"}], id="unsuffixed-environment", ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_pin_skip_spelling( empty_project_wheel: Path, capsys: pytest.CaptureFixture[str], suffix: str, *, inject_flags: list[str], skip: str, pinned: bool, reported: list[str], skipped: list[dict[str, str]], ) -> None: assert not run_pipx_cli(["install", "pycowsay", f"--suffix={suffix}"]) environment: Final[str] = f"pycowsay{suffix}" assert not run_pipx_cli(["inject", environment, str(empty_project_wheel), *inject_flags]) assert not run_pipx_cli(["inject", environment, PKG["black"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["pin", environment, "--skip", skip, "--output", "json"]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / canonicalize_name(environment)) data: Final = json.loads(capsys.readouterr().out)["data"] assert ( metadata.main_package.pinned, {name: package.pinned for name, package in metadata.injected_packages.items()}, [package["package"] for package in data["packages"]], data["skipped"], ) == (False, {"empty-project": pinned, "black": True}, reported, skipped) @pytest.mark.parametrize( "spelling", [ pytest.param("Mixed_Case", id="declared"), pytest.param("mixed-case", id="canonical"), pytest.param("MIXED.CASE", id="other"), pytest.param("MIXED.CASE@1", id="other-suffixed"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_pin_skip_non_canonical_injected_package_with_suffix( capsys: pytest.CaptureFixture[str], tmp_path: Path, spelling: str ) -> None: assert not run_pipx_cli(["install", "pycowsay", "--suffix=@1"]) assert not run_pipx_cli(["inject", "pycowsay@1", str(write_wheel(tmp_path, "Mixed_Case", "1")), "--with-suffix"]) assert not run_pipx_cli(["inject", "pycowsay@1", PKG["black"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["pin", "pycowsay@1", "--skip", spelling, "--output", "json"]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay@1") data: Final = json.loads(capsys.readouterr().out)["data"] assert ( {name: package.pinned for name, package in metadata.injected_packages.items()}, [package["package"] for package in data["packages"]], data["skipped"], ) == ( {"Mixed_Case": False, "black": True}, ["black"], [{"environment": "pycowsay@1", "package": "Mixed_Case", "reason": "requested"}], ) pipx-1.18.16/tests/test_pipx_metadata_file.py000066400000000000000000000325671527355705200212720ustar00rootroot00000000000000from __future__ import annotations import json import operator import os import stat import sys from dataclasses import asdict, replace from pathlib import Path from typing import TYPE_CHECKING, Any, Final import pytest from helpers import ( LEGACY_MAIN_PACKAGE, SKIP_IF_WINDOWS, LegacyMetadata, assert_package_metadata, create_package_info_ref, run_pipx_cli, ) from package_info import PKG from pipx import paths, pipx_metadata_file from pipx.backends._base import UV # ruff:ignore[import-private-name] # no public home from pipx.constants import PIPX_SHARED_PTH from pipx.pipx_metadata_file import PIPX_INFO_FILENAME, JsonEncoderHandlesPath, PackageInfo, PipxMetadata from pipx.redact import redact_url_credentials from pipx.util import PipxError from pipx.venv import Venv if TYPE_CHECKING: from collections.abc import Callable from pytest_mock import MockerFixture _MAIN_PACKAGE: Final[PackageInfo] = PackageInfo( package="test_package", package_or_url="test_package_url", pip_args=[], include_apps=True, include_dependencies=False, apps=["testapp"], app_paths=[Path("/usr/bin")], apps_of_dependencies=["dep1"], app_paths_of_dependencies={"dep1": [Path("bin")]}, man_pages=[str(Path("man1/testapp.1"))], man_pages_of_dependencies=[str(Path("man1/dep1.1"))], man_paths_of_dependencies={"dep1": [Path("man1/dep1.1")]}, package_version="0.1.2", expected_apps=["testapp"], ) _INJECTED_PACKAGE: Final[PackageInfo] = PackageInfo( package="inj_package", package_or_url="inj_package_url", pip_args=["-e"], include_apps=True, include_dependencies=False, apps=["injapp"], app_paths=[Path("/usr/bin")], apps_of_dependencies=["dep2"], app_paths_of_dependencies={"dep2": [Path("bin")]}, man_pages=[str(Path("man1/injapp.1"))], man_pages_of_dependencies=[str(Path("man1/dep2.1"))], man_paths_of_dependencies={"dep2": [Path("man1/dep2.1")]}, package_version="6.7.8", ) @pytest.fixture def venv_dir(tmp_path: Path) -> Path: (venv := tmp_path / "venv").mkdir() return venv @pytest.fixture def payload(venv_dir: Path) -> dict[str, Any]: metadata: Final[PipxMetadata] = PipxMetadata(venv_dir, read=False) metadata.main_package = _MAIN_PACKAGE return metadata.to_dict() @pytest.fixture def read_payload(venv_dir: Path) -> Callable[[dict[str, Any]], PipxMetadata]: def read(payload: dict[str, Any]) -> PipxMetadata: (venv_dir / PIPX_INFO_FILENAME).write_text(json.dumps(payload, cls=JsonEncoderHandlesPath), encoding="utf-8") return PipxMetadata(venv_dir) return read def test_json_encoder_rejects_unsupported_value() -> None: with pytest.raises(TypeError, match=r"complex.*not JSON serializable"): json.dumps(1j, cls=JsonEncoderHandlesPath) def test_pipx_metadata_file_create(venv_dir: Path) -> None: pipx_metadata: Final[PipxMetadata] = PipxMetadata(venv_dir) pipx_metadata.main_package = _MAIN_PACKAGE pipx_metadata.environment = "test-package" pipx_metadata.python_version = "3.4.5" pipx_metadata.source_interpreter = Path(sys.executable) pipx_metadata.venv_args = ["--system-site-packages"] pipx_metadata.injected_packages = {"inj_package": _INJECTED_PACKAGE} pipx_metadata.exposure_enabled = False pipx_metadata.write() recorded: Final = operator.attrgetter( "venv_dir", "environment", "main_package", "python_version", "venv_args", "injected_packages", "exposure_enabled", ) assert recorded(PipxMetadata(venv_dir)) == recorded(pipx_metadata) def test_pipx_metadata_file_defaults_exposure_for_version_0_6( payload: dict[str, Any], read_payload: Callable[[dict[str, Any]], PipxMetadata] ) -> None: payload["pipx_metadata_version"] = "0.6" payload.pop("exposure_enabled") assert read_payload(payload).exposure_enabled is True def test_pipx_metadata_file_defaults_expected_apps_from_version_0_7( payload: dict[str, Any], read_payload: Callable[[dict[str, Any]], PipxMetadata] ) -> None: payload["pipx_metadata_version"] = "0.7" payload["main_package"].pop("expected_apps") assert read_payload(payload).main_package.expected_apps == [] def test_pipx_metadata_file_defaults_lock_file_from_version_0_8( payload: dict[str, Any], read_payload: Callable[[dict[str, Any]], PipxMetadata] ) -> None: payload["pipx_metadata_version"] = "0.8" payload["main_package"].pop("lock_file") assert read_payload(payload).main_package.lock_file is None def test_pipx_metadata_file_defaults_environment_from_version_0_8( payload: dict[str, Any], read_payload: Callable[[dict[str, Any]], PipxMetadata] ) -> None: payload["pipx_metadata_version"] = "0.8" payload.pop("environment") assert read_payload(payload).environment is None def test_pipx_metadata_file_defaults_included_apps_from_version_0_9( payload: dict[str, Any], read_payload: Callable[[dict[str, Any]], PipxMetadata] ) -> None: payload["pipx_metadata_version"] = "0.9" payload["main_package"].pop("include_resources_from") assert read_payload(payload).main_package.include_resources_from == [] def test_pipx_metadata_file_migrates_include_apps_from( payload: dict[str, Any], read_payload: Callable[[dict[str, Any]], PipxMetadata] ) -> None: payload["main_package"].pop("include_resources_from") payload["main_package"]["include_apps_from"] = ["numpy"] assert read_payload(payload).main_package.include_resources_from == ["numpy"] def test_pipx_metadata_file_keys_injected_package_by_name( payload: dict[str, Any], read_payload: Callable[[dict[str, Any]], PipxMetadata] ) -> None: payload["main_package"]["suffix"] = "@1" payload["injected_packages"] = {"inj_package@1": asdict(replace(_INJECTED_PACKAGE, suffix="@1"))} assert list(read_payload(payload).injected_packages) == ["inj_package"] def test_pipx_metadata_file_defaults_cooldown_from_version_0_10( payload: dict[str, Any], read_payload: Callable[[dict[str, Any]], PipxMetadata] ) -> None: payload["pipx_metadata_version"] = "0.10" payload["main_package"].pop("cooldown_days") assert read_payload(payload).main_package.cooldown_days is None def test_pipx_metadata_file_keeps_only_recordable_venv_args( tmp_path: Path, payload: dict[str, Any], read_payload: Callable[[dict[str, Any]], PipxMetadata] ) -> None: payload["venv_args"] = ["--clear", str(tmp_path / "other"), "--system-site-packages"] assert read_payload(payload).venv_args == ["--system-site-packages"] def test_pipx_metadata_file_warns_about_ignored_venv_args( payload: dict[str, Any], read_payload: Callable[[dict[str, Any]], PipxMetadata], caplog: pytest.LogCaptureFixture ) -> None: payload["venv_args"] = ["--clear", "other"] read_payload(payload) assert "venv: ignoring recorded venv arguments --clear other" in caplog.text def test_pipx_metadata_file_reads_unknown_backend_as_pip( payload: dict[str, Any], read_payload: Callable[[dict[str, Any]], PipxMetadata], caplog: pytest.LogCaptureFixture ) -> None: payload["backend"] = "conda" assert ( read_payload(payload).backend, "venv: ignoring unknown recorded backend 'conda'; treating as 'pip'." in caplog.text, ) == ("pip", True) def test_metadata_round_trip_includes_backend(tmp_path: Path) -> None: (venv_dir := tmp_path / "venv").mkdir() metadata: Final = pipx_metadata_file.PipxMetadata(venv_dir, read=False) metadata.main_package = pipx_metadata_file.PackageInfo( package="demo", package_or_url="demo", pip_args=[], include_dependencies=False, include_apps=True, apps=["demo"], app_paths=[Path("demo")], apps_of_dependencies=[], app_paths_of_dependencies={}, package_version="1.0", ) metadata.python_version = "3.12" metadata.backend = "uv" metadata.write() assert ( json.loads((venv_dir / pipx_metadata_file.PIPX_INFO_FILENAME).read_text())["backend"], pipx_metadata_file.PipxMetadata(venv_dir).backend, ) == ("uv", "uv") def test_metadata_version_order_for_shared_libs(tmp_path: Path) -> None: venv_dir: Final[Path] = tmp_path / "venv" (site_packages := venv_dir / "lib/python/site-packages").mkdir(parents=True) (site_packages / PIPX_SHARED_PTH).touch() venv: Final = Venv(venv_dir) venv.pipx_metadata.read_metadata_version = "0.10" venv.pipx_metadata.backend = UV assert venv.uses_shared_libs is False def test_legacy_metadata_defaults_to_pip(tmp_path: Path) -> None: (venv_dir := tmp_path / "venv").mkdir() legacy_payload: Final[LegacyMetadata] = { "main_package": LEGACY_MAIN_PACKAGE, "python_version": "3.12", "source_interpreter": None, "venv_args": [], "injected_packages": {}, "pipx_metadata_version": "0.5", } (venv_dir / pipx_metadata_file.PIPX_INFO_FILENAME).write_text(json.dumps(legacy_payload)) assert pipx_metadata_file.PipxMetadata(venv_dir).backend == "pip" @pytest.mark.parametrize( "test_package", [ pytest.param(replace(_MAIN_PACKAGE, include_apps=False), id="without-apps"), pytest.param(replace(_MAIN_PACKAGE, package=None), id="without-package"), pytest.param(replace(_MAIN_PACKAGE, package_or_url=None), id="without-package-or-url"), ], ) def test_pipx_metadata_file_validation(venv_dir: Path, test_package: PackageInfo) -> None: pipx_metadata: Final[PipxMetadata] = PipxMetadata(venv_dir) pipx_metadata.main_package = test_package with pytest.raises(PipxError, match="PipxMetadata is corrupt"): pipx_metadata.write() def test_write_preserves_existing_metadata_after_os_error( venv_dir: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ) -> None: metadata_path: Final[Path] = venv_dir / PIPX_INFO_FILENAME previous_metadata: Final[str] = '{"existing": true}' metadata_path.write_text(previous_metadata, encoding="utf-8") metadata: Final[PipxMetadata] = PipxMetadata(venv_dir, read=False) metadata.main_package = _MAIN_PACKAGE mocker.patch.object(Path, "replace", side_effect=OSError("disk full")) metadata.write() assert ( metadata_path.read_text(encoding="utf-8"), sorted(path.name for path in venv_dir.iterdir()), f"Unable to write {PIPX_INFO_FILENAME}" in caplog.text, ) == (previous_metadata, [PIPX_INFO_FILENAME], True) @pytest.mark.parametrize( "change", [ pytest.param(lambda path: path.write_bytes(b'{"a": 1}'), id="replaced"), pytest.param(Path.unlink, id="removed"), ], ) def test_preserved_restores_the_record(tmp_path: Path, change: Callable[[Path], int | None]) -> None: (metadata_path := tmp_path / PIPX_INFO_FILENAME).write_bytes(b"{}") with PipxMetadata(tmp_path, read=False).preserved(): change(metadata_path) assert {path.name: path.read_bytes() for path in tmp_path.iterdir()} == {PIPX_INFO_FILENAME: b"{}"} def test_preserved_removes_a_record_the_block_created(tmp_path: Path) -> None: with PipxMetadata(tmp_path, read=False).preserved(): (tmp_path / PIPX_INFO_FILENAME).write_bytes(b"{}") assert list(tmp_path.iterdir()) == [] @SKIP_IF_WINDOWS def test_write_keeps_metadata_owner_only(venv_dir: Path) -> None: metadata: Final[PipxMetadata] = PipxMetadata(venv_dir, read=False) metadata.main_package = _MAIN_PACKAGE previous_umask: Final[int] = os.umask(0o022) try: metadata.write() finally: os.umask(previous_umask) # pip_args and package_or_url keep index credentials as given assert stat.S_IMODE((venv_dir / PIPX_INFO_FILENAME).stat().st_mode) == 0o600 @pytest.mark.usefixtures("pipx_temp_env") def test_package_install() -> None: pipx_venvs_dir: Final[Path] = paths.CTX.home / "venvs" assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert (pipx_venvs_dir / "pycowsay" / "pipx_metadata.json").is_file() pipx_metadata: Final[PipxMetadata] = PipxMetadata(pipx_venvs_dir / "pycowsay") assert_package_metadata(pipx_metadata.main_package, create_package_info_ref("pycowsay", "pycowsay", pipx_venvs_dir)) assert pipx_metadata.injected_packages == {} @pytest.mark.usefixtures("pipx_temp_env") def test_package_inject() -> None: pipx_venvs_dir: Final[Path] = paths.CTX.home / "venvs" assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) assert (pipx_venvs_dir / "pycowsay" / "pipx_metadata.json").is_file() pipx_metadata: Final[PipxMetadata] = PipxMetadata(pipx_venvs_dir / "pycowsay") assert pipx_metadata.injected_packages.keys() == {"black"} black_package_ref: Final[PackageInfo] = create_package_info_ref( "pycowsay", "black", pipx_venvs_dir, include_apps=False ) assert_package_metadata(pipx_metadata.injected_packages["black"], black_package_ref) def test_pipx_metadata_corrupt_write_logs_without_url_credentials( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: index: Final[str] = "--index-url=https://u:s3cr3ttok@host/simple" metadata: Final[PipxMetadata] = PipxMetadata(venv_dir=tmp_path) metadata.main_package = replace(_MAIN_PACKAGE, package=None, pip_args=[index]) with caplog.at_level("DEBUG"), pytest.raises(PipxError, match="PipxMetadata is corrupt"): metadata.write() assert (redact_url_credentials(index) in caplog.text, "s3cr3ttok" in caplog.text) == (True, False) pipx-1.18.16/tests/test_pth_encoding.py000066400000000000000000000012511527355705200200760ustar00rootroot00000000000000from __future__ import annotations import site import sys from typing import TYPE_CHECKING, Final if TYPE_CHECKING: from pathlib import Path def test_pth_file_readable_with_non_ascii_path(tmp_path: Path) -> None: non_ascii_path: Final[Path] = ( tmp_path / ("ร รฉรฎรถรผ" if sys.platform == "win32" else "็”จๆˆท") / "pipx" / "shared" / "site-packages" ) non_ascii_path.mkdir(parents=True) (tmp_path / "pipx_shared.pth").write_text(f"{non_ascii_path}\n") known_paths: Final[set[str]] = set() site.addpackage(str(tmp_path), "pipx_shared.pth", known_paths) assert str(non_ascii_path).casefold() in {path.casefold() for path in known_paths} pipx-1.18.16/tests/test_recorded_pip_args.py000066400000000000000000000341531527355705200211170ustar00rootroot00000000000000from __future__ import annotations import os import re import sys from typing import TYPE_CHECKING, Final import pytest from helpers import SKIP_IF_WINDOWS from pipx.package_specifier import ( absolute_path_pip_args, extract_index_options, recordable_pip_args, replayable_pip_args, trusted_recorded_pip_args, ) from pipx.util import PipxError if TYPE_CHECKING: from pathlib import Path from pytest_mock import MockerFixture _ONE_SHOT_OPTIONS: Final[list[str]] = [ "-r", "--requirement", "--requirements", "--requirements-from-script", "--group", "-t", "--target", "--root", "--prefix", "--src", "--source", "--source-dir", "--source-directory", "--report", "--log", "--log-file", "--local-log", "--cache-dir", ] @pytest.fixture def shared_dir(tmp_path: Path) -> Path: shared: Final[Path] = tmp_path.resolve() / "shared" shared.mkdir() shared.chmod(0o777) return shared @pytest.fixture def sticky_dir(tmp_path: Path) -> Path: sticky: Final[Path] = tmp_path.resolve() / "sticky" sticky.mkdir() sticky.chmod(0o1777) return sticky @pytest.mark.parametrize("option", _ONE_SHOT_OPTIONS) def test_recordable_pip_args_drops_one_shot_option_separated(option: str) -> None: assert recordable_pip_args(["--pre", option, "value", "--no-compile"]) == ["--pre", "--no-compile"] @pytest.mark.parametrize("option", _ONE_SHOT_OPTIONS) def test_recordable_pip_args_drops_one_shot_option_equals(option: str) -> None: assert recordable_pip_args([f"{option}=value", "--no-compile"]) == ["--no-compile"] @pytest.mark.parametrize("option", ["-r", "-t"]) def test_recordable_pip_args_drops_one_shot_option_attached(option: str) -> None: assert recordable_pip_args([f"{option}value", "--no-compile"]) == ["--no-compile"] def test_recordable_pip_args_drops_trailing_one_shot_option() -> None: assert recordable_pip_args(["--no-compile", "--log"]) == ["--no-compile"] @pytest.mark.parametrize( ("pip_args", "expected"), [ pytest.param(["-qr", "/shared/req.txt"], ["-q"], id="clustered-separated"), pytest.param(["-qr/shared/req.txt"], ["-q"], id="clustered-attached"), pytest.param(["-qr=/shared/req.txt"], ["-q"], id="clustered-equals"), pytest.param(["-vvUr", "/shared/req.txt"], ["-vvU"], id="clustered-flags"), pytest.param(["-qt/shared/target"], ["-q"], id="clustered-target"), pytest.param(["--log-f", "/shared/pip.log"], [], id="abbreviated-log-file"), pytest.param(["--loc=/shared/pip.log"], [], id="abbreviated-local-log"), pytest.param(["--rep", "/shared/report.json"], [], id="abbreviated-report"), pytest.param(["--cache-d", "/shared/cache"], [], id="abbreviated-cache-dir"), pytest.param(["--requirem", "/shared/req.txt"], [], id="ambiguous-requirement"), pytest.param(["--requiremen=/shared/req.txt"], [], id="ambiguous-requirement-equals"), pytest.param(["--ro", "/shared/root"], [], id="ambiguous-root"), ], ) def test_recordable_pip_args_drops_one_shot_spelling(pip_args: list[str], expected: list[str]) -> None: assert recordable_pip_args([*pip_args, "--no-compile"]) == [*expected, "--no-compile"] @pytest.mark.parametrize( "kept", [ pytest.param(["-c", "constraints.txt"], id="constraint"), pytest.param(["--build-constraint=constraints.txt"], id="build-constraint"), pytest.param(["-fwheels"], id="find-links-attached"), pytest.param(["--find-links", "wheels"], id="find-links"), pytest.param(["--cert", "ca.pem"], id="cert"), pytest.param(["--client-cert=client.pem"], id="client-cert"), pytest.param(["--index-url", "https://index.example/simple"], id="index-url"), pytest.param(["--index", "https://index.example/simple"], id="uv-index"), pytest.param(["--extra", "dev"], id="uv-extra"), pytest.param(["-vv", "--pre"], id="flag-cluster"), pytest.param(["--no-b", ":all:"], id="ambiguous-prefix"), pytest.param(["--requir", "requirements.txt"], id="ambiguous-prefix-of-a-flag"), pytest.param(["--no-deps=yes"], id="flag-with-value"), pytest.param(["-x"], id="unknown-short"), ], ) def test_recordable_pip_args_keeps_replayable_option(kept: list[str]) -> None: assert recordable_pip_args(kept) == kept @pytest.mark.parametrize( ("pip_args", "expected"), [ pytest.param(["-qf", "/wheels"], ["-q", "-f", "/wheels"], id="clustered-find-links"), pytest.param(["-qc/c.txt"], ["-q", "-c/c.txt"], id="clustered-constraint"), pytest.param(["-f=/wheels"], ["-f", "/wheels"], id="short-equals"), pytest.param(["--find-l", "/wheels"], ["--find-links", "/wheels"], id="abbreviated-find-links"), pytest.param(["--find-l=/wheels"], ["--find-links=/wheels"], id="abbreviated-equals"), pytest.param(["--constr", "/c.txt"], ["--constraint", "/c.txt"], id="abbreviated-constraint"), pytest.param(["--cer", "/ca.pem"], ["--cert", "/ca.pem"], id="abbreviated-cert"), pytest.param(["--no-ind"], ["--no-index"], id="abbreviated-flag"), ], ) def test_recordable_pip_args_spells_out_option(pip_args: list[str], expected: list[str]) -> None: assert recordable_pip_args(pip_args) == expected @pytest.mark.parametrize( ("pip_args", "expected"), [ pytest.param(["-qf", "wheels"], ["-q", "-f", "{base}{sep}wheels"], id="clustered"), pytest.param(["--find-l=wheels"], ["--find-links={base}{sep}wheels"], id="abbreviated"), pytest.param(["-vc", "c.txt"], ["-v", "-c", "{base}{sep}c.txt"], id="clustered-constraint"), pytest.param(["--project", "proj"], ["--project", "{base}{sep}proj"], id="uv-project"), pytest.param(["--directory=work"], ["--directory={base}{sep}work"], id="uv-directory"), ], ) def test_absolute_path_pip_args_resolves_spelling(tmp_path: Path, pip_args: list[str], expected: list[str]) -> None: base: Final[Path] = tmp_path.resolve() assert absolute_path_pip_args(pip_args, base) == [token.format(base=base, sep=os.sep) for token in expected] @pytest.mark.parametrize( ("pip_args", "expected"), [ pytest.param(["-qf", "/wheels", "--pre"], ["-f", "/wheels"], id="clustered-find-links"), pytest.param(["--find-l", "/wheels"], ["--find-links", "/wheels"], id="abbreviated-find-links"), pytest.param(["--index-u", "https://i.example"], ["--index-url", "https://i.example"], id="abbreviated-index"), pytest.param( ["--b", "--index-url", "https://i.example"], ["--index-url", "https://i.example"], id="after-flag" ), ], ) def test_extract_index_options_reads_spelling(pip_args: list[str], expected: list[str]) -> None: assert extract_index_options(pip_args) == expected @pytest.mark.parametrize( ("pip_args", "expected"), [ pytest.param(["-r", "requirements.txt", "--no-compile"], ["--no-compile"], id="separated"), pytest.param(["-qr", "requirements.txt"], ["-q"], id="clustered"), pytest.param(["--requirem", "requirements.txt", "--no-compile"], ["--no-compile"], id="ambiguous-prefix"), ], ) def test_replayable_pip_args_drops_one_shot_option(tmp_path: Path, pip_args: list[str], expected: list[str]) -> None: assert replayable_pip_args(pip_args, tmp_path) == expected @pytest.mark.parametrize("option", ["--cert", "--config-file", "--overrides", "--excludes", "--project", "--directory"]) def test_replayable_pip_args_resolves_relative_path_against_venv(tmp_path: Path, option: str) -> None: assert replayable_pip_args([option, "file"], tmp_path) == [option, str((tmp_path / "file").resolve())] def test_absolute_path_pip_args_keeps_group_name(tmp_path: Path) -> None: assert absolute_path_pip_args(["--group", "dev"], tmp_path) == ["--group", "dev"] @SKIP_IF_WINDOWS def test_replayable_pip_args_keeps_path_others_can_change(shared_dir: Path) -> None: pip_args: Final[list[str]] = ["--find-links", str(shared_dir / "wheels")] assert replayable_pip_args(pip_args, shared_dir) == pip_args @pytest.mark.parametrize( "kept", [ pytest.param(["--find-links", "https://wheels.example/"], id="url"), pytest.param(["--find-links"], id="missing-value"), ], ) def test_replayable_pip_args_keeps_value_without_local_path(tmp_path: Path, kept: list[str]) -> None: assert replayable_pip_args(kept, tmp_path) == kept @SKIP_IF_WINDOWS @pytest.mark.parametrize( ("pip_args", "spelled"), [ pytest.param(["--find-links", "{shared}/wheels"], "--find-links {shared}/wheels", id="separated"), pytest.param(["--find-links={shared}/wheels"], "--find-links={shared}/wheels", id="equals"), pytest.param(["-f{shared}/wheels"], "-f{shared}/wheels", id="attached"), pytest.param(["-qf", "{shared}/wheels"], "-f {shared}/wheels", id="clustered"), pytest.param(["--find-l", "{shared}/wheels"], "--find-links {shared}/wheels", id="abbreviated"), pytest.param(["--constr", "{shared}/c.txt"], "--constraint {shared}/c.txt", id="abbreviated-constraint"), pytest.param(["--cert", "{shared}/ca.pem"], "--cert {shared}/ca.pem", id="cert"), pytest.param(["--con", "{shared}/c.txt"], "--con {shared}/c.txt", id="ambiguous-constraint"), pytest.param(["--b", "-f", "{shared}/wheels"], "-f {shared}/wheels", id="after-ambiguous-flag"), pytest.param(["-b", "{shared}/c.txt"], "-b {shared}/c.txt", id="uv-build-constraints"), pytest.param(["-f", "{venv}/wheels,{shared}/wheels"], "-f {venv}/wheels,{shared}/wheels", id="uv-comma"), pytest.param(["--config-file", "{shared}/uv.toml"], "--config-file {shared}/uv.toml", id="uv-config-file"), pytest.param(["--config-file={shared}/uv.toml"], "--config-file={shared}/uv.toml", id="uv-config-file-equals"), pytest.param(["--overrides", "{shared}/o.txt"], "--overrides {shared}/o.txt", id="uv-overrides"), pytest.param(["--override", "{shared}/o.txt"], "--override {shared}/o.txt", id="uv-override"), pytest.param(["--excludes", "{shared}/x.txt"], "--excludes {shared}/x.txt", id="uv-excludes"), pytest.param(["--exclude", "{shared}/x.txt"], "--exclude {shared}/x.txt", id="uv-exclude"), pytest.param(["--project", "{shared}"], "--project {shared}", id="uv-project"), pytest.param(["--directory={shared}/work"], "--directory={shared}/work", id="uv-directory"), ], ) def test_trusted_recorded_pip_args_refuses_source_others_can_change( shared_dir: Path, tmp_path: Path, pip_args: list[str], spelled: str ) -> None: venv: Final[Path] = tmp_path.resolve() / "venv" expected: Final[str] = ( f"venv: refusing to replay the recorded {spelled}: another account can change {{shared}}, and with it what " "gets installed. Move the file or directory to a place only you and root can change, or pass the options " "again with --pip-args to use them for this run." ) with pytest.raises(PipxError) as error: trusted_recorded_pip_args([argument.format(shared=shared_dir, venv=venv) for argument in pip_args], venv) assert str(error.value) == expected.format(shared=shared_dir, venv=venv) @SKIP_IF_WINDOWS @pytest.mark.parametrize( "pip_args", [ pytest.param(["-f", "{shared_uri}/wheels"], id="find-links-file-url"), pytest.param(["--index-url", "{shared_uri}/simple"], id="index-url-file-url"), pytest.param(["-i{shared}/simple"], id="index-url-path"), pytest.param(["--extra-index-url={shared_uri}/simple"], id="extra-index-url"), pytest.param(["--index", "internal={shared_uri}/simple"], id="uv-named-index"), pytest.param(["--default-index", "{shared}/simple"], id="uv-default-index"), pytest.param(["-i", "simple"], id="relative-index-in-venv"), ], ) def test_trusted_recorded_pip_args_refuses_local_index_others_can_change(shared_dir: Path, pip_args: list[str]) -> None: with pytest.raises(PipxError, match=re.escape(f"another account can change {shared_dir},")): trusted_recorded_pip_args( [argument.format(shared=shared_dir, shared_uri=shared_dir.as_uri()) for argument in pip_args], shared_dir ) @SKIP_IF_WINDOWS @pytest.mark.parametrize( "pip_args", [ pytest.param(["-c", "{sticky}/constraints.txt"], id="missing-path"), pytest.param(["--find-links", "{sticky}"], id="the-directory-itself"), ], ) def test_trusted_recorded_pip_args_refuses_sticky_dir(sticky_dir: Path, tmp_path: Path, pip_args: list[str]) -> None: with pytest.raises(PipxError, match=re.escape(f"another account can change {sticky_dir},")): trusted_recorded_pip_args([argument.format(sticky=sticky_dir) for argument in pip_args], tmp_path) @SKIP_IF_WINDOWS @pytest.mark.skipif(sys.platform != "win32" and os.geteuid() == 0, reason="root owns the files it creates") def test_trusted_recorded_pip_args_refuses_path_owned_by_another_account(tmp_path: Path, mocker: MockerFixture) -> None: certificate: Final[Path] = tmp_path.resolve() / "ca.pem" certificate.write_text("", encoding="utf-8") mocker.patch("os.geteuid", return_value=certificate.stat().st_uid + 1) with pytest.raises(PipxError, match=re.escape(f"another account can change {certificate},")): trusted_recorded_pip_args(["--cert", str(certificate)], tmp_path) @SKIP_IF_WINDOWS def test_trusted_recorded_pip_args_keeps_own_path_in_sticky_dir(sticky_dir: Path, tmp_path: Path) -> None: wheels: Final[Path] = sticky_dir / "wheels" wheels.mkdir(mode=0o755) assert trusted_recorded_pip_args(["--find-links", str(wheels)], tmp_path) == ["--find-links", str(wheels)] @pytest.mark.parametrize( "pip_args", [ pytest.param(["--find-links", "https://wheels.example/"], id="url"), pytest.param(["--index-url", "https://index.example/simple"], id="index-url"), pytest.param(["--find-links"], id="missing-value"), pytest.param(["--log", "{shared}/pip.log"], id="not-a-source"), ], ) def test_trusted_recorded_pip_args_keeps_value_without_shared_source(shared_dir: Path, pip_args: list[str]) -> None: recorded: Final[list[str]] = [argument.format(shared=shared_dir) for argument in pip_args] assert trusted_recorded_pip_args(recorded, shared_dir.parent) == recorded pipx-1.18.16/tests/test_redact.py000066400000000000000000000042451527355705200167050ustar00rootroot00000000000000from __future__ import annotations import pytest from pipx.redact import redact_url_credentials @pytest.mark.parametrize( ("text", "expected"), [ pytest.param( "pip --index-url https://alice:s3cr3t@host/simple", "pip --index-url https://****:****@host/simple", id="user-and-password", ), pytest.param("pip git+https://t0ken@host/repo.git", "pip git+https://****@host/repo.git", id="token"), pytest.param("pip foo @ https://host/a@b.whl", "pip foo @ https://host/a@b.whl", id="no-credentials"), pytest.param("https://host?x=a@b", "https://host?x=a@b", id="at-in-query"), pytest.param("https://host#egg=x@1", "https://host#egg=x@1", id="at-in-fragment"), pytest.param("https://u:p@w@host/x", "https://****:****@host/x", id="unescaped-at-in-password"), pytest.param("https://t0ken:@host/simple", "https://****:****@host/simple", id="token-with-empty-password"), pytest.param( "git+https://t0ken:x-oauth-basic@host/repo.git", "git+https://****:****@host/repo.git", id="token-with-placeholder-password", ), pytest.param("git+ssh://git@github.com/a/b", "git+ssh://****@github.com/a/b", id="bare-user"), pytest.param( "https://alice:correct horse@host/simple", "https://****:****@host/simple", id="space-in-password" ), pytest.param("https://alice:correct\thorse@host/simple", "https://****:****@host/simple", id="tab-in-password"), pytest.param("see https://host\nfetching foo@1.0/x", "see https://host\nfetching foo@1.0/x", id="line-break"), pytest.param( "--index-url https://t0ken@host --trusted-host x:y@z", "--index-url https://****@z", id="token-then-later-colon-and-at", ), pytest.param( "--index-url https://a:b@one/s --extra-index-url https://c@two/s", "--index-url https://****:****@one/s --extra-index-url https://****@two/s", id="two-urls", ), ], ) def test_redact_url_credentials_masks_userinfo(text: str, expected: str) -> None: assert redact_url_credentials(text) == expected pipx-1.18.16/tests/test_reinstall.py000066400000000000000000000673071527355705200174500ustar00rootroot00000000000000from __future__ import annotations import importlib import json import os import shutil import signal import subprocess import sys from dataclasses import replace from typing import TYPE_CHECKING, Final import pytest from helpers import ( PIPX_METADATA_LEGACY_VERSIONS, SKIP_IF_WINDOWS, app_name, mock_legacy_venv, module_version, run_pipx_cli, write_import_probe, write_wheel, ) from package_info import PKG from pipx import paths, trash, util, venv_inspect from pipx.commands import common from pipx.commands import reinstall as reinstall_module from pipx.pipx_metadata_file import PIPX_INFO_FILENAME, PackageInfo, PipxMetadata from pipx.redact import redact_url_credentials if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path from unittest.mock import MagicMock from _pytest.capture import CaptureResult from pytest_mock import MockerFixture @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall() -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_resolves_recorded_relative_find_links_in_venv( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: assert not run_pipx_cli(["install", "--backend", "pip", "pycowsay"]) venv_dir: Final[Path] = paths.CTX.venvs / "pycowsay" metadata: Final[PipxMetadata] = PipxMetadata(venv_dir) metadata.main_package = replace(metadata.main_package, pip_args=["--find-links", "wheels"]) metadata.write() monkeypatch.chdir(tmp_path) assert not run_pipx_cli(["reinstall", "pycowsay"]) assert PipxMetadata(venv_dir).main_package.pip_args == ["--find-links", str((venv_dir / "wheels").resolve())] @SKIP_IF_WINDOWS def test_reinstall_refuses_recorded_find_links_writable_by_others( shared_find_links: Path, capsys: pytest.CaptureFixture[str] ) -> None: capsys.readouterr() assert run_pipx_cli(["reinstall", "pycowsay"]) assert ( f"another account can change {shared_find_links}, and with it what gets installed." in capsys.readouterr().err ) @pytest.fixture def shared_find_links( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing tmp_path: Path, ) -> Path: assert not run_pipx_cli(["install", "--backend", "pip", "pycowsay"]) (shared := tmp_path.resolve() / "shared").mkdir() shared.chmod(0o777) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") metadata.main_package = replace(metadata.main_package, pip_args=["--find-links", str(shared / "wheels")]) metadata.write() return shared @SKIP_IF_WINDOWS @pytest.mark.usefixtures("shared_find_links") def test_reinstall_keeps_environment_of_refused_find_links() -> None: metadata_file: Final[Path] = paths.CTX.venvs / "pycowsay" / PIPX_INFO_FILENAME recorded: Final[bytes] = metadata_file.read_bytes() run_pipx_cli(["reinstall", "pycowsay"]) assert metadata_file.read_bytes() == recorded @pytest.fixture def running_venv( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing run_from_venv: Callable[[Path], None], ) -> Path: assert not run_pipx_cli(["install", "pycowsay"]) venv_dir: Final[Path] = paths.CTX.venvs / "pycowsay" run_from_venv(venv_dir) return venv_dir def test_reinstall_self_imports_submodules_of_the_running_version(running_venv: Path) -> None: assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) write_import_probe(running_venv, "new") assert importlib.import_module("running_probe.late").VALUE == "old" @pytest.mark.skipif(sys.version_info < (3, 15), reason="lazy imports require Python 3.15") def test_reinstall_self_resolves_lazy_imports_from_the_running_version(running_venv: Path) -> None: namespace: Final[dict[str, Callable[[], str]]] = {} exec( # ruff:ignore[exec-builtin] # keep Python 3.15 syntax parseable on older versions "lazy from running_probe import late\ndef value():\n return late.VALUE\n", namespace ) assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) write_import_probe(running_venv, "new") assert namespace["value"]() == "old" def test_reinstall_self_failure_imports_from_the_restored_venv(running_venv: Path, tmp_path: Path) -> None: metadata: Final[PipxMetadata] = PipxMetadata(running_venv) metadata.main_package = replace(metadata.main_package, package_or_url=str(tmp_path / "missing")) metadata.write() assert run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) assert importlib.import_module("running_probe.late").VALUE == "old" @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_inline_script(inline_script: Path) -> None: assert not run_pipx_cli(["install", str(inline_script)]) inline_script.write_text( inline_script.read_text(encoding="utf-8").replace("installed", "reinstalled"), encoding="utf-8", ) assert not run_pipx_cli(["reinstall", "--python", sys.executable, "hello"]) assert ( subprocess.run([paths.CTX.bin_dir / app_name("hello")], check=True, capture_output=True, text=True).stdout == "reinstalled\n" ) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_preserves_cooldowns( package_cache_dir: Path, empty_project_wheel: Path, caplog: pytest.LogCaptureFixture, ) -> None: pip_args: Final[str] = f"--pip-args=--no-index --find-links={package_cache_dir}" assert not run_pipx_cli(["install", "--cooldown", "7", pip_args, "pycowsay"]) assert not run_pipx_cli(["inject", "--cooldown", "5", pip_args, "pycowsay", str(empty_project_wheel)]) caplog.clear() assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") assert ( "--uploaded-prior-to P7D" in caplog.text, "--uploaded-prior-to P5D" in caplog.text, metadata.main_package.cooldown_days, metadata.injected_packages["empty-project"].cooldown_days, ) == (True, True, 7, 5) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_pylock_restores_source_after_build_failure( make_pylock: Callable[[str, str], Path], make_project_with_dependency: Callable[[str], Path], capsys: pytest.CaptureFixture[str], ) -> None: project: Final[Path] = make_project_with_dependency("pycowsay==0.0.0.2") lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert not run_pipx_cli(["install", "--lock", str(lock_file), str(project)]) capsys.readouterr() pyproject: Final[Path] = project / "pyproject.toml" pyproject.write_text( pyproject.read_text(encoding="utf-8").replace("flit_core.buildapi", "missing"), encoding="utf-8", ) assert run_pipx_cli(["reinstall", "empty-project"]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "empty-project").main_package err: Final[str] = capsys.readouterr().err assert ( "Reinstall failed; restored empty-project" in err, "Traceback" in err, metadata.package_version, metadata.lock_file, (paths.CTX.bin_dir / app_name("empty-project")).exists(), ) == (True, False, "0.1.0", lock_file.resolve(), True) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_global() -> None: assert not run_pipx_cli(["install", "--global", "pycowsay"]) assert not run_pipx_cli(["reinstall", "--global", "--python", sys.executable, "pycowsay"]) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_nonexistent(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["reinstall", "--python", sys.executable, "nonexistent"]) assert "Nothing to reinstall for nonexistent" in capsys.readouterr().out @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_legacy_venv(metadata_version: str | None) -> None: assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=metadata_version) assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_legacy_venv_inspects_once_for_resources(mocker: MockerFixture) -> None: assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay") run_subprocess: Final[MagicMock] = mocker.spy(venv_inspect, "run_subprocess") assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) assert ((paths.CTX.bin_dir / app_name("pycowsay")).exists(), run_subprocess.call_count) == (True, 2) def _record_from_newer_pipx(venv_dir: Path) -> None: record: Final[Path] = venv_dir / PIPX_INFO_FILENAME record.write_text( json.dumps(json.loads(record.read_text(encoding="utf-8")) | {"pipx_metadata_version": "99.0"}), encoding="utf-8" ) def _killed_before_recording(venv_dir: Path) -> None: (venv_dir / PIPX_INFO_FILENAME).unlink() (venv_dir / "pipx_unfinished").touch() def _killed_before_installing(venv_dir: Path) -> None: (venv_dir / PIPX_INFO_FILENAME).unlink() for dist_info in venv_dir.rglob("pycowsay-*.dist-info"): shutil.rmtree(dist_info) def _killed_while_creating(venv_dir: Path) -> None: shutil.rmtree(venv_dir) venv_dir.mkdir() (venv_dir / "pyvenv.cfg").touch() @pytest.mark.parametrize( "lose_record", [ pytest.param(_record_from_newer_pipx, id="record-from-newer-pipx"), pytest.param(_killed_before_recording, id="killed-before-recording"), pytest.param(_killed_before_installing, id="killed-before-installing"), pytest.param(_killed_while_creating, id="killed-while-creating"), ], ) @pytest.mark.parametrize( "command", [pytest.param(["reinstall", "pycowsay"], id="reinstall"), pytest.param(["reinstall-all"], id="reinstall-all")], ) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_refuses_to_guess_source_of_unrecorded_venv( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, lose_record: Callable[[Path], None], command: list[str], ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) lose_record(paths.CTX.venvs / "pycowsay") install: Final[MagicMock] = mocker.patch("pipx.commands.reinstall.install", autospec=True) capsys.readouterr() assert run_pipx_cli(command) assert ( "Not reinstalling pycowsay. It has no internal pipx metadata" in capsys.readouterr().err, install.called, (paths.CTX.venvs / "pycowsay").is_dir(), ) == (True, False, True) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_suffix() -> None: assert not run_pipx_cli(["install", "pycowsay", "--suffix=_x"]) assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay_x"]) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_keeps_a_dropped_app_name_taken_over_during_the_rebuild( make_wheel: Callable[[dict[str, str]], Path], before_venv_subprocess: Callable[[Path, Callable[[], None]], None], tmp_path: Path, ) -> None: def release(*apps: str) -> Path: entry_points: Final[str] = "".join(f"{app} = foo_bar_x:main\n" for app in apps) return make_wheel({ "foo_bar_x.py": "def main() -> None:\n pass\n", "foo_bar_x-1.dist-info/METADATA": "Metadata-Version: 2.1\nName: foo-bar-x\nVersion: 1\n", "foo_bar_x-1.dist-info/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", "foo_bar_x-1.dist-info/entry_points.txt": f"[console_scripts]\n{entry_points}", "foo_bar_x-1.dist-info/RECORD": "", }) assert not run_pipx_cli(["install", str(release("tool", "shared"))]) release("tool") shared: Final[Path] = paths.CTX.bin_dir / "shared" (other := tmp_path / "other").touch() def take_over_dangling_name() -> None: if not shared.exists(): shared.unlink() shared.symlink_to(other) before_venv_subprocess(paths.CTX.venvs / "foo-bar-x", take_over_dangling_name) assert not run_pipx_cli(["reinstall", "foo-bar-x"]) assert shared.resolve() == other.resolve() @pytest.mark.parametrize( "symlinks", [pytest.param(True, id="symlink", marks=SKIP_IF_WINDOWS), pytest.param(False, id="copy")] ) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_keeps_a_file_it_did_not_expose( dotted_app_wheel: Path, mocker: MockerFixture, symlinks: bool ) -> None: mocker.patch.object(common, "can_symlink", autospec=True, return_value=symlinks) paths.CTX.bin_dir.mkdir(parents=True, exist_ok=True) (user_file := paths.CTX.bin_dir / app_name("tool3.12")).write_text("mine", encoding="utf-8") assert not run_pipx_cli(["install", str(dotted_app_wheel)]) assert not run_pipx_cli(["reinstall", "foo-bar-x"]) assert (user_file.is_symlink(), user_file.read_text(encoding="utf-8")) == (False, "mine") @pytest.mark.parametrize( "home_depth", [ pytest.param(0, id="short-home"), # past the shebang length limit, installers start launchers with a /bin/sh trampoline that execs the interpreter pytest.param(3, id="long-home", marks=SKIP_IF_WINDOWS), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_refreshes_a_stale_copy_it_exposed( dotted_app_wheel: Path, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, home_depth: int ) -> None: monkeypatch.setattr(paths, "OVERRIDE_PIPX_HOME", paths.CTX.home.joinpath(*["h" * 200] * home_depth)) paths.CTX.make_local() mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) assert not run_pipx_cli(["install", str(dotted_app_wheel)]) exposed: Final[Path] = paths.CTX.bin_dir / app_name("tool3.12") exposed.write_bytes(exposed.read_bytes() + b"# stale\n") assert not run_pipx_cli(["reinstall", "foo-bar-x"]) # Windows launchers embed their build time, so compare with the launcher this reinstall wrote assert exposed.read_bytes() == (util.get_venv_paths(paths.CTX.venvs / "foo-bar-x")[0] / exposed.name).read_bytes() @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_refreshes_its_own_stale_copy_quietly( dotted_app_wheel: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ) -> None: mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) assert not run_pipx_cli(["install", str(dotted_app_wheel)]) exposed: Final[Path] = paths.CTX.bin_dir / app_name("tool3.12") exposed.write_bytes(exposed.read_bytes() + b"# stale\n") caplog.clear() assert not run_pipx_cli(["reinstall", "foo-bar-x"]) assert f"Overwriting file {exposed}" not in caplog.text @pytest.mark.usefixtures("pipx_temp_env") def test_force_install_warns_when_overwriting_a_foreign_copy( dotted_app_wheel: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ) -> None: mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) paths.CTX.bin_dir.mkdir(parents=True) (exposed := paths.CTX.bin_dir / app_name("tool3.12")).write_text("#!/elsewhere/bin/python\n", encoding="utf-8") assert not run_pipx_cli(["install", "--force", str(dotted_app_wheel)]) assert f"Overwriting file {exposed}" in caplog.text @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_suffix_legacy_venv() -> None: assert not run_pipx_cli(["install", "pycowsay", "--suffix=_x"]) mock_legacy_venv("pycowsay_x", metadata_version="0.1") assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay_x"]) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_keeps_injected_package_suffix() -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"], "--suffix=@1"]) assert not run_pipx_cli(["inject", "pycowsay@1", PKG["black"]["spec"], "--with-suffix", "--include-apps"]) assert not run_pipx_cli(["reinstall", "pycowsay@1"]) assert sorted(path.name for path in paths.CTX.bin_dir.iterdir()) == sorted( app_name(app) for app in ("black@1", "blackd@1", "pycowsay@1") ) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_specifier(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "isort==5.6.4"]) capsys.readouterr() assert not run_pipx_cli(["reinstall", "--python", sys.executable, "isort"]) assert "installed package isort 5.6.4" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_preserves_included_dependency(local_extras_wheel: Path) -> None: assert not run_pipx_cli(["install", f"{local_extras_wheel}[tools]", "--include-resources-from", "pycowsay"]) assert not run_pipx_cli(["reinstall", "--python", sys.executable, "repeatme"]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "repeatme").main_package assert ( metadata.include_resources_from, (paths.CTX.bin_dir / app_name("pycowsay")).exists(), (paths.CTX.bin_dir / app_name("black")).exists(), ) == (["pycowsay"], True, False) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_removes_stale_apps_after_success(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() venv_dir: Final[Path] = paths.CTX.venvs / "pycowsay" stale_app_name: Final[str] = app_name("removed-cowsay") (stale_app_path := util.get_venv_paths(venv_dir)[0] / stale_app_name).write_text("#!/bin/sh\n") stale_app_path.chmod(0o755) (stale_exposed_path := paths.CTX.bin_dir / stale_app_name).symlink_to(stale_app_path) metadata: Final[PipxMetadata] = PipxMetadata(venv_dir) metadata.main_package = replace( metadata.main_package, apps=[*metadata.main_package.apps, stale_app_name], app_paths=[*metadata.main_package.app_paths, stale_app_path], ) metadata.write() assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) assert (stale_exposed_path.exists(), stale_exposed_path.is_symlink()) == (False, False) @pytest.mark.parametrize( "spell", [pytest.param(lambda path: path, id="as-given"), pytest.param(lambda path: path.resolve(), id="resolved")] ) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_with_path(capsys: pytest.CaptureFixture[str], tmp_path: Path, spell: Callable[[Path], Path]) -> None: assert run_pipx_cli(["reinstall", str(spell(tmp_path / "some" / "path"))]) assert "Expected the name of an installed package" in capsys.readouterr().err.replace("\n", " ") @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_pinned_package_fails(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "black"]) assert not run_pipx_cli(["pin", "black"]) assert run_pipx_cli(["reinstall", "black"]) assert "pinned" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_unpinned_package(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "black"]) assert not run_pipx_cli(["pin", "black"]) assert not run_pipx_cli(["unpin", "black"]) capsys.readouterr() assert not run_pipx_cli(["reinstall", "black"]) assert "installed package black" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_keeps_injected_pin() -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) assert not run_pipx_cli(["pin", "pycowsay", "--injected-only"]) assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) assert PipxMetadata(paths.CTX.venvs / "pycowsay").injected_packages["black"].pinned @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_quiet_says_nothing(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["reinstall-all", "--python", sys.executable, "--quiet"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err) == ("", "") @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_json_reports_the_environments(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["reinstall-all", "--python", sys.executable, "--output", "json"]) assert json.loads(capsys.readouterr().out) == { "command": ["reinstall-all"], "data": {"environments": [{"environment": "pycowsay"}]}, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", } @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_json_reports_a_missing_environment(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["reinstall", "--python", sys.executable, "--output", "json", "missing"]) assert json.loads(capsys.readouterr().out)["status"] == "error" @pytest.mark.parametrize( ("suffixes", "status", "environments"), [ pytest.param([], "error", [], id="error"), pytest.param(["-other"], "partial", [{"environment": "pycowsay-other"}], id="partial"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_json_reports_returned_errors( capsys: pytest.CaptureFixture[str], suffixes: list[str], status: str, environments: list[dict[str, str]] ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) for suffix in suffixes: assert not run_pipx_cli(["install", "pycowsay", f"--suffix={suffix}"]) python: Final[Path] = util.get_venv_paths(paths.CTX.venvs / "pycowsay")[1] capsys.readouterr() assert run_pipx_cli(["reinstall-all", "--python", str(python), "--output", "json"]) == 1 result: Final = json.loads(capsys.readouterr().out) assert ( result["status"], result["data"]["environments"], [(error["environment"], error["code"]) for error in result["errors"]], "the python executable would be deleted" in result["errors"][0]["message"], python.is_file(), ) == (status, environments, [("pycowsay", "environment_reinstall_failed")], True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_json_reports_no_environments(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["reinstall-all", "--python", sys.executable, "--output", "json"]) assert json.loads(capsys.readouterr().out)["data"] == {"environments": []} @pytest.mark.parametrize( ("argv", "command"), [ pytest.param(["reinstall", "pycowsay"], "reinstall", id="reinstall"), pytest.param(["reinstall-all"], "reinstall-all", id="reinstall-all"), ], ) def test_reinstall_json_with_injected_package_prints_only_the_envelope( installed_with_injection: pytest.CaptureFixture[str], argv: list[str], command: str, ) -> None: assert not run_pipx_cli([*argv, "--python", sys.executable, "--output", "json"]) assert json.loads(installed_with_injection.readouterr().out) == { "command": [command], "data": {"environments": [{"environment": "pycowsay"}]}, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", } @pytest.fixture def installed_with_injection( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # requested for its isolated pipx home capsys: pytest.CaptureFixture[str], ) -> pytest.CaptureFixture[str]: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) capsys.readouterr() return capsys def test_reinstall_reports_the_reinjected_package(installed_with_injection: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) assert "injected package black into venv pycowsay" in installed_with_injection.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_records_injected_local_project_under_its_new_name( empty_project_wheel: Path, local_extras_project: Path ) -> None: assert not run_pipx_cli(["install", "--backend", "pip", str(empty_project_wheel)]) assert not run_pipx_cli(["inject", "--include-apps", "empty-project", str(local_extras_project)]) pyproject: Final[Path] = local_extras_project / "pyproject.toml" pyproject.write_text( pyproject.read_text(encoding="utf-8").replace('name = "repeatme"', 'name = "renamed"') + '[tool.flit.module]\nname = "repeatme"\n', encoding="utf-8", ) assert not run_pipx_cli(["reinstall", "empty-project"]) assert list(PipxMetadata(paths.CTX.venvs / "empty-project").injected_packages) == ["renamed"] @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_keeps_main_package_a_plugin_depends_on(tmp_path: Path) -> None: plugin_index: Final[Path] = tmp_path / "plugin-index" write_wheel(plugin_index, "plugin", "1.0", requires=("tool",)) write_wheel(plugin_index, "tool", "99.0") assert not run_pipx_cli(["install", "--backend", "pip", str(write_wheel(tmp_path / "local", "tool", "1.0"))]) assert not run_pipx_cli(["inject", "tool", "plugin", f"--pip-args=--no-index --find-links={plugin_index}"]) assert not run_pipx_cli(["reinstall", "--python", sys.executable, "tool"]) assert module_version("tool") == "1.0" @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env", "signal_handlers") def test_reinstall_restores_venv_on_sigterm_to_pipx_alone( capsys: pytest.CaptureFixture[str], before_venv_subprocess: Callable[[Path, Callable[[], None]], None] ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() metadata_before: Final[str] = (paths.CTX.venvs / "pycowsay" / PIPX_INFO_FILENAME).read_text() before_venv_subprocess(paths.CTX.venvs / "pycowsay", lambda: os.kill(os.getpid(), signal.SIGTERM)) exit_code: Final[int] = run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) assert (exit_code, (paths.CTX.venvs / "pycowsay" / PIPX_INFO_FILENAME).read_text()) == (1, metadata_before) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env", "signal_handlers") def test_reinstall_finishes_the_restore_through_a_second_signal( mocker: MockerFixture, before_venv_subprocess: Callable[[Path, Callable[[], None]], None] ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) metadata_before: Final[str] = (record := paths.CTX.venvs / "pycowsay" / PIPX_INFO_FILENAME).read_text() before_venv_subprocess(paths.CTX.venvs / "pycowsay", lambda: os.kill(os.getpid(), signal.SIGTERM)) def rmdir_then_interrupt(path: Path, *, safe_rm: bool = True) -> None: trash.rmdir(path, safe_rm=safe_rm) signal.raise_signal(signal.SIGINT) mocker.patch.object(reinstall_module, "rmdir", autospec=True, side_effect=rmdir_then_interrupt) run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) assert record.read_text() == metadata_before @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_injected_package_without_source_hides_url_credentials(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") index: Final[str] = "--index-url=https://u:s3cr3ttok@host/simple" metadata.injected_packages["helper"] = replace( metadata.main_package, package="helper", package_or_url=None, pip_args=[index] ) metadata.write() capsys.readouterr() assert run_pipx_cli(["reinstall", "pycowsay"]) err: Final[str] = " ".join(capsys.readouterr().err.split()) assert (redact_url_credentials(index) in err, "s3cr3ttok" in err) == (True, False) pipx-1.18.16/tests/test_reinstall_all.py000066400000000000000000000332271527355705200202720ustar00rootroot00000000000000from __future__ import annotations import datetime as dt import importlib import json import os import re import signal import sys import threading import time from contextlib import suppress from dataclasses import replace from pathlib import Path from typing import TYPE_CHECKING, Final, NoReturn import pytest from helpers import PIPX_METADATA_LEGACY_VERSIONS, SKIP_IF_WINDOWS, mock_legacy_venv, run_pipx_cli from package_info import PKG from pipx import constants, paths, shared_libs from pipx.pipx_metadata_file import PIPX_INFO_FILENAME, PipxMetadata from pipx.util import PipxError from pipx.venv import Venv if TYPE_CHECKING: from collections.abc import Callable from pytest_mock import MockerFixture @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all() -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["reinstall-all", "--python", sys.executable]) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_keeps_injected_pin() -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) assert not run_pipx_cli(["pin", "pycowsay", "--injected-only"]) assert not run_pipx_cli(["reinstall-all", "--python", sys.executable]) assert PipxMetadata(paths.CTX.venvs / "pycowsay").injected_packages["black"].pinned @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_none(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["reinstall-all"]) assert "No packages reinstalled after running 'pipx reinstall-all'" in capsys.readouterr().out @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_legacy_venv(metadata_version: str | None) -> None: assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=metadata_version) assert not run_pipx_cli(["reinstall-all", "--python", sys.executable]) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_suffix() -> None: assert not run_pipx_cli(["install", "pycowsay", "--suffix=_x"]) assert not run_pipx_cli(["reinstall-all", "--python", sys.executable]) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_suffix_legacy_venv() -> None: assert not run_pipx_cli(["install", "pycowsay", "--suffix=_x"]) mock_legacy_venv("pycowsay_x", metadata_version="0.1") assert not run_pipx_cli(["reinstall-all", "--python", sys.executable]) @pytest.mark.parametrize( ("age_sec", "refreshed"), [ pytest.param(dt.timedelta(days=1).total_seconds(), False, id="recent"), pytest.param(shared_libs.SHARED_LIBS_MAX_AGE_SEC + 60, True, id="stale"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_refreshes_shared_pip_only_when_stale( age_shared_pip: Callable[[float], float], age_sec: float, refreshed: bool ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) last_upgrade: Final[float] = age_shared_pip(age_sec) assert not run_pipx_cli(["reinstall-all"]) assert (shared_libs.SHARED_LIBS.pip_path.stat().st_mtime != last_upgrade) is refreshed @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_restores_package_after_keyboard_interrupt( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() venv_dir: Final[Path] = paths.CTX.venvs / "pycowsay" metadata_before: Final[str] = (venv_dir / PIPX_INFO_FILENAME).read_text() def interrupting_install(replacement_venv_dir: Path, *args: object, **kwargs: object) -> NoReturn: del args, kwargs assert not venv_dir.exists() replacement_venv_dir.mkdir() (replacement_venv_dir / "partial-install").write_text("new") raise KeyboardInterrupt monkeypatch.setattr(importlib.import_module("pipx.commands.reinstall"), "install", interrupting_install) assert run_pipx_cli(["reinstall-all", "--python", sys.executable]) assert ( "Interrupted; restored pycowsay." in capsys.readouterr().err, (venv_dir / PIPX_INFO_FILENAME).read_text(), (venv_dir / "partial-install").exists(), any(path.name.endswith("-pipx-reinstall") for path in paths.CTX.venvs.iterdir()), ) == (True, metadata_before, False, False) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_prints_progress_in_venv_order( capsys: pytest.CaptureFixture[str], before_venv_subprocess: Callable[[Path, Callable[[], None]], None], venv_names: list[str], ) -> None: before_venv_subprocess(paths.CTX.venvs / venv_names[0], lambda: time.sleep(0.3)) assert not run_pipx_cli(["reinstall-all"]) assert re.findall(r"^installing ([\w-]+)", capsys.readouterr().err, re.MULTILINE) == venv_names @pytest.fixture def venv_names(capsys: pytest.CaptureFixture[str]) -> list[str]: for spec in ("pycowsay", PKG["pbr"]["spec"], PKG["black"]["spec"]): assert not run_pipx_cli(["install", spec]) capsys.readouterr() return [venv_dir.name for venv_dir in paths.CTX.venvs.iterdir() if venv_dir.is_dir()] @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_reinstalls_the_rest_after_a_failure( tmp_path: Path, capsys: pytest.CaptureFixture[str], venv_names: list[str] ) -> None: metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / venv_names[0]) metadata.main_package = replace(metadata.main_package, package_or_url=str(tmp_path / "missing")) metadata.write() exit_code: Final[int] = run_pipx_cli(["reinstall-all", "--output", "json"]) result: Final = json.loads(capsys.readouterr().out) assert ( exit_code, [environment["environment"] for environment in result["data"]["environments"]], [error["environment"] for error in result["errors"]], ) == (1, venv_names[1:], venv_names[:1]) @pytest.mark.usefixtures("pipx_temp_env", "venv_names") def test_reinstall_all_refreshes_stale_shared_pip_once( capsys: pytest.CaptureFixture[str], age_shared_pip: Callable[[float], float] ) -> None: age_shared_pip(shared_libs.SHARED_LIBS_MAX_AGE_SEC + 60) assert not run_pipx_cli(["reinstall-all"]) assert capsys.readouterr().err.count("upgrading shared libraries") == 1 @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_restores_interrupted_venv_among_several( before_venv_subprocess: Callable[[Path, Callable[[], None]], None], venv_names: list[str] ) -> None: interrupted: Final[Path] = paths.CTX.venvs / venv_names[0] metadata_before: Final[str] = (interrupted / PIPX_INFO_FILENAME).read_text() before_venv_subprocess(interrupted, _interrupt) assert (run_pipx_cli(["reinstall-all"]), (interrupted / PIPX_INFO_FILENAME).read_text()) == (1, metadata_before) def _interrupt() -> NoReturn: raise KeyboardInterrupt @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_reports_interrupted_venv_without_traceback( capsys: pytest.CaptureFixture[str], before_venv_subprocess: Callable[[Path, Callable[[], None]], None], venv_names: list[str], ) -> None: before_venv_subprocess(paths.CTX.venvs / venv_names[0], _interrupt) run_pipx_cli(["reinstall-all"]) err: Final[str] = capsys.readouterr().err assert (f"Interrupted; restored {venv_names[0]}." in err, "Traceback" in err) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_retries_venv_briefly_held_open(mocker: MockerFixture) -> None: assert not run_pipx_cli(["install", "pycowsay"]) rename: Final = Path.rename held: Final[list[Path]] = [paths.CTX.venvs / "pycowsay"] def rename_once_released(source: Path, target: Path) -> Path: if source in held: held.remove(source) raise PermissionError(source) return rename(source, target) mocker.patch.object(Path, "rename", autospec=True, side_effect=rename_once_released) assert not run_pipx_cli(["reinstall-all"]) @pytest.mark.usefixtures("pipx_temp_env") def test_reinstall_all_keeps_shared_man_page_with_its_owner( before_venv_subprocess: Callable[[Path, Callable[[], None]], None], ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["install", "pycowsay", "--suffix=_x"]) before_venv_subprocess(owner := paths.CTX.venvs / "pycowsay", lambda: time.sleep(0.5)) assert not run_pipx_cli(["reinstall-all"]) assert (paths.CTX.man_dir / "man6" / "pycowsay.6").resolve().is_relative_to(owner) @SKIP_IF_WINDOWS @pytest.mark.parametrize( "failure", [ pytest.param(PipxError("rebuild failed"), id="pipx-error"), pytest.param(OSError("disk full"), id="unexpected"), pytest.param(KeyboardInterrupt(), id="interrupt"), ], ) def test_reinstall_all_failed_rebuild_gets_back_shared_man_page( shared_man_page_venvs: tuple[Path, Path], fail_owner_rebuild_after_takeover: Callable[[Callable[[], None], BaseException], None], failure: BaseException, ) -> None: fail_owner_rebuild_after_takeover(lambda: None, failure) with suppress(OSError): run_pipx_cli(["reinstall-all"]) assert (paths.CTX.man_dir / "man6" / "pycowsay.6").resolve().is_relative_to(shared_man_page_venvs[1]) @pytest.fixture def shared_man_page_venvs(pipx_temp_env: None) -> tuple[Path, Path]: # ruff:ignore[unused-function-argument] # it sets the pipx home assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["install", "pycowsay", "--suffix=_x"]) earlier, owner = (venv_dir.resolve() for venv_dir in paths.CTX.venvs.iterdir() if venv_dir.is_dir()) (paths.CTX.man_dir / "man6" / "pycowsay.6").unlink() (paths.CTX.man_dir / "man6" / "pycowsay.6").symlink_to(owner / "share" / "man" / "man6" / "pycowsay.6") return earlier, owner @pytest.fixture def fail_owner_rebuild_after_takeover( shared_man_page_venvs: tuple[Path, Path], mocker: MockerFixture ) -> Callable[[Callable[[], None], BaseException], None]: earlier, owner = shared_man_page_venvs man_page: Final[Path] = paths.CTX.man_dir / "man6" / "pycowsay.6" symlink_to: Final = Path.symlink_to check_upgrade_shared_libs: Final = Venv.check_upgrade_shared_libs def arm(meanwhile: Callable[[], None], failure: BaseException) -> None: taken_over: Final[threading.Event] = threading.Event() def link_and_signal(path: Path, target: Path, target_is_directory: bool = False) -> None: symlink_to(path, target, target_is_directory) if path == man_page and Path(target).resolve().is_relative_to(earlier): taken_over.set() def fail_once_taken_over(venv: Venv, *, verbose: bool, pip_args: list[str]) -> None: # until the rebuild creates the owner's venv again, its man page dangles and the earlier task takes it if venv.root.name != owner.name or venv.root.exists(): check_upgrade_shared_libs(venv, verbose=verbose, pip_args=pip_args) return assert taken_over.wait(timeout=60) meanwhile() raise failure mocker.patch.object(Path, "symlink_to", autospec=True, side_effect=link_and_signal) mocker.patch.object(Venv, "check_upgrade_shared_libs", autospec=True, side_effect=fail_once_taken_over) return arm @SKIP_IF_WINDOWS def test_reinstall_all_failed_rebuild_keeps_man_page_replaced_elsewhere( fail_owner_rebuild_after_takeover: Callable[[Callable[[], None], BaseException], None], tmp_path: Path ) -> None: man_page: Final[Path] = paths.CTX.man_dir / "man6" / "pycowsay.6" (elsewhere := tmp_path / "pycowsay.6").touch() def replace_link() -> None: man_page.unlink() man_page.symlink_to(elsewhere) fail_owner_rebuild_after_takeover(replace_link, PipxError("rebuild failed")) assert run_pipx_cli(["reinstall-all"]) assert man_page.resolve() == elsewhere.resolve() @SKIP_IF_WINDOWS def test_reinstall_all_failed_rebuild_keeps_shared_man_page_nobody_took( shared_man_page_venvs: tuple[Path, Path], before_venv_subprocess: Callable[[Path, Callable[[], None]], None] ) -> None: def fail() -> NoReturn: msg = "rebuild failed" raise PipxError(msg) before_venv_subprocess(shared_man_page_venvs[1] / "bin", fail) assert run_pipx_cli(["reinstall-all"]) assert (paths.CTX.man_dir / "man6" / "pycowsay.6").resolve().is_relative_to(shared_man_page_venvs[1]) @pytest.mark.skipif(constants.WINDOWS, reason="os.kill with SIGINT terminates the process on Windows") @pytest.mark.usefixtures("pipx_temp_env", "signal_handlers") def test_reinstall_all_restores_running_venvs_on_sigint_to_pipx_alone( before_venv_subprocess: Callable[[Path, Callable[[], None]], None], venv_names: list[str] ) -> None: venv_dirs: Final[list[Path]] = [paths.CTX.venvs / name for name in venv_names] for venv_dir in venv_dirs: (venv_dir / "marker").touch() def interrupt() -> None: # signals this process only, as kill -INT does, so the running pip and venv children carry on os.kill(os.getpid(), signal.SIGINT) def hold() -> None: # until pipx has acted on the signal and restored the first venv, or another one could finish first and rightly # keep its new venv deadline: Final[float] = time.monotonic() + 5 while not (venv_dirs[0] / "marker").exists() and time.monotonic() < deadline: time.sleep(0.01) before_venv_subprocess(venv_dirs[0], interrupt) for venv_dir in venv_dirs[1:]: before_venv_subprocess(venv_dir, hold) exit_code: Final[int] = run_pipx_cli(["reinstall-all"]) assert (exit_code, [(venv_dir / "marker").exists() for venv_dir in venv_dirs]) == (1, [True] * len(venv_dirs)) pipx-1.18.16/tests/test_requires_python.py000066400000000000000000000400221527355705200206740ustar00rootroot00000000000000from __future__ import annotations import json import os import shutil import sys from subprocess import CompletedProcess from typing import TYPE_CHECKING, Final, TypeAlias import pytest from flit_core.wheel import make_wheel_in from packaging.specifiers import SpecifierSet from helpers import run_pipx_cli from pipx import paths, standalone_python from pipx.constants import MINIMUM_PYTHON_VERSION, FetchPythonOptions from pipx.interpreter import InterpreterResolutionError from pipx.pipx_metadata_file import PipxMetadata from pipx.requires_python import interpreter_for, rejected_constraint, unsatisfied_constraint from pipx.util import PipxError if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path from _pytest.capture import CaptureResult from pytest_mock import MockerFixture _FakeInterpreters: TypeAlias = Callable[[dict[str, str], dict[str, str]], None] _REQUIRES_UV: Final[pytest.MarkDecorator] = pytest.mark.skipif( shutil.which("uv") is None, reason="uv binary not on PATH" ) @pytest.mark.parametrize( ("output", "expected"), [ pytest.param( "ERROR: Package 'oldpkg' requires a different Python: 3.14.6 not in '<3.12'", SpecifierSet("<3.12"), id="rejection", ), pytest.param( "ERROR: Package 'x' requires a different Python: 3.14.6 not in '>=3.9,<3.12'", SpecifierSet("<3.12,>=3.9"), id="two-clauses", ), pytest.param( " cause: Because the current Python version (3.11.17) does not satisfy Python>=3.12 and x==1 depends on", SpecifierSet(">=3.12"), id="uv-rejection", ), pytest.param( " cause: Because the current Python version (3.11.17) does not satisfy\n Python>=3.12,<3.16, we", SpecifierSet("<3.16,>=3.12"), id="uv-wrapped-two-clauses", ), pytest.param( " cause: Because the current Python version (3.11.17) does not satisfy all of:\n" " Python>=3.12,<3.15\n Python>=3.16\n and x==0.1 depends on one of:", SpecifierSet("<3.15,>=3.12"), id="uv-union", ), pytest.param( " cause: Because the current Python version (3.11.17) does not satisfy all of:\n" " Python>=3.12,<3.13\n Python>=3.14\n and x==0.1 depends on one of:", SpecifierSet(">=3.14"), id="uv-union-newest-range", ), pytest.param( " cause: Because the current Python version (3.11.17) does not satisfy all of:\n" " Python>=3.20,<3.21\n Python>=3.22\n", SpecifierSet("<3.21,>=3.20"), id="uv-union-beyond-pipx", ), pytest.param("ERROR: No matching distribution found for oldpkg", None, id="other-error"), pytest.param("", None, id="empty"), ], ) def test_rejected_constraint_reads_the_backend_complaint(output: str, expected: SpecifierSet | None) -> None: assert rejected_constraint(output) == expected @pytest.mark.parametrize( ("requires_python", "python_version", "expected"), [ pytest.param("<3.12", "3.14.6", SpecifierSet("<3.12"), id="violated"), pytest.param("<3.12", "3.11.9", None, id="satisfied"), pytest.param(None, "3.14.6", None, id="undeclared"), pytest.param("", "3.14.6", None, id="empty"), pytest.param("not-a-specifier", "3.14.6", None, id="unreadable"), ], ) def test_unsatisfied_constraint_reports_only_a_mismatch( requires_python: str | None, python_version: str, expected: SpecifierSet | None, ) -> None: assert unsatisfied_constraint(requires_python, python_version) == expected def test_interpreter_for_takes_the_newest_installed_match( fake_interpreters: _FakeInterpreters, ) -> None: fake_interpreters({"3.11": "3.11.9", "3.10": "3.10.14"}, {}) assert interpreter_for(SpecifierSet("<3.12"), FetchPythonOptions.NEVER) == "/py/3.11.9" @pytest.fixture def fake_interpreters(mocker: MockerFixture) -> _FakeInterpreters: # minor X.Y resolves to the installed full version; a missing minor downloads the fetched one when allowed def install(installed: dict[str, str], fetched: dict[str, str]) -> None: def resolve(minor: str, fetch: FetchPythonOptions) -> str: if minor in installed: return f"/py/{installed[minor]}" if fetch is FetchPythonOptions.MISSING and minor in fetched: return f"/downloaded/{fetched[minor]}" raise InterpreterResolutionError(source="the system", version=minor) mocker.patch("pipx.requires_python.find_python_interpreter", side_effect=resolve) mocker.patch( "pipx.requires_python.run_subprocess", side_effect=lambda cmd, **_: CompletedProcess(cmd, 0, stdout=f"{cmd[0].rsplit('/', 1)[1]}\n"), ) return install def test_interpreter_for_skips_a_version_the_system_lacks( fake_interpreters: _FakeInterpreters, ) -> None: fake_interpreters({"3.11": "3.11.9"}, {}) assert interpreter_for(SpecifierSet("<3.13"), FetchPythonOptions.NEVER) == "/py/3.11.9" def test_interpreter_for_accepts_a_patch_above_a_lower_bound( fake_interpreters: _FakeInterpreters, ) -> None: fake_interpreters({"3.13": "3.13.7"}, {}) assert interpreter_for(SpecifierSet(">=3.13.5,<3.14"), FetchPythonOptions.NEVER) == "/py/3.13.7" def test_interpreter_for_rejects_a_patch_above_an_upper_bound( fake_interpreters: _FakeInterpreters, ) -> None: fake_interpreters({"3.14": "3.14.6", "3.13": "3.13.7"}, {}) assert interpreter_for(SpecifierSet("<3.14.1"), FetchPythonOptions.NEVER) == "/py/3.13.7" def test_interpreter_for_rejects_every_patch_below_a_lower_bound( fake_interpreters: _FakeInterpreters, ) -> None: fake_interpreters({"3.13": "3.13.2"}, {}) with pytest.raises(PipxError, match="--fetch-python=missing"): interpreter_for(SpecifierSet(">=3.13.5,<3.14"), FetchPythonOptions.NEVER) def test_interpreter_for_fetches_when_the_caller_allows_it( fake_interpreters: _FakeInterpreters, ) -> None: fake_interpreters({}, {"3.11": "3.11.9"}) assert interpreter_for(SpecifierSet("==3.11.*"), FetchPythonOptions.MISSING) == "/downloaded/3.11.9" def test_interpreter_for_reports_a_system_without_a_match( fake_interpreters: _FakeInterpreters, ) -> None: fake_interpreters({}, {}) with pytest.raises(PipxError, match="--fetch-python=missing"): interpreter_for(SpecifierSet("==3.11.*"), FetchPythonOptions.NEVER) def test_interpreter_for_reports_a_constraint_no_release_meets() -> None: with pytest.raises(PipxError, match="No Python that pipx supports satisfies"): interpreter_for(SpecifierSet("<3.5"), FetchPythonOptions.NEVER) @pytest.mark.parametrize( "backend", [ pytest.param("pip", id="pip"), pytest.param("uv", id="uv", marks=_REQUIRES_UV), ], ) @pytest.mark.usefixtures("pipx_temp_env", "older_python", "capsys") def test_install_moves_to_a_python_the_package_supports(wheel_excluding_this_python: Path, backend: str) -> None: assert not run_pipx_cli(["install", "--backend", backend, str(wheel_excluding_this_python)]) recorded: Final[str] = PipxMetadata(paths.CTX.venvs / "needs-older").python_version or "" assert recorded != f"Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" @pytest.fixture def older_python(pipx_temp_env: None, monkeypatch: pytest.MonkeyPatch) -> str: # pipx_temp_env strips the system directories out of PATH, and pipx looks the interpreters up there del pipx_temp_env monkeypatch.setenv("PATH", os.environ["PATH_ORIG"]) for minor in range(sys.version_info[1] - 1, int(MINIMUM_PYTHON_VERSION.split(".")[1]) - 1, -1): if (found := shutil.which(f"python3.{minor}")) is not None: return found pytest.skip("no supported Python older than the one running the tests") @pytest.fixture def wheel_excluding_this_python(project_excluding_this_python: Path, tmp_path: Path) -> Path: # installing the source tree builds it in an isolated environment on each Python pipx tries, which takes long enough # on the free-threaded CI runner to hit the timeout return make_wheel_in(project_excluding_this_python / "pyproject.toml", tmp_path).file @pytest.fixture def project_excluding_this_python(make_needs_project: Callable[[str], Path]) -> Path: return make_needs_project(f"<{sys.version_info.major}.{sys.version_info.minor}") @pytest.fixture def make_needs_project(tmp_path: Path) -> Callable[[str], Path]: def create(requires_python: str) -> Path: project: Final[Path] = tmp_path / "needs-older" (project / "needs_older").mkdir(parents=True, exist_ok=True) (project / "needs_older" / "__init__.py").touch() (project / "needs_older" / "main.py").write_text("def main() -> None:\n print('needs-older')\n") (project / "pyproject.toml").write_text( '[build-system]\nrequires = ["flit-core"]\nbuild-backend = "flit_core.buildapi"\n\n' '[project]\nname = "needs-older"\nversion = "0.1"\ndescription = "Need another Python."\n' f'requires-python = "{requires_python}"\n' 'scripts.needs-older = "needs_older.main:main"\n' ) return project return create @pytest.mark.usefixtures("older_python", "capsys") def test_install_keeps_the_default_python_for_the_other_packages(project_excluding_this_python: Path) -> None: # a URL carries no name, so pipx meets the constraint while it resolves the names, before it builds any environment assert not run_pipx_cli(["install", "--backend", "pip", project_excluding_this_python.as_uri(), "pycowsay"]) recorded: Final[str] = PipxMetadata(paths.CTX.venvs / "pycowsay").python_version or "" assert recorded == f"Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" @_REQUIRES_UV @pytest.mark.parametrize( ("recorded", "other", "backend_args"), [ pytest.param("pip", "uv", [], id="pip-default-backend"), pytest.param("pip", "uv", ["--backend", "uv"], id="pip-ignored-flag"), pytest.param("uv", "pip", [], id="uv-default-backend"), pytest.param("uv", "pip", ["--backend", "pip"], id="uv-ignored-flag"), ], ) @pytest.mark.usefixtures("older_python", "capsys") def test_install_force_moving_to_a_supported_python_keeps_the_recorded_backend( make_needs_project: Callable[[str], Path], monkeypatch: pytest.MonkeyPatch, recorded: str, other: str, backend_args: list[str], ) -> None: this_python: Final[str] = f"{sys.version_info.major}.{sys.version_info.minor}" assert not run_pipx_cli(["install", "--backend", recorded, str(make_needs_project(f">={this_python}"))]) monkeypatch.setenv("PIPX_DEFAULT_BACKEND", other) assert not run_pipx_cli(["install", "--force", *backend_args, str(make_needs_project(f"<{this_python}"))]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "needs-older") assert (metadata.backend, (metadata.python_version or "").startswith(f"Python {this_python}.")) == (recorded, False) @_REQUIRES_UV def test_install_uv_moves_off_a_default_python_below_the_lower_bound( make_needs_project: Callable[[str], Path], older_python: str, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("PIPX_DEFAULT_PYTHON", older_python) project: Final[Path] = make_needs_project(f">={sys.version_info.major}.{sys.version_info.minor}") assert not run_pipx_cli(["install", "--backend", "uv", str(project)]) recorded: Final[str] = PipxMetadata(paths.CTX.venvs / "needs-older").python_version or "" assert not recorded.startswith(f"Python {older_python.rsplit('python', 1)[1]}.") @pytest.mark.parametrize("backend", [pytest.param("pip", id="pip"), pytest.param("uv", id="uv", marks=_REQUIRES_UV)]) def test_install_json_reports_the_python_it_moved_to_on_stderr( make_needs_project: Callable[[str], Path], older_python: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], backend: str, ) -> None: monkeypatch.setenv("PIPX_DEFAULT_PYTHON", older_python) project: Final[Path] = make_needs_project(f">={sys.version_info.major}.{sys.version_info.minor}") assert not run_pipx_cli(["install", "--output", "json", "--backend", backend, str(project)]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out)["status"], "This package needs Python >=" in captured.err) == ("success", True) @pytest.mark.usefixtures("older_python") def test_install_moving_to_a_supported_python_reports_no_pip_error( project_excluding_this_python: Path, capsys: pytest.CaptureFixture[str] ) -> None: assert not run_pipx_cli(["install", "--backend", "pip", str(project_excluding_this_python)]) assert "Fatal error from pip" not in capsys.readouterr().err @pytest.mark.parametrize("backend", [pytest.param("pip", id="pip"), pytest.param("uv", id="uv", marks=_REQUIRES_UV)]) @pytest.mark.usefixtures("pipx_temp_env") def test_install_refuses_a_named_python_the_package_rules_out( project_excluding_this_python: Path, backend: str, capsys: pytest.CaptureFixture[str], ) -> None: run_pipx_cli(["install", "--backend", backend, "--python", sys.executable, str(project_excluding_this_python)]) assert "The Python you named does not satisfy" in capsys.readouterr().err @pytest.mark.parametrize( ("working", "broken"), [ pytest.param('version = "0.1"\n', 'version = "0.2"\ndependencies = ["unobtainable-package"]\n', id="build"), pytest.param('scripts.needs-older = "needs_older.main:main"\n', "", id="post-install"), ], ) @pytest.mark.usefixtures("older_python", "capsys") def test_install_force_failing_on_a_supported_python_keeps_the_environment( make_needs_project: Callable[[str], Path], working: str, broken: str ) -> None: project: Final[Path] = make_needs_project(f">={MINIMUM_PYTHON_VERSION}") assert not run_pipx_cli(["install", "--backend", "pip", str(project)]) venv_dir: Final[Path] = paths.CTX.venvs / "needs-older" recorded: Final[str] = (metadata_file := venv_dir / "pipx_metadata.json").read_text() (marker := venv_dir / "marker").touch() pyproject: Final[Path] = project / "pyproject.toml" pyproject.write_text( pyproject .read_text() .replace(f">={MINIMUM_PYTHON_VERSION}", f"<{sys.version_info.major}.{sys.version_info.minor}") .replace(working, broken) ) assert run_pipx_cli(["install", "--force", str(project)]) assert (marker.exists(), metadata_file.read_text()) == (True, recorded) @pytest.mark.parametrize( "backend", [ pytest.param("pip", id="pip"), pytest.param("uv", id="uv", marks=_REQUIRES_UV), ], ) @pytest.mark.usefixtures("older_python", "mocked_github_api", "capsys") def test_interpreter_upgrade_keeps_the_standalone_python_the_package_rules_out( make_needs_project: Callable[[str], Path], standalone_python_index: Callable[[str], dict[str, object]], monkeypatch: pytest.MonkeyPatch, backend: str, ) -> None: this_minor: Final[str] = f"{sys.version_info.major}.{sys.version_info.minor}" project: Final[Path] = make_needs_project(f">={MINIMUM_PYTHON_VERSION}") assert not run_pipx_cli([ "install", "--backend", backend, "--python", this_minor, "--fetch-python=always", str(project), ]) standalone: Final[Path | None] = PipxMetadata(paths.CTX.venvs / "needs-older").source_interpreter pyproject: Final[Path] = project / "pyproject.toml" pyproject.write_text(pyproject.read_text().replace(f">={MINIMUM_PYTHON_VERSION}", f"<{this_minor}")) newer_index: Final[dict[str, object]] = standalone_python_index(f"{this_minor}.{sys.version_info.micro + 1}") monkeypatch.setattr(standalone_python, "get_or_update_index", lambda **_: newer_index) assert ( run_pipx_cli(["interpreter", "upgrade"]), PipxMetadata(paths.CTX.venvs / "needs-older").source_interpreter, ) == ( 1, standalone, ) pipx-1.18.16/tests/test_reset.py000066400000000000000000000146721527355705200165720ustar00rootroot00000000000000from __future__ import annotations import json from io import StringIO from typing import TYPE_CHECKING, Final import pytest from helpers import app_name, run_pipx_cli from pipx import paths if TYPE_CHECKING: from pathlib import Path from _pytest.capture import CaptureResult from pytest_mock import MockerFixture @pytest.mark.usefixtures("installed_pycowsay") def test_reset_removes_the_installed_packages( capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["reset", "--yes"]) capsys.readouterr() assert not run_pipx_cli(["list", "--short"]) assert not capsys.readouterr().out @pytest.fixture def installed_pycowsay(installed_pycowsay: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: # reset deletes the shared libraries; point it at a stand-in so the worker's session copy, which every later test # reuses, does not have to be rebuilt (shared_libs := tmp_path / "reset-shared-libs").mkdir() monkeypatch.setattr(paths, "OVERRIDE_PIPX_SHARED_LIBS", shared_libs) paths.CTX.make_local() return installed_pycowsay @pytest.mark.usefixtures("installed_pycowsay") def test_reset_unlinks_the_apps() -> None: app: Final[Path] = paths.CTX.bin_dir / app_name("pycowsay") assert app.exists() or app.is_symlink() assert not run_pipx_cli(["reset", "--yes"]) assert (app.exists(), app.is_symlink()) == (False, False) @pytest.mark.parametrize( "location", [ pytest.param("venvs", id="venvs"), pytest.param("venv_cache", id="cache"), pytest.param("standalone_python_cachedir", id="interpreters"), ], ) @pytest.mark.usefixtures("installed_pycowsay") def test_reset_removes_the_pipx_state(location: str) -> None: target: Final[Path] = getattr(paths.CTX, location) target.mkdir(parents=True, exist_ok=True) assert not run_pipx_cli(["reset", "--yes"]) assert not target.is_dir() @pytest.mark.usefixtures("installed_pycowsay") def test_reset_keeps_the_log_it_writes() -> None: stale: Final[Path] = paths.CTX.logs / "cmd_stale.log" stale.parent.mkdir(parents=True, exist_ok=True) stale.touch() assert not run_pipx_cli(["reset", "--yes"]) assert list(paths.CTX.logs.iterdir()) == [paths.CTX.log_file] @pytest.mark.usefixtures("installed_pycowsay") def test_reset_dry_run_lists_what_it_would_remove( capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["reset", "--dry-run"]) reported: Final[str] = capsys.readouterr().out assert ( f"Would remove {paths.CTX.venvs}" in reported, f"Would remove {paths.CTX.bin_dir / app_name('pycowsay')}" in reported, ) == (True, True) @pytest.mark.usefixtures("installed_pycowsay") def test_reset_dry_run_keeps_everything( capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["reset", "--dry-run"]) capsys.readouterr() assert not run_pipx_cli(["list", "--short"]) assert capsys.readouterr().out == "pycowsay 0.0.0.2\n" @pytest.mark.usefixtures("installed_pycowsay") def test_reset_json_reports_what_it_removed( capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["reset", "--yes", "--output", "json"]) assert json.loads(capsys.readouterr().out) == { "command": ["reset"], "data": { "packages": ["pycowsay"], "removed": [ str(paths.CTX.venvs), str(paths.CTX.shared_libs), str(paths.CTX.venv_cache), str(paths.CTX.standalone_python_cachedir), str(paths.CTX.logs), ], }, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", } @pytest.mark.usefixtures("installed_pycowsay") def test_reset_without_a_terminal_demands_force( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, ) -> None: mocker.patch("pipx.main.sys.stdin.isatty", return_value=False) assert run_pipx_cli(["reset"]) assert "Pass --yes to reset" in capsys.readouterr().err @pytest.mark.parametrize( ("answer", "resets"), [ pytest.param("y", True, id="y"), pytest.param("Yes", True, id="yes"), pytest.param("", False, id="empty"), pytest.param("n", False, id="n"), ], ) @pytest.mark.usefixtures("installed_pycowsay") def test_reset_asks_before_it_removes( mocker: MockerFixture, answer: str, resets: bool, ) -> None: mocker.patch("pipx.main.sys.stdin.isatty", return_value=True) mocker.patch("pipx.main.input", return_value=answer) assert (bool(run_pipx_cli(["reset"])), paths.CTX.venvs.is_dir()) == (not resets, not resets) @pytest.mark.usefixtures("installed_pycowsay", "interactive_stdin") def test_reset_eof_cancels( capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["reset"]) == 1 assert ("Reset cancelled." in capsys.readouterr().err, paths.CTX.venvs.is_dir()) == (True, True) @pytest.fixture def interactive_stdin(mocker: MockerFixture) -> StringIO: stream: Final[StringIO] = StringIO() mocker.patch("pipx.main.sys.stdin", new=stream) mocker.patch.object(stream, "isatty", return_value=True) return stream @pytest.mark.usefixtures("installed_pycowsay", "interactive_stdin") def test_reset_eof_reports_json_error( capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["reset", "--output", "json"]) == 1 assert (json.loads(capsys.readouterr().out), paths.CTX.venvs.is_dir()) == ( { "command": ["reset"], "data": {}, "errors": [ { "code": "pipx_error", "environment": None, "message": "Reset cancelled.", "package": None, } ], "exit_code": 1, "pipx_result_version": "1", "status": "error", }, True, ) @pytest.mark.usefixtures("installed_pycowsay") def test_reset_confirmed_json_keeps_prompt_off_stdout( capsys: pytest.CaptureFixture[str], interactive_stdin: StringIO, ) -> None: interactive_stdin.write("y\n") interactive_stdin.seek(0) assert run_pipx_cli(["reset", "--output", "json"]) == 0 captured: Final[CaptureResult[str]] = capsys.readouterr() assert ( json.loads(captured.out)["status"], "Remove every pipx-installed package" in captured.err, paths.CTX.venvs.is_dir(), ) == ("success", True, False) pipx-1.18.16/tests/test_result_contract.py000066400000000000000000000047541527355705200206630ustar00rootroot00000000000000from __future__ import annotations import json from typing import TYPE_CHECKING, Final, TypeAlias, TypedDict import pytest from helpers import run_pipx_cli if TYPE_CHECKING: from pytest_mock import MockerFixture _JsonValue: TypeAlias = bool | int | float | str | list["_JsonValue"] | dict[str, "_JsonValue"] | None class _Error(TypedDict): code: str message: str environment: str | None package: str | None class _Envelope(TypedDict): pipx_result_version: str command: list[str] status: str exit_code: int data: dict[str, _JsonValue] errors: list[_Error] @pytest.mark.usefixtures("pipx_temp_env") def test_contract_success_envelope(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "--output", "json", "pycowsay"]) document: Final[_Envelope] = _envelope(capsys.readouterr().out) assert (document["command"], document["status"], document["exit_code"], document["errors"]) == ( ["install"], "success", 0, [], ) def _envelope(captured: str) -> _Envelope: document: Final[_Envelope] = json.loads(captured) assert (set(document), document["pipx_result_version"], isinstance(document["command"], list)) == ( set(_Envelope.__annotations__), "1", True, ) return document @pytest.mark.usefixtures("pipx_temp_env") def test_contract_error_envelope_names_the_failure( capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["install", "--output", "json", "--app", "missing", "pycowsay"]) document: Final[_Envelope] = _envelope(capsys.readouterr().out) assert (document["status"], document["exit_code"], set(document["errors"][0]), document["errors"][0]["code"]) == ( "error", 1, set(_Error.__annotations__), "package_install_failed", ) @pytest.mark.usefixtures("pipx_temp_env") def test_contract_dispatch_error_uses_the_envelope( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, ) -> None: # a PipxError raised before a command returns a result must still speak JSON when the user selected --output json mocker.patch("pipx.main.sys.stdin.isatty", return_value=False) assert run_pipx_cli(["reset", "--output", "json"]) document: Final[_Envelope] = _envelope(capsys.readouterr().out) assert (document["status"], document["exit_code"], document["command"], document["errors"][0]["code"]) == ( "error", 1, ["reset"], "pipx_error", ) pipx-1.18.16/tests/test_run.py000066400000000000000000002110231527355705200162410ustar00rootroot00000000000000from __future__ import annotations import datetime as dt import logging import os import shutil import subprocess import sys import textwrap import zipfile from contextlib import contextmanager from functools import partial from http.server import BaseHTTPRequestHandler, SimpleHTTPRequestHandler from pathlib import Path from socketserver import ThreadingTCPServer from threading import Thread from typing import TYPE_CHECKING, Final, NoReturn import pytest from filelock import AcquireReturnProxy, FileLock, Timeout from flit_core.wheel import make_wheel_in if TYPE_CHECKING: from collections.abc import Callable, Generator, Iterator, Mapping, Sequence from unittest.mock import MagicMock from pytest_mock import MockerFixture import pipx.main from helpers import PACKAGE_CACHE_DIR_NAME, WIN, pipx_subprocess_env, run_pipx_cli, write_wheel from package_info import PKG from pipx import paths, shared_libs, subprocess_runner from pipx.commands.run import maybe_script_content, run_package from pipx.constants import FetchPythonOptions from pipx.options import DEFAULT_BACKEND_OPTIONS, CooldownOptions, RunOptions from pipx.pipx_metadata_file import PipxMetadata from pipx.venv import VenvContainer @pytest.mark.usefixtures("pipx_temp_env") def test_help_text(capsys: pytest.CaptureFixture[str], mocker: MockerFixture) -> None: mocker.patch.object(sys, "exit", autospec=True, side_effect=ValueError("raised in test to exit early")) with pytest.raises(ValueError, match="raised in test to exit early"): run_pipx_cli(["run", "--help"]) assert "Download the latest version of a package" in capsys.readouterr().out def execvpe_via_subprocess(_cmd_path: str, cmd_args: Sequence[str], env: Mapping[str, str]) -> None: sys.exit( subprocess.run( [str(arg) for arg in cmd_args], env=env, capture_output=False, encoding="utf-8", text=True, check=False, ).returncode ) @pytest.fixture def exec_via_subprocess(mocker: MockerFixture) -> None: mocker.patch("os.execvpe", autospec=True, side_effect=execvpe_via_subprocess) def run_pipx_cli_exit(pipx_cmd_list: list[str], assert_exit: int | None = None) -> None: with pytest.raises(SystemExit) as sys_exit: run_pipx_cli(pipx_cmd_list) if assert_exit is not None: assert sys_exit.value.code == assert_exit @pytest.mark.parametrize( "package_name", [ pytest.param("pycowsay", id="name"), pytest.param("pycowsay==0.0.0.2", id="pinned"), pytest.param("pycowsay>=0.0.0.2", id="lower-bound"), ], ) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_simple_run(capsys: pytest.CaptureFixture[str], package_name: str) -> None: run_pipx_cli_exit(["run", package_name, "--help"]) assert "Download the latest version of a package" not in capsys.readouterr().out @pytest.mark.parametrize("backend", [pytest.param("pip", id="pip"), pytest.param("uv", id="uv")]) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_normalizes_inferred_package_name(monkeypatch: pytest.MonkeyPatch, backend: str) -> None: monkeypatch.setenv("PATH", f"{os.environ['PATH']}{os.pathsep}{os.defpath}") run_pipx_cli_exit(["run", "--backend", backend, "bLaCk", "--version"], assert_exit=0) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_normalized_name_skips_uv_tool_run(caplog: pytest.LogCaptureFixture) -> None: # `uv tool run PyCowSay` would look for a `PyCowSay` script; only the venv path knows what the package installed run_pipx_cli_exit(["run", "--backend", "uv", "PyCowSay", "cowsay", "hi"], assert_exit=0) assert f"exec_app: {paths.CTX.venv_cache}" in caplog.text @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_uv_index_url_beats_uv_default_index( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, pipx_local_pypiserver: str ) -> None: monkeypatch.setenv("UV_DEFAULT_INDEX", tmp_path.as_uri()) # uv's app launcher calls realpath and dirname, which the sandboxed PATH drops monkeypatch.setenv("PATH", f"{os.environ['PATH']}{os.pathsep}{os.defpath}") run_pipx_cli_exit(["run", "--backend", "uv", "--index-url", pipx_local_pypiserver, "pycowsay", "hi"], assert_exit=0) @pytest.mark.parametrize( "spec_args", [ pytest.param([], id="package-name"), pytest.param(["--spec", "pycowsay"], id="explicit-spec"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_run_matching_name_uses_uv_tool_run(mocker: MockerFixture, spec_args: list[str]) -> None: # patch the handover rather than os.execvpe, which exec_app calls on POSIX only exec_app: Final = mocker.patch("pipx.commands.run_uv.exec_app", autospec=True, side_effect=SystemExit(0)) run_pipx_cli_exit(["run", "--backend", "uv", *spec_args, "pycowsay", "cowsay", "hi"], assert_exit=0) exec_app.assert_called_once() assert exec_app.call_args.args[0][1:3] == ["tool", "run"] @pytest.mark.parametrize( "spec_template", [ pytest.param("{path}", id="local-path"), pytest.param("empty-project @ {uri}", id="direct-reference"), ], ) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_spec_honors_dotted_pipx_run_entry_point( empty_project: Path, caplog: pytest.LogCaptureFixture, spec_template: str, ) -> None: pyproject: Final[Path] = empty_project / "pyproject.toml" pyproject.write_text( pyproject .read_text(encoding="utf-8") .replace('scripts.empty-project = "empty_project.main:cli"', "") .replace( 'entry-points."pipx.run".empty-project = "empty_project.main:cli"', 'entry-points."pipx.run"."empty.project" = "empty_project.main:main"', ), encoding="utf-8", ) caplog.set_level(logging.INFO) run_pipx_cli_exit( [ "run", "--backend", "uv", "--spec", spec_template.format(path=empty_project, uri=empty_project.as_uri()), "empty.project", ], assert_exit=0, ) assert "Using discovered entry point for 'pipx run'" in caplog.text assert f"exec_app: {paths.CTX.venv_cache}" in caplog.text @pytest.mark.usefixtures("pipx_temp_env") def test_run_preserves_explicit_app_name( capsys: pytest.CaptureFixture[str], ) -> None: assert run_pipx_cli(["run", "--spec", "black", "bLaCk.app"]) == 1 assert "'bLaCk.app' executable script not found in package 'black'." in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_cache_reuses_venv(caplog: pytest.LogCaptureFixture) -> None: run_pipx_cli_exit(["run", "pycowsay", "cowsay", "args"]) caplog.set_level(logging.DEBUG) run_pipx_cli_exit(["run", "--verbose", "pycowsay", "cowsay", "args"], assert_exit=0) assert "Reusing cached venv" in caplog.text @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_no_cache_removes_cached_venv(caplog: pytest.LogCaptureFixture) -> None: run_pipx_cli_exit(["run", "pycowsay", "cowsay", "args"]) caplog.set_level(logging.DEBUG) run_pipx_cli_exit(["run", "--no-cache", "pycowsay", "cowsay", "args"]) assert "Removing cached venv" in caplog.text @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_refresh_rebuilds_cached_environment() -> None: run_pipx_cli_exit(["run", "pycowsay", "cowsay", "args"]) venv_dir: Final[Path] = next(path for path in paths.CTX.venv_cache.iterdir() if path.is_dir()) marker: Final[Path] = venv_dir / "marker" marker.touch() run_pipx_cli_exit(["run", "--refresh", "pycowsay", "cowsay", "args"]) assert not marker.exists() @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_refresh_retains_replacement() -> None: run_pipx_cli_exit(["run", "--refresh", "pycowsay", "cowsay", "args"]) venv_dir: Final[Path] = next(path for path in paths.CTX.venv_cache.iterdir() if path.is_dir()) marker: Final[Path] = venv_dir / "marker" marker.touch() run_pipx_cli_exit(["run", "pycowsay", "cowsay", "args"]) assert marker.exists() @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_skips_a_cache_entry_held_by_another_run(mocker: MockerFixture) -> None: paths.CTX.venv_cache.mkdir(parents=True, exist_ok=True) held: Final[Path] = paths.CTX.venv_cache / "held-by-another-run" held.mkdir() (held / "pipx_expired_venv").touch() real_acquire: Final = FileLock.acquire def acquire( self: FileLock, timeout: float | None = None, poll_interval: float | None = None, *, poll_intervall: float | None = None, blocking: bool | None = None, cancel_check: Callable[[], bool] | None = None, ) -> AcquireReturnProxy: if timeout == 0: raise Timeout(str(self.lock_file)) return real_acquire( self, timeout, poll_interval, poll_intervall=poll_intervall, blocking=blocking, cancel_check=cancel_check, ) mocker.patch.object(FileLock, "acquire", acquire) run_pipx_cli_exit(["run", "pycowsay", "cowsay", "hi"]) assert held.is_dir() @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_with_isolates_cache_by_dependency() -> None: run_pipx_cli_exit(["run", "pycowsay", "cowsay", "hi"]) plain_dirs: Final[set[Path]] = {path for path in paths.CTX.venv_cache.iterdir() if path.is_dir()} run_pipx_cli_exit(["run", "--with", "six", "pycowsay", "cowsay", "hi"]) with_dirs: Final[set[Path]] = {path for path in paths.CTX.venv_cache.iterdir() if path.is_dir()} - plain_dirs assert [bool(list(path.rglob("six-*.dist-info"))) for path in (*plain_dirs, *with_dirs)] == [False, True] @pytest.fixture def script_interpreters(mocker: MockerFixture) -> None: # each searched minor 3.X resolves to /py/3.X, and the default Python reports 3.14.0 mocker.patch( "pipx.requires_python.find_python_interpreter", autospec=True, side_effect=lambda minor, _: f"/py/{minor}" ) mocker.patch( "pipx.requires_python.run_subprocess", autospec=True, side_effect=lambda cmd, **_: subprocess.CompletedProcess( cmd, 0, stdout=f"{cmd[0].removeprefix('/py/')}.0" if cmd[0].startswith("/py/") else "3.14.0" ), ) @pytest.mark.parametrize("backend", [pytest.param("pip", id="pip"), pytest.param("uv", id="uv")]) @pytest.mark.parametrize( ("requires_python", "searched"), [ pytest.param(">=3.10", set(), id="default-satisfies"), pytest.param("<3.13", {"/py/3.12"}, id="default-fails"), ], ) @pytest.mark.usefixtures("pipx_temp_env", "script_interpreters") def test_run_script_searches_python_only_when_default_fails_requires_python( tmp_path: Path, mocker: MockerFixture, backend: str, requires_python: str, searched: set[str] ) -> None: exec_mock: Final = mocker.create_autospec(subprocess_runner.exec_app, side_effect=SystemExit(0)) mocker.patch("pipx.commands.run.exec_app", new=exec_mock) mocker.patch("pipx.commands.run_uv.exec_app", new=exec_mock) script: Final[Path] = tmp_path / "script.py" script.write_text( f'# /// script\n# requires-python = "{requires_python}"\n# dependencies = []\n# ///\n', encoding="utf-8" ) run_pipx_cli_exit(["run", "--backend", backend, str(script)], assert_exit=0) assert {Path(arg) for arg in exec_mock.call_args.args[0]} & {Path("/py/3.12"), Path("/py/3.15")} == { Path(path) for path in searched } @pytest.mark.parametrize("backend", [pytest.param("pip", id="pip"), pytest.param("uv", id="uv")]) @pytest.mark.usefixtures("pipx_temp_env") def test_run_rejects_python_failing_requires_python( tmp_path: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str], backend: str ) -> None: mocker.patch("pipx.commands.run_uv.exec_app", autospec=True, side_effect=SystemExit(0)) script: Final[Path] = tmp_path / "script.py" script.write_text('# /// script\n# requires-python = ">=3.99"\n# ///\nprint("hi")\n', encoding="utf-8") assert run_pipx_cli(["run", "--backend", backend, "--python", sys.executable, str(script)]) == 1 assert ">=3.99" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_run_reports_invalid_inline_metadata(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: script: Final[Path] = tmp_path / "script.py" script.write_text("# /// script\n# dependencies = [\n# ///\nprint('hi')\n", encoding="utf-8") assert run_pipx_cli(["run", "--backend", "pip", str(script)]) == 1 assert "Invalid inline script metadata" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_run_cache_options_are_mutually_exclusive( capsys: pytest.CaptureFixture[str], ) -> None: with pytest.raises(SystemExit, match="2"): run_pipx_cli(["run", "--no-cache", "--refresh", "pycowsay"]) assert "not allowed with argument" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_does_not_mark_cache_as_installation() -> None: run_pipx_cli_exit(["run", "pycowsay", "cowsay", "args"]) assert PipxMetadata(next(path for path in paths.CTX.venv_cache.iterdir() if path.is_dir())).environment is None @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_cooldown_separates_cache(package_cache_dir: Path, caplog: pytest.LogCaptureFixture) -> None: pip_args: Final[str] = f"--pip-args=--no-index --find-links={package_cache_dir}" run_pipx_cli_exit( ["run", "--backend", "pip", "--cooldown", "7", pip_args, PKG["pycowsay"]["spec"], "hello"], assert_exit=0, ) cached_venvs: Final[set[Path]] = {path for path in paths.CTX.venv_cache.iterdir() if path.is_dir()} caplog.clear() run_pipx_cli_exit( ["run", "--backend", "pip", "--cooldown", "0", pip_args, PKG["pycowsay"]["spec"], "hello"], assert_exit=0, ) assert ( len({path for path in paths.CTX.venv_cache.iterdir() if path.is_dir()}), "--uploaded-prior-to" in caplog.text, ) == (len(cached_venvs) + 1, False) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_no_path_check(mocker: MockerFixture, caplog: pytest.LogCaptureFixture) -> None: which: Final[MagicMock] = mocker.create_autospec(shutil.which, return_value="/fake/bin/pycowsay") mocker.patch("pipx.commands.run.which", new=which) mocker.patch("pipx.commands.run_uv.which", new=which) run_pipx_cli_exit(["run", "pycowsay", "cowsay", "args"]) which.assert_called_once_with("pycowsay") assert "is already on your PATH" in caplog.text caplog.clear() run_pipx_cli_exit(["run", "--no-path-check", "pycowsay", "cowsay", "args"]) which.assert_called_once_with("pycowsay") assert "is already on your PATH" not in caplog.text @pytest.mark.usefixtures("pipx_ultra_temp_env", "exec_via_subprocess") def test_cachedir_tag_created_with_signature(caplog: pytest.LogCaptureFixture) -> None: tag_path: Final[Path] = paths.CTX.venv_cache / "CACHEDIR.TAG" assert not tag_path.exists() caplog.set_level(logging.DEBUG) run_pipx_cli_exit(["run", "pycowsay", "cowsay", "args"]) assert ( "Adding CACHEDIR.TAG to cache directory" in caplog.text, tag_path.read_text().startswith("Signature: 8a477f597d28d172789f06886806bc55"), ) == (True, True) @pytest.mark.usefixtures("pipx_ultra_temp_env", "exec_via_subprocess") def test_cachedir_tag_not_recreated(caplog: pytest.LogCaptureFixture) -> None: run_pipx_cli_exit(["run", "pycowsay", "cowsay", "args"]) caplog.set_level(logging.DEBUG) caplog.clear() run_pipx_cli_exit(["run", "pycowsay", "cowsay", "args"]) assert ( "Adding CACHEDIR.TAG to cache directory" in caplog.text, (paths.CTX.venv_cache / "CACHEDIR.TAG").exists(), ) == (False, True) @pytest.mark.usefixtures("pipx_ultra_temp_env", "exec_via_subprocess") def test_cache_sweep_ignores_tag(mocker: MockerFixture, caplog: pytest.LogCaptureFixture) -> None: run_pipx_cli_exit(["run", "pycowsay", "cowsay", "args"]) tag_path: Final[Path] = paths.CTX.venv_cache / "CACHEDIR.TAG" expired_venv: Final[Path] = paths.CTX.venv_cache / "expired" expired_venv.mkdir() mocker.patch( "pipx.commands.run.dt", UTC=dt.UTC, **{"datetime.now.return_value": dt.datetime.now(dt.UTC) + dt.timedelta(days=15)}, ) caplog.set_level(logging.INFO) caplog.clear() run_pipx_cli_exit(["run", "pycowsay", "cowsay", "args"]) assert not expired_venv.exists() assert tag_path.exists() assert f"Removing expired venv {tag_path}" not in caplog.text @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_script_from_url(served_script: str) -> None: run_pipx_cli_exit(["run", served_script], assert_exit=0) @pytest.mark.usefixtures("pipx_temp_env", "planted_json_cwd", "exec_via_subprocess") def test_run_script_from_url_ignores_working_directory_modules( served_script: str, tmp_path: Path, capfd: pytest.CaptureFixture[str] ) -> None: (tmp_path / "imports-json.py").write_text("import json\n\nprint(json.dumps('ok'))\n", encoding="utf-8") run_pipx_cli_exit(["run", served_script.replace("pipx-demo.py", "imports-json.py")], assert_exit=0) assert capfd.readouterr().out.splitlines() == ['"ok"'] @pytest.mark.usefixtures("pipx_temp_env", "planted_json_cwd", "exec_via_subprocess") def test_run_entry_point_ignores_working_directory_modules( empty_project: Path, capfd: pytest.CaptureFixture[str] ) -> None: (empty_project / "empty_project" / "main.py").write_text( "import json\n\n\ndef cli() -> None:\n print(json.dumps('ok'))\n", encoding="utf-8" ) run_pipx_cli_exit(["run", "--spec", str(empty_project), "empty-project"], assert_exit=0) assert capfd.readouterr().out.splitlines() == ['"ok"'] @pytest.fixture def planted_json_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: (cwd := tmp_path / "cwd").mkdir() (cwd / "json.py").write_text("raise SystemExit('imported json.py from the working directory')\n", encoding="utf-8") monkeypatch.chdir(cwd) @pytest.fixture def served_script(tmp_path: Path) -> Iterator[str]: # loopback rather than a third-party gist: that host rate-limited the CI runners, and its leaked socket surfaced as # an unraisable ResourceWarning in whichever test the GC interrupted (tmp_path / "pipx-demo.py").write_text("print('pipx works!')\n", encoding="utf-8") with _serving(partial(SimpleHTTPRequestHandler, directory=str(tmp_path))) as port: yield f"http://127.0.0.1:{port}/pipx-demo.py" @contextmanager def _serving(handler: type[BaseHTTPRequestHandler] | partial[SimpleHTTPRequestHandler]) -> Generator[int, None, None]: # not http.server.HTTPServer: its bind resolves the host name, which stalls for 30 s on macOS CI runners server: Final[ThreadingTCPServer] = ThreadingTCPServer(("127.0.0.1", 0), handler) thread: Final[Thread] = Thread(target=server.serve_forever, daemon=True) thread.start() try: yield server.server_address[1] finally: server.shutdown() server.server_close() thread.join(timeout=5) @pytest.mark.parametrize( ("input_run_args", "expected_app_with_args"), [ pytest.param(["--", "pycowsay", "--", "hello"], ["pycowsay", "--", "hello"], id="sep-app-sep-arg"), pytest.param( ["--", "pycowsay", "--", "--", "hello"], ["pycowsay", "--", "--", "hello"], id="sep-app-sep-sep-arg" ), pytest.param(["--", "pycowsay", "hello", "--"], ["pycowsay", "hello", "--"], id="sep-app-arg-sep"), pytest.param( ["--", "pycowsay", "hello", "--", "--"], ["pycowsay", "hello", "--", "--"], id="sep-app-arg-sep-sep" ), pytest.param(["--", "pycowsay", "--"], ["pycowsay", "--"], id="sep-app-sep"), pytest.param(["--", "pycowsay", "--", "--"], ["pycowsay", "--", "--"], id="sep-app-sep-sep"), pytest.param(["pycowsay", "--", "hello"], ["pycowsay", "--", "hello"], id="app-sep-arg"), pytest.param(["pycowsay", "--", "--", "hello"], ["pycowsay", "--", "--", "hello"], id="app-sep-sep-arg"), pytest.param(["pycowsay", "hello", "--"], ["pycowsay", "hello", "--"], id="app-arg-sep"), pytest.param(["pycowsay", "hello", "--", "--"], ["pycowsay", "hello", "--", "--"], id="app-arg-sep-sep"), pytest.param(["pycowsay", "--"], ["pycowsay", "--"], id="app-sep"), pytest.param(["pycowsay", "--", "--"], ["pycowsay", "--", "--"], id="app-sep-sep"), pytest.param(["--", "--", "pycowsay", "--"], ["--", "pycowsay", "--"], id="sep-sep-app-sep"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_appargs_doubledash( monkeypatch: pytest.MonkeyPatch, input_run_args: list[str], expected_app_with_args: list[str] ) -> None: parser = pipx.main.get_command_parser() monkeypatch.setattr(sys, "argv", ["pipx", "run", *input_run_args]) parsed_pipx_args: Final = parser.parse_args() pipx.main.check_args(parsed_pipx_args) assert parsed_pipx_args.app_with_args == expected_app_with_args @pytest.mark.usefixtures("pipx_temp_env") def test_run_ensure_null_pythonpath(make_wheel: Callable[[dict[str, str]], Path]) -> None: wheel: Final[Path] = make_wheel({ "foo_bar_x.py": "import os\ndef main() -> None:\n print(os.environ.get('PYTHONPATH'))\n", "foo_bar_x-1.dist-info/METADATA": "Metadata-Version: 2.1\nName: foo-bar-x\nVersion: 1\n", "foo_bar_x-1.dist-info/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", "foo_bar_x-1.dist-info/entry_points.txt": "[console_scripts]\nfoo-bar-x = foo_bar_x:main\n", "foo_bar_x-1.dist-info/RECORD": "", }) assert ( subprocess.run( [sys.executable, "-m", "pipx", "run", "--spec", str(wheel), "foo-bar-x"], env=pipx_subprocess_env() | {"PYTHONPATH": "test"}, capture_output=True, text=True, check=True, ).stdout == "None\n" ) # packages listed roughly in order of increasing test duration @pytest.mark.parametrize( ("package", "package_or_url", "app_appargs"), [ pytest.param("pycowsay", "pycowsay", ["pycowsay", "hello"], id="pycowsay"), pytest.param( "shell-functools", PKG["shell-functools"]["spec"], ["filter", "--help"], id="shell-functools", marks=pytest.mark.skipif(sys.platform.startswith("win"), reason="setup.py scripts do not run on Windows"), ), pytest.param("black", PKG["black"]["spec"], ["black", "--help"], id="black"), pytest.param("kaggle", PKG["kaggle"]["spec"], ["kaggle", "--help"], id="kaggle"), ], ) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_package_determination( caplog: pytest.LogCaptureFixture, package: str, package_or_url: str, app_appargs: list[str] ) -> None: caplog.set_level(logging.INFO) run_pipx_cli_exit(["run", "--verbose", "--spec", package_or_url, "--", *app_appargs]) assert "Cannot determine package name" not in caplog.text assert f"Determined package name: {package}" in caplog.text @pytest.mark.parametrize( "run_args", [ pytest.param(["{uri}"], id="file-uri"), pytest.param(["--path", "{path}"], id="absolute-name"), pytest.param(["test.py"], id="relative-name"), ], ) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_without_requirements( hello_script: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, run_args: list[str] ) -> None: script, out = hello_script monkeypatch.chdir(script.parent) run_pipx_cli_exit(["run", *(arg.format(uri=script.as_uri(), path=script) for arg in run_args)]) assert out.read_text() == "Hello, world!" @pytest.fixture def hello_script(tmp_path: Path) -> tuple[Path, Path]: out: Final[Path] = tmp_path / "output.txt" (script := tmp_path / "test.py").write_text( f"from pathlib import Path\nPath({str(out)!r}).write_text('Hello, world!')" ) return script, out @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_passes_python_args_to_script(tmp_path: Path) -> None: output: Final[Path] = tmp_path / "flags.txt" script: Final[Path] = tmp_path / "flags.py" script.write_text( f"import sys\nfrom pathlib import Path\nPath({str(output)!r}).write_text(str(sys.flags.bytes_warning))", encoding="utf-8", ) run_pipx_cli_exit(["run", "--python-args=-bb", str(script)], assert_exit=0) assert output.read_text(encoding="utf-8") == "2" def test_run_rejects_invalid_python_args(capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit, match="2"): run_pipx_cli(["run", '--python-args="', "demo"]) assert "invalid Python arguments: No closing quotation" in capsys.readouterr().err @pytest.mark.parametrize("backend", [pytest.param("pip", id="pip"), pytest.param("uv", id="uv")]) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_passes_python_args_to_app(tmp_path: Path, backend: str) -> None: project: Final[Path] = tmp_path / "flags-app" project.mkdir() (project / "pyproject.toml").write_text( """\ [build-system] requires = ["flit-core"] build-backend = "flit_core.buildapi" [project] name = "flags-app" version = "1" description = "Record the interpreter flags." [project.scripts] flags-app = "flags_app:main" """, encoding="utf-8", ) output: Final[Path] = tmp_path / "flags.txt" (project / "flags_app.py").write_text( ( "import sys\nfrom pathlib import Path\n\n" "def main() -> None:\n Path(sys.argv[1]).write_text(str(sys.flags.bytes_warning))" ), encoding="utf-8", ) run_pipx_cli_exit( [ "run", "--backend", backend, "--python-args=-X dev -bb", "--spec", str(project), "flags-app", str(output), ], assert_exit=0, ) assert output.read_text(encoding="utf-8") == "2" @pytest.mark.usefixtures("pipx_temp_env") def test_run_rejects_python_args_for_pypackages( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: app: Final[Path] = ( tmp_path / "__pypackages__" / f"{sys.version_info.major}.{sys.version_info.minor}" / "lib" / "bin" / "demo" ) app.parent.mkdir(parents=True) app.touch() monkeypatch.chdir(tmp_path) assert run_pipx_cli(["run", "--python-args=-b", "demo"]) == 1 assert capsys.readouterr().err == "--python-args cannot run applications from __pypackages__.\n" @pytest.mark.parametrize( ("encoding", "script_text"), [ pytest.param("cp850", 'print("รครถรผ")', id="cp850-text"), pytest.param( "cp437", 'import sys; print("โ”โ”โ”“" if sys.stdout.encoding == "utf-8" else "+-+")', id="cp437-terminal-fallback", ), ], ) def test_run_preserves_console_encoding(tmp_path: Path, encoding: str, script_text: str) -> None: script: Final[Path] = tmp_path / "console_output.py" script.write_text(script_text, encoding="utf-8") env: Final[dict[str, str]] = pipx_subprocess_env() | {"PIPX_DEFAULT_BACKEND": "pip", "PYTHONIOENCODING": encoding} direct_output: Final[bytes] = subprocess.run( [sys.executable, script], env=env, stdout=subprocess.PIPE, check=True, ).stdout assert ( subprocess.run( [sys.executable, "-m", "pipx", "run", script], env=env, stdout=subprocess.PIPE, check=True, ).stdout == direct_output ) @pytest.mark.parametrize( ("script_text", "expected_output"), [ pytest.param( """ # /// script # dependencies = [] # /// from pathlib import Path Path({out!r}).write_text("explicit-empty") """, "explicit-empty", id="explicit-empty-dependencies", ), pytest.param( """ # /// script # /// from pathlib import Path Path({out!r}).write_text("implicit-empty") """, "implicit-empty", id="implicit-empty-dependencies", ), pytest.param( """ # /// script # dependencies = ["requests==2.31.0"] # /// # Check requests can be imported import requests # Check dependencies of requests can be imported import certifi # Check the installed version from pathlib import Path Path({out!r}).write_text(requests.__version__) """, "2.31.0", id="non-empty-dependencies", ), ], ) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_with_requirements(script_text: str, expected_output: str, tmp_path: Path) -> None: script: Final[Path] = tmp_path / "test.py" out: Final[Path] = tmp_path / "output.txt" script.write_text( textwrap.dedent(script_text.format(out=str(out))).strip(), encoding="utf-8", ) run_pipx_cli_exit(["run", script.as_uri()]) assert out.read_text() == expected_output @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_script_with_uv_ignores_uv_toml_beside_script(tmp_path: Path) -> None: # uv fails on a malformed config file it discovers, so success proves it never read the one beside the script; a # sibling of PIPX_HOME, since a uv.toml above PIPX_HOME would still apply (script_dir := tmp_path / "scripts").mkdir() (script_dir / "uv.toml").write_text("index-url = [\n") out: Final[Path] = tmp_path / "output.txt" (script := script_dir / "test.py").write_text( f"# /// script\n# dependencies = []\n# ///\nfrom pathlib import Path\nPath({str(out)!r}).write_text('ran')\n" ) run_pipx_cli_exit(["run", "--backend", "uv", str(script)], assert_exit=0) assert out.read_text() == "ran" @pytest.mark.usefixtures("pipx_temp_env") def test_run_script_uv_releases_cache_lock_before_handover(tmp_path: Path, mocker: MockerFixture) -> None: def handover(_cmd: Sequence[str | Path], env: dict[str, str]) -> NoReturn: # raises Timeout while held; exec_app waits for the script on Windows, so the lock would outlive the handover with VenvContainer(paths.CTX.venv_cache).venv_lock(Path(env["VIRTUAL_ENV"])).acquire(timeout=0): raise SystemExit(0) mocker.patch("pipx.commands.run_uv.exec_app", autospec=True, side_effect=handover) (script := tmp_path / "test.py").write_text("# /// script\n# dependencies = []\n# ///\n") run_pipx_cli_exit(["run", "--backend", "uv", str(script)], assert_exit=0) @pytest.fixture def uv_script( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while running tmp_path: Path, ) -> Path: if shutil.which("uv") is None: pytest.skip("needs the uv binary") (script := tmp_path / "test.py").write_text("# /// script\n# dependencies = []\n# ///\n") return script @pytest.fixture def run_beside_running_uv_script(uv_script: Path, mocker: MockerFixture) -> Callable[[list[str], str], Path]: # exec_app waits for the script on Windows, so the handover runs it and then stands in for it still running there # while another pipx process runs the same script def run(flags: list[str], planted: str) -> Path: venv_dirs: Final[list[Path]] = [] def handover(cmd: Sequence[str | Path], env: dict[str, str]) -> NoReturn: subprocess.run([str(arg) for arg in cmd], env=env, check=True) venv_dirs.append(venv_dir := Path(env["VIRTUAL_ENV"])) (venv_dir / planted).touch() subprocess.run( [sys.executable, "-m", "pipx", "run", "--backend", "uv", *flags, str(uv_script)], env=pipx_subprocess_env(), check=True, ) raise SystemExit(0) mocker.patch("pipx.commands.run_uv.exec_app", autospec=True, side_effect=handover) run_pipx_cli_exit(["run", "--backend", "uv", str(uv_script)], assert_exit=0) # autospec refuses to wrap a mock, so a test patching exec_app again needs the original back mocker.stopall() return venv_dirs[0] return run @pytest.mark.parametrize( ("flags", "planted"), [ pytest.param(["--no-cache"], "running", id="no-cache"), pytest.param(["--refresh"], "running", id="refresh"), pytest.param([], "pipx_expired_venv", id="expired"), ], ) def test_run_script_uv_keeps_environment_a_running_script_uses( run_beside_running_uv_script: Callable[[list[str], str], Path], flags: list[str], planted: str ) -> None: assert (run_beside_running_uv_script(flags, planted) / planted).is_file() def test_run_script_uv_rebuilds_environment_refreshed_while_running( run_beside_running_uv_script: Callable[[list[str], str], Path], uv_script: Path, mocker: MockerFixture ) -> None: venv_dir: Final[Path] = run_beside_running_uv_script(["--refresh"], "running") def handover(_cmd: Sequence[str | Path], env: dict[str, str]) -> NoReturn: raise SystemExit((Path(env["VIRTUAL_ENV"]), (venv_dir / "running").exists())) mocker.patch("pipx.commands.run_uv.exec_app", autospec=True, side_effect=handover) with pytest.raises(SystemExit) as sys_exit: run_pipx_cli(["run", "--backend", "uv", str(uv_script)]) assert sys_exit.value.code == (venv_dir, False) def test_run_script_uv_leaves_no_run_lock_files(uv_script: Path, mocker: MockerFixture) -> None: venv_dirs: Final[list[Path]] = [] def handover(_cmd: Sequence[str | Path], env: dict[str, str]) -> NoReturn: venv_dirs.append(Path(env["VIRTUAL_ENV"])) raise SystemExit(0) mocker.patch("pipx.commands.run_uv.exec_app", autospec=True, side_effect=handover) run_pipx_cli_exit(["run", "--backend", "uv", str(uv_script)], assert_exit=0) # what a run that handed its process over to uv leaves venv_dirs[0].with_name(f".{venv_dirs[0].name}.run-ended.lock").touch() run_pipx_cli_exit(["run", "--backend", "uv", str(uv_script)], assert_exit=0) assert list(paths.CTX.venv_cache.glob(".*.run-*.lock")) == [] @pytest.mark.skipif(not WIN, reason="on POSIX pipx hands its process to uv, so it cannot remove the environment after") def test_run_script_uv_removes_its_own_environment_once_done( run_beside_running_uv_script: Callable[[list[str], str], Path], ) -> None: venv_dir: Final[Path] = run_beside_running_uv_script(["--no-cache"], "running") assert [path for path in paths.CTX.venv_cache.iterdir() if path.is_dir()] == [venv_dir] @pytest.mark.parametrize( ("second_links", "second_flags"), [ pytest.param("new", [], id="changed-find-links"), pytest.param("old", ["--refresh"], id="refresh"), ], ) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_script_uv_rebuilds_for_new_request( demodep_version_script: tuple[Path, Path], second_links: str, second_flags: list[str], publish_demodep: Callable[[str, str], Path], ) -> None: script, out = demodep_version_script run_pipx_cli_exit( ["run", "--backend", "uv", f"--pip-args=--find-links={publish_demodep('old', '1.0')}", str(script)], assert_exit=0, ) links: Final[Path] = publish_demodep(second_links, "2.0") run_pipx_cli_exit(["run", "--backend", "uv", *second_flags, f"--pip-args=--find-links={links}", str(script)]) assert out.read_text() == "2.0" @pytest.fixture def demodep_version_script(tmp_path: Path) -> tuple[Path, Path]: out: Final[Path] = tmp_path / "output.txt" (script := tmp_path / "test.py").write_text( "# /// script\n# dependencies = ['demodep']\n# ///\nfrom importlib.metadata import version\n" f"from pathlib import Path\nPath({str(out)!r}).write_text(version('demodep'))\n" ) return script, out @pytest.fixture def publish_demodep(tmp_path: Path) -> Callable[[str, str], Path]: def publish(links_name: str, version: str) -> Path: return write_wheel(tmp_path / links_name, "demodep", version).parent return publish @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_rebuilds_after_installer_env_changes( demodep_version_script: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, publish_demodep: Callable[[str, str], Path], ) -> None: script, out = demodep_version_script monkeypatch.setenv("PIP_FIND_LINKS", str(publish_demodep("old", "1.0"))) run_pipx_cli_exit(["run", "--backend", "pip", str(script)], assert_exit=0) monkeypatch.setenv("PIP_FIND_LINKS", str(publish_demodep("new", "2.0"))) run_pipx_cli_exit(["run", "--backend", "pip", str(script)]) assert out.read_text() == "2.0" @pytest.fixture def run_demodep_script(tmp_path: Path, publish_demodep: Callable[[str, str], Path]) -> Callable[[str], set[Path]]: (script := tmp_path / "test.py").write_text("# /// script\n# dependencies = ['demodep']\n# ///\nimport demodep\n") links: Final[Path] = publish_demodep("links", "1.0") def run(backend: str) -> set[Path]: run_pipx_cli_exit(["run", "--backend", backend, f"--pip-args=--find-links={links}", str(script)], assert_exit=0) return {path for path in paths.CTX.venv_cache.iterdir() if path.is_dir()} return run @pytest.mark.parametrize( ("backend", "variable", "value"), [ pytest.param("pip", "PIP_TIMEOUT", "60", id="pip-timeout"), pytest.param("pip", "PIP_timeout", "60", id="pip-timeout-lower-case"), pytest.param("pip", "PIP_NO-COLOR", "1", id="pip-no-color-dashed"), pytest.param("uv", "UV_RUN_RECURSION_DEPTH", "1", id="uv-run-recursion-depth"), ], ) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_cache_key_ignores_unrelated_installer_env( monkeypatch: pytest.MonkeyPatch, run_demodep_script: Callable[[str], set[Path]], backend: str, variable: str, value: str, ) -> None: first: Final[set[Path]] = run_demodep_script(backend) monkeypatch.setenv(variable, value) assert run_demodep_script(backend) == first @pytest.mark.parametrize( ("backend", "variable", "value"), [ pytest.param("pip", "PIP_PRE", "1", id="pip-pre"), pytest.param("pip", "PIP_index_url", "file:///pipx-missing-index", id="pip-index-url-lower-case"), pytest.param("pip", "PIP_PIPX_UNLISTED", "1", id="pip-unlisted"), pytest.param("uv", "UV_PRERELEASE", "allow", id="uv-prerelease"), pytest.param("uv", "UV_PYTHON_INSTALL_MIRROR", "https://mirror.invalid", id="uv-python-install-mirror"), pytest.param("uv", "UV_PIPX_UNLISTED", "1", id="uv-unlisted"), ], ) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_cache_key_follows_resolution_installer_env( monkeypatch: pytest.MonkeyPatch, run_demodep_script: Callable[[str], set[Path]], backend: str, variable: str, value: str, ) -> None: first: Final[set[Path]] = run_demodep_script(backend) monkeypatch.setenv(variable, value) assert len(run_demodep_script(backend) - first) == 1 @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_with_requirements_and_cli_with_pip_backend(tmp_path: Path) -> None: script: Final[Path] = tmp_path / "test.py" out: Final[Path] = tmp_path / "output.txt" script.write_text( textwrap.dedent( f""" # /// script # dependencies = ["requests==2.31.0"] # /// import black import requests from pathlib import Path Path({str(out)!r}).write_text(f"requests={{requests.__version__}}, package={{black.__name__}}") """ ).strip(), encoding="utf-8", ) run_pipx_cli_exit( ["run", "--backend", "pip", "--with", PKG["black"]["spec"], script.as_uri()], assert_exit=0, ) assert out.read_text() == "requests=2.31.0, package=black" out.unlink() run_pipx_cli_exit(["run", "--backend", "pip", script.as_uri()], assert_exit=1) assert not out.exists() @pytest.fixture def pycowsay_script(tmp_path: Path) -> tuple[Path, Path]: output: Final[Path] = tmp_path / "output.txt" (script := tmp_path / "test.py").write_text( textwrap.dedent( f""" # /// script # dependencies = ["pycowsay==0.0.0.2"] # /// from pathlib import Path Path({str(output)!r}).write_text("done") """ ).strip(), encoding="utf-8", ) return script, output @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_script_cooldown( package_cache_dir: Path, pycowsay_script: tuple[Path, Path], caplog: pytest.LogCaptureFixture ) -> None: script, output = pycowsay_script run_pipx_cli_exit( [ "run", "--backend", "pip", "--cooldown", "7", f"--pip-args=--no-index --find-links={package_cache_dir}", script.as_uri(), ], assert_exit=0, ) assert (output.read_text(encoding="utf-8"), "--uploaded-prior-to P7D" in caplog.text) == ("done", True) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_script_resolves_relative_find_links( root: Path, pycowsay_script: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch ) -> None: script, output = pycowsay_script monkeypatch.chdir(root) run_pipx_cli_exit( [ "run", "--backend", "pip", f"--pip-args=--no-index --find-links=.pipx_tests/package_cache/{PACKAGE_CACHE_DIR_NAME}", str(script), ], assert_exit=0, ) assert output.read_text(encoding="utf-8") == "done" @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_with_requirements_old(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: script: Final[Path] = tmp_path / "test.py" script.write_text( textwrap.dedent( """ # /// pyproject # run.requirements = ["requests==2.31.0"] # /// import requests """ ).strip(), encoding="utf-8", ) assert run_pipx_cli(["run", script.as_uri()]) == 1 assert "Use `# /// script`" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_correct_traceback(capfd: pytest.CaptureFixture[str], tmp_path: Path) -> None: script: Final[Path] = tmp_path / "test.py" script.write_text( textwrap.dedent( """ raise RuntimeError("Should fail") """ ).strip() ) with pytest.raises(SystemExit): run_pipx_cli(["run", str(script)]) assert "test.py" in capfd.readouterr().err @pytest.mark.parametrize( "header", [ pytest.param("", id="plain"), pytest.param('# /// script\n# dependencies = ["packaging"]\n# ///\nimport packaging\n', id="with-requirements"), ], ) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_with_args(tmp_path: Path, header: str) -> None: script: Final[Path] = tmp_path / "test.py" out: Final[Path] = tmp_path / "output.txt" script.write_text( f"{header}import sys\nfrom pathlib import Path\nPath({str(out)!r}).write_text(str(int(sys.argv[1]) + 1))" ) run_pipx_cli_exit(["run", script.as_uri(), "1"]) assert out.read_text() == "2" @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_with_failing_requirements(capfd: pytest.CaptureFixture[str], tmp_path: Path) -> None: script: Final[Path] = tmp_path / "test.py" script.write_text( textwrap.dedent( """ # /// script # dependencies = ["will_fail @ git+https://0.0.0.0/will_fail.git"] # /// import will_fail """ ).strip() ) assert run_pipx_cli(["run", str(script)]) != 0 assert "Error installing will_fail @ git+https://0.0.0.0/will_fail.git." in capfd.readouterr().err # a venv kept from the failed install would run the script, which then fails with ModuleNotFoundError assert run_pipx_cli(["run", str(script)]) != 0 err: Final[str] = capfd.readouterr().err assert "ModuleNotFoundError: No module named 'will_fail'" not in err assert "Error installing will_fail @ git+https://0.0.0.0/will_fail.git." in err @pytest.mark.usefixtures("pipx_temp_env") def test_run_script_interrupted_venv_creation_expires_cache( inline_script: Path, mocker: MockerFixture, capfd: pytest.CaptureFixture[str], before_venv_subprocess: Callable[[Path, Callable[[], None]], None], ) -> None: def interrupt() -> None: raise KeyboardInterrupt before_venv_subprocess(paths.CTX.venv_cache, interrupt) assert run_pipx_cli(["run", "--backend", "pip", str(inline_script)]) == 1 mocker.stopall() mocker.patch("os.execvpe", autospec=True, side_effect=execvpe_via_subprocess) run_pipx_cli_exit(["run", "--backend", "pip", str(inline_script)], assert_exit=0) assert capfd.readouterr().out.splitlines()[-1] == "installed" @pytest.mark.usefixtures("pipx_temp_env") def test_run_package_install_failure_expires_cache(package_cache_dir: Path) -> None: # the find links carry a current pip: when this test builds the session's shared libs, a bare --no-index would # fail their pip reinstall and leave the base interpreter's bundled pip for later tests on this worker pip_args: Final[str] = f"--pip-args=--no-index --find-links={package_cache_dir}" assert run_pipx_cli(["run", "--backend", "pip", pip_args, "--spec", "pipx-missing-package", "missing-app"]) == 1 assert len(list(paths.CTX.venv_cache.glob("*/pipx_expired_venv"))) == 1 @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_app_not_found_keeps_cache(caplog: pytest.LogCaptureFixture) -> None: assert run_pipx_cli(["run", "--spec", "pycowsay", "pycowsayy"]) == 1 caplog.set_level(logging.INFO) run_pipx_cli_exit(["run", "--spec", "pycowsay", "pycowsay", "hi"], assert_exit=0) assert "Reusing cached venv" in caplog.text @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_pip_args_forwarded_to_shared_libs( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture ) -> None: # an empty env makes the next command upgrade the shared libs, which --no-index must then fail assert shared_libs.SHARED_LIBS.needs_upgrade assert run_pipx_cli(["run", "--verbose", "--pip-args=--no-index", "pycowsay", "hello"]) != 0 assert "ERROR: Could not find a version that satisfies the requirement pip" in capsys.readouterr().err assert "Upgrading shared libraries in" in caplog.text assert "Failed to upgrade shared libraries" in caplog.text @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_with_invalid_requirement(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: script: Final[Path] = tmp_path / "test.py" script.write_text( textwrap.dedent( """ # /// script # dependencies = ["this is an invalid requirement"] # /// print() """ ).strip() ) assert run_pipx_cli(["run", script.as_uri()]) == 1 assert "Invalid requirement this is an invalid requirement" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") @pytest.mark.skipif(sys.platform.startswith("win"), reason="uses file descriptor") def test_run_script_by_file_descriptor(monkeypatch: pytest.MonkeyPatch, hello_script: tuple[Path, Path]) -> None: script, out = hello_script read_fd, write_fd = os.pipe() os.write(write_fd, script.read_bytes()) os.close(write_fd) monkeypatch.chdir(script.parent) try: run_pipx_cli_exit(["run", f"/dev/fd/{read_fd}"]) finally: os.close(read_fd) assert out.read_text() == "Hello, world!" @pytest.mark.skipif(not sys.platform.startswith("win"), reason="uses windows version format") @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_with_windows_python_version(tmp_path: Path) -> None: script: Final[Path] = tmp_path / "test.py" out: Final[Path] = tmp_path / "output.txt" script.write_text( textwrap.dedent( f""" import sys from pathlib import Path Path({str(out)!r}).write_text(sys.version) """ ).strip() ) version: Final[str] = f"{sys.version_info.major}.{sys.version_info.minor}" run_pipx_cli_exit(["run", script.as_uri(), "--python", version]) assert version in out.read_text() @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_verify_script_name_provided(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None: (tmp_path / "black").mkdir() run_pipx_cli_exit(["run", "black"]) assert "black" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_shared_lib_as_app(capfd: pytest.CaptureFixture[str]) -> None: run_pipx_cli_exit(["run", "pip", "--help"]) assert "pip [options]" in capfd.readouterr().out @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_local_path_entry_point( caplog: pytest.LogCaptureFixture, empty_project: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.chdir(empty_project.parent) caplog.set_level(logging.INFO) run_pipx_cli_exit(["run", f".{os.sep}{empty_project.name}"]) assert "Using discovered entry point for 'pipx run'" in caplog.text @pytest.fixture def vcs_project(root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Callable[[str, str], str]: def create(old: str, new: str) -> str: project: Final[Path] = tmp_path / "vcs-project" shutil.copytree(root / "testdata" / "empty_project", project) pyproject: Final[Path] = project / "pyproject.toml" pyproject.write_text(pyproject.read_text().replace(old, new)) # uv clones through `git submodule`, a shell script needing the platform utilities the sandboxed PATH drops monkeypatch.setenv("PATH", os.environ["PATH_ORIG"]) git: Final[str | None] = shutil.which("git") assert git is not None for git_args in ( ["init", "--quiet"], ["add", "."], [ "-c", "user.name=pipx tests", "-c", "user.email=pipx@example.invalid", "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "test fixture", ], ): subprocess.run([git, *git_args], cwd=project, check=True) return f"git+{project.as_uri()}" return create @pytest.mark.parametrize("backend", [pytest.param("pip", id="pip"), pytest.param("uv", id="uv")]) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_vcs_url_infers_app_name( vcs_project: Callable[[str, str], str], caplog: pytest.LogCaptureFixture, backend: str, ) -> None: command: Final[list[str]] = [ "run", "--backend", backend, vcs_project("empty_project.main:cli", "empty_project.main:main"), ] run_pipx_cli_exit(command, assert_exit=0) run_pipx_cli_exit(command, assert_exit=0) assert caplog.text.count("Determined package name: empty-project") == 1 assert "Reusing cached venv" in caplog.text @pytest.mark.parametrize("backend", [pytest.param("pip", id="pip"), pytest.param("uv", id="uv")]) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_vcs_url_runs_sole_script_of_other_name( vcs_project: Callable[[str, str], str], caplog: pytest.LogCaptureFixture, capsys: pytest.CaptureFixture[str], backend: str, ) -> None: # drop the pipx.run entry point as well: it matches on package name and would hide the console script lookup declared: Final[str] = ( 'scripts.empty-project = "empty_project.main:cli"\n' 'entry-points."pipx.run".empty-project = "empty_project.main:cli"' ) command: Final[list[str]] = [ "run", "--backend", backend, vcs_project(declared, 'scripts.renamed = "empty_project.main:main"'), ] run_pipx_cli_exit(command, assert_exit=0) assert "NOTE: running app 'renamed' from 'empty-project'" in capsys.readouterr().out run_pipx_cli_exit(command, assert_exit=0) assert "Reusing cached venv" in caplog.text @pytest.mark.parametrize( "source_args", [ pytest.param(["--spec", "./dist/tool-1.0-py3-none-any.whl"], id="dot-slash"), pytest.param(["--spec", "dist/tool-1.0-py3-none-any.whl"], id="bare"), pytest.param(["--pip-args=--find-links=dist"], id="find-links"), ], ) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_relative_path_keys_cache_per_directory( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str], source_args: list[str] ) -> None: for checkout in ("first", "second"): (dist := tmp_path / checkout / "dist").mkdir(parents=True) with zipfile.ZipFile(dist / "tool-1.0-py3-none-any.whl", "w") as archive: archive.writestr("tool.py", f"def main():\n print('built in {checkout}')\n") archive.writestr("tool-1.0.dist-info/METADATA", "Metadata-Version: 2.1\nName: tool\nVersion: 1.0\n") archive.writestr( "tool-1.0.dist-info/WHEEL", "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n" ) archive.writestr("tool-1.0.dist-info/entry_points.txt", "[console_scripts]\ntool = tool:main\n") archive.writestr("tool-1.0.dist-info/RECORD", "") monkeypatch.chdir(tmp_path / "first") run_pipx_cli_exit(["run", *source_args, "tool"], assert_exit=0) monkeypatch.chdir(tmp_path / "second") capfd.readouterr() run_pipx_cli_exit(["run", *source_args, "tool"], assert_exit=0) assert capfd.readouterr().out.splitlines()[-1] == "built in second" @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_uv_dash_app_is_not_a_uv_option() -> None: # uv rejects the operand as a usage error instead of reading -n as --no-cache and running pycowsay run_pipx_cli_exit(["run", "--backend", "uv", "--", "-n", "pycowsay", "hi"], assert_exit=2) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_with() -> None: run_pipx_cli_exit(["run", "--refresh", "--with", "black", "pycowsay", "--help"]) assert list(paths.CTX.venv_cache.rglob("black-*.dist-info")) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_with_cooldown( package_cache_dir: Path, empty_project_wheel: Path, caplog: pytest.LogCaptureFixture ) -> None: run_pipx_cli_exit( [ "run", "--backend", "pip", "--cooldown", "7", "--with", str(empty_project_wheel), f"--pip-args=--no-index --find-links={package_cache_dir}", PKG["pycowsay"]["spec"], "hello", ], assert_exit=0, ) assert ( bool(list(paths.CTX.venv_cache.rglob("empty_project-*.dist-info"))), "--uploaded-prior-to P7D" in caplog.text, ) == (True, True) @pytest.mark.parametrize("backend", [pytest.param("pip", id="pip"), pytest.param("uv", id="uv")]) @pytest.mark.usefixtures("pipx_temp_env") def test_run_with_pins_app_dependency( make_project_with_dependency: Callable[[str], Path], tmp_path: Path, mocker: MockerFixture, backend: str ) -> None: mocker.patch("os.execvpe", autospec=True, side_effect=SystemExit(0)) run_pipx_cli_exit([ "run", "--backend", backend, "--spec", str(make_wheel_in(make_project_with_dependency("black") / "pyproject.toml", tmp_path).file), "--with", "black==22.8.0", "empty-project", ]) assert [path.name for path in paths.CTX.venv_cache.rglob("black-*.dist-info")] == ["black-22.8.0.dist-info"] @pytest.mark.usefixtures("pipx_temp_env") def test_run_with_app_extra_exposes_nothing(local_extras_wheel: Path, mocker: MockerFixture) -> None: mocker.patch("os.execvpe", autospec=True, side_effect=SystemExit(0)) run_pipx_cli_exit(["run", "--spec", str(local_extras_wheel), "--with", f"{local_extras_wheel}[cow]", "repeatme"]) assert list(paths.CTX.bin_dir.iterdir()) == [] @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_run_with_cache(caplog: pytest.LogCaptureFixture) -> None: run_pipx_cli_exit(["run", "--refresh", "--with", "black", "pycowsay", "args"], assert_exit=0) caplog.set_level(logging.DEBUG) caplog.clear() run_pipx_cli_exit(["run", "--verbose", "--with", "black", "pycowsay", "args"], assert_exit=0) assert "Reusing cached venv" in caplog.text def test_maybe_script_content_accepts_url_query_string(mocker: MockerFixture) -> None: mocker.patch("pipx.commands.run.download_script", autospec=True, return_value="print('hi')") assert maybe_script_content("https://example.invalid/tool.py?raw=1", is_path=False) == "print('hi')" @pytest.mark.parametrize( "app", [ pytest.param("demo==1.0", id="exact"), pytest.param("demo~=1.0", id="compatible"), pytest.param("demo[extra]", id="extras", marks=pytest.mark.skipif(WIN, reason="invalid Windows filename")), pytest.param( 'demo; python_version >= "3.11"', id="marker", marks=pytest.mark.skipif(WIN, reason="invalid Windows filename"), ), pytest.param( "demo @ https://example.invalid/tool", id="direct-url", marks=pytest.mark.skipif(WIN, reason="invalid Windows filename"), ), ], ) def test_maybe_script_content_ignores_requirement_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, app: str ) -> None: planted = tmp_path / app planted.parent.mkdir(parents=True, exist_ok=True) planted.write_text("print('planted')", encoding="utf-8") monkeypatch.chdir(tmp_path) assert maybe_script_content(app, is_path=False) is None assert maybe_script_content(app, is_path=True) == Path(app) @pytest.mark.parametrize( ("filename", "app"), [pytest.param("demo", f".{os.sep}demo", id="relative-path"), pytest.param("demo.py", "demo.py", id="bare-py")], ) def test_maybe_script_content_accepts_local_script( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, filename: str, app: str ) -> None: (tmp_path / filename).write_text("print('script')", encoding="utf-8") monkeypatch.chdir(tmp_path) assert maybe_script_content(app, is_path=False) == Path(app) @pytest.mark.skipif(WIN, reason="Windows does not allow a colon in a file name") def test_maybe_script_content_ignores_url_shaped_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture ) -> None: mocker.patch("pipx.commands.run.download_script", autospec=True, return_value="print('downloaded')") planted = tmp_path / "https:" / "example.invalid" / "tool.py" planted.parent.mkdir(parents=True) planted.write_text("print('planted')", encoding="utf-8") monkeypatch.chdir(tmp_path) assert maybe_script_content("https://example.invalid/tool.py", is_path=False) == "print('downloaded')" @pytest.mark.parametrize("command", [pytest.param("install", id="install"), pytest.param("run", id="run")]) @pytest.mark.usefixtures("pipx_temp_env", "exec_via_subprocess") def test_script_url_cut_short_is_rejected( truncated_script_url: str, capsys: pytest.CaptureFixture[str], command: str ) -> None: assert run_pipx_cli([command, truncated_script_url]) == 1 assert f"Script {truncated_script_url} ended 80 bytes short of its Content-Length." in " ".join( capsys.readouterr().err.split() ) @pytest.fixture def truncated_script_url() -> Iterator[str]: with _serving(_TruncatingHandler) as port: yield f"http://127.0.0.1:{port}/tool.py" class _TruncatingHandler(BaseHTTPRequestHandler): def do_GET(self) -> None: self.send_response(200) self.send_header("Content-Length", "100") self.end_headers() self.wfile.write(b"print('first half')\n") self.close_connection = True def log_message(self, format: str, *args: object) -> None: # ruff:ignore[builtin-argument-shadowing] # overrides the stdlib signature pass @pytest.mark.parametrize( ("first", "second"), [ pytest.param((["black", "isort"], [], []), (["blackisort"], [], []), id="requirements"), pytest.param(([], ["-q", "--pre"], []), ([], ["-q--pre"], []), id="pip-short-options"), pytest.param( ([], ["--index-url", "https://example.invalid"], []), ([], ["--index-urlhttps://example.invalid"], []), id="pip-long-options", ), pytest.param(([], [], ["--upgrade", "--copies"]), ([], [], ["--upgrade--copies"]), id="venv-options"), pytest.param( ([], ["--system-site-packages"], []), ([], [], ["--system-site-packages"]), id="pip-venv-boundary", ), pytest.param(([], [""], []), ([], [], []), id="empty-argument"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_run_cache_keeps_argument_boundaries( cache_lock_path: Callable[[list[str], list[str], list[str]], Path], first: tuple[list[str], list[str], list[str]], second: tuple[list[str], list[str], list[str]], ) -> None: assert cache_lock_path(*first) != cache_lock_path(*second) @pytest.mark.usefixtures("pipx_temp_env") def test_run_cache_key_is_stable(cache_lock_path: Callable[[list[str], list[str], list[str]], Path]) -> None: assert cache_lock_path(["black", "isort"], ["-q"], []) == cache_lock_path(["black", "isort"], ["-q"], []) @pytest.mark.parametrize( ("variable", "value"), [ pytest.param("XDG_CONFIG_HOME", "/pipx-tests/config", id="xdg-config-home"), pytest.param("SSL_CERT_FILE", "/pipx-tests/ca.pem", id="ssl-cert-file"), pytest.param("HTTPS_PROXY", "http://proxy.invalid:3128", id="https-proxy"), pytest.param("https_proxy", "http://proxy.invalid:3128", id="https-proxy-lower-case"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_run_cache_key_follows_tool_env( cache_lock_path: Callable[[list[str], list[str], list[str]], Path], monkeypatch: pytest.MonkeyPatch, variable: str, value: str, ) -> None: monkeypatch.delenv(variable, raising=False) first: Final[Path] = cache_lock_path(["black"], [], []) monkeypatch.setenv(variable, value) assert cache_lock_path(["black"], [], []) != first @pytest.mark.usefixtures("pipx_temp_env") def test_run_cache_key_ignores_unrelated_env( cache_lock_path: Callable[[list[str], list[str], list[str]], Path], monkeypatch: pytest.MonkeyPatch ) -> None: first: Final[Path] = cache_lock_path(["black"], [], []) monkeypatch.setenv("PIPX_TESTS_UNRELATED", "1") assert cache_lock_path(["black"], [], []) == first @pytest.fixture def cache_lock_path(mocker: MockerFixture) -> Callable[[list[str], list[str], list[str]], Path]: # stop at the filesystem boundary before installing conflicting argument sets lock: Final = mocker.patch("filelock.FileLock", autospec=True) # Python before 3.14 does not configure a dunder of an autospec through keyword arguments lock.return_value.__enter__.side_effect = OSError("cache lock unavailable") def capture(dependencies: list[str], pip_args: list[str], venv_args: list[str]) -> Path: with pytest.raises(OSError, match="cache lock unavailable"): run_package( "pycowsay", "pycowsay", RunOptions( app_args=[], dependencies=dependencies, python="python3", pip_args=pip_args, venv_args=venv_args, is_path=False, pypackages=False, verbose=False, use_cache=True, python_args=[], refresh=False, no_path_check=True, backend=DEFAULT_BACKEND_OPTIONS, cooldown=CooldownOptions(), python_flag_passed=False, fetch_python=FetchPythonOptions.NEVER, ), infer_app_name=False, resolved_backend="pip", ) return lock.call_args.args[0] return capture @pytest.fixture def plant_secret() -> Callable[[bytes], Path]: def plant(secret: bytes) -> Path: paths.CTX.venv_cache.mkdir(parents=True, exist_ok=True) secret_path: Final[Path] = paths.CTX.venv_cache / ".run-key" secret_path.write_bytes(secret) secret_path.chmod(0o600) return secret_path return plant @pytest.mark.usefixtures("pipx_temp_env") def test_run_cache_name_depends_on_secret( cache_lock_path: Callable[[list[str], list[str], list[str]], Path], plant_secret: Callable[[bytes], Path] ) -> None: plant_secret(b"a" * 32) first: Final[Path] = cache_lock_path(["black"], [], []) plant_secret(b"b" * 32) assert cache_lock_path(["black"], [], []) != first @pytest.mark.usefixtures("pipx_temp_env") def test_run_cache_name_is_stable_for_a_secret( cache_lock_path: Callable[[list[str], list[str], list[str]], Path], plant_secret: Callable[[bytes], Path] ) -> None: plant_secret(b"a" * 32) first: Final[Path] = cache_lock_path(["black"], [], []) plant_secret(b"b" * 32) other: Final[Path] = cache_lock_path(["black"], [], []) plant_secret(b"a" * 32) assert cache_lock_path(["black"], [], []) == first != other @pytest.mark.skipif(sys.platform == "win32", reason="Windows keeps the secret private through profile ACLs") @pytest.mark.usefixtures("pipx_temp_env") def test_run_cache_secret_is_private(cache_lock_path: Callable[[list[str], list[str], list[str]], Path]) -> None: cache_lock_path(["black"], [], []) assert (paths.CTX.venv_cache / ".run-key").stat().st_mode & 0o777 == 0o600 @pytest.mark.parametrize( ("secret", "mode"), [ pytest.param(b"a" * 32, 0o644, id="world-readable"), pytest.param(b"a" * 32, 0o640, id="group-readable"), pytest.param(b"short", 0o600, id="truncated"), ], ) @pytest.mark.skipif(sys.platform == "win32", reason="Windows has no mode bits to check") @pytest.mark.usefixtures("pipx_temp_env") def test_run_cache_secret_replaces_unusable( cache_lock_path: Callable[[list[str], list[str], list[str]], Path], plant_secret: Callable[[bytes], Path], secret: bytes, mode: int, ) -> None: secret_path: Final[Path] = plant_secret(secret) secret_path.chmod(mode) cache_lock_path(["black"], [], []) assert secret_path.read_bytes() != secret @pytest.mark.skipif(sys.platform == "win32", reason="Windows has no owner to check") @pytest.mark.usefixtures("pipx_temp_env") def test_run_cache_secret_replaces_foreign_owned( cache_lock_path: Callable[[list[str], list[str], list[str]], Path], plant_secret: Callable[[bytes], Path], mocker: MockerFixture, ) -> None: secret_path: Final[Path] = plant_secret(b"a" * 32) mocker.patch("os.geteuid", return_value=secret_path.stat().st_uid + 1) cache_lock_path(["black"], [], []) assert secret_path.read_bytes() != b"a" * 32 @pytest.mark.usefixtures("pipx_temp_env") def test_run_cache_secret_keeps_the_one_a_concurrent_run_wrote( cache_lock_path: Callable[[list[str], list[str], list[str]], Path], plant_secret: Callable[[bytes], Path], mocker: MockerFixture, ) -> None: plant_secret(b"a" * 32) expected: Final[Path] = cache_lock_path(["black"], [], []) plant_secret(b"b" * 32) own: Final[Path] = cache_lock_path(["black"], [], []) (paths.CTX.venv_cache / ".run-key").unlink() def concurrent_run_wins(size: int) -> bytes: plant_secret(b"a" * 32) return b"b" * size mocker.patch("secrets.token_bytes", side_effect=concurrent_run_wins) assert cache_lock_path(["black"], [], []) == expected != own @pytest.mark.usefixtures("pipx_temp_env") def test_run_cache_without_usable_secret_logs_fallback( cache_lock_path: Callable[[list[str], list[str], list[str]], Path], caplog: pytest.LogCaptureFixture, mocker: MockerFixture, ) -> None: mocker.patch("tempfile.mkstemp", side_effect=PermissionError(13, "Permission denied")) caplog.set_level(logging.DEBUG) cache_lock_path(["black"], [], []) assert "run cache names are not keyed with a secret" in caplog.text pipx-1.18.16/tests/test_run_uv.py000066400000000000000000000232511527355705200167570ustar00rootroot00000000000000from __future__ import annotations from dataclasses import replace from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Final, NoReturn import pytest from pipx import paths, subprocess_runner from pipx.commands.run_uv import run_script_via_uv_run, run_via_uv_tool_run, translate_pip_args_for_uv from pipx.constants import FetchPythonOptions from pipx.options import DEFAULT_BACKEND_OPTIONS, CooldownOptions, RunOptions from pipx.redact import redact_url_credentials from pipx.util import PipxError if TYPE_CHECKING: from unittest.mock import MagicMock from pytest_mock import MockerFixture @pytest.fixture def fake_uv(mocker: MockerFixture) -> Path: binary: Final[Path] = Path("/opt/uv/bin/uv") mocker.patch("pipx.commands.run_uv.resolve_uv_binary", return_value=binary) return binary _RUN_OPTIONS: Final[RunOptions] = RunOptions( app_args=[], dependencies=[], python="", pip_args=[], venv_args=[], is_path=False, pypackages=False, verbose=False, use_cache=True, python_args=[], refresh=False, no_path_check=False, backend=DEFAULT_BACKEND_OPTIONS, cooldown=CooldownOptions(), python_flag_passed=False, fetch_python=FetchPythonOptions.NEVER, ) @pytest.fixture def tool_run(mocker: MockerFixture) -> partial[NoReturn]: mocker.patch("pipx.commands.run_uv.which", autospec=True, return_value=None) return partial(run_via_uv_tool_run, "black", "black") @pytest.fixture def script_run(tmp_path: Path) -> partial[NoReturn]: (script := tmp_path / "demo.py").write_text("print('hi')\n") return partial(run_script_via_uv_run, script, tmp_path / "venv") @pytest.fixture def exec_mock(mocker: MockerFixture) -> MagicMock: mocked_exec: Final[MagicMock] = mocker.create_autospec(subprocess_runner.exec_app, side_effect=RuntimeError("exec")) mocker.patch("pipx.commands.run_uv.exec_app", new=mocked_exec) return mocked_exec @pytest.mark.parametrize( ("cooldown_days", "cooldown_args"), [ pytest.param(None, [], id="unset"), pytest.param(7, ["--exclude-newer", "P7D"], id="seven-days"), ], ) def test_run_via_uv_tool_run_basic( exec_mock: MagicMock, fake_uv: Path, tool_run: partial[NoReturn], cooldown_days: int | None, cooldown_args: list[str], ) -> None: with pytest.raises(RuntimeError, match="exec"): tool_run( replace( _RUN_OPTIONS, app_args=["--check"], python="python3.12", cooldown=CooldownOptions(cli_days=cooldown_days), ) ) assert exec_mock.call_args.args[0] == [ str(fake_uv), "tool", "run", "--python", "python3.12", *cooldown_args, "--", "black", "--check", ] @pytest.mark.usefixtures("tool_run") def test_run_via_uv_tool_run_threads_spec_and_with(exec_mock: MagicMock, fake_uv: Path) -> None: with pytest.raises(RuntimeError, match="exec"): run_via_uv_tool_run( "rope", "git+https://example.com/rope.git", replace( _RUN_OPTIONS, dependencies=["rich", "click"], app_args=["--version"], python="python3.12", pip_args=["--index-url", "https://example.com/simple", "--pre"], use_cache=False, verbose=True, ), ) assert exec_mock.call_args.args[0] == [ str(fake_uv), "tool", "run", "--from", "git+https://example.com/rope.git", "--python", "python3.12", "--with", "rich", "--with", "click", "--no-cache", "--verbose", "--default-index", "https://example.com/simple", "--prerelease=allow", "--", "rope", "--version", ] @pytest.mark.usefixtures("fake_uv") def test_run_via_uv_tool_run_omits_from_when_app_matches_spec( exec_mock: MagicMock, tool_run: partial[NoReturn] ) -> None: with pytest.raises(RuntimeError, match="exec"): tool_run(_RUN_OPTIONS) assert "--from" not in exec_mock.call_args.args[0] @pytest.mark.usefixtures("fake_uv") def test_run_via_uv_tool_run_refreshes_cache(exec_mock: MagicMock, tool_run: partial[NoReturn]) -> None: with pytest.raises(RuntimeError, match="exec"): tool_run(replace(_RUN_OPTIONS, refresh=True)) assert "--refresh" in exec_mock.call_args.args[0] @pytest.mark.parametrize( ("cooldown_days", "cooldown_args"), [ pytest.param(None, [], id="unset"), pytest.param(7, ["--exclude-newer", "P7D"], id="seven-days"), ], ) def test_run_script_via_uv_run_uses_uv_run_script( exec_mock: MagicMock, fake_uv: Path, script_run: partial[NoReturn], cooldown_days: int | None, cooldown_args: list[str], ) -> None: with pytest.raises(RuntimeError, match="exec"): script_run( replace( _RUN_OPTIONS, app_args=["--quiet"], python="python3.12", cooldown=CooldownOptions(cli_days=cooldown_days), ) ) assert exec_mock.call_args.args[0] == [ str(fake_uv), "run", "--active", "--script", "--project", str(paths.CTX.home), "--python", "python3.12", *cooldown_args, str(script_run.args[0]), "--quiet", ] @pytest.mark.usefixtures("fake_uv") def test_run_script_via_uv_run_refreshes_cache(exec_mock: MagicMock, script_run: partial[NoReturn]) -> None: with pytest.raises(RuntimeError, match="exec"): script_run(replace(_RUN_OPTIONS, refresh=True)) assert "--refresh" in exec_mock.call_args.args[0] @pytest.mark.parametrize( ("venv_arg", "expected"), [ pytest.param( "--system-site-packages", "--system-site-packages is not supported by `pipx run` under the uv backend.\n", id="plain", ), pytest.param( "--prompt=http://u:s3cr3ttok@host/", f"{redact_url_credentials('--prompt=http://u:s3cr3ttok@host/')} is not supported", id="credentials-masked", ), ], ) @pytest.mark.usefixtures("fake_uv", "exec_mock") def test_run_via_uv_tool_run_rejects_venv_args(tool_run: partial[NoReturn], venv_arg: str, expected: str) -> None: with pytest.raises(PipxError) as error: tool_run(replace(_RUN_OPTIONS, venv_args=[venv_arg])) assert str(error.value).startswith(expected) @pytest.mark.parametrize( ("pip_args", "expected"), [ pytest.param([], [], id="empty"), pytest.param(["-q"], [], id="strip-quiet-short"), pytest.param(["--quiet"], [], id="strip-quiet-long"), pytest.param( ["--index-url", "https://example.com/simple"], ["--default-index", "https://example.com/simple"], id="index-url", ), pytest.param( ["-i", "https://example.com/simple"], ["--default-index", "https://example.com/simple"], id="index-url-short", ), pytest.param( ["--extra-index-url", "https://other.example/simple"], ["--extra-index-url", "https://other.example/simple"], id="extra-index", ), pytest.param(["--pre"], ["--prerelease=allow"], id="pre-to-prerelease"), pytest.param(["--index-url=https://example.com"], ["--default-index=https://example.com"], id="equals-form"), pytest.param(["--no-binary", ":all:"], ["--no-binary"], id="no-binary-all"), pytest.param( ["--no-binary", "one,two"], ["--no-binary-package", "one", "--no-binary-package", "two"], id="no-binary-packages", ), pytest.param(["--no-binary=:none:"], [], id="no-binary-none"), pytest.param(["--only-binary", ":all:"], ["--no-build"], id="only-binary-all"), pytest.param( ["--only-binary=one,two"], ["--no-build-package", "one", "--no-build-package", "two"], id="only-binary-packages", ), pytest.param( ["--trusted-host", "packages.example"], ["--allow-insecure-host", "packages.example"], id="trusted-host", ), pytest.param( ["--trusted-host=packages.example"], ["--allow-insecure-host=packages.example"], id="trusted-host-equals", ), ], ) def test_translate_pip_args_for_uv(pip_args: list[str], expected: list[str]) -> None: assert translate_pip_args_for_uv(pip_args) == expected @pytest.mark.parametrize( ("pip_args", "match"), [ pytest.param(["--editable"], "is not supported", id="editable"), pytest.param(["-e"], "is not supported", id="editable-short"), pytest.param( ["--no-build-isolation"], "contains '--no-build-isolation', which has no", id="unknown-flag", ), pytest.param(["--no-deps"], "contains '--no-deps', which has no", id="no-deps"), pytest.param(["--no-binary="], "Invalid value", id="empty-no-binary"), pytest.param(["--index-url"], "Missing value", id="missing-value"), ], ) def test_translate_pip_args_for_uv_errors(pip_args: list[str], match: str) -> None: with pytest.raises(PipxError, match=match): translate_pip_args_for_uv(pip_args) def test_translate_pip_args_for_uv_rejection_hides_url_credentials() -> None: proxy: Final[str] = "--proxy=http://u:s3cr3ttok@proxy:3128" with pytest.raises(PipxError) as error: translate_pip_args_for_uv([proxy]) assert str(error.value).startswith(f"--pip-args contains {redact_url_credentials(proxy)!r}, which has no") pipx-1.18.16/tests/test_runpip.py000066400000000000000000000157531527355705200167660ustar00rootroot00000000000000from __future__ import annotations import json import shutil from typing import TYPE_CHECKING, Final import pytest from helpers import SKIP_IF_WINDOWS, run_pipx_cli from pipx import paths from pipx.pipx_metadata_file import PipxMetadata from pipx.venv import Venv if TYPE_CHECKING: from pathlib import Path @pytest.mark.parametrize( ("scope_args", "pip_command"), [ pytest.param([], "list", id="list"), pytest.param([], "list --format=freeze", id="splits-single-argument"), pytest.param(["--global"], "list", id="global", marks=SKIP_IF_WINDOWS), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_runpip(scope_args: list[str], pip_command: str) -> None: assert not run_pipx_cli(["install", *scope_args, "pycowsay"]) assert not run_pipx_cli(["runpip", *scope_args, "pycowsay", pip_command]) @pytest.mark.parametrize( ("backend", "option_args"), [ pytest.param("pip", (), id="pip-no-option"), pytest.param("pip", ("--only-binary", "empty-project"), id="pip-only-binary"), pytest.param("pip", ("--no-binary", "empty-project"), id="pip-no-binary"), pytest.param("pip", ("--proxy", "empty-project"), id="pip-proxy"), pytest.param("pip", ("--only-bin", "empty-project"), id="pip-only-binary-abbreviated"), pytest.param("pip", ("--pre",), id="pip-pre-flag-prefixes-prefix"), pytest.param("pip", ("--no-build-iso",), id="pip-no-build-isolation-abbreviated"), pytest.param("uv", ("-P", "empty-project"), id="uv-upgrade-package"), pytest.param("uv", ("--refresh",), id="uv-refresh-flag-prefixes-refresh-package"), pytest.param("uv", ("--no-sources",), id="uv-no-sources-flag-prefixes-no-sources-package"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_runpip_install_refreshes_main_package_metadata( empty_project: Path, empty_project_wheel: Path, backend: str, option_args: tuple[str, ...] ) -> None: assert not run_pipx_cli(["install", "--backend", backend, "--editable", str(empty_project)]) metadata_path: Final[Path] = paths.CTX.venvs / "empty-project" / "pipx_metadata.json" before: Final = json.loads(metadata_path.read_text())["main_package"] assert (before["package_or_url"], before["pip_args"]) == (str(empty_project.resolve()), ["--editable"]) assert not run_pipx_cli([ "runpip", "empty-project", "install", "--force-reinstall", *option_args, str(empty_project_wheel), ]) after: Final = json.loads(metadata_path.read_text())["main_package"] assert (after["package_or_url"], after["pip_args"]) == (str(empty_project_wheel.resolve()), []) @pytest.mark.parametrize( "editable_args", [ pytest.param(("--edit", "{project}"), id="abbreviated"), pytest.param(("--edit={project}",), id="abbreviated-attached"), pytest.param(("--editable={project}",), id="attached"), pytest.param(("-qe", "{project}"), id="clustered"), pytest.param(("-e{project}",), id="short-attached"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_runpip_install_records_editable_spelling( empty_project: Path, empty_project_wheel: Path, editable_args: tuple[str, ...] ) -> None: assert not run_pipx_cli(["install", str(empty_project_wheel)]) assert not run_pipx_cli([ "runpip", "empty-project", "install", *(arg.format(project=empty_project) for arg in editable_args), ]) main_package: Final = PipxMetadata(paths.CTX.venvs / "empty-project").main_package assert (main_package.package_or_url, main_package.pip_args) == (str(empty_project.resolve()), ["--editable"]) _NEW_INDEX: Final[tuple[str, ...]] = ("--no-index", "--find-links", "{new}") @pytest.mark.parametrize("backend", [pytest.param("pip", id="pip"), pytest.param("uv", id="uv")]) @pytest.mark.parametrize( ("install_args", "runpip_args", "expected"), [ pytest.param((), (*_NEW_INDEX, "empty-project"), _NEW_INDEX, id="record"), pytest.param(("--pip-args=--find-links {old}",), (*_NEW_INDEX, "empty-project"), _NEW_INDEX, id="replace"), pytest.param(("--pip-args=--find-links {old}",), ("{wheel}",), ("--find-links", "{old}"), id="keep"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_runpip_install_records_index_options( empty_project: Path, empty_project_wheel: Path, *, backend: str, install_args: tuple[str, ...], runpip_args: tuple[str, ...], expected: tuple[str, ...], ) -> None: links: Final[dict[str, str]] = { "old": str(empty_project.parent), "new": str(empty_project_wheel.parent), "wheel": str(empty_project_wheel), } def fill(args: tuple[str, ...]) -> list[str]: return [arg.format_map(links) for arg in args] assert not run_pipx_cli(["install", "--backend", backend, *fill(install_args), "--editable", str(empty_project)]) assert not run_pipx_cli(["runpip", "empty-project", "install", "--force-reinstall", *fill(runpip_args)]) assert PipxMetadata(paths.CTX.venvs / "empty-project").main_package.pip_args == fill(expected) @pytest.mark.parametrize( "backend", [ pytest.param("pip", id="pip"), pytest.param("uv", id="uv", marks=pytest.mark.skipif(shutil.which("uv") is None, reason="uv is not installed")), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_runpip_resolves_relative_paths_in_working_directory( empty_project_wheel: Path, local_extras_wheel: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, backend: str ) -> None: assert not run_pipx_cli(["install", "--backend", backend, str(empty_project_wheel)]) # a wheel's data files unpack into the venv root, so a file there must not stand in for the user's (paths.CTX.venvs / "empty-project" / "requirements.txt").write_text("picked-by-an-installed-wheel\n") (tmp_path / "requirements.txt").write_text(f"{local_extras_wheel}\n") monkeypatch.chdir(tmp_path) assert not run_pipx_cli(["runpip", "empty-project", "install", "-r", "requirements.txt"]) assert "repeatme" in Venv(paths.CTX.venvs / "empty-project").list_installed_packages() @pytest.mark.parametrize( "find_links_args", [ pytest.param(("-f", "wheels"), id="short"), pytest.param(("-fwheels",), id="short-attached"), pytest.param(("--find-links", "./wheels"), id="long"), pytest.param(("--find-links=wheels",), id="long-attached"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_runpip_relative_find_links_survive_reinstall( empty_project_wheel: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, find_links_args: tuple[str, ...] ) -> None: assert not run_pipx_cli(["install", str(empty_project_wheel)]) (wheels := tmp_path / "wheels").mkdir() shutil.copy2(empty_project_wheel, wheels) monkeypatch.chdir(tmp_path) assert not run_pipx_cli(["runpip", "empty-project", "install", "--no-index", *find_links_args, "empty-project"]) monkeypatch.chdir(paths.CTX.home) assert not run_pipx_cli(["reinstall", "empty-project"]) pipx-1.18.16/tests/test_script.py000066400000000000000000000262361527355705200167530ustar00rootroot00000000000000from __future__ import annotations import traceback from typing import TYPE_CHECKING, Final from urllib.error import URLError from zipfile import ZipFile import pytest from helpers import SKIP_IF_WINDOWS, run_pipx_cli from pipx.redact import redact_url_credentials from pipx.script import ( ScriptMetadata, download_script, installable_script, local_script, read_script_metadata, script_name_from_spec, ) from pipx.util import PipxError if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock from pytest_mock import MockerFixture def test_read_script_metadata() -> None: metadata: Final[ScriptMetadata | None] = read_script_metadata( """# /// script # dependencies = ["requests >= 2"] # requires-python = ">=3.10" # /// """ ) assert metadata == ScriptMetadata(dependencies=("requests>=2",), requires_python=">=3.10") def test_read_script_metadata_path(tmp_path: Path) -> None: script: Final[Path] = tmp_path / "app.py" script.write_text("# /// script\n# dependencies = []\n# ///\n", encoding="utf-8-sig", newline="\r\n") assert read_script_metadata(script) == ScriptMetadata(dependencies=(), requires_python=None) @pytest.mark.parametrize( ("quote", "line"), [ pytest.param('"""', "# ///", id="basic-string-closing"), pytest.param("'''", "# ///", id="literal-string-closing"), pytest.param('"""', "# /// other", id="basic-string-header"), ], ) def test_read_script_metadata_delimiter_in_multiline_string(quote: str, line: str) -> None: content: Final[str] = ( f'# /// script\n# dependencies = ["demo"]\n# [tool.demo]\n# text = {quote}\n{line}\n# {quote}\n# ///\n' ) assert read_script_metadata(content) == ScriptMetadata(dependencies=("demo",), requires_python=None) def test_read_script_metadata_missing() -> None: assert read_script_metadata("print('hello')\n") is None @pytest.mark.parametrize( ("content", "message", "error_type"), [ pytest.param( "# /// script\n# dependencies = [1]\n# ///\n", "dependencies.*array of strings", PipxError, id="dependencies", ), pytest.param( '# /// script\n# dependencies = ["???"]\n# ///\n', "Invalid requirement", PipxError, id="requirement", ), pytest.param( "# /// script\n# dependencies = []\n# requires-python = []\n# ///\n", "requires-python.*string", PipxError, id="requires-python-type", ), pytest.param( '# /// script\n# dependencies = []\n# requires-python = "invalid"\n# ///\n', "Invalid.*requires-python", PipxError, id="requires-python-value", ), pytest.param( "# /// pyproject\n# dependencies = []\n# ///\n", "obsolete.*pyproject", ValueError, id="obsolete", ), pytest.param( "# /// script\n# dependencies = []\n# ///\n\n# /// script\n# dependencies = []\n# ///\n", "Multiple", ValueError, id="multiple", ), pytest.param( "# /// script\n# dependencies = []\n# ///\n# /// script\n# dependencies = []\n# ///\n", "Invalid statement", ValueError, id="adjacent", ), ], ) def test_read_script_metadata_invalid(content: str, message: str, error_type: type[Exception]) -> None: with pytest.raises(error_type, match=message): read_script_metadata(content) @pytest.mark.parametrize( ("spec", "expected_apps", "name"), [ pytest.param("https://example.invalid/My_App.py", (), "my-app", id="url"), pytest.param("https://example.invalid/app.py?version=1", ("Other_App",), "other-app", id="override"), pytest.param("https://example.invalid/app.whl", (), None, id="package"), ], ) def test_script_name_from_spec(spec: str, expected_apps: tuple[str, ...], name: str | None) -> None: assert script_name_from_spec(spec, expected_apps) == name @pytest.mark.parametrize("filename", [pytest.param("My_App.py", id="suffix"), pytest.param("My_App", id="no-suffix")]) @pytest.mark.parametrize( "directory", [ pytest.param("scripts", id="plain"), pytest.param("scripts#123", id="hash"), pytest.param("Project #123", id="space-and-hash"), ], ) def test_script_name_from_spec_path(tmp_path: Path, directory: str, filename: str) -> None: (script_dir := tmp_path / directory).mkdir() (script := script_dir / filename).write_text("# /// script\n# dependencies = []\n# ///\n", encoding="utf-8") assert script_name_from_spec(str(script), ()) == "my-app" def test_script_name_from_spec_binary_without_suffix(tmp_path: Path) -> None: binary: Final[Path] = tmp_path / "binary" binary.write_bytes(b"\xff\xfe") assert script_name_from_spec(str(binary), ()) is None @pytest.mark.parametrize( ("spec", "name"), [ pytest.param("my_app", None, id="bare-name"), pytest.param("./my_app", "my-app", id="relative-path"), ], ) def test_script_name_from_spec_without_suffix_needs_a_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, spec: str, name: str | None ) -> None: monkeypatch.chdir(tmp_path) (tmp_path / "my_app").write_text("# /// script\n# dependencies = []\n# ///\n", encoding="utf-8") assert script_name_from_spec(spec, ()) == name @SKIP_IF_WINDOWS @pytest.mark.parametrize( "url", [ pytest.param("https://example.invalid/app.py", id="py-suffix"), pytest.param("https://example.invalid/app", id="no-suffix"), pytest.param("file:///srv/app.py", id="file-url"), ], ) def test_local_script_ignores_file_below_cwd_named_like_url( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, url: str ) -> None: monkeypatch.chdir(tmp_path) (lookalike := tmp_path / url).parent.mkdir(parents=True) lookalike.write_text("# /// script\n# dependencies = []\n# ///\n", encoding="utf-8") assert local_script(url) is None @SKIP_IF_WINDOWS def test_installable_script_downloads_url_over_file_below_cwd( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, urlopen: MagicMock ) -> None: monkeypatch.chdir(tmp_path) url: Final[str] = "https://example.invalid/app.py" (lookalike := tmp_path / url).parent.mkdir(parents=True) lookalike.write_text("# /// script\n# dependencies = []\n# ///\nprint('local')\n", encoding="utf-8") remote: Final[str] = "# /// script\n# dependencies = []\n# ///\nprint('remote')\n" urlopen.return_value.configure_mock(**{ "read.return_value": remote.encode(), "headers.get_content_charset.return_value": "utf-8", "length": 0, }) with installable_script("app", url, ()) as wheel, ZipFile(wheel) as archive: assert archive.read("_pipx_script_app/_script.py").decode() == remote @pytest.fixture def urlopen(mocker: MockerFixture) -> MagicMock: response: Final[MagicMock] = mocker.MagicMock() response.__enter__.return_value = response return mocker.patch("urllib.request.urlopen", return_value=response) def test_installable_script_rejects_name_mismatch() -> None: with ( pytest.raises(PipxError, match="does not match"), installable_script("other", "https://example.invalid/app.py", ()), ): pass @pytest.mark.parametrize( ("expected_apps", "message"), [ pytest.param(("first", "second"), "one --app", id="multiple"), pytest.param(("bad/app",), "portable command name", id="invalid"), ], ) def test_script_name_from_spec_invalid(expected_apps: tuple[str, ...], message: str) -> None: with pytest.raises(PipxError, match=message): script_name_from_spec("https://example.invalid/app.py", expected_apps) def test_download_script_bounds_the_response_size(urlopen: MagicMock) -> None: urlopen.return_value.configure_mock(**{ "read.return_value": b"x" * (10 * 1024 * 1024 + 1), "headers.get_content_charset.return_value": "utf-8", }) with pytest.raises(PipxError, match="larger than"): download_script("https://example.invalid/big.py") def test_download_script_bounds_the_wait(urlopen: MagicMock) -> None: urlopen.return_value.configure_mock( length=None, **{"read.return_value": b"print('hi')\n", "headers.get_content_charset.return_value": "utf-8"} ) download_script("https://example.invalid/app.py") assert urlopen.call_args.kwargs["timeout"] == 30 def test_read_script_metadata_invalid_requirement_hides_url_credentials() -> None: dependency: Final[str] = "pycowsay http://u:s3cr3ttok@host/pycowsay.whl" with pytest.raises(PipxError) as error: read_script_metadata(f'# /// script\n# dependencies = ["{dependency}"]\n# ///\n') report: Final[str] = "".join(traceback.format_exception(error.value)) assert (f"Invalid requirement {redact_url_credentials(dependency)}:" in report, "s3cr3ttok" in report) == ( True, False, ) @pytest.mark.parametrize( ("body", "charset", "cause"), [ pytest.param(b"\xff", "utf-8", UnicodeDecodeError, id="undecodable"), pytest.param(b"x", "no-such-charset", LookupError, id="unknown-charset"), ], ) def test_download_script_reports_decode_failure( urlopen: MagicMock, body: bytes, charset: str, cause: type[Exception] ) -> None: urlopen.return_value.configure_mock( length=None, **{"read.return_value": body, "headers.get_content_charset.return_value": charset} ) with pytest.raises(PipxError, match=r"^Unable to read script https://example\.invalid/app\.py: ") as error: download_script("https://example.invalid/app.py") assert isinstance(error.value.__cause__, cause) @pytest.mark.parametrize( ("userinfo", "masked"), [pytest.param("alice:s3cr3t@", "****:****@", id="password"), pytest.param("t0ken@", "****@", id="token")], ) @pytest.mark.parametrize("command", [pytest.param("install", id="install"), pytest.param("run", id="run")]) @pytest.mark.usefixtures("pipx_temp_env") def test_script_url_with_credentials_is_rejected_masked( capsys: pytest.CaptureFixture[str], command: str, userinfo: str, masked: str ) -> None: assert run_pipx_cli([command, f"https://{userinfo}localhost/tool.py"]) == 1 assert f"Script URL https://{masked}localhost/tool.py must not contain credentials." in capsys.readouterr().err @pytest.mark.parametrize("command", [pytest.param("install", id="install"), pytest.param("run", id="run")]) @pytest.mark.usefixtures("pipx_temp_env") def test_script_url_with_invalid_port_reports_url(capsys: pytest.CaptureFixture[str], command: str) -> None: assert run_pipx_cli([command, "https://localhost:port/tool.py"]) == 1 assert "Unable to read script https://localhost:port/tool.py: nonnumeric port: 'port'" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_run_ftp_script_url_passes_credentials_to_urllib(mocker: MockerFixture) -> None: urlopen: Final[MagicMock] = mocker.patch("urllib.request.urlopen", side_effect=URLError("refused")) assert run_pipx_cli(["run", "ftp://alice:s3cr3t@localhost/tool.py"]) == 1 assert urlopen.call_args.args[0] == "ftp://alice:s3cr3t@localhost/tool.py" pipx-1.18.16/tests/test_self_managed.py000066400000000000000000000243211527355705200200450ustar00rootroot00000000000000from __future__ import annotations import json import os import subprocess import sys from dataclasses import dataclass, replace from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from pipx.pipx_metadata_file import PIPX_INFO_FILENAME from pipx.self_install import discover_self_managed_environment if TYPE_CHECKING: from collections.abc import Callable, Mapping _SKIP_ON_WINDOWS: Final[pytest.MarkDecorator] = pytest.mark.skipif( os.name == "nt", reason="Self-managed path discovery is only supported on Unix" ) @_SKIP_ON_WINDOWS @pytest.mark.parametrize( ("name", "attribute"), [ pytest.param("PIPX_HOME", "home", id="home"), pytest.param("PIPX_BIN_DIR", "bin_dir", id="bin-dir"), pytest.param("PIPX_DEFAULT_PYTHON", "source_interpreter", id="default-python"), ], ) def test_self_managed_value_is_discovered( self_managed_installation: _SelfManagedInstallation, environment_value: Callable[[_SelfManagedInstallation, str, Mapping[str, str]], Path], name: str, attribute: str, ) -> None: assert environment_value(self_managed_installation, name, {}) == getattr(self_managed_installation, attribute) @_SKIP_ON_WINDOWS def test_self_managed_explicit_environment_wins( self_managed_installation: _SelfManagedInstallation, environment_value: Callable[[_SelfManagedInstallation, str, Mapping[str, str]], Path], ) -> None: explicit_home: Final[Path] = self_managed_installation.home.parent / "explicit" assert environment_value(self_managed_installation, "PIPX_HOME", {"PIPX_HOME": str(explicit_home)}) == explicit_home @_SKIP_ON_WINDOWS @pytest.mark.parametrize( ("name", "attribute"), [ pytest.param("PIPX_HOME", "home", id="home"), pytest.param("PIPX_BIN_DIR", "bin_dir", id="bin-dir"), ], ) def test_self_managed_moved_value_is_discovered( self_managed_installation: _SelfManagedInstallation, environment_value: Callable[[_SelfManagedInstallation, str, Mapping[str, str]], Path], name: str, attribute: str, ) -> None: moved_home: Final[Path] = self_managed_installation.home.parent.parent / "moved" self_managed_installation.home.rename(moved_home) moved_installation: Final[_SelfManagedInstallation] = replace( self_managed_installation, home=moved_home, bin_dir=moved_home / "bin", venv=moved_home / "venvs" / "pipx", executable=moved_home / "bin" / "pipx", ) assert environment_value(moved_installation, name, {}) == getattr(moved_installation, attribute) @_SKIP_ON_WINDOWS @pytest.mark.parametrize( "venv_script", [ pytest.param(lambda installation, _linked_home: installation.venv / "bin" / "pipx", id="direct"), pytest.param( lambda installation, _linked_home: installation.bin_dir / ".." / "venvs" / "pipx" / "bin" / "pipx", id="through-parent-directory", ), pytest.param( lambda _installation, linked_home: linked_home / "venvs" / "pipx" / "bin" / "pipx", id="through-linked-directory", ), ], ) def test_self_managed_venv_script_does_not_set_bin_dir( self_managed_installation: _SelfManagedInstallation, linked_home: Path, discover: Callable[[Path, str], dict[str, str]], venv_script: Callable[[_SelfManagedInstallation, Path], Path], ) -> None: assert "PIPX_BIN_DIR" not in discover( self_managed_installation.venv, str(venv_script(self_managed_installation, linked_home)) ) @_SKIP_ON_WINDOWS def test_self_managed_linked_home_sets_bin_dir( self_managed_installation: _SelfManagedInstallation, linked_home: Path, discover: Callable[[Path, str], dict[str, str]], ) -> None: assert discover(linked_home / "venvs" / "pipx", str(self_managed_installation.executable))["PIPX_BIN_DIR"] == str( self_managed_installation.bin_dir ) @_SKIP_ON_WINDOWS def test_self_managed_executable_on_path_sets_bin_dir( self_managed_installation: _SelfManagedInstallation, discover: Callable[[Path, str], dict[str, str]], monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("PATH", f"{self_managed_installation.bin_dir}{os.pathsep}{os.environ['PATH']}") assert discover(self_managed_installation.venv, "pipx")["PIPX_BIN_DIR"] == str(self_managed_installation.bin_dir) @_SKIP_ON_WINDOWS def test_self_managed_missing_executable_does_not_set_bin_dir( self_managed_installation: _SelfManagedInstallation, discover: Callable[[Path, str], dict[str, str]], ) -> None: assert "PIPX_BIN_DIR" not in discover(self_managed_installation.venv, "pipx-command-that-does-not-exist") @_SKIP_ON_WINDOWS @pytest.mark.parametrize( "global_home", [ pytest.param(lambda installation, _linked_home: installation.home, id="direct"), pytest.param(lambda _installation, linked_home: linked_home, id="symlink"), ], ) def test_self_managed_global_home_keeps_default_paths( self_managed_installation: _SelfManagedInstallation, linked_home: Path, discover: Callable[[Path, str], dict[str, str]], monkeypatch: pytest.MonkeyPatch, global_home: Callable[[_SelfManagedInstallation, Path], Path], ) -> None: monkeypatch.setenv("PIPX_GLOBAL_HOME", str(global_home(self_managed_installation, linked_home))) assert discover(self_managed_installation.venv, str(self_managed_installation.executable)) == { "PIPX_DEFAULT_PYTHON": str(self_managed_installation.source_interpreter) } @_SKIP_ON_WINDOWS @pytest.mark.parametrize( "recorded", [ pytest.param(lambda tmp_path: tmp_path / "bootstrap" / "bin" / "python3", id="removed"), pytest.param(lambda _tmp_path: Path(sys.executable), id="other-interpreter"), ], ) def test_self_managed_default_python_not_backing_the_venv_is_ignored( self_managed_installation: _SelfManagedInstallation, discover: Callable[[Path, str], dict[str, str]], tmp_path: Path, recorded: Callable[[Path], Path], ) -> None: (self_managed_installation.venv / PIPX_INFO_FILENAME).write_text( json.dumps({ "main_package": {"package": "pipx"}, "source_interpreter": {"__type__": "Path", "__Path__": str(recorded(tmp_path))}, }), encoding="utf-8", ) assert discover(self_managed_installation.venv, str(self_managed_installation.executable)) == { "PIPX_HOME": str(self_managed_installation.home), "PIPX_BIN_DIR": str(self_managed_installation.bin_dir), } def test_self_managed_windows_is_ignored( self_managed_installation: _SelfManagedInstallation, discover: Callable[[Path, str], dict[str, str]], monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(os, "name", "nt") assert discover(self_managed_installation.venv, str(self_managed_installation.executable)) == {} @_SKIP_ON_WINDOWS def test_self_managed_missing_metadata_is_ignored( tmp_path: Path, discover: Callable[[Path, str], dict[str, str]] ) -> None: (venv := tmp_path / "pipx" / "venvs" / "pipx").mkdir(parents=True) assert discover(venv, "pipx") == {} @_SKIP_ON_WINDOWS @pytest.mark.parametrize( "metadata", [ pytest.param([], id="non-object"), pytest.param({"main_package": {"package": "black"}}, id="different-package"), ], ) def test_self_managed_invalid_metadata_is_ignored( self_managed_installation: _SelfManagedInstallation, discover: Callable[[Path, str], dict[str, str]], metadata: list[None] | dict[str, dict[str, str]], ) -> None: (self_managed_installation.venv / PIPX_INFO_FILENAME).write_text(json.dumps(metadata), encoding="utf-8") assert discover(self_managed_installation.venv, str(self_managed_installation.executable)) == {} @pytest.fixture def discover(monkeypatch: pytest.MonkeyPatch) -> Callable[[Path, str], dict[str, str]]: def run(prefix: Path, executable: str) -> dict[str, str]: with monkeypatch.context() as patch: patch.setattr(sys, "prefix", str(prefix)) patch.setattr(sys, "argv", [executable]) return discover_self_managed_environment() return run @pytest.fixture def environment_value() -> Callable[[_SelfManagedInstallation, str, Mapping[str, str]], Path]: def run(installation: _SelfManagedInstallation, name: str, overrides: Mapping[str, str]) -> Path: code: Final[str] = ( "import sys; " f"sys.prefix = {str(installation.venv)!r}; " f"sys.argv = [{str(installation.executable)!r}]; " "from pipx.commands.environment import environment; " f"raise SystemExit(environment({name!r}))" ) env: Final[dict[str, str]] = { key: value for key, value in os.environ.items() if key not in {"PIPX_HOME", "PIPX_BIN_DIR", "PIPX_DEFAULT_PYTHON"} } return Path( subprocess.run( [sys.executable, "-c", code], env={**env, **overrides}, capture_output=True, text=True, check=True ).stdout.strip() ) return run @pytest.fixture def linked_home(self_managed_installation: _SelfManagedInstallation, tmp_path: Path) -> Path: (link := tmp_path / "home-link").symlink_to(self_managed_installation.home) return link @pytest.fixture def self_managed_installation(tmp_path: Path) -> _SelfManagedInstallation: home: Final[Path] = tmp_path / "custom" / "pipx" venv: Final[Path] = home / "venvs" / "pipx" (venv / "bin").mkdir(parents=True) (venv / "bin" / "pipx").touch(mode=0o755) (source_interpreter := tmp_path / "python-custom").touch(mode=0o755) (venv / "bin" / "python").symlink_to(source_interpreter) (venv / PIPX_INFO_FILENAME).write_text( json.dumps({ "main_package": {"package": "pipx"}, "source_interpreter": {"__type__": "Path", "__Path__": str(source_interpreter)}, }), encoding="utf-8", ) (bin_dir := home / "bin").mkdir() (executable := bin_dir / "pipx").symlink_to(Path("../venvs/pipx/bin/pipx")) return _SelfManagedInstallation( home=home, bin_dir=bin_dir, venv=venv, executable=executable, source_interpreter=source_interpreter ) @dataclass(frozen=True, slots=True, kw_only=True) class _SelfManagedInstallation: home: Path bin_dir: Path venv: Path executable: Path source_interpreter: Path pipx-1.18.16/tests/test_shared_libs.py000066400000000000000000000517311527355705200177240ustar00rootroot00000000000000from __future__ import annotations import json import os import shutil import subprocess import sys import time from concurrent.futures import ThreadPoolExecutor from threading import Event from typing import TYPE_CHECKING, Final import pytest from filelock import AcquireReturnProxy, FileLock from helpers import SKIP_IF_WINDOWS, run_pipx_cli from pipx import paths, shared_libs from pipx.backends.pip import PipBackend from pipx.constants import PIPX_SHARED_PTH, WINDOWS from pipx.subprocess_runner import get_site_packages, run_subprocess from pipx.util import PipxError from pipx.venv import Venv if TYPE_CHECKING: from collections.abc import Callable, Sequence from pathlib import Path from unittest.mock import MagicMock from pytest_mock import MockerFixture @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_create_venv_lists_every_shared_site_packages_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # purelib and platlib coincide on the platforms CI runs, so pin a split layout to keep both lines under test shared_site_packages: Final[list[Path]] = [tmp_path / "shared" / "purelib", tmp_path / "shared" / "platlib"] monkeypatch.setattr(type(shared_libs.SHARED_LIBS), "site_packages", property(lambda _self: shared_site_packages)) venv_root: Final[Path] = tmp_path / "venv" PipBackend().create_venv( venv_root, python=sys.executable, venv_args=[], pip_args=[], include_pip=False, verbose=False ) pth_file: Final[Path] = next(venv_root.glob(f"**/{PIPX_SHARED_PTH}")) assert pth_file.read_text().splitlines() == [str(path) for path in shared_site_packages] @pytest.mark.parametrize( ("mtime_minus_now", "needs_upgrade"), [ pytest.param(-shared_libs.SHARED_LIBS_MAX_AGE_SEC - 5 * 60, True, id="older-than-max-age"), pytest.param(-shared_libs.SHARED_LIBS_MAX_AGE_SEC + 5 * 60, False, id="younger-than-max-age"), ], ) @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_auto_update_shared_libs(mtime_minus_now: float, needs_upgrade: bool) -> None: now: Final[float] = time.time() shared_libs.SHARED_LIBS.create(verbose=True, pip_args=[]) shared_libs.SHARED_LIBS.has_been_updated_this_run = False os.utime(shared_libs.SHARED_LIBS.pip_path, (now, mtime_minus_now + now)) assert shared_libs.SHARED_LIBS.needs_upgrade is needs_upgrade @pytest.mark.skipif(not WINDOWS, reason="Windows-specific test") @pytest.mark.parametrize( ("installed", "expected"), [ pytest.param("pythonw.exe", False, id="missing-interpreter"), pytest.param("python.exe", True, id="existing-interpreter"), ], ) @pytest.mark.usefixtures("shared_pip_layout") def test_shared_libs_validity_checks_base_interpreter(tmp_path: Path, installed: str, expected: bool) -> None: (home := tmp_path / "base-python").mkdir() (home / installed).touch() (shared_libs.SHARED_LIBS.root / "pyvenv.cfg").write_text(f"home = {home}\nversion = 3.14.0\n", encoding="utf-8") assert shared_libs.SHARED_LIBS.is_valid is expected @SKIP_IF_WINDOWS @pytest.mark.usefixtures("shared_pip_layout") def test_shared_libs_validity_ignores_base_interpreter_off_windows() -> None: (shared_libs.SHARED_LIBS.root / "pyvenv.cfg").write_text("home = /nonexistent\n", encoding="utf-8") assert shared_libs.SHARED_LIBS.is_valid @pytest.mark.parametrize( "pip_args", [ pytest.param([], id="default"), pytest.param(["setuptools"], id="installed-with-pip"), ], ) @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_shared_libs_excludes_setuptools(pip_args: list[str]) -> None: shared_libs.SHARED_LIBS.create(verbose=True, pip_args=pip_args) listing: Final[str] = subprocess.run( [str(shared_libs.SHARED_LIBS.python_path), "-m", "pip", "list", "--format=json"], capture_output=True, text=True, check=True, ).stdout installed: Final[set[str]] = {pkg["name"].lower() for pkg in json.loads(listing)} assert ("pip" in installed, "setuptools" in installed) == (True, False) @pytest.mark.skipif(sys.version_info < (3, 12), reason="venvs bundle setuptools before Python 3.12") @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_shared_libs_create_does_not_warn_about_missing_setuptools(capfd: pytest.CaptureFixture[str]) -> None: shared_libs.SHARED_LIBS.create(verbose=False, pip_args=[]) assert "Skipping setuptools" not in capfd.readouterr().err @pytest.mark.skipif(sys.version_info < (3, 12), reason="venvs bundle setuptools before Python 3.12") @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_shared_libs_create_skips_setuptools_uninstall_when_absent(mocker: MockerFixture) -> None: spy: Final[MagicMock] = mocker.patch("pipx.shared_libs.run_subprocess", autospec=True, side_effect=run_subprocess) shared_libs.SHARED_LIBS.create(pip_args=[], verbose=False) assert [call.args[0] for call in spy.call_args_list if "uninstall" in call.args[0]] == [] @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_shared_libs_create_without_bundled_pip_wheel(mocker: MockerFixture) -> None: def hide_bundled_pip( cmd: Sequence[str | Path], *, log_cmd_str: str | None = None, log_stdout: bool = True, log_stderr: bool = True, run_dir: str | None = None, env_overrides: dict[str, str | None] | None = None, ) -> subprocess.CompletedProcess[str]: process: Final[subprocess.CompletedProcess[str]] = run_subprocess( cmd, log_cmd_str=log_cmd_str, log_stdout=log_stdout, log_stderr=log_stderr, run_dir=run_dir, env_overrides=env_overrides, ) if "--without-pip" in cmd: process.stdout = "" return process mocker.patch("pipx.shared_libs.run_subprocess", autospec=True, side_effect=hide_bundled_pip) shared_libs.SHARED_LIBS.create(pip_args=[], verbose=False) assert shared_libs.SHARED_LIBS.is_valid @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_shared_libs_create_preserves_pip_args() -> None: pip_args: Final[list[str]] = ["--disable-pip-version-check"] shared_libs.SHARED_LIBS.create(pip_args=pip_args, verbose=False) assert (pip_args, shared_libs.SHARED_LIBS.is_valid) == (["--disable-pip-version-check"], True) @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_shared_libs_create_without_index_when_auto_upgrade_disabled(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(shared_libs.DISABLE_SHARED_LIBS_AUTO_UPGRADE, "1") shared_libs.SHARED_LIBS.create(pip_args=["--no-index"], verbose=False) assert shared_libs.SHARED_LIBS.is_valid @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_global_install_creates_shared_libs_in_global_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr(paths, "OVERRIDE_PIPX_GLOBAL_SHARED_LIBS", None) assert not run_pipx_cli(["install", "--global", "pycowsay"]) assert (shared_libs.SHARED_LIBS.root, shared_libs.SHARED_LIBS.is_valid) == ( (tmp_path / "global" / "pipxhome" / "shared").resolve(), True, ) @pytest.fixture def shared_pip_layout(pipx_ultra_temp_env: None) -> dict[str, Path]: # ruff:ignore[unused-function-argument] # required fixture dependency; usefixtures has no effect on fixtures # empty files stand in for pip, sparing a pip install per test subprocess.run([sys.executable, "-m", "venv", "--without-pip", shared_libs.SHARED_LIBS.root], check=True) shared_libs.SHARED_LIBS.pip_path.touch() pip_main: Final[Path] = shared_libs.SHARED_LIBS.site_packages[0] / "pip" / "_internal" / "cli" / "main.py" pip_main.parent.mkdir(parents=True) pip_main.touch() return { "python": shared_libs.SHARED_LIBS.python_path, "pip-script": shared_libs.SHARED_LIBS.pip_path, "pip-module": pip_main, } @pytest.mark.usefixtures("shared_pip_layout") def test_shared_libs_validity_accepts_complete_layout() -> None: assert shared_libs.SHARED_LIBS.is_valid @pytest.mark.parametrize( ("version", "valid"), [ pytest.param("3.9.25", False, id="too-old"), pytest.param("3.10.0", True, id="minimum"), pytest.param("3.15.0", True, id="current"), pytest.param("unknown", True, id="unrecognized"), pytest.param("", True, id="missing"), ], ) @pytest.mark.usefixtures("shared_pip_layout") def test_shared_libs_checks_python_compatibility(version: str, *, valid: bool) -> None: (shared_libs.SHARED_LIBS.root / "pyvenv.cfg").write_text(f"version = {version}\n" * bool(version), encoding="utf-8") assert shared_libs.SHARED_LIBS.is_valid is valid @pytest.fixture def legacy_shared_pip( pipx_ultra_temp_env: None, # ruff:ignore[unused-function-argument] # activate isolated paths before venv creation ) -> Path: subprocess.run([sys.executable, "-m", "venv", "--without-pip", shared_libs.SHARED_LIBS.root], check=True) site_packages: Final[Path] = get_site_packages(shared_libs.SHARED_LIBS.python_path)[0] if not WINDOWS: legacy_site_packages: Final[Path] = shared_libs.SHARED_LIBS.root / "lib" / "python3.9" / "site-packages" legacy_site_packages.parent.mkdir() site_packages.rename(legacy_site_packages) site_packages.parent.rmdir() (pip_main := shared_libs.SHARED_LIBS.site_packages[0] / "pip" / "_internal" / "cli" / "main.py").parent.mkdir( parents=True ) pip_main.touch() shared_libs.SHARED_LIBS.pip_path.touch() (shared_libs.SHARED_LIBS.root / "pyvenv.cfg").write_text("version = 3.9.25\n", encoding="utf-8") return shared_libs.SHARED_LIBS.site_packages[0] def test_upgrade_shared_rebuilds_old_python_and_refreshes_existing_app( legacy_shared_pip: Path, tmp_path: Path, capfd: pytest.CaptureFixture[str] ) -> None: app: Final[Path] = tmp_path / "app" subprocess.run([sys.executable, "-m", "venv", "--without-pip", app], check=True) venv: Final[Venv] = Venv(app) shared_pth: Final[Path] = venv.site_packages[0] / PIPX_SHARED_PTH shared_pth.write_text(f"{legacy_shared_pip}\n", encoding="utf-8") assert run_pipx_cli(["upgrade-shared"]) == 0 capfd.readouterr() assert venv.run_pip_get_exit_code(["--version"]) == 0 assert ( shared_libs.SHARED_LIBS.site_packages, shared_pth.read_text(encoding="utf-8"), "pip 26." in capfd.readouterr().out, ) == ( get_site_packages(shared_libs.SHARED_LIBS.python_path), "".join(f"{path}\n" for path in get_site_packages(shared_libs.SHARED_LIBS.python_path)), True, ) @pytest.mark.usefixtures("legacy_shared_pip") def test_shared_libs_refuses_incompatible_default_before_clearing(mocker: MockerFixture) -> None: mocker.patch( "pipx.requires_python.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout="3.9.25\n", stderr=""), ) (sentinel := shared_libs.SHARED_LIBS.root / "sentinel").touch() with pytest.raises(PipxError, match=r"Shared pip requires Python 3\.10 or newer"): shared_libs.SHARED_LIBS.create(pip_args=[], verbose=False) assert sentinel.is_file() @pytest.mark.parametrize( "removed", [ pytest.param("python", id="python-missing"), pytest.param("pip-script", id="pip-script-missing"), pytest.param("pip-module", id="pip-module-missing"), ], ) def test_shared_libs_validity_checks_files(shared_pip_layout: dict[str, Path], removed: str) -> None: shared_pip_layout[removed].unlink() assert not shared_libs.SHARED_LIBS.is_valid def test_shared_libs_validity_check_is_cached(shared_pip_layout: dict[str, Path]) -> None: assert shared_libs.SHARED_LIBS.is_valid shared_pip_layout["pip-module"].unlink() assert shared_libs.SHARED_LIBS.is_valid @pytest.mark.parametrize( ("version", "needs_upgrade"), [ pytest.param("26.2.1", True, id="below-floor"), pytest.param("26.2.2", False, id="at-floor"), ], ) @pytest.mark.usefixtures("shared_pip_layout") def test_fresh_shared_pip_needs_upgrade_below_floor(version: str, *, needs_upgrade: bool) -> None: (dist_info := shared_libs.SHARED_LIBS.site_packages[0] / f"pip-{version}.dist-info").mkdir() (dist_info / "METADATA").write_text(f"Metadata-Version: 2.1\nName: pip\nVersion: {version}\n", encoding="utf-8") assert shared_libs.SHARED_LIBS.needs_upgrade is needs_upgrade def test_install_rebuilds_shared_libs_missing_pip_before_running_pip( shared_pip_layout: dict[str, Path], caplog: pytest.LogCaptureFixture ) -> None: shared_pip_layout["pip-module"].unlink() return_code: Final[int] = run_pipx_cli(["install", "--backend", "pip", "pycowsay"]) assert (return_code, "does not import" in caplog.text) == (0, False) def _truncate_pip_main() -> None: (shared_libs.SHARED_LIBS.site_packages[0] / "pip" / "_internal" / "cli" / "main.py").write_text("") def _remove_shared_libs() -> None: shutil.rmtree(shared_libs.SHARED_LIBS.root) @pytest.mark.parametrize( "break_shared_libs", [pytest.param(_truncate_pip_main, id="truncated-pip-module"), pytest.param(_remove_shared_libs, id="removed")], ) @pytest.mark.usefixtures("shared_pip_layout") def test_install_rebuilds_shared_libs_broken_after_validation(break_shared_libs: Callable[[], None]) -> None: assert shared_libs.SHARED_LIBS.is_valid break_shared_libs() assert not run_pipx_cli(["install", "--backend", "pip", "pycowsay"]) @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_install_failure_with_working_pip_keeps_shared_libs() -> None: shared_libs.SHARED_LIBS.create(pip_args=[], verbose=False) (sentinel := shared_libs.SHARED_LIBS.root / "sentinel").touch() return_code: Final[int] = run_pipx_cli(["install", "--backend", "pip", "pipx-package-does-not-exist"]) assert (return_code, sentinel.exists()) == (1, True) def test_shared_libs_upgrade_enforces_pip_floor(upgrade_subprocess: MagicMock) -> None: shared_libs.SHARED_LIBS.upgrade(pip_args=["pip==20"], verbose=True, raises=True) install_command: Final[list[str]] = upgrade_subprocess.call_args.args[0] assert (upgrade_subprocess.call_count, "pip==20" in install_command, "pip >= 26.2.2" in install_command) == ( 1, True, True, ) @pytest.fixture def upgrade_subprocess(pipx_ultra_temp_env: None, mocker: MockerFixture) -> MagicMock: # ruff:ignore[unused-function-argument] # required fixture dependency; usefixtures has no effect on fixtures shared_libs.SHARED_LIBS.create(verbose=True, pip_args=[]) shared_libs.SHARED_LIBS.has_been_updated_this_run = False return mocker.patch( "pipx.shared_libs.run_subprocess", return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""), ) def test_shared_libs_upgrade_runs_pip_in_shared_root(upgrade_subprocess: MagicMock) -> None: shared_libs.SHARED_LIBS.upgrade(pip_args=["--find-links", "wheels"], verbose=True, raises=True) assert upgrade_subprocess.call_args.kwargs["run_dir"] == str(shared_libs.SHARED_LIBS.root) def test_shared_libs_upgrade_resolves_relative_cert_against_cwd( upgrade_subprocess: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) shared_libs.SHARED_LIBS.upgrade(pip_args=["--cert=ca.pem"], verbose=True, raises=True) assert f"--cert={(tmp_path / 'ca.pem').resolve()}" in upgrade_subprocess.call_args.args[0] @pytest.mark.usefixtures("shared_pip_layout") def test_shared_libs_upgrade_serializes_concurrent_calls(mocker: MockerFixture) -> None: first_started: Final[Event] = Event() release_first: Final[Event] = Event() second_started: Final[Event] = Event() def run_upgrade( _command: Sequence[str | Path], **_kwargs: dict[str, str | None] | None ) -> subprocess.CompletedProcess[str]: if first_started.is_set(): second_started.set() else: first_started.set() if not release_first.wait(5): msg = "concurrent shared library test did not release the first upgrade" raise TimeoutError(msg) return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") mocker.patch("pipx.shared_libs.run_subprocess", autospec=True, side_effect=run_upgrade) with ThreadPoolExecutor(max_workers=2) as executor: first_upgrade: Final = executor.submit(shared_libs.SHARED_LIBS.upgrade, pip_args=[], verbose=True, raises=True) assert first_started.wait(5) second_upgrade: Final = executor.submit(shared_libs.SHARED_LIBS.upgrade, pip_args=[], verbose=True, raises=True) try: assert not second_started.wait(0.5) finally: release_first.set() first_upgrade.result(timeout=5) second_upgrade.result(timeout=5) @pytest.mark.parametrize( ("env_value", "expected_calls"), [ pytest.param("1", 0, id="disabled"), pytest.param("0", 1, id="enabled"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_disable_shared_libs_auto_upgrade( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, env_value: str, expected_calls: int ) -> None: (site_packages := tmp_path / "venv" / "lib" / "python" / "site-packages").mkdir(parents=True) (site_packages / PIPX_SHARED_PTH).write_text("") venv: Final[Venv] = Venv(tmp_path / "venv") monkeypatch.setenv(shared_libs.DISABLE_SHARED_LIBS_AUTO_UPGRADE, env_value) monkeypatch.setattr(type(shared_libs.SHARED_LIBS), "is_valid", property(lambda _self: True)) monkeypatch.setattr(type(shared_libs.SHARED_LIBS), "needs_upgrade", property(lambda _self: True)) upgrade: Final[MagicMock] = mocker.patch.object(shared_libs.SHARED_LIBS, "upgrade", autospec=True) venv.check_upgrade_shared_libs(verbose=True, pip_args=[]) assert upgrade.call_count == expected_calls # rebuilds the shared libraries and reinstalls two venvs: just over 10 s on a free-threaded 2-core CI runner @pytest.mark.timeout(60) @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_reinstall_all_rebuilds_broken_shared_pip_once(caplog: pytest.LogCaptureFixture) -> None: for suffix in ("_a", "_b"): assert not run_pipx_cli(["install", "--backend", "pip", "pycowsay", f"--suffix={suffix}"]) _truncate_pip_main() caplog.clear() exit_code: Final[int] = run_pipx_cli(["reinstall-all"]) assert (exit_code, caplog.text.count("does not import")) == (0, 1) @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_runpip_skips_rebuild_when_another_process_repaired_shared_pip( mocker: MockerFixture, caplog: pytest.LogCaptureFixture ) -> None: assert not run_pipx_cli(["install", "--backend", "pip", "pycowsay"]) pip_main: Final[Path] = shared_libs.SHARED_LIBS.site_packages[0] / "pip" / "_internal" / "cli" / "main.py" source: Final[str] = pip_main.read_text() pip_main.write_text("") acquire: Final = FileLock.acquire def repaired_while_waiting(lock: FileLock) -> AcquireReturnProxy: if lock.lock_file.endswith(f"{shared_libs.SHARED_LIBS.root.name}.lock"): pip_main.write_text(source) return acquire(lock) mocker.patch.object(FileLock, "acquire", autospec=True, side_effect=repaired_while_waiting) caplog.clear() exit_code: Final[int] = run_pipx_cli(["runpip", "pycowsay", "list"]) assert (exit_code, "does not import" in caplog.text) == (0, False) @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_install_cooldown_applies_to_new_shared_pip(package_cache_dir: Path, caplog: pytest.LogCaptureFixture) -> None: caplog.set_level("INFO") # pip filters an index by its upload-time metadata, which the test index lacks; local files pass the filter pip_args: Final[str] = f"--no-index --find-links={package_cache_dir}" assert not run_pipx_cli(["install", "--backend", "pip", "--cooldown", "7", f"--pip-args={pip_args}", "pycowsay"]) assert [ message.partition(" -m pip ")[2] for record in caplog.records if (message := record.getMessage()).startswith(f"running {shared_libs.SHARED_LIBS.python_path} -m pip") and " install " in message ] == [ f"--no-input --disable-pip-version-check install --upgrade {pip_args} --force-reinstall -q pip == 26.2.2", f"--no-input --disable-pip-version-check install --upgrade {pip_args} --uploaded-prior-to P7D -q pip >= 26.2.2", ] def test_shared_libs_upgrade_under_cooldown_filters_by_upload_time(upgrade_subprocess: MagicMock) -> None: with shared_libs.shared_pip_cooldown(7): shared_libs.SHARED_LIBS.upgrade(pip_args=[], verbose=True, raises=True) assert [call.args[0][5:] for call in upgrade_subprocess.call_args_list] == [ ["install", "--upgrade", "--uploaded-prior-to", "P7D", "pip >= 26.2.2"] ] @pytest.mark.usefixtures("pipx_ultra_temp_env") def test_shared_libs_create_under_cooldown_keeps_floor_when_index_lacks_upload_times() -> None: with shared_libs.shared_pip_cooldown(7): shared_libs.SHARED_LIBS.create(pip_args=[], verbose=False) assert sorted(path.name for path in shared_libs.SHARED_LIBS.site_packages[0].glob("pip-*.dist-info")) == [ "pip-26.2.2.dist-info" ] pipx-1.18.16/tests/test_standalone_interpreter.py000066400000000000000000001064071527355705200222210ustar00rootroot00000000000000from __future__ import annotations import contextlib import datetime as dt import email.message import errno import hashlib import http.client import io import json import os import shutil import subprocess import sys import tarfile import urllib.error import warnings from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import replace from http import HTTPStatus from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from helpers import SKIP_IF_WINDOWS, run_pipx_cli from package_info import PKG from pipx import constants, paths, pipx_metadata_file, standalone_python if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator from unittest.mock import MagicMock from pytest_mock import MockerFixture _TARGET_PYTHON_VERSION: Final[str] = f"{sys.version_info.major}.{sys.version_info.minor}" _DARWIN_LINK: Final[str] = "https://example.invalid/cpython-3.99.0-aarch64-apple-darwin-install_only.tar.gz" @pytest.mark.usefixtures("pipx_temp_env") def test_legacy_standalone_python_index_is_refreshed(monkeypatch: pytest.MonkeyPatch) -> None: legacy_link: Final[str] = ( "https://github.com/astral-sh/python-build-standalone/releases/download/" "20250818/cpython-3.13.7%2B20250818-x86_64-unknown-linux-gnu-install_only.tar.gz" ) digest: Final[str] = f"sha256:{'0' * 64}" current_releases: Final[list[tuple[str, str]]] = [(legacy_link, digest)] (cache_dir := standalone_python.paths.CTX.standalone_python_cachedir).mkdir(parents=True) (index_file := cache_dir / "index.json").write_text( json.dumps({ "fetched": dt.datetime.now(tz=dt.UTC).timestamp(), "releases": [legacy_link], }) ) monkeypatch.setattr(standalone_python, "get_latest_python_releases", lambda: current_releases) assert ( standalone_python.get_or_update_index()["releases"], json.loads(index_file.read_text())["releases"], ) == (current_releases, [[legacy_link, digest]]) @pytest.mark.parametrize( "corrupt_content", [ pytest.param("", id="empty"), pytest.param("not valid json", id="invalid-json"), pytest.param('{"fetched": "not-a-timestamp", "releases": []}', id="invalid-fetched-type"), pytest.param('{"fetched": 1e30, "releases": []}', id="overflow-timestamp"), pytest.param('{"fetched": 1000.0, "releases": "not-a-list"}', id="invalid-releases-type"), pytest.param('{"fetched": 1000.0, "releases": [["only-link"]]}', id="missing-digest"), pytest.param("[]", id="json-array"), ], ) def test_corrupt_standalone_python_index_is_refreshed( cached_index: tuple[Path, list[tuple[str, str]]], corrupt_content: str ) -> None: index_file: Final[Path] = cached_index[0] index_file.write_text(corrupt_content, encoding="utf-8") assert ( standalone_python.get_or_update_index()["releases"], json.loads(index_file.read_text(encoding="utf-8"))["releases"], ) == (cached_index[1], [[_DARWIN_LINK, f"sha256:{'0' * 64}"]]) def test_directory_at_standalone_python_index_is_refreshed(cached_index: tuple[Path, list[tuple[str, str]]]) -> None: index_file: Final[Path] = cached_index[0] index_file.mkdir() assert ( standalone_python.get_or_update_index()["releases"], json.loads(index_file.read_text(encoding="utf-8"))["releases"], ) == (cached_index[1], [[_DARWIN_LINK, f"sha256:{'0' * 64}"]]) def test_invalid_encoding_standalone_python_index_is_refreshed( cached_index: tuple[Path, list[tuple[str, str]]], ) -> None: cached_index[0].write_bytes(b"\xff") assert standalone_python.get_or_update_index()["releases"] == cached_index[1] def test_unreadable_standalone_python_index_is_refreshed( cached_index: tuple[Path, list[tuple[str, str]]], mocker: MockerFixture ) -> None: cached_index[0].write_text("{}", encoding="utf-8") mocker.patch.object(Path, "read_text", autospec=True, side_effect=PermissionError) assert standalone_python.get_or_update_index()["releases"] == cached_index[1] @pytest.fixture def cached_index( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the cache path belongs to the isolated home mocker: MockerFixture, ) -> tuple[Path, list[tuple[str, str]]]: (cache_dir := standalone_python.paths.CTX.standalone_python_cachedir).mkdir(parents=True, exist_ok=True) releases: Final[list[tuple[str, str]]] = [(_DARWIN_LINK, f"sha256:{'0' * 64}")] mocker.patch.object(standalone_python, "get_latest_python_releases", autospec=True, return_value=releases) return cache_dir / "index.json", releases @pytest.mark.usefixtures("pipx_temp_env") def test_valid_fresh_standalone_python_index_is_reused(mocker: MockerFixture) -> None: (cache_dir := standalone_python.paths.CTX.standalone_python_cachedir).mkdir(parents=True, exist_ok=True) (cache_dir / "index.json").write_text( json.dumps({ "fetched": dt.datetime.now(tz=dt.UTC).timestamp(), "releases": [(_DARWIN_LINK, f"sha256:{'1' * 64}")], }), encoding="utf-8", ) fetch: Final[MagicMock] = mocker.patch.object(standalone_python, "get_latest_python_releases", autospec=True) assert standalone_python.get_or_update_index()["releases"] == [[_DARWIN_LINK, f"sha256:{'1' * 64}"]] fetch.assert_not_called() @pytest.mark.usefixtures("pipx_temp_env", "mocked_github_api") def test_download_standalone_python_sets_tar_filter() -> None: with warnings.catch_warnings(record=True) as caught_warnings: warnings.simplefilter("always") python_path: Final[str] = standalone_python.download_python_build_standalone(_TARGET_PYTHON_VERSION) assert ( Path(python_path).is_file(), [warning for warning in caught_warnings if "filter extracted tar archives" in str(warning.message)], ) == (True, []) @pytest.mark.usefixtures("pipx_temp_env", "mocked_github_api") def test_download_standalone_python_supports_early_python_311( monkeypatch: pytest.MonkeyPatch, ) -> None: # Python 3.11.0-3.11.3 lack tarfile's data filter, so pipx validates members and extracts them by hand monkeypatch.delattr(tarfile, "data_filter") python_path: Final[str] = standalone_python.download_python_build_standalone(_TARGET_PYTHON_VERSION) assert Path(python_path).is_file() @pytest.mark.parametrize( "unsafe", [ pytest.param(("../outside", tarfile.REGTYPE, ""), id="path-traversal"), pytest.param(("python/escape", tarfile.SYMTYPE, "../../../../etc/passwd"), id="escaping-symlink"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_early_python_311_rejects_unsafe_archive( monkeypatch: pytest.MonkeyPatch, published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], unsafe: tuple[str, bytes, str], ) -> None: monkeypatch.delattr(tarfile, "data_filter") archive: Final[io.BytesIO] = io.BytesIO() with tarfile.open(fileobj=archive, mode="w:gz") as python_tar: python_tar.addfile(tarfile.TarInfo("python/bin/python3"), io.BytesIO()) _add_links(python_tar, [unsafe]) published_darwin_release[1](archive.getvalue()) with pytest.raises(standalone_python.PipxError, match="outside"): standalone_python.download_python_build_standalone("3.99") def _add_links(python_tar: tarfile.TarFile, links: Iterable[tuple[str, bytes, str]]) -> None: for name, kind, linkname in links: member = tarfile.TarInfo(name) member.type, member.linkname = kind, linkname python_tar.addfile(member) @pytest.fixture def published_darwin_release(mocker: MockerFixture) -> tuple[Path, Callable[[bytes], MagicMock]]: mocker.patch.object(standalone_python.platform, "system", return_value="Darwin") mocker.patch.object(standalone_python.platform, "machine", return_value="arm64") (cache_dir := standalone_python.paths.CTX.standalone_python_cachedir).mkdir(parents=True, exist_ok=True) def publish(archive_bytes: bytes) -> MagicMock: (cache_dir / "index.json").write_text( json.dumps({ "fetched": dt.datetime.now(tz=dt.UTC).timestamp(), "releases": [[_DARWIN_LINK, f"sha256:{hashlib.sha256(archive_bytes).hexdigest()}"]], }) ) return mocker.patch.object(standalone_python, "urlopen", return_value=io.BytesIO(archive_bytes)) return cache_dir, publish _DEEP_DIR: Final[str] = f"python/{'/'.join(['d'] * 12)}" _CLIMB_TO_FILESYSTEM_ROOT: Final[str] = "/".join([".."] * 12) _SHORTCUTS: Final[str] = "abcdefghijklmnop" _UNFOLLOWABLE_LINK: Final[str] = f"{'/'.join(_SHORTCUTS)}/{'q' * 254}" def _links_too_long_to_follow() -> list[tuple[str, bytes, str]]: # one-letter links keep the names short enough for the kernel, but resolved they exceed PATH_MAX, so a lenient # realpath cannot stat the last two links and takes them for plain directories links: Final[list[tuple[str, bytes, str]]] = [] for depth, shortcut in enumerate(_SHORTCUTS): parent = f"{'d' * 247}/" * depth links.extend([ (f"{parent}{'d' * 247}", tarfile.DIRTYPE, ""), (f"{parent}{shortcut}", tarfile.SYMTYPE, "d" * 247), ]) return [ *links, (_UNFOLLOWABLE_LINK, tarfile.SYMTYPE, "/".join([".."] * len(_SHORTCUTS))), (f"{_UNFOLLOWABLE_LINK}/out", tarfile.SYMTYPE, _CLIMB_TO_FILESYSTEM_ROOT), ] @SKIP_IF_WINDOWS @pytest.mark.parametrize( ("links", "way_out"), [ pytest.param([], f"{_DEEP_DIR}/up/{_CLIMB_TO_FILESYSTEM_ROOT}", id="name-through-link"), pytest.param( [("python/out", tarfile.SYMTYPE, f"{'d/' * 12}up/{_CLIMB_TO_FILESYSTEM_ROOT}")], "python/out", id="link-through-link", ), pytest.param([("python/out", tarfile.LNKTYPE, f"{_DEEP_DIR}/up")], "python/out", id="hard-link-moves-link"), pytest.param(_links_too_long_to_follow(), f"{_UNFOLLOWABLE_LINK}/out", id="links-too-long-to-follow"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_early_python_311_stays_inside_through_archive_links( monkeypatch: pytest.MonkeyPatch, published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], tmp_path: Path, links: list[tuple[str, bytes, str]], way_out: str, ) -> None: # the data filter's stand-in must follow the links unpacked before a member monkeypatch.delattr(tarfile, "data_filter") victim: Final[Path] = tmp_path / "victim" archive: Final[io.BytesIO] = io.BytesIO() with tarfile.open(fileobj=archive, mode="w:gz") as python_tar: python_tar.addfile(tarfile.TarInfo("python/bin/python3"), io.BytesIO()) # on its own this link is fine, it points at the download directory _add_links(python_tar, [(f"{_DEEP_DIR}/up", tarfile.SYMTYPE, "/".join([".."] * 13)), *links]) python_tar.addfile(tarfile.TarInfo(f"{way_out}{victim}"), io.BytesIO()) published_darwin_release[1](archive.getvalue()) with contextlib.suppress(standalone_python.PipxError): standalone_python.download_python_build_standalone("3.99") assert not victim.exists() @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_early_python_311_keeps_links_inside_archive( monkeypatch: pytest.MonkeyPatch, published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], ) -> None: monkeypatch.delattr(tarfile, "data_filter") archive: Final[io.BytesIO] = io.BytesIO() with tarfile.open(fileobj=archive, mode="w:gz") as python_tar: python_tar.addfile(tarfile.TarInfo("python/bin/python3.99"), io.BytesIO()) _add_links( python_tar, [ ("python/bin/python3", tarfile.SYMTYPE, "python3.99"), ("python/bin/python", tarfile.SYMTYPE, "../bin/python3"), ("python/lib/python3.99", tarfile.LNKTYPE, "python/bin/python3.99"), ], ) published_darwin_release[1](archive.getvalue()) python_path: Final[Path] = Path(standalone_python.download_python_build_standalone("3.99")) assert ( python_path.is_symlink(), python_path.resolve().name, (python_path.parent / "python").resolve(), (python_path.parents[1] / "lib" / "python3.99").stat().st_nlink, ) == (True, "python3.99", python_path.resolve(), 2) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_standalone_python_keeps_hard_links_with_temp_dir_on_another_filesystem( mocker: MockerFixture, published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], ) -> None: cache_dir, publish = published_darwin_release real_rename: Final = os.rename def rename_within_cache_dir(source: str | Path, target: str | Path) -> None: if not Path(source).is_relative_to(cache_dir): raise OSError(errno.EXDEV, os.strerror(errno.EXDEV)) real_rename(source, target) # like a tmpfs /tmp or another btrfs subvolume, renames from outside the pipx home fail so shutil.move copies mocker.patch.object(os, "rename", side_effect=rename_within_cache_dir) mocker.patch.object(os, "replace", side_effect=rename_within_cache_dir) archive: Final[io.BytesIO] = io.BytesIO() with tarfile.open(fileobj=archive, mode="w:gz") as python_tar: python_tar.addfile(tarfile.TarInfo("python/bin/python3"), io.BytesIO()) _add_links(python_tar, [("python/bin/python3.99", tarfile.LNKTYPE, "python/bin/python3")]) publish(archive.getvalue()) assert Path(standalone_python.download_python_build_standalone("3.99")).stat().st_nlink == 2 @pytest.mark.usefixtures("pipx_temp_env") def test_standalone_python_failed_fetch_leaves_no_staging_dir( published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], ) -> None: cache_dir, publish = published_darwin_release (stale := cache_dir / ".3.99.incoming" / "download").mkdir(parents=True) publish(b"not a gzip archive") with pytest.raises(standalone_python.PipxError, match="Unable to unpack"): standalone_python.download_python_build_standalone("3.99") assert not stale.parent.exists() @pytest.mark.usefixtures("pipx_temp_env") def test_standalone_python_upgrade_keeps_old_build_when_unpack_fails( published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], ) -> None: cache_dir, publish = published_darwin_release (install_dir := cache_dir / "3.99").joinpath("bin").mkdir(parents=True) (install_dir / "bin" / "python3").write_text("old", encoding="utf-8") (install_dir / "keepme").write_text("v1", encoding="utf-8") publish(b"not a gzip archive") with pytest.raises(standalone_python.PipxError, match="Unable to unpack"): standalone_python.download_python_build_standalone("3.99", override=True) assert ((install_dir / "keepme").read_text(encoding="utf-8"), (install_dir / "bin" / "python3").read_text()) == ( "v1", "old", ) def _python_archive_bytes() -> bytes: archive: Final[io.BytesIO] = io.BytesIO() with tarfile.open(fileobj=archive, mode="w:gz") as python_tar: for name in ("python/bin/python3", "python/python.exe"): python_tar.addfile(tarfile.TarInfo(name), io.BytesIO()) return archive.getvalue() @pytest.mark.usefixtures("pipx_temp_env") def test_standalone_python_upgrade_replaces_existing_build( published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], ) -> None: cache_dir, publish = published_darwin_release (install_dir := cache_dir / "3.99").mkdir(parents=True) (stale := install_dir / "stale").write_text("old", encoding="utf-8") publish(_python_archive_bytes()) python_path: Final[str] = standalone_python.download_python_build_standalone("3.99", override=True) assert (Path(python_path).is_file(), stale.exists()) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_standalone_python_upgrade_clears_backup_left_by_crashed_upgrade( published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], ) -> None: cache_dir, publish = published_darwin_release (cache_dir / "3.99").mkdir(parents=True) (leftover := cache_dir / ".3.99.previous").mkdir() (leftover / "stale").write_text("old", encoding="utf-8") publish(_python_archive_bytes()) python_path: Final[str] = standalone_python.download_python_build_standalone("3.99", override=True) assert (Path(python_path).is_file(), leftover.exists()) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_standalone_python_upgrade_restores_backup_when_swap_fails( published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], mocker: MockerFixture, ) -> None: cache_dir, publish = published_darwin_release (install_dir := cache_dir / "3.99").joinpath("bin").mkdir(parents=True) (install_dir / "keepme").write_text("v1", encoding="utf-8") publish(_python_archive_bytes()) real_replace: Final = Path.replace failed: Final[list[bool]] = [] def replace(self: Path, target: Path) -> Path: if target == install_dir and not failed: failed.append(True) msg = "swap failed" raise OSError(msg) return real_replace(self, target) mocker.patch.object(Path, "replace", autospec=True, side_effect=replace) with pytest.raises(OSError, match="swap failed"): standalone_python.download_python_build_standalone("3.99", override=True) assert (install_dir / "keepme").read_text(encoding="utf-8") == "v1" @pytest.mark.usefixtures("pipx_temp_env") def test_standalone_python_concurrent_fetch_waits_and_reuses_build( published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], mocker: MockerFixture, ) -> None: published_darwin_release[1](_python_archive_bytes()) real_replace: Final = os.replace concurrent: Final[list[Future[str]]] = [] def swap_into_place(source: str | Path, destination: str | Path) -> None: # start the second fetch while the first one is swapping its build into place if not concurrent: concurrent.append(executor.submit(standalone_python.download_python_build_standalone, "3.99")) with pytest.raises(TimeoutError): concurrent[0].result(timeout=0.2) real_replace(source, destination) swaps: Final = mocker.patch.object(os, "replace", side_effect=swap_into_place) with ThreadPoolExecutor(max_workers=1) as executor: python_path: Final[str] = standalone_python.download_python_build_standalone("3.99") assert (concurrent[0].result(), swaps.call_count, Path(python_path).is_file()) == (python_path, 1, True) @pytest.mark.usefixtures("pipx_temp_env") def test_standalone_python_download_retries_transient_http_error( published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], mocker: MockerFixture, http_error: Callable[[int], urllib.error.HTTPError], ) -> None: archive_bytes: Final[bytes] = _python_archive_bytes() published_darwin_release[1](archive_bytes) mocker.patch.object(standalone_python, "urlopen", side_effect=[http_error(503), io.BytesIO(archive_bytes)]) sleep: Final[MagicMock] = mocker.patch.object(standalone_python, "_sleep") python_path: Final[str] = standalone_python.download_python_build_standalone("3.99") assert (Path(python_path).is_file(), sleep.call_count) == (True, 1) @pytest.fixture def http_error() -> Iterator[Callable[[int], urllib.error.HTTPError]]: errors: Final[list[urllib.error.HTTPError]] = [] def make(code: int) -> urllib.error.HTTPError: error = urllib.error.HTTPError("https://example.invalid", code, "boom", email.message.Message(), None) errors.append(error) return error yield make # urllib hands a synthetic HTTPError an unclosed body, which warns once the object is collected for error in errors: error.close() @pytest.mark.parametrize( ("code", "sleeps"), [ pytest.param(404, 0, id="missing-build-not-retried"), pytest.param(500, 2, id="server-error-retried-until-giving-up"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_standalone_python_download_fails_on_http_error( # pytest injects fixtures by name published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], mocker: MockerFixture, http_error: Callable[[int], urllib.error.HTTPError], code: int, sleeps: int, ) -> None: published_darwin_release[1](_python_archive_bytes()) mocker.patch.object(standalone_python, "urlopen", side_effect=http_error(code)) sleep: Final[MagicMock] = mocker.patch.object(standalone_python, "_sleep") with pytest.raises(standalone_python.PipxError, match="Unable to download"): standalone_python.download_python_build_standalone("3.99") assert sleep.call_count == sleeps @pytest.mark.usefixtures("pipx_temp_env") def test_standalone_python_download_resumes_after_disconnect( published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], mocker: MockerFixture, ) -> None: archive_bytes: Final[bytes] = _python_archive_bytes() published_darwin_release[1](archive_bytes) urlopen: Final[MagicMock] = mocker.patch.object( standalone_python, "urlopen", side_effect=[ _interrupted_response(mocker, archive_bytes[:10]), _range_response(mocker, HTTPStatus.PARTIAL_CONTENT, archive_bytes[10:]), ], ) sleep: Final[MagicMock] = mocker.patch.object(standalone_python, "_sleep") python_path: Final[str] = standalone_python.download_python_build_standalone("3.99") first_request, second_request = (call.args[0] for call in urlopen.call_args_list) assert ( Path(python_path).is_file(), sleep.call_count, first_request.get_header("Range"), second_request.get_header("Range"), ) == (True, 1, None, "bytes=10-") def _interrupted_response(mocker: MockerFixture, chunk: bytes) -> MagicMock: return mocker.MagicMock(**{ "__enter__.return_value.read.side_effect": [chunk, http.client.RemoteDisconnected("closed")] }) def _range_response(mocker: MockerFixture, status: HTTPStatus, body: bytes) -> MagicMock: return mocker.MagicMock(**{ "__enter__.return_value.status": status, "__enter__.return_value.read.side_effect": [body, b""], }) @pytest.mark.usefixtures("pipx_temp_env") def test_standalone_python_download_restarts_when_range_ignored( published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], mocker: MockerFixture, ) -> None: archive_bytes: Final[bytes] = _python_archive_bytes() published_darwin_release[1](archive_bytes) mocker.patch.object( standalone_python, "urlopen", side_effect=[ _interrupted_response(mocker, archive_bytes[:10]), _range_response(mocker, HTTPStatus.OK, archive_bytes), ], ) sleep: Final[MagicMock] = mocker.patch.object(standalone_python, "_sleep") python_path: Final[str] = standalone_python.download_python_build_standalone("3.99") assert (Path(python_path).is_file(), sleep.call_count) == (True, 1) def test_get_latest_python_releases_retries_transient_http_error( mocker: MockerFixture, http_error: Callable[[int], urllib.error.HTTPError] ) -> None: release: Final[dict[str, str]] = { "browser_download_url": "https://example.invalid/x.tar.gz", "digest": f"sha256:{'0' * 64}", } mocker.patch.object( standalone_python, "urlopen", side_effect=[http_error(503), io.StringIO(json.dumps({"assets": [release]}))], ) sleep: Final[MagicMock] = mocker.patch.object(standalone_python, "_sleep") releases: Final[list[tuple[str, str]]] = standalone_python.get_latest_python_releases() assert (releases, sleep.call_count) == ([(release["browser_download_url"], release["digest"])], 1) @pytest.mark.parametrize( ("code", "sleeps"), [ pytest.param(404, 0, id="missing-release-not-retried"), pytest.param(500, 2, id="server-error-retried-until-giving-up"), ], ) def test_get_latest_python_releases_fails_on_http_error( mocker: MockerFixture, http_error: Callable[[int], urllib.error.HTTPError], code: int, sleeps: int ) -> None: mocker.patch.object(standalone_python, "urlopen", side_effect=http_error(code)) sleep: Final[MagicMock] = mocker.patch.object(standalone_python, "_sleep") with pytest.raises(standalone_python.PipxError, match="Unable to fetch"): standalone_python.get_latest_python_releases() assert sleep.call_count == sleeps def test_get_latest_python_releases_sets_request_timeout(mocker: MockerFixture) -> None: urlopen: Final[MagicMock] = mocker.patch.object( standalone_python, "urlopen", return_value=io.StringIO(json.dumps({"assets": []})), ) assert standalone_python.get_latest_python_releases() == [] urlopen.assert_called_once_with(standalone_python.GITHUB_API_URL, timeout=30) @pytest.mark.usefixtures("pipx_temp_env") def test_download_standalone_python_sets_request_timeout( published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], ) -> None: urlopen: Final[MagicMock] = published_darwin_release[1](_python_archive_bytes()) python_path: Final[str] = standalone_python.download_python_build_standalone("3.99") (request,), kwargs = urlopen.call_args assert (Path(python_path).is_file(), urlopen.call_count, request.full_url, kwargs) == ( True, 1, _DARWIN_LINK, {"timeout": 30}, ) @pytest.mark.usefixtures("pipx_temp_env") def test_download_standalone_python_rejects_unsafe_archive( published_darwin_release: tuple[Path, Callable[[bytes], MagicMock]], ) -> None: archive: Final[io.BytesIO] = io.BytesIO() with tarfile.open(fileobj=archive, mode="w:gz") as python_tar: for name, content in (("python/bin/python3", b""), ("../outside", b"unsafe")): member = tarfile.TarInfo(name) member.size = len(content) python_tar.addfile(member, io.BytesIO(content)) published_darwin_release[1](archive.getvalue()) with pytest.raises(standalone_python.PipxError, match="Unable to unpack"): standalone_python.download_python_build_standalone("3.99") @pytest.mark.usefixtures("mocked_github_api") def test_list_pythons_windows_arm64( mocker: MockerFixture, ) -> None: mocker.patch.object(standalone_python.platform, "system", return_value="Windows") mocker.patch.object(standalone_python.platform, "machine", return_value="ARM64") full_version: Final[str] = "{}.{}.{}".format(*sys.version_info[:3]) assert standalone_python.list_pythons()[full_version][0].endswith("aarch64-pc-windows-msvc-install_only.tar.gz") @pytest.mark.usefixtures("pipx_temp_env") def test_list_no_standalone_interpreters(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["interpreter", "list"]) out: Final[str] = capsys.readouterr().out assert ("Standalone interpreters" in out, len(out.splitlines())) == (True, 1) @pytest.mark.usefixtures("pycowsay_on_fetched_python") def test_list_used_standalone_interpreters(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["interpreter", "list"]) out: Final[str] = capsys.readouterr().out assert (_TARGET_PYTHON_VERSION in out, "pycowsay" in out) == (True, True) @pytest.fixture def pycowsay_on_fetched_python( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing mocked_github_api: None, # ruff:ignore[unused-function-argument] # the fetched build comes from the canned index monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: which: Final = shutil.which monkeypatch.setattr(shutil, "which", lambda name: None if name == _TARGET_PYTHON_VERSION else which(name)) assert not run_pipx_cli([ "install", "--fetch-python=missing", "--python", _TARGET_PYTHON_VERSION, PKG["pycowsay"]["spec"], ]) capsys.readouterr() @pytest.mark.usefixtures("pycowsay_on_fetched_python") def test_list_unused_standalone_interpreters(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["uninstall", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["interpreter", "list"]) out: Final[str] = capsys.readouterr().out assert (_TARGET_PYTHON_VERSION in out, "pycowsay" in out, "Unused" in out) == (True, False, True) @pytest.mark.usefixtures("pycowsay_on_fetched_python") def test_prune_keeps_used_standalone_interpreter(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["interpreter", "prune"]) assert "Nothing to remove" in capsys.readouterr().out @pytest.mark.usefixtures("pycowsay_on_fetched_python") def test_prune_removes_unused_standalone_interpreter(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["uninstall", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["interpreter", "prune"]) out: Final[str] = capsys.readouterr().out assert ("Removed:" in out, f"- Python {_TARGET_PYTHON_VERSION}" in out) == (True, True) @pytest.mark.usefixtures("pruned_unused_interpreter") def test_list_after_prune_shows_no_standalone_interpreter(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["interpreter", "list"]) out: Final[str] = capsys.readouterr().out assert ("Standalone interpreters" in out, len(out.splitlines())) == (True, 1) @pytest.fixture def pruned_unused_interpreter( pycowsay_on_fetched_python: None, # ruff:ignore[unused-function-argument] # prune needs an interpreter to remove capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["uninstall", "pycowsay"]) assert not run_pipx_cli(["interpreter", "prune"]) capsys.readouterr() @pytest.mark.usefixtures("pruned_unused_interpreter") def test_prune_after_prune_has_nothing_to_remove(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["interpreter", "prune"]) assert "Nothing to remove" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_prune_keeps_standalone_interpreter_being_fetched(capsys: pytest.CaptureFixture[str]) -> None: (staging_dir := paths.CTX.standalone_python_cachedir / ".3.99.incoming").mkdir(parents=True) assert not run_pipx_cli(["interpreter", "prune"]) assert ("Nothing to remove" in capsys.readouterr().out, staging_dir.is_dir()) == (True, True) @pytest.mark.usefixtures("pycowsay_on_fetched_python") def test_upgrade_standalone_interpreter( monkeypatch: pytest.MonkeyPatch, standalone_python_index: Callable[[str], dict[str, object]], capsys: pytest.CaptureFixture[str], ) -> None: newer_index: Final[dict[str, object]] = standalone_python_index( f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2] + 1}" ) monkeypatch.setattr(standalone_python, "get_or_update_index", lambda **_: newer_index) assert not run_pipx_cli(["interpreter", "upgrade"]) out: Final[str] = capsys.readouterr().out assert ("Successfully upgraded the interpreter(s):" in out, "pycowsay" in out) == (True, True) @pytest.mark.usefixtures("pipx_temp_env", "mocked_github_api") def test_upgrade_patch_pinned_standalone_interpreter_moves_venv_to_new_build( monkeypatch: pytest.MonkeyPatch, standalone_python_index: Callable[[str], dict[str, object]], ) -> None: assert not run_pipx_cli([ "install", "--fetch-python=always", "--python", "{}.{}.{}".format(*sys.version_info[:3]), PKG["pycowsay"]["spec"], ]) newer_index: Final[dict[str, object]] = standalone_python_index( f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2] + 1}" ) monkeypatch.setattr(standalone_python, "get_or_update_index", lambda **_: newer_index) assert not run_pipx_cli(["interpreter", "upgrade"]) interpreter_dir: Final[Path] = paths.CTX.standalone_python_cachedir / _TARGET_PYTHON_VERSION expected: Final[Path] = interpreter_dir / "python.exe" if constants.WINDOWS else interpreter_dir / "bin" / "python3" assert pipx_metadata_file.PipxMetadata(paths.CTX.venvs / "pycowsay").source_interpreter == expected @pytest.mark.parametrize("windows", [False, True], ids=["posix", "windows"]) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_standalone_interpreters_reads_each_venv_once( mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, windows: bool, ) -> None: monkeypatch.setattr(constants, "WINDOWS", windows) for python_version in ("3.11", "3.12"): interpreter_dir = paths.CTX.standalone_python_cachedir / python_version interpreter_python = interpreter_dir / "python.exe" if windows else interpreter_dir / "bin" / "python3" interpreter_python.parent.mkdir(parents=True) interpreter_python.touch() (venv_dir := paths.CTX.venvs / f"demo-{python_version}").mkdir(parents=True) metadata = pipx_metadata_file.PipxMetadata(venv_dir, read=False) metadata.main_package = replace( metadata.main_package, package=venv_dir.name, package_or_url=venv_dir.name, ) metadata.source_interpreter = interpreter_python metadata.write() def read_version(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: python_path = Path(command[0]) python_version = python_path.parent.name if windows else python_path.parent.parent.name return subprocess.CompletedProcess(command, 0, stdout=f"Python {python_version}.0\n") mocker.patch.object(standalone_python, "list_pythons", return_value=["3.11.1", "3.12.1"]) mocker.patch("pipx.commands.interpreter.subprocess.run", side_effect=read_version) download: Final[MagicMock] = mocker.patch.object(standalone_python, "download_python_build_standalone") reinstall: Final[MagicMock] = mocker.patch("pipx.commands.interpreter.reinstall", autospec=True) metadata_read: Final[MagicMock] = mocker.spy(pipx_metadata_file.PipxMetadata, "read") assert not run_pipx_cli(["interpreter", "upgrade"]) assert (download.call_count, reinstall.call_count, metadata_read.call_count) == (2, 2, 2) @pytest.mark.usefixtures("pipx_temp_env", "mocked_github_api") def test_upgrade_standalone_interpreter_nothing_to_upgrade(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["interpreter", "upgrade"]) assert "Nothing to upgrade" in capsys.readouterr().out @pytest.mark.parametrize( "corrupt_error", [ pytest.param(FileNotFoundError("missing interpreter"), id="missing-executable"), pytest.param(subprocess.CalledProcessError(1, ["python", "--version"]), id="nonzero-exit"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_standalone_interpreter_skips_corrupt( mocker: MockerFixture, corrupt_error: OSError | subprocess.CalledProcessError, ) -> None: for name in ("corrupt", "valid"): (standalone_python.paths.CTX.standalone_python_cachedir / name).mkdir(parents=True) def read_version(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: if "corrupt" in command[0]: raise corrupt_error return subprocess.CompletedProcess(command, 0, stdout="Python 3.12.0\n") mocker.patch.object(standalone_python, "list_pythons", return_value=["not-a-version", "3.12.1"]) mocker.patch("pipx.commands.interpreter.subprocess.run", side_effect=read_version) download: Final[MagicMock] = mocker.patch.object(standalone_python, "download_python_build_standalone") assert not run_pipx_cli(["interpreter", "upgrade"]) download.assert_called_once_with("3.12", override=True) pipx-1.18.16/tests/test_subprocess_runner.py000066400000000000000000000443031527355705200212230ustar00rootroot00000000000000from __future__ import annotations import logging import os import subprocess import sys import threading from concurrent.futures import Future, ThreadPoolExecutor from io import BytesIO, StringIO, TextIOWrapper from pathlib import Path from typing import TYPE_CHECKING, Final from venv import EnvBuilder import pytest from helpers import SKIP_IF_WINDOWS from pipx.fanout import fan_out from pipx.redact import redact_url_credentials from pipx.subprocess_runner import exec_app, get_site_packages, run_subprocess, subprocess_post_check from pipx.util import PipxError if TYPE_CHECKING: from collections.abc import Callable, Iterator from unittest.mock import MagicMock from _pytest.capture import CaptureResult from pytest_mock import MockerFixture _UNDECODABLE_OUTPUT: Final[str] = "import os; os.write(1, b'\\xff bad\\n'); os.write(1, b'after\\n')" _COLOURED_OUTPUT: Final[str] = "satisfy\n\x1b[31m \x1b[0mPython>=3.12 \x1b]8;;https://x\x07link\x1b]8;;\x1b\\\n" _STREAM_ENCODING: Final[dict[str, str]] = {"PYTHONIOENCODING": "cp850", "PYTHONLEGACYWINDOWSSTDIO": "cp850"} def test_exec_app_preserves_stream_encoding_on_posix(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("pipx.subprocess_runner.WINDOWS", False) execvpe: Final[MagicMock] = mocker.patch( "pipx.subprocess_runner.os.execvpe", autospec=True, side_effect=RuntimeError ) with pytest.raises(RuntimeError): exec_app(["python"], dict(_STREAM_ENCODING)) assert {name: execvpe.call_args.args[2][name] for name in _STREAM_ENCODING} == _STREAM_ENCODING def test_exec_app_preserves_stream_encoding_on_windows(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("pipx.subprocess_runner.WINDOWS", True) run: Final[MagicMock] = mocker.patch( "pipx.subprocess_runner.subprocess.run", autospec=True, side_effect=RuntimeError ) with pytest.raises(RuntimeError): exec_app(["python"], dict(_STREAM_ENCODING)) assert {name: run.call_args.kwargs["env"][name] for name in _STREAM_ENCODING} == _STREAM_ENCODING def test_subprocess_keyring_provider_defaults_to_subprocess(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PIP_KEYRING_PROVIDER", raising=False) result: Final = run_subprocess([sys.executable, "-c", "import os; print(os.environ['PIP_KEYRING_PROVIDER'])"]) assert result.stdout.strip() == "subprocess" @pytest.mark.parametrize( ("variable", "value", "expected"), [ pytest.param("PIP_KEYRING_PROVIDER", "import", "import", id="keyring-provider-preserved"), pytest.param("PIP_TARGET", "/some/custom/target", "unset", id="pip-target-removed"), pytest.param("PIP_USER", "1", "0", id="pip-user-disabled"), ], ) def test_subprocess_env_seen_by_the_child( monkeypatch: pytest.MonkeyPatch, variable: str, value: str, expected: str ) -> None: monkeypatch.setenv(variable, value) result: Final = run_subprocess([ sys.executable, "-c", f"import os; print(os.environ.get({variable!r}, 'unset'))", ]) assert result.stdout.strip() == expected def test_subprocess_pythonsafepath_set_for_python_commands() -> None: result: Final = run_subprocess([ sys.executable, "-c", "import os, sys; sys.stdout.write(os.environ.get('PYTHONSAFEPATH', ''))", ]) assert result.stdout == "1" @pytest.mark.parametrize( ("capture_stderr", "stream_output"), [ pytest.param(False, False, id="uncaptured"), pytest.param(True, True, id="streamed"), ], ) def test_subprocess_holds_output_of_fan_out_task_waiting_its_turn( capfd: pytest.CaptureFixture[str], capture_stderr: bool, stream_output: bool ) -> None: second_done: Final[threading.Event] = threading.Event() def first() -> None: second_done.wait(timeout=5) sys.stderr.write("first ") sys.stderr.flush() def second() -> None: run_subprocess( [sys.executable, "-c", "import sys; sys.stderr.write('second')"], capture_stderr=capture_stderr, stream_output=stream_output, ) second_done.set() fan_out([first, second], sequential=False) assert capfd.readouterr().err == "first second" def test_subprocess_held_uncaptured_stderr_stays_unset() -> None: second_done: Final[threading.Event] = threading.Event() def second() -> str | None: stderr = run_subprocess([sys.executable, "-c", "import sys"], capture_stderr=False).stderr second_done.set() return stderr assert fan_out([lambda: second_done.wait(timeout=5), second], sequential=False) == [True, None] @pytest.mark.parametrize("stream_output", [pytest.param(False, id="captured"), pytest.param(True, id="streamed")]) @pytest.mark.usefixtures("typed_input") def test_subprocess_of_fan_out_task_waiting_its_turn_reads_no_input(stream_output: bool) -> None: second_done: Final[threading.Event] = threading.Event() def second() -> str: stdout = run_subprocess( [sys.executable, "-c", "import sys; print(repr(sys.stdin.read()))"], stream_output=stream_output ).stdout second_done.set() return stdout assert fan_out([lambda: second_done.wait(timeout=5), second], sequential=False) == [True, "''\n"] @pytest.fixture def typed_input() -> Iterator[None]: read_end, write_end = os.pipe() os.write(write_end, b"typed") os.close(write_end) saved: Final[int] = os.dup(0) os.dup2(read_end, 0) os.close(read_end) try: yield finally: os.dup2(saved, 0) os.close(saved) def test_subprocess_streams_and_captures_output(capsys: pytest.CaptureFixture[str]) -> None: result: Final[subprocess.CompletedProcess[str]] = run_subprocess( [ sys.executable, "-c", ( "import sys; sys.stdout.buffer.write('stdรธut 1\\rstdรธut 2\\r'.encode()); sys.stdout.flush(); " "sys.stderr.buffer.write('stdรซrr 1\\rstdรซrr 2\\r\\n'.encode()); sys.stderr.flush()" ), ], stream_output=True, ) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err, result.stdout, result.stderr) == ( "stdรธut 1\rstdรธut 2\r", "stdรซrr 1\rstdรซrr 2\n", "stdรธut 1\rstdรธut 2\r", "stdรซrr 1\rstdรซrr 2\n", ) @pytest.mark.parametrize("stream_output", [pytest.param(False, id="captured"), pytest.param(True, id="streamed")]) def test_subprocess_captures_output_without_terminal_escapes(mocker: MockerFixture, stream_output: bool) -> None: mocker.patch("pipx.subprocess_runner.sys.stdout", StringIO()) result: Final[subprocess.CompletedProcess[str]] = run_subprocess( [sys.executable, "-c", f"import sys; sys.stdout.write({_COLOURED_OUTPUT!r})"], capture_stderr=False, stream_output=stream_output, ) assert result.stdout == "satisfy\n Python>=3.12 link\n" def test_subprocess_stream_keeps_terminal_escapes_on_screen(mocker: MockerFixture) -> None: destination: Final[StringIO] = StringIO() mocker.patch("pipx.subprocess_runner.sys.stdout", destination) run_subprocess( [sys.executable, "-c", f"import sys; sys.stdout.write({_COLOURED_OUTPUT!r})"], capture_stderr=False, stream_output=True, ) assert destination.getvalue() == _COLOURED_OUTPUT @pytest.mark.parametrize( ("stdout_is_a_terminal", "expected"), [ pytest.param(True, "1 120", id="terminal"), pytest.param(False, "unset unset", id="pipe"), ], ) def test_subprocess_stream_reports_the_terminal_to_the_child( mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, stdout_is_a_terminal: bool, expected: str, ) -> None: monkeypatch.delenv("TTY_COMPATIBLE", raising=False) monkeypatch.delenv("COLUMNS", raising=False) mocker.patch("pipx.subprocess_runner.sys.stdout.isatty", return_value=stdout_is_a_terminal) mocker.patch("pipx.subprocess_runner.shutil.get_terminal_size", return_value=os.terminal_size((120, 40))) result: Final[subprocess.CompletedProcess[str]] = run_subprocess( [ sys.executable, "-c", "import os; print(os.environ.get('TTY_COMPATIBLE', 'unset'), os.environ.get('COLUMNS', 'unset'))", ], stream_output=True, ) assert result.stdout.strip() == expected @pytest.mark.parametrize( ("destination_is_a_terminal", "expected"), [ pytest.param(True, "True", id="hands-a-terminal", marks=SKIP_IF_WINDOWS), pytest.param(False, "False", id="leaves-a-pipe"), ], ) def test_subprocess_stream_child_sees_a_terminal_only_behind_one( mocker: MockerFixture, destination_is_a_terminal: bool, expected: str ) -> None: destination: Final[StringIO] = StringIO() mocker.patch.object(destination, "isatty", return_value=destination_is_a_terminal) mocker.patch("pipx.subprocess_runner.sys.stdout", destination) result: Final[subprocess.CompletedProcess[str]] = run_subprocess( [sys.executable, "-c", "import sys; print(sys.stdout.isatty())"], capture_stderr=False, stream_output=True, ) assert result.stdout.strip() == expected def test_subprocess_stream_normalizes_split_crlf(mocker: MockerFixture) -> None: destination: Final[StringIO] = StringIO() mocker.patch("pipx.subprocess_runner.sys.stdout", destination) read_size: Final[int] = 8 * 1024 result: Final[subprocess.CompletedProcess[str]] = run_subprocess( [ sys.executable, "-c", f"import os, sys; os.write(sys.stdout.fileno(), b'x' * {read_size - 1} + b'\\r\\n')", ], capture_stderr=False, stream_output=True, ) expected: Final[str] = "x" * (read_size - 1) + "\n" assert (destination.getvalue(), result.stdout) == (expected, expected) @pytest.mark.parametrize( "statement", [ pytest.param("print('stdout')", id="stdout"), pytest.param("print('stderr', file=sys.stderr)", id="stderr"), ], ) def test_subprocess_stream_does_not_log_capture( caplog: pytest.LogCaptureFixture, statement: str, ) -> None: with caplog.at_level(logging.DEBUG, logger="pipx.subprocess_runner"): run_subprocess( [sys.executable, "-c", f"import sys; {statement}"], stream_output=True, ) assert [record.getMessage() for record in caplog.records if record.levelno == logging.DEBUG] == ["returncode: 0"] def test_subprocess_stream_drains_before_output_error(mocker: MockerFixture) -> None: destination: Final[TextIOWrapper] = TextIOWrapper(BytesIO(), encoding="ascii") mocker.patch("pipx.subprocess_runner.sys.stdout", destination) with pytest.raises(UnicodeEncodeError): run_subprocess( [sys.executable, "-c", "print('รธ' * 100_000)"], capture_stderr=False, stream_output=True, ) @pytest.mark.skipif(sys.platform != "darwin", reason="only macOS drops what a closing pseudo-terminal still queues") @pytest.mark.usefixtures("terminal_stdout") @pytest.mark.timeout(60) # 300 process launches take about 30 s on a macOS CI runner def test_subprocess_stream_keeps_output_of_a_child_that_exits_at_once() -> None: # the loss hit about 1 run in 75 on a CI macOS runner, so a few hundred runs catch it reliably outputs: Final[set[str]] = { run_subprocess([sys.executable, "-c", "print('payload')"], stream_output=True).stdout.strip() for _ in range(300) } assert outputs == {"payload"} @SKIP_IF_WINDOWS @pytest.mark.usefixtures("terminal_stdout") def test_subprocess_stream_propagates_ctrl_c() -> None: # Ctrl-C on a terminal reaches the whole foreground process group: pipx and the child alike with pytest.raises(KeyboardInterrupt): run_subprocess( [ sys.executable, "-c", "import os, signal; os.kill(os.getppid(), signal.SIGINT); os.kill(os.getpid(), signal.SIGINT)", ], stream_output=True, ) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("terminal_stdout") def test_subprocess_stream_propagates_ctrl_c_while_readers_start(mocker: MockerFixture) -> None: submit: Final = ThreadPoolExecutor.submit def interrupted_second_reader( executor: ThreadPoolExecutor, fn: Callable[..., None], /, *args: object ) -> Future[None]: # Ctrl-C lands after the terminal's reader started but before the next one did, as seen on free-threaded CI if submitted: raise KeyboardInterrupt submitted.append(True) return submit(executor, fn, *args) submitted: Final[list[bool]] = [] mocker.patch.object(ThreadPoolExecutor, "submit", autospec=True, side_effect=interrupted_second_reader) with pytest.raises(KeyboardInterrupt): run_subprocess([sys.executable, "-c", "import time; time.sleep(30)"], stream_output=True) @pytest.mark.parametrize("stream_output", [pytest.param(False, id="captured"), pytest.param(True, id="streamed")]) def test_subprocess_replaces_undecodable_output(stream_output: bool) -> None: result: Final[subprocess.CompletedProcess[str]] = run_subprocess( [sys.executable, "-c", _UNDECODABLE_OUTPUT], capture_stderr=False, stream_output=stream_output ) assert result.stdout == "\ufffd bad\nafter\n" @SKIP_IF_WINDOWS @pytest.mark.usefixtures("terminal_stdout") def test_subprocess_stream_replaces_undecodable_terminal_output() -> None: result: Final[subprocess.CompletedProcess[str]] = run_subprocess( [sys.executable, "-c", _UNDECODABLE_OUTPUT], capture_stderr=False, stream_output=True ) assert result.stdout == "\ufffd bad\nafter\n" @SKIP_IF_WINDOWS def test_subprocess_stream_stops_draining_a_failed_reader(mocker: MockerFixture) -> None: destination: Final[StringIO] = StringIO() mocker.patch.object(destination, "isatty", return_value=True) mocker.patch("pipx.subprocess_runner.sys.stdout", destination) destination.close() with pytest.raises(ValueError, match="closed file"): run_subprocess( # the pause leaves the second line unread after the first write kills the reader [ sys.executable, "-c", "import os, time; os.write(1, b'first\\n'); time.sleep(0.2); os.write(1, b'second\\n')", ], capture_stderr=False, stream_output=True, ) @pytest.fixture def terminal_stdout(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("TTY_COMPATIBLE", raising=False) monkeypatch.delenv("COLUMNS", raising=False) mocker.patch("pipx.subprocess_runner.sys.stdout.isatty", return_value=True) mocker.patch("pipx.subprocess_runner.shutil.get_terminal_size", return_value=os.terminal_size((120, 40))) @pytest.mark.parametrize( ("site_dirs", "expected"), [ pytest.param(["lib/python3.99/site-packages"], ["lib/python3.99/site-packages"], id="posix"), pytest.param(["lib/python3.99t/site-packages"], ["lib/python3.99t/site-packages"], id="free-threaded"), pytest.param(["Lib/site-packages"], ["Lib/site-packages"], id="windows"), pytest.param( ["lib/python3.99/site-packages", "lib64/python3.99/site-packages"], ["lib/python3.99/site-packages", "lib64/python3.99/site-packages"], id="separate-lib64", ), ], ) def test_get_site_packages_reads_the_venv_layout(tmp_path: Path, site_dirs: list[str], expected: list[str]) -> None: for site_dir in site_dirs: (tmp_path / site_dir).mkdir(parents=True) # no interpreter exists at this path, so only the layout can answer assert get_site_packages(tmp_path / "bin" / "python") == [tmp_path / site_dir for site_dir in expected] @SKIP_IF_WINDOWS def test_get_site_packages_skips_a_lib64_linked_to_lib(tmp_path: Path) -> None: (purelib := tmp_path / "lib" / "python3.99" / "site-packages").mkdir(parents=True) (tmp_path / "lib64").symlink_to("lib") assert get_site_packages(tmp_path / "bin" / "python") == [purelib] @SKIP_IF_WINDOWS def test_get_site_packages_asks_the_interpreter_when_the_layout_is_ambiguous(tmp_path: Path) -> None: EnvBuilder(symlinks=True).create(tmp_path) (tmp_path / "lib" / "python3.0" / "site-packages").mkdir(parents=True) interpreter: Final[Path] = tmp_path / "bin" / "python" output: Final[list[str]] = subprocess.run( ( str(interpreter), "-c", "import sysconfig; print(sysconfig.get_path('purelib')); print(sysconfig.get_path('platlib'))", ), capture_output=True, check=True, text=True, ).stdout.splitlines() purelib, platlib = (Path(path) for path in output) assert get_site_packages(interpreter) == ( [purelib] if purelib.resolve() == platlib.resolve() else [purelib, platlib] ) @pytest.mark.parametrize( ("args", "expected"), [ pytest.param( ["pip", "--index-url", "https://alice:s3cr3t@host/simple"], "'pip --index-url https://****:****@host/simple' failed", id="password", ), pytest.param( ["pip", "--index-url", "https://alice:correct horse@host/simple"], "'pip --index-url https://****:****@host/simple' failed", id="space-in-password", ), pytest.param( ["pip", "--index-url", "https://host", "tool @ https://host/tool.whl"], "'pip --index-url https://host tool @ https://host/tool.whl' failed", id="no-credentials-across-arguments", ), ], ) def test_subprocess_post_check_reports_failed_command_without_credentials(args: list[str], expected: str) -> None: with pytest.raises(PipxError) as error: subprocess_post_check(subprocess.CompletedProcess(args, 1, "", "")) assert error.value.raw_message == expected @pytest.mark.parametrize("stream", [pytest.param("stdout", id="stdout"), pytest.param("stderr", id="stderr")]) def test_run_subprocess_masks_url_credentials_in_output(stream: str) -> None: url: Final[str] = "https://deploy:s3cr3ttok@host/team/tool" process: Final = run_subprocess([sys.executable, "-c", f"import sys; print({url!r}, file=sys.{stream})"]) assert getattr(process, stream).strip() == redact_url_credentials(url) pipx-1.18.16/tests/test_transaction.py000066400000000000000000000233531527355705200177710ustar00rootroot00000000000000from __future__ import annotations import importlib import signal from contextlib import contextmanager from dataclasses import replace from pathlib import Path from threading import Event, Thread from typing import TYPE_CHECKING, Final import pytest from helpers import SKIP_IF_WINDOWS, run_pipx_cli, write_import_probe from package_info import PKG from pipx import paths, trash from pipx.commands import transaction from pipx.commands.transaction import VenvBackup, preserve_venv from pipx.pipx_metadata_file import PipxMetadata from pipx.util import PipxError from pipx.venv import VenvContainer if TYPE_CHECKING: from collections.abc import Callable, Generator from pytest_mock import MockerFixture def test_preserve_venv_keeps_the_backup_out_of_the_venv_namespace(venv_dir: Path) -> None: with preserve_venv(venv_dir, enabled=True): siblings: Final[set[str]] = {path.name for path in paths.CTX.venvs.iterdir() if path.is_dir()} backups: Final[list[Path]] = [path for path in paths.CTX.trash.iterdir() if path.is_dir()] assert (siblings, len(backups)) == ({"demo"}, 1) @pytest.fixture def venv_dir( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # requested for its isolated pipx home ) -> Path: venv_dir: Final[Path] = paths.CTX.venvs / "demo" venv_dir.mkdir(parents=True) (venv_dir / "marker").write_text("v1", encoding="utf-8") return venv_dir def test_preserve_venv_restores_the_backup_on_failure(venv_dir: Path) -> None: with pytest.raises(PipxError, match="boom"), preserve_venv(venv_dir, enabled=True): _corrupt_then_fail(venv_dir) assert (venv_dir / "marker").read_text(encoding="utf-8") == "v1" def _corrupt_then_fail(venv_dir: Path) -> None: (venv_dir / "marker").write_text("v2", encoding="utf-8") msg = "boom" raise PipxError(msg) def test_preserve_venv_without_upfront_copy_restores_a_removed_venv_on_failure(venv_dir: Path) -> None: def rebuild_then_fail(backup: VenvBackup) -> None: backup.remove_venv() venv_dir.mkdir() msg = "boom" raise PipxError(msg) with pytest.raises(PipxError, match="boom"), preserve_venv(venv_dir, enabled=True, copy_upfront=False) as backup: rebuild_then_fail(backup) assert (venv_dir / "marker").read_text(encoding="utf-8") == "v1" def test_preserve_venv_without_upfront_copy_discards_the_removed_venv_on_success(venv_dir: Path) -> None: with preserve_venv(venv_dir, enabled=True, copy_upfront=False) as backup: backup.remove_venv() venv_dir.mkdir() assert (list(venv_dir.iterdir()), [path for path in paths.CTX.trash.iterdir() if path.is_dir()]) == ([], []) @pytest.mark.usefixtures("signal_handlers") def test_preserve_venv_finishes_the_restore_through_an_interrupt(venv_dir: Path, mocker: MockerFixture) -> None: mocker.patch.object(transaction, "rmdir", autospec=True, side_effect=_rmdir_then_interrupt) with pytest.raises(KeyboardInterrupt), preserve_venv(venv_dir, enabled=True): _corrupt_then_fail(venv_dir) assert (venv_dir / "marker").read_text(encoding="utf-8") == "v1" def _rmdir_then_interrupt(path: Path, *, safe_rm: bool = True) -> None: trash.rmdir(path, safe_rm=safe_rm) signal.raise_signal(signal.SIGINT) def test_remove_venv_keeps_the_venv_when_the_rename_keeps_failing(venv_dir: Path, mocker: MockerFixture) -> None: mocker.patch.object(transaction, "_sleep", autospec=True) mocker.patch.object(Path, "rename", autospec=True, side_effect=PermissionError(32, "in use")) with ( pytest.raises(PermissionError, match="in use"), preserve_venv(venv_dir, enabled=True, copy_upfront=False) as backup, ): backup.remove_venv() assert (venv_dir / "marker").read_text(encoding="utf-8") == "v1" def test_remove_venv_moves_the_venv_once_the_rename_succeeds_on_retry(venv_dir: Path, mocker: MockerFixture) -> None: mocker.patch.object(transaction, "_sleep", autospec=True) real_rename: Final = Path.rename attempts: Final[list[Path]] = [] def rename_after_one_refusal(source: Path, target: Path) -> Path: attempts.append(source) if len(attempts) == 1: raise PermissionError(32, "in use") return real_rename(source, target) mocker.patch.object(Path, "rename", autospec=True, side_effect=rename_after_one_refusal) with preserve_venv(venv_dir, enabled=True, copy_upfront=False) as backup: backup.remove_venv() moved: Final[list[str]] = [(path / "marker").read_text(encoding="utf-8") for path in paths.CTX.trash.iterdir()] assert (venv_dir.exists(), moved, attempts) == (False, ["v1"], [venv_dir, venv_dir]) @pytest.mark.usefixtures("signal_handlers") def test_remove_venv_restores_a_venv_moved_just_before_an_interrupt(venv_dir: Path, mocker: MockerFixture) -> None: real_rename: Final = Path.rename def rename_then_interrupt(source: Path, target: Path) -> Path: moved: Final[Path] = real_rename(source, target) if source == venv_dir: signal.raise_signal(signal.SIGINT) return moved mocker.patch.object(Path, "rename", autospec=True, side_effect=rename_then_interrupt) with pytest.raises(KeyboardInterrupt), preserve_venv(venv_dir, enabled=True, copy_upfront=False) as backup: backup.remove_venv() assert (venv_dir / "marker").read_text(encoding="utf-8") == "v1" @pytest.mark.parametrize("copy_upfront", [pytest.param(True, id="copied"), pytest.param(False, id="moved")]) def test_preserve_venv_restores_after_another_command_swept_the_trash(venv_dir: Path, copy_upfront: bool) -> None: def rebuild_while_listing_then_fail(backup: VenvBackup) -> None: backup.remove_venv() venv_dir.mkdir() run_pipx_cli(["environment"]) msg = "boom" raise PipxError(msg) with ( pytest.raises(PipxError, match="boom"), _venv_lock_held_elsewhere(venv_dir), preserve_venv(venv_dir, enabled=True, copy_upfront=copy_upfront) as backup, ): rebuild_while_listing_then_fail(backup) assert (venv_dir / "marker").read_text(encoding="utf-8") == "v1" @contextmanager def _venv_lock_held_elsewhere(venv_dir: Path) -> Generator[None]: # the command owning the backup runs in another process, so the sweep must not find the lock reentrant held, release = Event(), Event() def hold() -> None: with VenvContainer(paths.CTX.venvs).venv_lock(venv_dir): held.set() release.wait() (holder := Thread(target=hold)).start() held.wait() try: yield finally: release.set() holder.join() @pytest.mark.usefixtures("pipx_temp_env") def test_setup_sweeps_trash_entries_no_command_holds() -> None: paths.CTX.trash.mkdir(parents=True) for name in ("demo-abcd1234-pipx-backup", "demo-abcd1234-pipx-reinstall", "pipx-trash.app"): (paths.CTX.trash / name).mkdir() # the lock a pipx that imported from its copy leaves behind once it exits (paths.CTX.trash / name / "pipx_in_use.lock").touch() (paths.CTX.trash / "pipx-trash.app.exe").touch() run_pipx_cli(["list"]) assert list(paths.CTX.trash.glob("*")) == [] @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_setup_leaves_the_target_of_a_symlinked_trash(tmp_path: Path) -> None: (target := tmp_path / "elsewhere").mkdir() (target / "file").touch() paths.CTX.trash.parent.mkdir(parents=True, exist_ok=True) paths.CTX.trash.symlink_to(target) run_pipx_cli(["list"]) assert [path.name for path in target.iterdir()] == ["file"] @pytest.mark.parametrize( ("package", "command"), [ pytest.param("pycowsay", ["upgrade", "pycowsay"], id="upgrade"), pytest.param(PKG["black"]["spec"], ["upgrade-all"], id="upgrade-all"), pytest.param("pycowsay", ["install", "--force", "pycowsay"], id="install-force"), pytest.param("pycowsay", ["inject", "pycowsay", PKG["black"]["spec"]], id="inject"), pytest.param("pycowsay", ["runpip", "pycowsay", "install", PKG["black"]["spec"]], id="runpip"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_self_change_imports_submodules_of_the_running_version( run_from_venv: Callable[[Path], None], package: str, command: list[str] ) -> None: assert not run_pipx_cli(["install", package]) venv_dir: Final[Path] = paths.CTX.venvs / package.partition("==")[0] run_from_venv(venv_dir) assert not run_pipx_cli(command) write_import_probe(venv_dir, "new") assert importlib.import_module("running_probe.late").VALUE == "old" @pytest.mark.usefixtures("pipx_temp_env") def test_self_upgrade_failure_imports_from_the_restored_venv( run_from_venv: Callable[[Path], None], tmp_path: Path ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) venv_dir: Final[Path] = paths.CTX.venvs / "pycowsay" run_from_venv(venv_dir) metadata: Final[PipxMetadata] = PipxMetadata(venv_dir) metadata.main_package = replace(metadata.main_package, package_or_url=str(tmp_path / "missing")) metadata.write() assert run_pipx_cli(["upgrade", "pycowsay"]) assert importlib.import_module("running_probe.late").VALUE == "old" @pytest.mark.parametrize( "command", [pytest.param(["upgrade", "pycowsay"], id="upgrade"), pytest.param(["reinstall", "pycowsay"], id="reinstall")], ) @pytest.mark.usefixtures("pipx_temp_env") def test_self_change_keeps_its_copy_through_a_later_trash_sweep( run_from_venv: Callable[[Path], None], command: list[str] ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) run_from_venv(paths.CTX.venvs / "pycowsay") assert not run_pipx_cli(command) # another pipx run sweeps the trash once the command above released the venv lock run_pipx_cli(["list"]) assert importlib.import_module("running_probe.late").VALUE == "old" pipx-1.18.16/tests/test_trash.py000066400000000000000000000050051527355705200165570ustar00rootroot00000000000000from __future__ import annotations import logging from pathlib import Path from typing import TYPE_CHECKING, Final from pipx import paths from pipx.trash import rmdir, rmdir_via_trash, safe_unlink if TYPE_CHECKING: from unittest.mock import MagicMock import pytest from pytest_mock import MockerFixture def test_rmdir_without_safe_rm_is_non_fatal_for_locked_files( caplog: pytest.LogCaptureFixture, mocker: MockerFixture, tmp_path: Path, ) -> None: (locked_dir := tmp_path / "locked").mkdir() (locked_dir / "locked.dll").write_text("locked") rmtree: Final[MagicMock] = mocker.patch("pipx.trash.shutil.rmtree", autospec=True) with caplog.at_level(logging.WARNING, logger="pipx.trash"): rmdir(locked_dir, safe_rm=False) rmtree.assert_called_once_with(locked_dir, ignore_errors=True) assert locked_dir.is_dir() assert f"Failed to delete {locked_dir}. You may need to delete it manually." in caplog.text def test_rmdir_with_safe_rm_is_non_fatal_when_move_fails( caplog: pytest.LogCaptureFixture, mocker: MockerFixture, tmp_path: Path, ) -> None: (path := tmp_path / "locked").mkdir() mocker.patch("pipx.trash.shutil.rmtree", autospec=True) mocker.patch.object(Path, "rename", side_effect=PermissionError("locked directory")) mocker.patch.object(type(paths.CTX), "trash", mocker.PropertyMock(return_value=tmp_path / "trash")) with caplog.at_level(logging.WARNING, logger="pipx.trash"): rmdir(path) assert path.is_dir() assert f"Failed to move {path} to the trash" in caplog.text def test_rmdir_via_trash_removes_in_place_when_the_move_fails(mocker: MockerFixture, tmp_path: Path) -> None: (path := tmp_path / "venv").mkdir() mocker.patch.object(Path, "rename", autospec=True, side_effect=OSError("cross-device link")) mocker.patch.object(type(paths.CTX), "trash", mocker.PropertyMock(return_value=tmp_path / "trash")) rmdir_via_trash(path) assert sorted(entry.name for entry in tmp_path.iterdir()) == ["trash"] def test_safe_unlink_handles_existing_trash_directory(mocker: MockerFixture, tmp_path: Path) -> None: (file := tmp_path / "locked").write_text("content") (trash_dir := tmp_path / "trash").mkdir() mocker.patch.object(type(paths.CTX), "trash", mocker.PropertyMock(return_value=trash_dir)) mocker.patch.object(Path, "unlink", side_effect=PermissionError("locked file")) safe_unlink(file) assert not file.exists() assert [path.read_text() for path in trash_dir.iterdir()] == ["content"] pipx-1.18.16/tests/test_uninject.py000066400000000000000000000463671527355705200172750ustar00rootroot00000000000000from __future__ import annotations import json import shutil import subprocess from typing import TYPE_CHECKING, Final import pytest from helpers import SKIP_IF_WINDOWS, app_name, file_or_symlink, mock_legacy_venv, run_pipx_cli, write_wheel from package_info import PKG from pipx import paths from pipx.commands import common from pipx.constants import WINDOWS from pipx.redact import redact_url_credentials from pipx.wrap import pipx_wrap if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path from _pytest.capture import CaptureResult from pytest_mock import MockerFixture @pytest.mark.usefixtures("installed_pycowsay") def test_uninject_json(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["uninject", "--output", "json", "pycowsay", "black"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["uninject"], "data": { "packages": [ { "environment": "pycowsay", "location": str(paths.CTX.venvs / "pycowsay"), "package": "black", "status": "uninjected", "version": "22.8.0", } ], "skipped": [], }, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", }, "", ) @pytest.mark.usefixtures("installed_pycowsay") def test_uninject_json_reports_unknown_package(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["uninject", "--output", "json", "pycowsay", "missing"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["uninject"], "data": {"packages": [], "skipped": []}, "errors": [ { "code": "package_uninject_failed", "environment": "pycowsay", "message": "missing is not in the pycowsay venv. Skipping.", "package": "missing", } ], "exit_code": 1, "pipx_result_version": "1", "status": "error", }, "", ) @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_json_reports_missing_environment(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["uninject", "--output", "json", "missing", "black"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["uninject"], "data": {"packages": [], "skipped": []}, "errors": [ { "code": "package_uninject_failed", "environment": "missing", "message": "Virtual environment missing does not exist.", "package": None, } ], "exit_code": 1, "pipx_result_version": "1", "status": "error", }, "", ) @pytest.mark.usefixtures("installed_pycowsay") def test_uninject_json_reports_missing_metadata(capsys: pytest.CaptureFixture[str]) -> None: mock_legacy_venv("pycowsay") assert run_pipx_cli(["uninject", "--output", "json", "pycowsay", "black"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["uninject"], "data": {"packages": [], "skipped": []}, "errors": [ { "code": "package_uninject_failed", "environment": "pycowsay", "message": pipx_wrap( """ Can't uninject from Virtual Environment 'pycowsay'. 'pycowsay' has no internal pipx metadata. pipx versions before 0.15.0.0 did not record it. Uninstall and install 'pycowsay' manually to fix it. """ ), "package": None, } ], "exit_code": 1, "pipx_result_version": "1", "status": "error", }, "", ) @pytest.mark.usefixtures("installed_pycowsay") def test_uninject_json_reports_main_package(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["uninject", "--output", "json", "pycowsay", "pycowsay"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["uninject"], "data": {"packages": [], "skipped": []}, "errors": [ { "code": "package_uninject_failed", "environment": "pycowsay", "message": pipx_wrap( """ pycowsay is the main package of pycowsay venv. Use `pipx uninstall pycowsay` to uninstall instead of uninject. """, subsequent_indent=" " * 4, ), "package": "pycowsay", } ], "exit_code": 1, "pipx_result_version": "1", "status": "error", }, "", ) @pytest.mark.usefixtures("installed_pycowsay") def test_uninject_quiet(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["inject", "pycowsay", "black"]) capsys.readouterr() assert not run_pipx_cli(["uninject", "-qq", "pycowsay", "black"]) assert "Uninjected package" not in capsys.readouterr().out @pytest.mark.parametrize( "scope", [ pytest.param([], id="user"), pytest.param(["--global"], id="global", marks=SKIP_IF_WINDOWS), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_simple(capsys: pytest.CaptureFixture[str], scope: list[str]) -> None: assert not run_pipx_cli(["install", *scope, "pycowsay"]) assert not run_pipx_cli(["inject", *scope, "pycowsay", PKG["black"]["spec"]]) assert not run_pipx_cli(["uninject", *scope, "pycowsay", "black"]) uninject_output: Final[str] = capsys.readouterr().out assert not run_pipx_cli(["list", *scope, "--include-injected"]) assert ("Uninjected package black" in uninject_output, "black" in capsys.readouterr().out) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_with_include_apps(caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"], "--include-deps", "--include-apps"]) assert not run_pipx_cli(["uninject", "pycowsay", "black", "--verbose"]) assert "removed file" in caplog.text @pytest.mark.skipif(not WINDOWS, reason="Windows-specific test") @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_running_app() -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"], "--include-apps"]) app: Final[Path] = paths.CTX.bin_dir / app_name("black") # the context manager closes the three pipes; leaking them trips the ResourceWarning that filterwarnings errors on with subprocess.Popen( [app, "-"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) as process: try: assert process.poll() is None assert not run_pipx_cli(["uninject", "pycowsay", "black"]) assert not app.exists() finally: process.terminate() process.wait(timeout=10) @pytest.mark.parametrize( "suffix", [ pytest.param("@1", id="canonical"), pytest.param("_x", id="non-canonical"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_with_suffix_removes_apps( empty_project_wheel: Path, local_extras_wheel: Path, suffix: str, ) -> None: assert not run_pipx_cli(["install", str(empty_project_wheel), f"--suffix={suffix}"]) assert not run_pipx_cli([ "inject", f"empty-project{suffix}", f"{local_extras_wheel}[cow]", "--include-deps", "--with-suffix", ]) app_paths: Final[set[Path]] = {paths.CTX.bin_dir / app_name(f"{app}{suffix}") for app in ("pycowsay", "repeatme")} assert all(file_or_symlink(path) for path in app_paths) assert not run_pipx_cli(["uninject", f"empty-project{suffix}", f"repeatme{suffix}"]) assert not any(file_or_symlink(path) for path in app_paths) @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_man_page() -> None: # pycowsay ships man6/pycowsay.6 man_page_paths: Final[list[Path]] = [paths.CTX.man_dir / man_page for man_page in PKG["pycowsay"]["man_pages"]] assert not run_pipx_cli(["install", PKG["black"]["spec"]]) assert not run_pipx_cli(["inject", "black", "pycowsay", "--include-apps"]) assert all(path.exists() for path in man_page_paths) assert not run_pipx_cli(["uninject", "black", "pycowsay"]) assert not any(file_or_symlink(path) for path in man_page_paths) @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_completion_scripts(local_completion_wheel: Path, empty_project_wheel: Path) -> None: completion_paths: Final[list[Path]] = [ paths.CTX.completion_dir / "bash-completion/completions" / "local-completion", paths.CTX.completion_dir / "zsh/site-functions" / "_local-completion", paths.CTX.completion_dir / "fish/vendor_completions.d" / "local-completion.fish", ] assert not run_pipx_cli(["install", str(empty_project_wheel)]) assert not run_pipx_cli(["inject", "empty-project", str(local_completion_wheel), "--include-apps"]) assert all(file_or_symlink(path) for path in completion_paths) assert not run_pipx_cli(["uninject", "empty-project", "local-completion"]) assert not any(file_or_symlink(path) for path in completion_paths) @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_removes_dependency_app_symlinks( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", "requests==2.31.0", "--include-deps", "--include-apps"]) assert "normalizer" in capsys.readouterr().out assert not run_pipx_cli(["uninject", "pycowsay", "requests", "--verbose"]) assert ("removed file" in caplog.text, "normalizer" in caplog.text) == (True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_removes_selected_dependency_resources( local_extras_wheel: Path, empty_project_wheel: Path, ) -> None: package: Final[str] = f"{local_extras_wheel}[tools]" assert not run_pipx_cli(["install", str(empty_project_wheel)]) assert not run_pipx_cli(["inject", "empty-project", package, "--include-resources-from", "pycowsay"]) exposed_paths: Final[tuple[Path, ...]] = ( paths.CTX.bin_dir / app_name("repeatme"), paths.CTX.bin_dir / app_name("pycowsay"), paths.CTX.man_dir / "man6" / "pycowsay.6", ) assert all(file_or_symlink(path) for path in exposed_paths) assert not run_pipx_cli(["uninject", "empty-project", "repeatme"]) assert not any(file_or_symlink(path) for path in exposed_paths) @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_keeps_resources_the_main_package_exposes(local_extras_wheel: Path) -> None: assert not run_pipx_cli(["install", "pycowsay==0.0.0.2"]) assert not run_pipx_cli([ "inject", "pycowsay", f"{local_extras_wheel}[cow]", "--include-resources-from", "pycowsay", ]) assert not run_pipx_cli(["uninject", "pycowsay", "repeatme"]) assert all( file_or_symlink(path) for path in (paths.CTX.bin_dir / app_name("pycowsay"), paths.CTX.man_dir / "man6" / "pycowsay.6") ) @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_leave_deps( capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) assert not run_pipx_cli(["uninject", "pycowsay", "black", "--leave-deps", "--verbose"]) assert ( "Uninjected package black from venv pycowsay" in capsys.readouterr().out, "Dependencies of uninstalled package:" in caplog.text, ) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_preserves_shared_deps(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) assert not run_pipx_cli(["inject", "pycowsay", PKG["pylint"]["spec"]]) assert not run_pipx_cli(["uninject", "pycowsay", "black"]) capsys.readouterr() assert not run_pipx_cli(["list", "--include-injected"]) listed: Final[str] = capsys.readouterr().out assert ("pylint" in listed, "black" in listed) == (True, False) @pytest.mark.usefixtures("installed_pycowsay") def test_uninject_editable_removes_its_dependencies(tmp_path: Path) -> None: # a src layout puts the project's own egg-info on the venv's search path until the uninstall drops its .pth (tmp_path / "src" / "editable_dep").mkdir(parents=True) (tmp_path / "src" / "editable_dep" / "__init__.py").touch() (tmp_path / "pyproject.toml").write_text( '[build-system]\nrequires = ["setuptools"]\nbuild-backend = "setuptools.build_meta"\n\n' '[project]\nname = "editable-dep"\nversion = "0.1"\ndependencies = ["six"]\n', encoding="utf-8", ) assert not run_pipx_cli(["inject", "pycowsay", "--editable", str(tmp_path)]) assert not run_pipx_cli(["uninject", "pycowsay", "editable-dep"]) assert not list((paths.CTX.venvs / "pycowsay").rglob("six-*.dist-info")) @pytest.mark.parametrize( "backend", [ pytest.param("pip", id="pip"), pytest.param( "uv", id="uv", marks=pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_removes_all_orphaned_dependencies(backend: str) -> None: assert not run_pipx_cli(["install", "pycowsay", "--backend", backend]) venv_dir: Final[Path] = paths.CTX.venvs / "pycowsay" installed_before: Final[set[str]] = {path.name for path in venv_dir.rglob("*.dist-info")} assert not run_pipx_cli(["inject", "pycowsay", "requests==2.31.0"]) assert not run_pipx_cli(["uninject", "pycowsay", "requests"]) assert {path.name for path in venv_dir.rglob("*.dist-info")} == installed_before @pytest.mark.usefixtures("installed_pycowsay") def test_uninject_reports_failed_dependency_uninstall(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["inject", "pycowsay", "requests==2.31.0"]) capsys.readouterr() # pip refuses to uninstall a distribution without a RECORD file next((paths.CTX.venvs / "pycowsay").rglob("urllib3-*.dist-info/RECORD")).unlink() assert run_pipx_cli(["uninject", "pycowsay", "requests"]) assert "Error uninstalling certifi, charset-normalizer, idna, urllib3." in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_removes_copied_app_named_before_last_dot( mocker: MockerFixture, empty_project_wheel: Path, dotted_app_wheel: Path ) -> None: mocker.patch.object(common, "can_symlink", autospec=True, return_value=False) assert not run_pipx_cli(["install", str(empty_project_wheel), "--suffix=@1"]) assert not run_pipx_cli(["inject", "empty-project@1", str(dotted_app_wheel), "--include-apps", "--with-suffix"]) # the name pipx 1.17.14 gave the app, with the suffix before the last dot legacy: Final[Path] = (paths.CTX.bin_dir / "tool.12@1").rename(paths.CTX.bin_dir / "tool@1.12") assert not run_pipx_cli(["uninject", "empty-project@1", "foo-bar-x@1"]) assert not legacy.exists() @pytest.mark.usefixtures("installed_pycowsay") def test_uninject_unknown_package_hides_url_credentials(capsys: pytest.CaptureFixture[str]) -> None: masked: Final[str] = redact_url_credentials("git+https://u:s3cr3ttok@host/org/plugin-git") assert run_pipx_cli(["uninject", "--output", "json", "pycowsay", "git+https://u:s3cr3ttok@host/org/plugin.git"]) assert json.loads(capsys.readouterr().out)["errors"] == [ { "code": "package_uninject_failed", "environment": "pycowsay", "message": f"{masked} is not in the pycowsay venv. Skipping.", "package": masked, } ] @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_keeps_an_app_name_taken_over_during_the_uninstall( before_venv_subprocess: Callable[[Path, Callable[[], None]], None], tmp_path: Path ) -> None: assert not run_pipx_cli(["install", str(write_wheel(tmp_path / "wheels", "alpha", "1"))]) assert not run_pipx_cli(["inject", "alpha", str(write_wheel(tmp_path / "wheels", "beta", "1")), "--include-apps"]) beta: Final[Path] = paths.CTX.bin_dir / "beta" (other := tmp_path / "other").touch() def take_over_dangling_name() -> None: if not beta.exists(): beta.unlink() beta.symlink_to(other) before_venv_subprocess(paths.CTX.venvs / "alpha", take_over_dangling_name) assert not run_pipx_cli(["uninject", "alpha", "beta"]) assert beta.resolve() == other.resolve() @pytest.mark.parametrize( "spelling", [pytest.param("Mixed_Case", id="declared"), pytest.param("mixed-case", id="canonical")] ) @pytest.mark.usefixtures("pipx_temp_env") def test_uninject_main_package_any_spelling(capsys: pytest.CaptureFixture[str], tmp_path: Path, spelling: str) -> None: # pip records the name the package declares, so pipx keeps the non-canonical Mixed_Case assert not run_pipx_cli(["install", str(write_wheel(tmp_path, "Mixed_Case", "1"))]) capsys.readouterr() assert run_pipx_cli(["uninject", "--output", "json", "mixed-case", spelling]) assert [error["message"] for error in json.loads(capsys.readouterr().out)["errors"]] == [ pipx_wrap( """ mixed-case is the main package of mixed-case venv. Use `pipx uninstall mixed-case` to uninstall instead of uninject. """, subsequent_indent=" " * 4, ) ] @pytest.mark.parametrize( "spelling", [ pytest.param("Mixed_Case", id="declared"), pytest.param("mixed-case", id="canonical"), pytest.param("MIXED.CASE", id="other"), ], ) @pytest.mark.usefixtures("installed_pycowsay") def test_uninject_injected_package_any_spelling( capsys: pytest.CaptureFixture[str], tmp_path: Path, spelling: str ) -> None: assert not run_pipx_cli(["inject", "pycowsay", str(write_wheel(tmp_path, "Mixed_Case", "1"))]) assert not run_pipx_cli(["uninject", "pycowsay", spelling]) capsys.readouterr() assert not run_pipx_cli(["list", "--short", "--include-injected"]) assert capsys.readouterr().out == "pycowsay 0.0.0.2\n" pipx-1.18.16/tests/test_uninstall.py000066400000000000000000000360351527355705200174560ustar00rootroot00000000000000from __future__ import annotations import importlib import json import sys from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from helpers import ( PIPX_METADATA_LEGACY_VERSIONS, SKIP_IF_WINDOWS, WIN, app_name, file_or_symlink, mock_legacy_venv, remove_venv_interpreter, run_pipx_cli, ) from package_info import PKG from pipx import paths, venv_inspect from pipx.util import get_venv_paths if TYPE_CHECKING: from pytest_mock import MockerFixture @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall() -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["uninstall", "pycowsay"]) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_empty_name_keeps_every_environment() -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert (run_pipx_cli(["uninstall", ""]), (paths.CTX.venvs / "pycowsay").is_dir()) == (1, True) def test_uninstall_json( uninstall_command: tuple[str, list[str]], capsys: pytest.CaptureFixture[str], ) -> None: command, arguments = uninstall_command assert not run_pipx_cli([command, *arguments, "--output", "json"]) captured: Final = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": [command], "data": { "packages": [ { "environment": "pycowsay", "location": str(paths.CTX.venvs / "pycowsay"), "package": "pycowsay", "version": "0.0.0.2", } ], }, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", }, "", ) def test_uninstall_quiet( uninstall_command: tuple[str, list[str]], capsys: pytest.CaptureFixture[str], ) -> None: command, arguments = uninstall_command assert not run_pipx_cli([command, *arguments, "--quiet"]) captured: Final = capsys.readouterr() assert (captured.out, captured.err) == ("", "") @pytest.fixture( params=[ pytest.param(("uninstall", ["pycowsay"]), id="one"), pytest.param(("uninstall-all", []), id="all"), ] ) def uninstall_command( request: pytest.FixtureRequest, pipx_temp_env: None, # ruff:ignore[unused-function-argument] # usefixtures marks do not apply to fixtures capsys: pytest.CaptureFixture[str], ) -> tuple[str, list[str]]: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() return request.param @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_json_reports_missing( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, ) -> None: mocker.patch.object( importlib.import_module("pipx.commands.uninstall"), "which", autospec=True, return_value="/usr/bin/missing", ) assert run_pipx_cli(["uninstall", "missing", "--output", "json"]) captured: Final = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["uninstall"], "data": {"packages": []}, "errors": [ { "code": "environment_uninstall_failed", "environment": "missing", "message": "Nothing to uninstall for missing.", "package": None, } ], "exit_code": 1, "pipx_result_version": "1", "status": "error", }, "", ) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_ignores_disappearing_resources( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() safe_unlink: Final = mocker.patch.object( importlib.import_module("pipx.commands.uninstall"), "safe_unlink", autospec=True, side_effect=FileNotFoundError, ) assert ( run_pipx_cli(["uninstall", "pycowsay"]), (paths.CTX.venvs / "pycowsay").exists(), safe_unlink.call_count > 0, ) == (0, False, True) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_global() -> None: assert not run_pipx_cli(["install", "--global", "pycowsay"]) assert not run_pipx_cli(["uninstall", "--global", "pycowsay"]) @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_legacy_venv(metadata_version: str | None) -> None: executable_path: Final[Path] = paths.CTX.bin_dir / app_name("pycowsay") assert not run_pipx_cli(["install", "pycowsay"]) assert executable_path.exists() mock_legacy_venv("pycowsay", metadata_version=metadata_version) assert not run_pipx_cli(["uninstall", "pycowsay"]) assert not file_or_symlink(executable_path) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_legacy_venv_inspects_once(mocker: MockerFixture) -> None: assert run_pipx_cli(["install", "pycowsay"]) == 0 executable_path: Final[Path] = paths.CTX.bin_dir / app_name("pycowsay") assert executable_path.exists() mock_legacy_venv("pycowsay") run_subprocess: Final = mocker.spy(venv_inspect, "run_subprocess") assert run_pipx_cli(["uninstall", "pycowsay"]) == 0 assert (file_or_symlink(executable_path), run_subprocess.call_count) == (False, 1) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_suffix() -> None: name: Final[str] = "pbr" suffix: Final[str] = "_a" executable_path: Final[Path] = paths.CTX.bin_dir / app_name(f"{name}{suffix}") assert not run_pipx_cli(["install", PKG[name]["spec"], f"--suffix={suffix}"]) assert executable_path.exists() assert not run_pipx_cli(["uninstall", f"{name}{suffix}"]) assert not file_or_symlink(executable_path) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_man_page() -> None: man_page_path: Final[Path] = paths.CTX.man_dir / "man6" / "pycowsay.6" assert not run_pipx_cli(["install", "pycowsay"]) assert man_page_path.exists() assert not run_pipx_cli(["uninstall", "pycowsay"]) assert not file_or_symlink(man_page_path) @pytest.mark.parametrize( ("exposure_dir", "relative"), [ pytest.param("man_dir", "man1/tool.1", id="man-page"), pytest.param("completion_dir", "bash-completion/completions/tool", id="completion"), ], ) @pytest.mark.usefixtures("uv_symlinked_install") def test_uninstall_removes_link_to_uv_symlinked_resource(exposure_dir: str, relative: str) -> None: path: Final[Path] = getattr(paths.CTX, exposure_dir) / relative assert path.is_symlink() assert not run_pipx_cli(["uninstall", "foo-bar-x"]) assert not file_or_symlink(path) @pytest.fixture def unc_tmp_path(tmp_path: Path) -> Path: # the administrative share reaches the local disk through a UNC path if not (share := Path(f"\\\\localhost\\{tmp_path.drive.removesuffix(':')}$\\")).is_dir(): pytest.skip("needs the Windows administrative share") return share / tmp_path.relative_to(tmp_path.anchor) @pytest.mark.skipif(not WIN, reason="UNC paths are a Windows spelling") @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_man_page_under_unc_path(unc_tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # Windows reads back a link written under a UNC path as \\?\UNC\..., which must still name the venv monkeypatch.setattr(paths, "OVERRIDE_PIPX_HOME", unc_tmp_path / "subdir" / "pipxhome") monkeypatch.setattr(paths, "OVERRIDE_PIPX_MAN_DIR", unc_tmp_path / "otherdir" / "pipxmandir") paths.CTX.make_local() man_page_path: Final[Path] = paths.CTX.man_dir / "man6" / "pycowsay.6" assert not run_pipx_cli(["install", "pycowsay"]) assert man_page_path.is_symlink() assert not run_pipx_cli(["uninstall", "pycowsay"]) assert not file_or_symlink(man_page_path) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_removes_selected_dependency_resources(local_extras_wheel: Path) -> None: package: Final[str] = f"{local_extras_wheel}[tools]" assert not run_pipx_cli(["install", package, "--include-resources-from", "pycowsay"]) exposed_paths: Final[tuple[Path, ...]] = ( paths.CTX.bin_dir / app_name("repeatme"), paths.CTX.bin_dir / app_name("pycowsay"), paths.CTX.man_dir / "man6" / "pycowsay.6", ) assert all(file_or_symlink(path) for path in exposed_paths) assert not run_pipx_cli(["uninstall", "repeatme"]) assert not any(file_or_symlink(path) for path in exposed_paths) def test_uninstall_preserves_colliding_dependency_resource( copied_dependency_resource: tuple[Path, bytes], local_extras_wheel: Path, ) -> None: exposed_app, first_contents = copied_dependency_resource assert not run_pipx_cli(["install", f"{local_extras_wheel}[cow]", "--include-deps"]) assert not run_pipx_cli(["uninstall", "repeatme"]) assert exposed_app.read_bytes() == first_contents @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_injected() -> None: exposed_paths: Final[list[Path]] = [ *(paths.CTX.bin_dir / app for app in [*PKG["pycowsay"]["apps"], *PKG["isort"]["apps"]]), *(paths.CTX.man_dir / man_page for man_page in PKG["pycowsay"]["man_pages"]), ] assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["inject", "--include-apps", "pycowsay", PKG["isort"]["spec"]]) assert all(path.exists() for path in exposed_paths) assert not run_pipx_cli(["uninstall", "pycowsay"]) assert not any(file_or_symlink(path) for path in exposed_paths) @pytest.mark.parametrize("metadata_version", ["0.1"]) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_suffix_legacy_venv(metadata_version: str) -> None: name: Final[str] = "pbr" # on Windows a legacy uninstall works only with canonical name characters in the suffix suffix: Final[str] = "-a" executable_path: Final[Path] = paths.CTX.bin_dir / app_name(f"{name}{suffix}") assert not run_pipx_cli(["install", PKG[name]["spec"], f"--suffix={suffix}"]) mock_legacy_venv(f"{name}{suffix}", metadata_version=metadata_version) assert executable_path.exists() assert not run_pipx_cli(["uninstall", f"{name}{suffix}"]) assert not file_or_symlink(executable_path) @SKIP_IF_WINDOWS @pytest.mark.parametrize( "command", [pytest.param(["uninstall", "pycowsay"], id="uninstall"), pytest.param(["uninstall-all"], id="uninstall-all")], ) @pytest.mark.parametrize( ("interpreter", "reason"), [ pytest.param(b"#!/bin/sh\necho not json\n", "cannot parse", id="unparsable-output"), pytest.param(b"#!/bin/sh\nexit 1\n", "exited with code 1", id="failing"), pytest.param(b"", "Unable to run", id="not-executable"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_removes_venv_it_cannot_inspect( capsys: pytest.CaptureFixture[str], command: list[str], interpreter: bytes, reason: str ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay") remove_venv_interpreter("pycowsay") (python := get_venv_paths(paths.CTX.venvs / "pycowsay")[1]).write_bytes(interpreter) python.chmod(0o755) capsys.readouterr() assert ( run_pipx_cli(command), reason in capsys.readouterr().err, (paths.CTX.venvs / "pycowsay").exists(), file_or_symlink(paths.CTX.bin_dir / app_name("pycowsay")), ) == (0, True, False, False) @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_with_missing_interpreter(metadata_version: str | None) -> None: executable_path: Final[Path] = paths.CTX.bin_dir / app_name("pycowsay") assert not run_pipx_cli(["install", "pycowsay"]) assert executable_path.exists() mock_legacy_venv("pycowsay", metadata_version=metadata_version) remove_venv_interpreter("pycowsay") assert not run_pipx_cli(["uninstall", "pycowsay"]) # on Windows, without metadata and python, pipx cannot tell which app binaries the environment owns if not (sys.platform.startswith("win") and metadata_version is None): assert not file_or_symlink(executable_path) @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_proper_dep_behavior(metadata_version: str | None) -> None: # isort is a dependency of pylint, so its apps are in both environments isort_app_paths: Final[list[Path]] = [paths.CTX.bin_dir / app for app in PKG["isort"]["apps"]] pylint_app_paths: Final[list[Path]] = [paths.CTX.bin_dir / app for app in PKG["pylint"]["apps"]] assert not run_pipx_cli(["install", PKG["pylint"]["spec"]]) assert not run_pipx_cli(["install", PKG["isort"]["spec"]]) mock_legacy_venv("pylint", metadata_version=metadata_version) mock_legacy_venv("isort", metadata_version=metadata_version) assert all(path.exists() for path in [*pylint_app_paths, *isort_app_paths]) assert not run_pipx_cli(["uninstall", "pylint"]) assert ( any(file_or_symlink(path) for path in pylint_app_paths), all(path.exists() for path in isort_app_paths), ) == (False, True) @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_proper_dep_behavior_missing_interpreter(metadata_version: str | None) -> None: # isort is a dependency of pylint, so its apps are in both environments isort_app_paths: Final[list[Path]] = [paths.CTX.bin_dir / app for app in PKG["isort"]["apps"]] pylint_app_paths: Final[list[Path]] = [paths.CTX.bin_dir / app for app in PKG["pylint"]["apps"]] assert not run_pipx_cli(["install", PKG["pylint"]["spec"]]) assert not run_pipx_cli(["install", PKG["isort"]["spec"]]) mock_legacy_venv("pylint", metadata_version=metadata_version) mock_legacy_venv("isort", metadata_version=metadata_version) remove_venv_interpreter("pylint") remove_venv_interpreter("isort") assert all(path.exists() for path in [*pylint_app_paths, *isort_app_paths]) assert not run_pipx_cli(["uninstall", "pylint"]) # on Windows, without metadata and python, pipx cannot tell which app binaries the environment owns if not (sys.platform.startswith("win") and metadata_version is None): assert not any(file_or_symlink(path) for path in pylint_app_paths) assert all(path.exists() for path in isort_app_paths) @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_killed_midway_leaves_nothing_for_reinstall_to_guess(mocker: MockerFixture) -> None: assert not run_pipx_cli(["install", "pycowsay"]) def lose_the_record_then_die(path: Path, **_kwargs: bool) -> None: (path / "pipx_metadata.json").unlink() raise KeyboardInterrupt mocker.patch("pipx.trash.shutil.rmtree", autospec=True, side_effect=lose_the_record_then_die) assert run_pipx_cli(["uninstall", "pycowsay"]) mocker.stopall() assert run_pipx_cli(["reinstall", "pycowsay"]) pipx-1.18.16/tests/test_uninstall_all.py000066400000000000000000000040041527355705200202750ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import pytest from helpers import PIPX_METADATA_LEGACY_VERSIONS, SKIP_IF_WINDOWS, mock_legacy_venv, run_pipx_cli, write_wheel from pipx import paths from pipx.commands import uninstall as uninstall_module from pipx.trash import rmdir_via_trash if TYPE_CHECKING: from pathlib import Path from pytest_mock import MockerFixture @pytest.mark.usefixtures("pipx_temp_env", "capsys") def test_uninstall_all() -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["uninstall-all"]) @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) @pytest.mark.usefixtures("pipx_temp_env", "capsys") def test_uninstall_all_legacy_venv(metadata_version: str | None) -> None: assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=metadata_version) assert not run_pipx_cli(["uninstall-all"]) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_uninstall_all_keeps_an_app_name_taken_over_while_it_runs(mocker: MockerFixture, tmp_path: Path) -> None: assert not run_pipx_cli(["install", str(write_wheel(tmp_path / "wheels", "alpha", "1"))]) assert not run_pipx_cli(["install", str(write_wheel(tmp_path / "wheels", "beta", "1"))]) (other := tmp_path / "other").touch() def remove_then_take_over_the_remaining_names(path: Path) -> None: # uninstall-all scans the bin directory once, so the later venvs act on what it held before this takeover rmdir_via_trash(path) for name in {"alpha", "beta"} & {entry.name for entry in paths.CTX.venvs.iterdir()}: (app := paths.CTX.bin_dir / name).unlink() app.symlink_to(other) mocker.patch.object( uninstall_module, "rmdir_via_trash", autospec=True, side_effect=remove_then_take_over_the_remaining_names ) assert not run_pipx_cli(["uninstall-all"]) assert [path.resolve() for path in paths.CTX.bin_dir.iterdir()] == [other.resolve()] pipx-1.18.16/tests/test_unpin.py000066400000000000000000000105521527355705200165720ustar00rootroot00000000000000from __future__ import annotations import json from typing import TYPE_CHECKING, Final import pytest from helpers import run_pipx_cli from package_info import PKG from pipx import paths if TYPE_CHECKING: from pathlib import Path from _pytest.capture import CaptureResult @pytest.mark.usefixtures("pipx_temp_env") def test_unpin(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["nox"]["spec"]]) assert not run_pipx_cli(["pin", "nox"]) assert not run_pipx_cli(["unpin", "nox"]) assert not run_pipx_cli(["upgrade", "nox"]) assert "nox is already at latest version" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_unpin_json(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["pin", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["unpin", "pycowsay", "--output", "json"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["unpin"], "data": { "packages": [ { "environment": "pycowsay", "injected": False, "location": str(paths.CTX.venvs / "pycowsay"), "package": "pycowsay", "status": "unpinned", "version": "0.0.0.2", } ], "skipped": [], }, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", }, "", ) @pytest.mark.usefixtures("pipx_temp_env") def test_unpin_quiet(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["pin", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["unpin", "pycowsay", "--quiet"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err) == ("", "") @pytest.mark.usefixtures("pipx_temp_env") def test_unpin_with_suffix(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["black"]["spec"], "--suffix", "@1"]) assert not run_pipx_cli(["pin", "black@1"]) assert not run_pipx_cli(["unpin", "black@1"]) unpin_out: Final[str] = capsys.readouterr().out assert not run_pipx_cli(["upgrade", "black@1"]) assert ( "pipx unpinned 1 package in venv black@1" in unpin_out, "upgraded package black@1 from 22.8.0 to 22.10.0" in capsys.readouterr().out, ) == (True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_unpin_warning(caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["install", PKG["nox"]["spec"]]) assert not run_pipx_cli(["pin", "nox"]) assert not run_pipx_cli(["unpin", "nox"]) assert not run_pipx_cli(["unpin", "nox"]) assert "pipx found no pinned packages in venv nox" in caplog.text @pytest.mark.usefixtures("pipx_temp_env") def test_unpin_not_installed_package(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["unpin", "abc"]) assert "pipx does not manage package abc" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_unpin_injected_packages(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "black"]) assert not run_pipx_cli(["inject", "black", "nox", "isort"]) assert not run_pipx_cli(["pin", "black"]) assert not run_pipx_cli(["unpin", "black"]) assert "pipx unpinned 3 packages in venv black" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_unpin_reports_only_changed_packages( empty_project_wheel: Path, capsys: pytest.CaptureFixture[str], ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["inject", "pycowsay", str(empty_project_wheel)]) assert not run_pipx_cli(["pin", "pycowsay", "--injected-only"]) capsys.readouterr() assert not run_pipx_cli(["unpin", "pycowsay"]) output: Final[str] = capsys.readouterr().out assert ( "pipx unpinned 1 package in venv pycowsay" in output, " - empty-project" in output, " - pycowsay" in output, ) == (True, True, False) pipx-1.18.16/tests/test_upgrade.py000066400000000000000000001012241527355705200170650ustar00rootroot00000000000000from __future__ import annotations import datetime as dt import json import os import shutil import subprocess import sys import threading from dataclasses import replace from functools import partial from http.server import SimpleHTTPRequestHandler from socketserver import ThreadingTCPServer from typing import TYPE_CHECKING, Final import pytest from helpers import ( PIPX_METADATA_LEGACY_VERSIONS, SKIP_IF_WINDOWS, WIN, app_name, drop_extra_app, mock_legacy_venv, remove_venv_interpreter, run_pipx_cli, unwrap_log_text, write_wheel, ) from package_info import PKG from pipx import paths, shared_libs from pipx.backends import uv as uv_backend from pipx.main import cli from pipx.pipx_metadata_file import PIPX_INFO_FILENAME, PackageInfo, PipxMetadata from pipx.redact import redact_url_credentials from pipx.util import get_venv_paths if TYPE_CHECKING: from collections.abc import Callable, Iterator from pathlib import Path from _pytest.capture import CaptureResult from pytest_mock import MockerFixture @pytest.mark.parametrize( "scope", [pytest.param([], id="user"), pytest.param(["--global"], id="global", marks=SKIP_IF_WINDOWS)] ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_not_installed_fails(capsys: pytest.CaptureFixture[str], scope: list[str]) -> None: assert run_pipx_cli(["upgrade", *scope, "pycowsay"]) assert "Package is not installed" in capsys.readouterr().err @pytest.mark.parametrize( "scope", [pytest.param([], id="user"), pytest.param(["--global"], id="global", marks=SKIP_IF_WINDOWS)] ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_reports_latest_version(capsys: pytest.CaptureFixture[str], scope: list[str]) -> None: assert not run_pipx_cli(["install", *scope, "pycowsay"]) assert "installed package pycowsay" in capsys.readouterr().out assert not run_pipx_cli(["upgrade", *scope, "pycowsay"]) assert "pycowsay is already at latest version" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") @pytest.mark.parametrize("flags", [pytest.param([], id="plain"), pytest.param(["--verbose"], id="verbose")]) def test_upgrade_failure_prints_installer_error_once(capsys: pytest.CaptureFixture[str], flags: list[str]) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) capsys.readouterr() assert run_pipx_cli(["upgrade", *flags, "--pip-args=--no-such-flag", "pycowsay"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out + captured.err).count("no such option: --no-such-flag") == 1 @pytest.mark.parametrize( ("age_sec", "disable_auto_upgrade", "upgrade_args", "refreshed"), [ pytest.param(dt.timedelta(days=1).total_seconds(), "0", [], False, id="recent"), pytest.param(shared_libs.SHARED_LIBS_MAX_AGE_SEC + 60, "0", [], True, id="stale"), pytest.param(shared_libs.SHARED_LIBS_MAX_AGE_SEC + 60, "1", [], False, id="stale-auto-upgrade-disabled"), pytest.param( shared_libs.SHARED_LIBS_MAX_AGE_SEC + 60, "0", ["--skip-maintenance"], False, id="stale-skip-maintenance" ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_refreshes_shared_pip_only_when_due( # ruff:ignore[too-many-positional-arguments] # pytest injects fixtures by name monkeypatch: pytest.MonkeyPatch, age_shared_pip: Callable[[float], float], age_sec: float, disable_auto_upgrade: str, upgrade_args: list[str], refreshed: bool, ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) monkeypatch.setenv(shared_libs.DISABLE_SHARED_LIBS_AUTO_UPGRADE, disable_auto_upgrade) last_upgrade: Final[float] = age_shared_pip(age_sec) assert not run_pipx_cli(["upgrade", "pycowsay", *upgrade_args]) assert (shared_libs.SHARED_LIBS.pip_path.stat().st_mtime != last_upgrade) is refreshed @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_records_relative_find_links_as_absolute(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: assert not run_pipx_cli(["install", "--backend", "pip", "pycowsay"]) monkeypatch.chdir(tmp_path) assert not run_pipx_cli(["upgrade", "pycowsay", "--pip-args=--find-links wheels"]) assert PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.pip_args == [ "--find-links", str((tmp_path / "wheels").resolve()), ] @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_resolves_recorded_relative_find_links_in_venv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: assert not run_pipx_cli(["install", "--backend", "pip", "pycowsay"]) venv_dir: Final[Path] = paths.CTX.venvs / "pycowsay" metadata: Final[PipxMetadata] = PipxMetadata(venv_dir) metadata.main_package = replace(metadata.main_package, pip_args=["--find-links", "wheels"]) metadata.write() monkeypatch.chdir(tmp_path) assert not run_pipx_cli(["upgrade", "pycowsay"]) assert PipxMetadata(venv_dir).main_package.pip_args == ["--find-links", str((venv_dir / "wheels").resolve())] @SKIP_IF_WINDOWS @pytest.mark.skipif(sys.platform != "win32" and os.geteuid() == 0, reason="root owns the files it creates") def test_upgrade_refuses_recorded_find_links_owned_by_another_account( recorded_find_links: Path, mocker: MockerFixture, capsys: pytest.CaptureFixture[str] ) -> None: mocker.patch("os.geteuid", return_value=recorded_find_links.stat().st_uid + 1) capsys.readouterr() assert run_pipx_cli(["upgrade", "pycowsay"]) assert ( f"pycowsay: refusing to replay the recorded --find-links {recorded_find_links}: another account can change " f"{recorded_find_links}, and with it what gets installed." ) in capsys.readouterr().err @pytest.fixture def recorded_find_links( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing tmp_path: Path, ) -> Path: assert not run_pipx_cli(["install", "--backend", "pip", "pycowsay"]) (wheels := tmp_path.resolve() / "wheels").mkdir() metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") metadata.main_package = replace(metadata.main_package, pip_args=["--find-links", str(wheels)]) metadata.write() return wheels @SKIP_IF_WINDOWS @pytest.mark.skipif(sys.platform != "win32" and os.geteuid() == 0, reason="root owns the files it creates") def test_upgrade_keeps_metadata_of_refused_find_links(recorded_find_links: Path, mocker: MockerFixture) -> None: metadata_file: Final[Path] = paths.CTX.venvs / "pycowsay" / PIPX_INFO_FILENAME recorded: Final[bytes] = metadata_file.read_bytes() mocker.patch("os.geteuid", return_value=recorded_find_links.stat().st_uid + 1) run_pipx_cli(["upgrade", "pycowsay"]) assert metadata_file.read_bytes() == recorded def test_upgrade_replays_recorded_find_links_owned_by_user(recorded_find_links: Path) -> None: assert not run_pipx_cli(["upgrade", "pycowsay"]) assert PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.pip_args == [ "--find-links", str(recorded_find_links), ] @SKIP_IF_WINDOWS @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") def test_upgrade_refuses_recorded_uv_project_writable_by_others( recorded_uv_project: Path, capsys: pytest.CaptureFixture[str] ) -> None: recorded_uv_project.chmod(0o777) capsys.readouterr() assert run_pipx_cli(["upgrade", "pycowsay"]) assert ( f"pycowsay: refusing to replay the recorded --project {recorded_uv_project}: another account can change " f"{recorded_uv_project}, and with it what gets installed." ) in capsys.readouterr().err @pytest.fixture def recorded_uv_project( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing tmp_path: Path, ) -> Path: assert not run_pipx_cli(["install", "--backend", "uv", "pycowsay"]) (project := tmp_path.resolve() / "project").mkdir() metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") metadata.main_package = replace(metadata.main_package, pip_args=["--project", str(project)]) metadata.write() return project @SKIP_IF_WINDOWS @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") def test_upgrade_keeps_metadata_of_refused_uv_project(recorded_uv_project: Path) -> None: metadata_file: Final[Path] = paths.CTX.venvs / "pycowsay" / PIPX_INFO_FILENAME recorded: Final[bytes] = metadata_file.read_bytes() recorded_uv_project.chmod(0o777) run_pipx_cli(["upgrade", "pycowsay"]) assert metadata_file.read_bytes() == recorded @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") def test_upgrade_replays_recorded_uv_project_owned_by_user(recorded_uv_project: Path) -> None: assert not run_pipx_cli(["upgrade", "pycowsay"]) assert PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.pip_args == ["--project", str(recorded_uv_project)] @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_refuses_recorded_file_index_url_writable_by_others( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: assert not run_pipx_cli(["install", "--backend", "pip", "pycowsay"]) (shared := tmp_path.resolve() / "shared").mkdir() shared.chmod(0o777) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") metadata.main_package = replace(metadata.main_package, pip_args=["--index-url", (shared / "simple").as_uri()]) metadata.write() capsys.readouterr() assert run_pipx_cli(["upgrade", "pycowsay"]) assert f"another account can change {shared}, and with it what gets installed." in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_inline_script(inline_script: Path) -> None: assert not run_pipx_cli(["install", "--app", "greet", str(inline_script)]) inline_script.write_text( inline_script.read_text(encoding="utf-8").replace("installed", "upgraded"), encoding="utf-8", ) assert not run_pipx_cli(["upgrade", "greet"]) assert ( subprocess.run([paths.CTX.bin_dir / app_name("greet")], check=True, capture_output=True, text=True).stdout == "upgraded\n" ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_unchanged_inline_script_offers_no_index_reinstall( inline_script: Path, capsys: pytest.CaptureFixture[str] ) -> None: assert not run_pipx_cli(["install", str(inline_script)]) capsys.readouterr() assert not run_pipx_cli(["upgrade", "hello"]) upgrade_out: Final[str] = " ".join(capsys.readouterr().out.split()) assert ("installed from the local source" in upgrade_out, "pipx install --force" in upgrade_out) == (True, False) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_json(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["upgrade", "pycowsay", "--output", "json"]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["upgrade"], "data": { "packages": [ { "environment": "pycowsay", "injected": False, "location": str(paths.CTX.venvs / "pycowsay"), "package": "pycowsay", "previous_version": "0.0.0.2", "status": "unchanged", "version": "0.0.0.2", "interpreter": metadata.python_version, "backend": metadata.backend, "source": None, } ], "skipped": [], }, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", }, "", ) @pytest.mark.parametrize( "command", [ pytest.param(["upgrade", "pycowsay"], id="one"), pytest.param(["upgrade-all"], id="all"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_skips_pylock( make_pylock: Callable[[str, str], Path], capsys: pytest.CaptureFixture[str], command: list[str], ) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert not run_pipx_cli(["install", "--lock", str(lock_file), "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(command) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "pycowsay").main_package assert ( "Not upgrading locked package pycowsay. Update its lock file and run `pipx reinstall pycowsay`." in unwrap_log_text(capsys.readouterr().out), metadata.package_version, metadata.lock_file, ) == (True, "0.0.0.2", lock_file.resolve()) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_legacy_venv_without_metadata_fails(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=None) capsys.readouterr() assert run_pipx_cli(["upgrade", "pycowsay"]) assert "Not upgrading pycowsay. It has no internal pipx metadata." in capsys.readouterr().err @pytest.mark.parametrize( "metadata_version", [version for version in PIPX_METADATA_LEGACY_VERSIONS if version is not None] ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_legacy_venv(metadata_version: str) -> None: assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=metadata_version) assert not run_pipx_cli(["upgrade", "pycowsay"]) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_suffix() -> None: assert not run_pipx_cli(["install", "pycowsay", "--suffix=_a"]) assert run_pipx_cli(["upgrade", "pycowsay"]) assert not run_pipx_cli(["upgrade", "pycowsay_a"]) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_suffix_legacy_venv() -> None: assert not run_pipx_cli(["install", "pycowsay", "--suffix=_a"]) mock_legacy_venv("pycowsay_a", metadata_version="0.1") assert run_pipx_cli(["upgrade", "pycowsay"]) assert not run_pipx_cli(["upgrade", "pycowsay_a"]) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_specifier(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["isort"]["spec"]]) assert not run_pipx_cli(["upgrade", "isort"]) assert f"upgraded package isort from {PKG['isort']['spec'].split('==')[-1]} to" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_missing_interpreter(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) remove_venv_interpreter("pycowsay") assert run_pipx_cli(["upgrade", "pycowsay"]) err: Final[str] = capsys.readouterr().err assert ("invalid python interpreter" in err, "pipx reinstall-all" in err) == (True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_reports_corrupt_package(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) metadata_path: Final[Path] = paths.CTX.venvs / "pycowsay" / PIPX_INFO_FILENAME metadata: Final[dict[str, dict[str, str | None]]] = json.loads(metadata_path.read_text(encoding="utf-8")) metadata["main_package"]["package_or_url"] = None metadata_path.write_text(json.dumps(metadata), encoding="utf-8") capsys.readouterr() assert run_pipx_cli(["upgrade", "pycowsay"]) assert "package pycowsay has corrupt pipx metadata" in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_ignores_venv_args_without_install(caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["install", "pycowsay"]) caplog.clear() assert not run_pipx_cli(["upgrade", "pycowsay", "--system-site-packages"]) assert "Ignoring --system-site-packages as not combined with --install" in caplog.text @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_editable(capsys: pytest.CaptureFixture[str], empty_project: Path) -> None: assert not run_pipx_cli(["install", "--editable", empty_project.as_posix(), "--force"]) assert not run_pipx_cli(["upgrade", "--editable", "empty_project"]) upgrade_out: Final[str] = " ".join(capsys.readouterr().out.split()) # pipx_wrap reflows messages across lines assert ( "empty-project is unchanged at version" in upgrade_out, "installed from the local source" in upgrade_out, "no package index was checked" in upgrade_out, ) == (True, True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_local_path_does_not_claim_latest(capsys: pytest.CaptureFixture[str], empty_project: Path) -> None: # https://github.com/pypa/pipx/issues/2040: pipx consulted no index, so it cannot know the version is the latest assert not run_pipx_cli(["install", str(empty_project.resolve())]) capsys.readouterr() assert not run_pipx_cli(["upgrade", "empty_project"]) upgrade_out: Final[str] = " ".join(capsys.readouterr().out.split()) # pipx_wrap reflows messages across lines assert ( "is already at latest version" in upgrade_out, "empty-project is unchanged at version" in upgrade_out, "no package index was checked" in upgrade_out, "pipx install --force empty-project" in upgrade_out, ) == (False, True, True, True) @pytest.mark.parametrize( ("flags", "injected_upgraded"), [ pytest.param(["--include-injected"], True, id="include-injected"), pytest.param([], False, id="main-package-only"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_include_injected( capsys: pytest.CaptureFixture[str], flags: list[str], injected_upgraded: bool ) -> None: assert not run_pipx_cli(["install", PKG["isort"]["spec"]]) assert not run_pipx_cli(["inject", "isort", PKG["black"]["spec"]]) capsys.readouterr() assert not run_pipx_cli(["upgrade", *flags, "isort"]) out: Final[str] = capsys.readouterr().out assert ("upgraded package isort" in out, "upgraded package black" in out) == (True, injected_upgraded) @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_uv_keeps_pinned_injected_dependency(tmp_path: Path) -> None: # uv's --upgrade re-resolves every dependency, unlike pip's default only-if-needed strategy index: Final[Path] = tmp_path / "index" write_wheel(index, "tool", "1.0", requires=("plug",)) write_wheel(index, "plug", "1.0") write_wheel(index, "plug", "1.5") pip_args: Final[str] = f"--pip-args=--no-index --find-links {index}" assert not run_pipx_cli(["install", "--backend", "uv", pip_args, "tool"]) assert not run_pipx_cli(["inject", pip_args, "tool", "plug==1.5"]) assert not run_pipx_cli(["pin", "tool", "--injected-only"]) write_wheel(index, "plug", "9.0") assert not run_pipx_cli(["upgrade", "tool"]) assert sorted(path.name for path in (paths.CTX.venvs / "tool").rglob("plug-*.dist-info")) == ["plug-1.5.dist-info"] @pytest.fixture def uv_tool_index( pipx_temp_env: None, # ruff:ignore[unused-function-argument] # the temp env must be active while installing tmp_path: Path, ) -> Path: index: Final[Path] = tmp_path / "index" # the app waits for its input to close, so a launcher started with a pipe stays running write_wheel(index, "tool", "1.0", module_code="import sys\n\nsys.stdin.read()\nsys.exit()\n") assert not run_pipx_cli(["install", "--backend", "uv", f"--pip-args=--no-index --find-links {index}", "tool"]) return index @pytest.fixture def uv_tool_launcher( uv_tool_index: Path, # ruff:ignore[unused-function-argument] # the tool must be installed first ) -> Path: return get_venv_paths(paths.CTX.venvs / "tool")[0] / app_name("tool") @pytest.fixture def read_only_trash() -> Iterator[None]: (trash := paths.CTX.trash).mkdir(parents=True, exist_ok=True) trash.chmod(0o500) yield trash.chmod(0o700) @pytest.mark.skipif(not WIN, reason="only Windows refuses to delete a running launcher") @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") def test_upgrade_uv_replaces_the_running_launcher( uv_tool_index: Path, uv_tool_launcher: Path, mocker: MockerFixture ) -> None: write_wheel(uv_tool_index, "tool", "2.0") with subprocess.Popen([uv_tool_launcher], stdin=subprocess.PIPE): exit_code: Final[int] = _upgrade_tool_started_from(uv_tool_launcher, mocker) assert (exit_code, _installed_tool_versions()) == (0, ["tool-2.0.dist-info"]) @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") def test_upgrade_uv_puts_back_the_launcher_it_did_not_replace(uv_tool_launcher: Path, mocker: MockerFixture) -> None: mocker.patch.object(uv_backend, "WINDOWS", True) content: Final[bytes] = uv_tool_launcher.read_bytes() assert not _upgrade_tool_started_from(uv_tool_launcher, mocker) assert uv_tool_launcher.read_bytes() == content @SKIP_IF_WINDOWS @pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") @pytest.mark.usefixtures("read_only_trash") def test_upgrade_uv_proceeds_when_the_launcher_cannot_move( uv_tool_index: Path, uv_tool_launcher: Path, mocker: MockerFixture ) -> None: mocker.patch.object(uv_backend, "WINDOWS", True) write_wheel(uv_tool_index, "tool", "2.0") assert (_upgrade_tool_started_from(uv_tool_launcher, mocker), _installed_tool_versions()) == ( 0, ["tool-2.0.dist-info"], ) def _upgrade_tool_started_from(launcher: Path, mocker: MockerFixture) -> int: # the Windows launcher names itself to Python without its .exe suffix mocker.patch.object(sys, "argv", [str(launcher.with_suffix("")), "upgrade", "tool"]) return cli() def _installed_tool_versions() -> list[str]: return sorted(path.name for path in (paths.CTX.venvs / "tool").rglob("tool-*.dist-info")) @pytest.mark.parametrize( "command", [ pytest.param(["upgrade", "empty-project"], id="upgrade"), pytest.param(["upgrade-all"], id="upgrade-all"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_removes_dropped_app( two_app_project: Path, command: list[str], capsys: pytest.CaptureFixture[str] ) -> None: assert not run_pipx_cli(["install", str(two_app_project)]) drop_extra_app(two_app_project) assert not run_pipx_cli(command) capsys.readouterr() assert not run_pipx_cli(["list"]) assert ( sorted(path.name for path in paths.CTX.bin_dir.iterdir()), "empty-project-extra" in capsys.readouterr().out, ) == ([app_name("empty-project")], False) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_include_injected_removes_dropped_app( two_app_project: Path, capsys: pytest.CaptureFixture[str] ) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["inject", "--include-apps", "pycowsay", str(two_app_project)]) drop_extra_app(two_app_project) assert not run_pipx_cli(["upgrade", "--include-injected", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["list", "--include-injected"]) assert ( sorted(path.name for path in paths.CTX.bin_dir.iterdir()), "empty-project-extra" in capsys.readouterr().out, ) == ([app_name("empty-project"), app_name("pycowsay")], False) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_include_injected_with_suffix_keeps_package_name() -> None: # upgrading rewrites the injected record under the name pipx keys it by; re-appending the environment suffix there # renamed the package on every run ("black" -> "black@1" -> "black@1@1") until `pipx uninject` could not name it assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"], "--suffix=@1"]) assert not run_pipx_cli(["inject", "pycowsay@1", PKG["black"]["spec"], "--with-suffix"]) assert not run_pipx_cli(["upgrade", "--include-injected", "pycowsay@1"]) assert not run_pipx_cli(["uninject", "pycowsay@1", "black"]) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_install_missing(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["upgrade", "pycowsay", "--install"]) assert "installed package pycowsay" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_install_missing_honors_specifier(capsys: pytest.CaptureFixture[str]) -> None: pkg_spec: Final[str] = PKG["isort"]["spec"] assert not run_pipx_cli(["upgrade", pkg_spec, "--install"]) assert f"installed package isort {pkg_spec.split('==')[-1]}" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_multiple(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["isort"]["spec"]]) assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["upgrade", "isort", "pycowsay"]) out: Final[str] = capsys.readouterr().out assert ( f"upgraded package isort from {PKG['isort']['spec'].split('==')[-1]} to" in out, "pycowsay is already at latest version" in out, ) == (True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_absolute_path(capsys: pytest.CaptureFixture[str], empty_project: Path) -> None: assert run_pipx_cli(["upgrade", "--verbose", str(empty_project.resolve())]) assert "Package cannot be a URL" not in capsys.readouterr().err @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_with_extras(capsys: pytest.CaptureFixture[str]) -> None: # https://github.com/pypa/pipx/issues/925 assert not run_pipx_cli(["install", "pycowsay"]) assert "installed package pycowsay" in capsys.readouterr().out assert not run_pipx_cli(["upgrade", "pycowsay[test_extra]"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert ("pycowsay is already at latest version" in captured.out, "Package is not installed" in captured.err) == ( True, False, ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_preserves_included_dependency(local_extras_wheel: Path) -> None: assert not run_pipx_cli(["install", f"{local_extras_wheel}[tools]", "--include-resources-from", "pycowsay"]) assert not run_pipx_cli(["upgrade", "repeatme"]) metadata: Final[PackageInfo] = PipxMetadata(paths.CTX.venvs / "repeatme").main_package assert ( metadata.include_resources_from, (paths.CTX.bin_dir / app_name("pycowsay")).exists(), (paths.CTX.bin_dir / app_name("black")).exists(), ) == (["pycowsay"], True, False) @pytest.mark.parametrize( ("upgrade_args", "expected_args"), [ pytest.param([], ["--no-cache-dir"], id="preserves_stored"), pytest.param(["--pip-args=--no-deps"], ["--no-deps"], id="cli_overrides_stored"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_pip_args(upgrade_args: list[str], expected_args: list[str]) -> None: assert not run_pipx_cli(["install", "pycowsay", "--pip-args=--no-cache-dir"]) assert not run_pipx_cli(["upgrade", "pycowsay", *upgrade_args]) assert PipxMetadata(paths.CTX.venvs / "pycowsay").main_package.pip_args == expected_args @pytest.mark.parametrize( ("command", "expected_options", "expected_cooldowns"), [ pytest.param(["upgrade", "--include-injected", "pycowsay"], (True, True), (7, 5), id="stored"), pytest.param( ["upgrade-all", "--include-injected", "--cooldown", "0"], (False, False), (0, 0), id="override-all", ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_cooldown( package_cache_dir: Path, empty_project_wheel: Path, caplog: pytest.LogCaptureFixture, *, command: list[str], expected_options: tuple[bool, bool], expected_cooldowns: tuple[int, int], ) -> None: pip_args: Final[str] = f"--pip-args=--no-index --find-links={package_cache_dir}" assert not run_pipx_cli(["install", "--cooldown", "7", pip_args, PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["inject", "--cooldown", "5", pip_args, "pycowsay", str(empty_project_wheel)]) caplog.clear() assert not run_pipx_cli(command) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") assert ( "--uploaded-prior-to P7D" in caplog.text, "--uploaded-prior-to P5D" in caplog.text, (metadata.main_package.cooldown_days, metadata.injected_packages["empty-project"].cooldown_days), ) == (*expected_options, expected_cooldowns) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_injected_uses_stored_pip_args(empty_project_wheel: Path) -> None: assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"], "--pip-args=--no-cache-dir"]) assert not run_pipx_cli(["inject", "pycowsay", str(empty_project_wheel), "--pip-args=--no-compile"]) assert not run_pipx_cli(["upgrade", "--include-injected", "pycowsay"]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") assert (metadata.main_package.pip_args, metadata.injected_packages["empty-project"].pip_args) == ( ["--no-cache-dir"], ["--no-compile"], ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_install_json_stays_pure(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["upgrade", "pycowsay", "--install", "--output", "json"]) captured: Final[CaptureResult[str]] = capsys.readouterr() # parsing fails if the internal install printed human text ahead of the document document: Final = json.loads(captured.out) assert (document["command"], document["status"], captured.err) == (["upgrade"], "success", "") @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_suffix_removes_link_named_before_the_last_dot(make_wheel: Callable[[dict[str, str]], Path]) -> None: wheel: Final[Path] = make_wheel({ "foo_bar_x.py": "def main() -> None:\n pass\n", "foo_bar_x-1.dist-info/METADATA": "Metadata-Version: 2.1\nName: foo-bar-x\nVersion: 1\n", "foo_bar_x-1.dist-info/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", "foo_bar_x-1.dist-info/entry_points.txt": "[console_scripts]\ntool3.12 = foo_bar_x:main\n", "foo_bar_x-1.dist-info/RECORD": "", }) assert not run_pipx_cli(["install", "--suffix=@1", str(wheel)]) # the name pipx 1.17.14 gave the link, with the suffix before the last dot (paths.CTX.bin_dir / "tool3@1.12").symlink_to(paths.CTX.venvs / "foo-bar-x@1" / "bin" / "tool3.12") assert not run_pipx_cli(["upgrade", "foo-bar-x@1"]) assert sorted(path.name for path in paths.CTX.bin_dir.iterdir()) == ["tool3.12@1"] @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_local_source_hides_url_credentials( credentialed_wheel_url: str, capsys: pytest.CaptureFixture[str] ) -> None: assert not run_pipx_cli(["install", credentialed_wheel_url]) capsys.readouterr() assert not run_pipx_cli(["upgrade", "foo-bar-x"]) out: Final[str] = capsys.readouterr().out assert ( f"local source {redact_url_credentials(credentialed_wheel_url)};" in " ".join(out.split()), "s3cr3ttok" in out, ) == (True, False) @pytest.fixture def credentialed_wheel_url(dotted_app_wheel: Path) -> Iterator[str]: handler: Final = partial(SimpleHTTPRequestHandler, directory=str(dotted_app_wheel.parent)) # not http.server.HTTPServer: its bind resolves the host name, which stalls for 30 s on macOS CI runners with ThreadingTCPServer(("127.0.0.1", 0), handler) as server: threading.Thread(target=server.serve_forever, daemon=True).start() try: yield f"http://u:s3cr3ttok@127.0.0.1:{server.server_address[1]}/{dotted_app_wheel.name}" finally: server.shutdown() @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_ignores_pip_config_shipped_by_wheel(make_wheel: Callable[[dict[str, str]], Path]) -> None: wheel: Final[Path] = make_wheel({ "foo_bar_x.py": "def main() -> None:\n pass\n", # data files unpack into the venv root, where pip reads its site configuration **{f"foo_bar_x-1.data/data/{name}": "[install]\ntarget = ../escaped\n" for name in ("pip.conf", "pip.ini")}, "foo_bar_x-1.dist-info/METADATA": "Metadata-Version: 2.1\nName: foo-bar-x\nVersion: 1\n", "foo_bar_x-1.dist-info/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", "foo_bar_x-1.dist-info/entry_points.txt": "[console_scripts]\ntool = foo_bar_x:main\n", "foo_bar_x-1.dist-info/RECORD": "", }) assert not run_pipx_cli(["install", str(wheel)]) assert not run_pipx_cli(["upgrade", "foo-bar-x"]) assert not (paths.CTX.venvs / "escaped").exists() pipx-1.18.16/tests/test_upgrade_all.py000066400000000000000000000516561527355705200177320ustar00rootroot00000000000000from __future__ import annotations import json import logging import os import re import shutil import signal import subprocess import time from threading import Barrier from typing import TYPE_CHECKING, Final, ParamSpec, TypeVar import pytest from helpers import ( PIPX_METADATA_LEGACY_VERSIONS, SKIP_IF_WINDOWS, mock_legacy_venv, remove_venv_interpreter, run_pipx_cli, ) from package_info import PKG from pipx import paths from pipx.constants import EXIT_CODE_LIST_PROBLEM from pipx.pipx_metadata_file import PIPX_INFO_FILENAME, PipxMetadata if TYPE_CHECKING: from collections.abc import Callable, Sequence from pathlib import Path from unittest.mock import MagicMock from _pytest.capture import CaptureResult from pytest_mock import MockerFixture _P = ParamSpec("_P") _R = TypeVar("_R") @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all() -> None: assert run_pipx_cli(["upgrade", "pycowsay"]) assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["upgrade-all"]) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_none(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["upgrade-all"]) assert "No packages upgraded after running 'pipx upgrade-all'" in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_warns_system_site_packages_is_ignored(caplog: pytest.LogCaptureFixture) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["upgrade-all", "--system-site-packages"]) assert any( record.levelno == logging.WARNING and "Ignoring --system-site-packages" in record.getMessage() for record in caplog.records ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_checks_current_packages_concurrently( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, ) -> None: for suffix in ("_one", "_two", "_three"): assert not run_pipx_cli(["install", "pycowsay", f"--suffix={suffix}"]) capsys.readouterr() barrier: Final[Barrier] = Barrier(3) def check_package( cmd: Sequence[str | Path], *, capture_stdout: bool = True, capture_stderr: bool = True, log_cmd_str: str | None = None, log_stdout: bool = True, log_stderr: bool = True, run_dir: str | None = None, env_overrides: dict[str, str | None] | None = None, ) -> subprocess.CompletedProcess[str]: del capture_stdout, capture_stderr, log_cmd_str, log_stdout, log_stderr, run_dir, env_overrides assert "list" in cmd assert "--outdated" in cmd barrier.wait(timeout=5) return subprocess.CompletedProcess(cmd, returncode=0, stdout="[]", stderr="") check: Final[MagicMock] = mocker.patch("pipx.backends.pip.run_subprocess", autospec=True, side_effect=check_package) assert (run_pipx_cli(["upgrade-all"]), check.call_count) == (0, 3) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_does_not_copy_current_environment( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, ) -> None: assert not run_pipx_cli(["install", "--app", "pycowsay", "pycowsay"]) capsys.readouterr() mocker.patch( "pipx.backends.pip.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=["pip", "list"], returncode=0, stdout="[]", stderr=""), ) backup: Final[MagicMock] = mocker.patch("pipx.commands.transaction.copytree", autospec=True) assert (run_pipx_cli(["upgrade-all"]), backup.call_count) == (0, 0) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_updates_current_cooldowns( package_cache_dir: Path, mocker: MockerFixture, ) -> None: pip_args: Final[str] = f"--pip-args=--no-index --find-links={package_cache_dir}" assert not run_pipx_cli(["install", "--cooldown", "7", pip_args, PKG["pycowsay"]["spec"]]) assert not run_pipx_cli(["inject", "--cooldown", "5", pip_args, "pycowsay", PKG["black"]["spec"]]) mocker.patch( "pipx.backends.pip.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=["pip", "list"], returncode=0, stdout="[]", stderr=""), ) assert not run_pipx_cli(["upgrade-all", "--include-injected", "--cooldown", "0"]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") assert (metadata.main_package.cooldown_days, metadata.injected_packages["black"].cooldown_days) == (0, 0) @pytest.mark.parametrize( ("setup_commands", "flags"), [ pytest.param([["install", PKG["black"]["spec"]]], [], id="main"), pytest.param( [["install", "pycowsay"], ["inject", "pycowsay", PKG["black"]["spec"]]], ["--include-injected"], id="injected", ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_upgrades_outdated_package( capsys: pytest.CaptureFixture[str], setup_commands: list[list[str]], flags: list[str] ) -> None: for command in setup_commands: assert not run_pipx_cli(command) capsys.readouterr() assert not run_pipx_cli(["upgrade-all", *flags]) assert "upgraded package black from 22.8.0 to " in capsys.readouterr().out @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_reports_outdated_check_failure( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() check: Final[MagicMock] = mocker.patch( "pipx.backends.pip.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess( args=["pip", "list"], returncode=1, stdout="", stderr="index unavailable", ), ) assert run_pipx_cli(["upgrade-all"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (check.call_count, "Package backend exited with code 1" in captured.err) == (1, True) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_skips_pinned_package_check( caplog: pytest.LogCaptureFixture, mocker: MockerFixture, ) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["pin", "pycowsay"]) caplog.clear() check: Final[MagicMock] = mocker.patch( "pipx.backends.pip.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=["pip", "list"], returncode=0, stdout="[]", stderr=""), ) assert ( run_pipx_cli(["upgrade-all"]), check.call_count, "Not upgrading pinned package pycowsay" in caplog.text, ) == (0, 0, True) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_quiet(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["upgrade-all", "--quiet"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (captured.out, captured.err) == ("", "") @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_json(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["upgrade-all", "--output", "json"]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["upgrade-all"], "data": { "packages": [ { "environment": "pycowsay", "injected": False, "location": str(paths.CTX.venvs / "pycowsay"), "package": "pycowsay", "previous_version": "0.0.0.2", "status": "unchanged", "version": "0.0.0.2", "interpreter": metadata.python_version, "backend": metadata.backend, "source": None, } ], "skipped": [], }, "pipx_result_version": "1", "errors": [], "exit_code": 0, "status": "success", }, "", ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_pylock_json( make_pylock: Callable[[str, str], Path], capsys: pytest.CaptureFixture[str], mocker: MockerFixture, ) -> None: lock_file: Final[Path] = make_pylock("pycowsay", "0.0.0.2") assert not run_pipx_cli(["install", "--lock", str(lock_file), "pycowsay"]) capsys.readouterr() check: Final[MagicMock] = mocker.patch( "pipx.backends.pip.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=["pip", "list"], returncode=0, stdout="[]", stderr=""), ) assert not run_pipx_cli(["upgrade-all", "--output", "json"]) metadata: Final[PipxMetadata] = PipxMetadata(paths.CTX.venvs / "pycowsay") assert (json.loads(capsys.readouterr().out)["data"]["packages"], check.call_count) == ( [ { "environment": "pycowsay", "injected": False, "location": str(paths.CTX.venvs / "pycowsay"), "package": "pycowsay", "previous_version": "0.0.0.2", "status": "locked", "version": "0.0.0.2", "interpreter": metadata.python_version, "backend": metadata.backend, "source": None, } ], 0, ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_json_failure(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay") capsys.readouterr() assert run_pipx_cli(["upgrade-all", "--output", "json"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert (json.loads(captured.out), captured.err) == ( { "command": ["upgrade-all"], "data": {"packages": [], "skipped": []}, "errors": [ { "code": "package_upgrade_failed", "environment": "pycowsay", "message": ( "Not upgrading pycowsay. It has no internal pipx metadata.\n" "pipx versions before 0.15.0.0 did not record it.\n" "Uninstall and install this package to fix it." ), "package": None, } ], "exit_code": 1, "pipx_result_version": "1", "status": "error", }, "", ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_json_requested_skip(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) capsys.readouterr() assert not run_pipx_cli(["upgrade-all", "--skip", "pycowsay", "--output", "json"]) assert json.loads(capsys.readouterr().out)["data"] == { "packages": [], "skipped": [{"environment": "pycowsay", "reason": "requested"}], } @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_json_editable_skip(capsys: pytest.CaptureFixture[str], empty_project: Path) -> None: assert not run_pipx_cli(["install", "--editable", str(empty_project), "--force"]) capsys.readouterr() assert not run_pipx_cli(["upgrade-all", "--output", "json"]) assert json.loads(capsys.readouterr().out)["data"] == { "packages": [], "skipped": [{"environment": "empty-project", "reason": "editable"}], } @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_with_index_args(mocker: MockerFixture) -> None: assert not run_pipx_cli(["install", "pycowsay"]) check: Final[MagicMock] = mocker.patch( "pipx.backends.pip.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=["pip", "list"], returncode=0, stdout="[]", stderr=""), ) assert ( run_pipx_cli(["upgrade-all", "--pip-args=--index-url=https://example.com"]), check.call_count, "--index-url=https://example.com" in check.call_args.args[0], ) == (0, 1, True) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_legacy_venv_without_metadata_fails(capsys: pytest.CaptureFixture[str]) -> None: assert run_pipx_cli(["upgrade", "pycowsay"]) assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=None) capsys.readouterr() assert run_pipx_cli(["upgrade-all"]) assert "The following package(s) failed to upgrade: pycowsay" in capsys.readouterr().err @pytest.mark.parametrize( "metadata_version", [version for version in PIPX_METADATA_LEGACY_VERSIONS if version is not None] ) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_legacy_venv(metadata_version: str) -> None: assert run_pipx_cli(["upgrade", "pycowsay"]) assert not run_pipx_cli(["install", "pycowsay"]) mock_legacy_venv("pycowsay", metadata_version=metadata_version) assert not run_pipx_cli(["upgrade-all"]) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_prints_progress_in_venv_order( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, outdated_venv_names: list[str] ) -> None: slow: Final[Path] = paths.CTX.venvs / outdated_venv_names[0] # the backup of an environment with expected apps precedes its upgrade progress line def backup(source: Path, destination: Path, *, symlinks: bool) -> Path: if source == slow: time.sleep(0.3) return shutil.copytree(source, destination, symlinks=symlinks) mocker.patch("pipx.commands.transaction.copytree", side_effect=backup) assert not run_pipx_cli(["upgrade-all"]) assert re.findall(r"^upgrading ([\w-]+)", capsys.readouterr().err, re.MULTILINE) == outdated_venv_names @pytest.fixture def outdated_venv_names(capsys: pytest.CaptureFixture[str]) -> list[str]: for app, spec in (("black", PKG["black"]["spec"]), ("isort", PKG["isort"]["spec"])): assert not run_pipx_cli(["install", "--app", app, spec]) capsys.readouterr() return [venv_dir.name for venv_dir in paths.CTX.venvs.iterdir() if venv_dir.is_dir()] @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_upgrades_the_rest_after_a_failure( capsys: pytest.CaptureFixture[str], outdated_venv_names: list[str] ) -> None: mock_legacy_venv(outdated_venv_names[0]) exit_code: Final[int] = run_pipx_cli(["upgrade-all", "--output", "json"]) result: Final = json.loads(capsys.readouterr().out) assert ( exit_code, [package["environment"] for package in result["data"]["packages"]], [error["environment"] for error in result["errors"]], ) == (1, outdated_venv_names[1:], outdated_venv_names[:1]) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_upgrades_the_rest_after_a_missing_interpreter( capsys: pytest.CaptureFixture[str], outdated_venv_names: list[str] ) -> None: remove_venv_interpreter(outdated_venv_names[0]) exit_code: Final[int] = run_pipx_cli(["upgrade-all"]) captured: Final[CaptureResult[str]] = capsys.readouterr() assert ( exit_code, f"upgraded package {outdated_venv_names[1]} from " in captured.out, f"Not upgrading {outdated_venv_names[0]}. It has an invalid python interpreter" in captured.err, f"The following package(s) failed to upgrade: {outdated_venv_names[0]}" in captured.err, ) == (1, True, True, True) @pytest.mark.usefixtures("pipx_temp_env") def test_upgrade_all_upgrades_beside_corrupt_metadata(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) assert not run_pipx_cli(["install", PKG["black"]["spec"]]) metadata_file: Final[Path] = paths.CTX.venvs / "pycowsay" / PIPX_INFO_FILENAME metadata: Final = json.loads(metadata_file.read_text(encoding="utf-8")) metadata["main_package"]["app_paths"] = None metadata_file.write_text(json.dumps(metadata), encoding="utf-8") capsys.readouterr() assert not run_pipx_cli(["upgrade-all", "--output", "json"]) assert { package["environment"]: package["status"] for package in json.loads(capsys.readouterr().out)["data"]["packages"] } == {"black": "upgraded", "pycowsay": "unchanged"} @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env", "signal_handlers", "black_installed") def test_upgrade_all_finishes_upgrade_in_progress_on_first_interrupt( capsys: pytest.CaptureFixture[str], interrupt_upgrade: Callable[[int], None] ) -> None: interrupt_upgrade(1) exit_code: Final[int] = run_pipx_cli(["upgrade-all"]) assert (exit_code, "upgraded package black from 22.8.0 to " in capsys.readouterr().out) == (1, True) @pytest.fixture def interrupt_upgrade(mocker: MockerFixture, caplog: pytest.LogCaptureFixture) -> Callable[[int], None]: # signal pipx alone as pip starts upgrading the first black venv, as pressing Ctrl-C that many times would def patch(presses: int) -> None: mocker.patch.object(subprocess, "Popen", side_effect=_interrupting(subprocess.Popen, presses, caplog)) return patch def _interrupting(call: Callable[_P, _R], presses: int, caplog: pytest.LogCaptureFixture) -> Callable[_P, _R]: pending: Final[list[int]] = [presses] def interrupting(*args: _P.args, **kwargs: _P.kwargs) -> _R: if pending[0] and "--upgrade" in (command := str(args[0])) and f"{os.sep}black" in command: for press in range(pending.pop()): os.kill(os.getpid(), signal.SIGINT) deadline: Final[float] = time.monotonic() + 5 while press == 0 and not any("Finishing" in message for message in caplog.messages): if time.monotonic() > deadline: break time.sleep(0.01) pending.append(0) return call(*args, **kwargs) return interrupting @pytest.fixture def black_installed(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", PKG["black"]["spec"]]) capsys.readouterr() @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env", "signal_handlers", "black_installed") def test_upgrade_all_reports_upgrade_stopped_by_second_interrupt( capsys: pytest.CaptureFixture[str], interrupt_upgrade: Callable[[int], None] ) -> None: interrupt_upgrade(2) exit_code: Final[int] = run_pipx_cli(["upgrade-all"]) assert (exit_code, "Interrupted while upgrading black;" in capsys.readouterr().err) == (1, True) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env", "signal_handlers", "black_installed") def test_upgrade_all_does_not_start_upgrades_after_interrupt( capsys: pytest.CaptureFixture[str], mocker: MockerFixture, interrupt_upgrade: Callable[[int], None] ) -> None: assert not run_pipx_cli(["install", PKG["black"]["spec"], "--suffix=_x"]) capsys.readouterr() # one worker, so the upgrade of the other venv waits for the interrupted one mocker.patch.object(os, "cpu_count", return_value=1) interrupt_upgrade(1) exit_code: Final[int] = run_pipx_cli(["upgrade-all"]) assert (exit_code, "Interrupted before upgrading: black" in capsys.readouterr().err) == (1, True) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env", "signal_handlers", "black_installed", "unfinished_black") def test_list_reports_venv_left_unfinished_by_interrupt(capsys: pytest.CaptureFixture[str]) -> None: exit_code: Final[int] = run_pipx_cli(["list"]) assert (exit_code, "left half changed by an interrupted pipx run: black." in capsys.readouterr().err) == ( EXIT_CODE_LIST_PROBLEM, True, ) @pytest.fixture def unfinished_black(capsys: pytest.CaptureFixture[str], interrupt_upgrade: Callable[[int], None]) -> None: interrupt_upgrade(2) assert run_pipx_cli(["upgrade-all"]) capsys.readouterr() @SKIP_IF_WINDOWS @pytest.mark.parametrize( "command", [ pytest.param(["upgrade-all"], id="upgrade-all"), pytest.param(["upgrade", "black"], id="upgrade"), pytest.param(["reinstall", "black"], id="reinstall"), ], ) @pytest.mark.usefixtures("pipx_temp_env", "signal_handlers", "black_installed", "unfinished_black") def test_command_repairs_venv_left_unfinished_by_interrupt( capsys: pytest.CaptureFixture[str], command: list[str] ) -> None: assert not run_pipx_cli(command) capsys.readouterr() assert (run_pipx_cli(["list"]), "interrupted" in capsys.readouterr().err) == (0, False) @SKIP_IF_WINDOWS @pytest.mark.usefixtures("pipx_temp_env", "signal_handlers", "black_installed", "unfinished_black") def test_upgrade_all_repairs_unfinished_venv_whose_python_is_gone(capsys: pytest.CaptureFixture[str]) -> None: remove_venv_interpreter("black") assert not run_pipx_cli(["upgrade-all"]) capsys.readouterr() assert (run_pipx_cli(["list"]), "interrupted" in capsys.readouterr().err) == (0, False) pipx-1.18.16/tests/test_upgrade_shared.py000066400000000000000000000076661527355705200204320ustar00rootroot00000000000000from __future__ import annotations import subprocess from typing import TYPE_CHECKING, Final import pytest from helpers import run_pipx_cli from pipx import shared_libs as shared_libs_module if TYPE_CHECKING: from pipx.shared_libs import _SharedLibs # fixture returns the private singleton, no public type def test_upgrade_shared_creates_and_upgrades( shared_libs: _SharedLibs, capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture ) -> None: assert (shared_libs.has_been_updated_this_run, shared_libs.is_valid) == (False, False) assert run_pipx_cli(["upgrade-shared", "-v"]) == 0 err: Final[str] = capsys.readouterr().err assert ( "creating shared libraries" in err, "upgrading shared libraries" in err, "Upgrading shared libraries in" in caplog.text, "Already upgraded libraries in" in caplog.text, shared_libs.has_been_updated_this_run, shared_libs.is_valid, ) == (True, True, True, False, True, True) @pytest.fixture def shared_libs(pipx_ultra_temp_env: None) -> _SharedLibs: # ruff:ignore[unused-function-argument] # required fixture dependency; usefixtures has no effect on fixtures # read after pipx_ultra_temp_env replaced the module's instance with a fresh one return shared_libs_module.SHARED_LIBS def test_upgrade_shared_upgrades_existing_without_creating( shared_libs: _SharedLibs, capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture ) -> None: assert run_pipx_cli(["upgrade-shared", "-v"]) == 0 shared_libs.has_been_updated_this_run = False capsys.readouterr() caplog.clear() assert run_pipx_cli(["upgrade-shared", "-v"]) == 0 err: Final[str] = capsys.readouterr().err assert ( "creating shared libraries" in err, "upgrading shared libraries" in err, "Upgrading shared libraries in" in caplog.text, "Already upgraded libraries in" in caplog.text, shared_libs.has_been_updated_this_run, ) == (False, True, True, False, True) @pytest.mark.usefixtures("shared_libs") def test_upgrade_shared_skips_libraries_upgraded_this_run(caplog: pytest.LogCaptureFixture) -> None: assert run_pipx_cli(["upgrade-shared", "-v"]) == 0 assert run_pipx_cli(["upgrade-shared", "-v"]) == 0 assert "Already upgraded libraries in" in caplog.text def test_upgrade_shared_pip_args( shared_libs: _SharedLibs, capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture ) -> None: assert (shared_libs.has_been_updated_this_run, shared_libs.is_valid) == (False, False) exit_code: Final[int] = run_pipx_cli(["upgrade-shared", "-v", "--pip-args='--no-index'"]) err: Final[str] = capsys.readouterr().err assert ( exit_code, "creating shared libraries" in err, "upgrading shared libraries" in err, "Upgrading shared libraries in" in caplog.text, "Already upgraded libraries in" in caplog.text, shared_libs.has_been_updated_this_run, shared_libs.is_valid, ) == (1, True, True, True, False, False, True) def test_upgrade_shared_pin_pip(shared_libs: _SharedLibs) -> None: # Pin to a version testdata/tests_packages/primary_packages.txt seeds explicitly: it clears the `pip >= 26.2.2` # floor upgrade-shared appends yet stays behind the latest, so a bare `pip` entry alone would drop it once pip # moves on. The assertion then proves --pip-args placed it rather than matching the default upgrade. pinned: Final[str] = "26.2.2" assert (shared_libs.has_been_updated_this_run, shared_libs.is_valid) == (False, False) assert run_pipx_cli(["upgrade-shared", "-v", f"--pip-args=pip=={pinned}"]) == 0 assert ( shared_libs.is_valid, subprocess.run( [shared_libs.python_path, "-c", "from importlib.metadata import version; print(version('pip'))"], check=True, capture_output=True, text=True, ).stdout.strip(), ) == (True, pinned) pipx-1.18.16/tests/test_util.py000066400000000000000000000006251527355705200164160ustar00rootroot00000000000000from __future__ import annotations from typing import Final from pipx.redact import redact_url_credentials from pipx.util import full_package_description def test_full_package_description_of_bare_spec_hides_url_credentials() -> None: spec: Final[str] = "helper @ http://u:s3cr3ttok@host/helper-1.0-py3-none-any.whl" assert full_package_description(spec, spec) == redact_url_credentials(spec) pipx-1.18.16/tests/test_venv.py000066400000000000000000000273551527355705200164300ustar00rootroot00000000000000from __future__ import annotations import json import platform import shutil import subprocess import sys from typing import TYPE_CHECKING, Final from venv import EnvBuilder import pytest from helpers import LEGACY_MAIN_PACKAGE, CurrentMetadata, run_pipx_cli from package_info import PKG from pipx import paths, pipx_metadata_file from pipx.backends._base import UV, Backend # ruff:ignore[import-private-name] # no public home from pipx.options import BackendOptions, ExposureOptions from pipx.pipx_metadata_file import PipxMetadata from pipx.redact import redact_url_credentials from pipx.util import PipxError, get_venv_paths from pipx.venv import Venv, reset_backend_override_warnings if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path from unittest.mock import MagicMock from pytest_mock import MockerFixture from pipx.constants import ExitCode @pytest.fixture def venv_with_site_packages(tmp_path: Path, mocker: MockerFixture) -> tuple[Venv, tuple[Path, Path], MagicMock]: venv: Final[Venv] = Venv(tmp_path / "demo") venv.python_path.parent.mkdir(parents=True) venv.python_path.touch() purelib: Final[Path] = venv.root / "purelib" platlib: Final[Path] = venv.root / "platlib" purelib_demo: Final[Path] = purelib / "demo-1.0.dist-info" purelib_demo.mkdir(parents=True) (purelib_demo / "METADATA").write_text("Name: demo\nVersion: 1.0\n", encoding="utf-8") platlib_demo: Final[Path] = platlib / "demo2-1.0.dist-info" platlib_demo.mkdir(parents=True) (platlib_demo / "METADATA").write_text("Name: demo2\nVersion: 1.0\n", encoding="utf-8") run_subprocess: Final[MagicMock] = mocker.patch( "pipx.subprocess_runner.run_subprocess", autospec=True, return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout=f"{purelib}\n{platlib}\n", stderr=""), ) return venv, (purelib_demo, platlib_demo), run_subprocess def test_has_package(venv_with_site_packages: tuple[Venv, tuple[Path, Path], MagicMock]) -> None: venv, _, _ = venv_with_site_packages assert (venv.has_package("demo"), venv.has_package("demo2")) == (True, True) def test_has_app_caches_site_packages(venv_with_site_packages: tuple[Venv, tuple[Path, Path], MagicMock]) -> None: venv, (purelib_demo, _), run_subprocess = venv_with_site_packages (purelib_demo / "entry_points.txt").write_text("[pipx.run]\ndemo = demo:main\n", encoding="utf-8") assert (venv.has_app("demo", "demo"), venv.has_app("demo", "demo"), run_subprocess.call_count) == (True, True, 1) def test_run_app_rejects_python_args_without_entry_point( venv_with_site_packages: tuple[Venv, tuple[Path, Path], MagicMock], ) -> None: venv, _, _ = venv_with_site_packages with pytest.raises(PipxError, match="'demo' is not a Python entry point"): venv.run_app("demo", "demo", [], python_args=["-b"]) @pytest.mark.parametrize( "backend", [ pytest.param("pip", id="pip"), pytest.param( "uv", id="uv", marks=pytest.mark.skipif(shutil.which("uv") is None, reason="uv binary not on PATH") ), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_records_the_venv_python_version(backend: str) -> None: assert not run_pipx_cli(["install", "--backend", backend, "pycowsay"]) assert PipxMetadata(paths.CTX.venvs / "pycowsay").python_version == f"Python {platform.python_version()}" _CREDENTIALED_REQUIREMENT: Final[str] = "helper @ http://u:s3cr3ttok@127.0.0.1:9/helper-1.0-py3-none-any.whl" def test_install_unmanaged_packages_error_hides_url_credentials(venv_failing_install: Venv) -> None: with pytest.raises(PipxError) as error: venv_failing_install.install_unmanaged_packages([_CREDENTIALED_REQUIREMENT], [], cooldown_days=None) assert ( " ".join(str(error.value).split()) == f"Error installing {redact_url_credentials(_CREDENTIALED_REQUIREMENT)}." ) @pytest.fixture def venv_failing_install(tmp_path: Path, mocker: MockerFixture) -> Venv: (root := tmp_path / "demo").mkdir() (root / "pyvenv.cfg").touch() venv: Final[Venv] = Venv(root) mocker.patch.object(venv.backend, "install", autospec=True, return_value=subprocess.CompletedProcess([], 1, "", "")) return venv def test_install_unmanaged_packages_logs_without_url_credentials( venv_failing_install: Venv, caplog: pytest.LogCaptureFixture ) -> None: with caplog.at_level("INFO"), pytest.raises(PipxError): venv_failing_install.install_unmanaged_packages([_CREDENTIALED_REQUIREMENT], [], cooldown_days=None) assert f"Installing {redact_url_credentials(_CREDENTIALED_REQUIREMENT)}" in caplog.messages @pytest.mark.parametrize( "operation", [ pytest.param(lambda venv: venv.list_installed_packages(), id="list"), pytest.param(lambda venv: venv.uninstall_packages("demo"), id="uninstall"), pytest.param(lambda venv: venv.run_pip_get_exit_code(["install", "demo"]), id="runpip"), pytest.param( lambda venv: venv.upgrade_package( "demo", "demo", [], exposure=ExposureOptions(), is_main_package=True, expected_apps=None, cooldown_days=None, ), id="upgrade", ), ], ) def test_venv_without_pyvenv_cfg_refuses_installer( operation: Callable[[Venv], set[str] | ExitCode | None], tmp_path: Path, mocker: MockerFixture ) -> None: backend: Final[MagicMock] = mocker.create_autospec(Backend, instance=True) mocker.patch("pipx.venv.get_backend", autospec=True, return_value=backend) venv: Final[Venv] = Venv(tmp_path / "demo") with pytest.raises(PipxError, match=r"has no pyvenv\.cfg, so it is no longer a virtual environment"): operation(venv) assert backend.method_calls == [] @pytest.mark.usefixtures("pipx_temp_env") def test_runpip_refuses_venv_without_pyvenv_cfg(capsys: pytest.CaptureFixture[str]) -> None: assert not run_pipx_cli(["install", "pycowsay"]) # a wheel's data file can own pyvenv.cfg, and upgrading to a release without it deletes the file (paths.CTX.venvs / "pycowsay" / "pyvenv.cfg").unlink() capsys.readouterr() assert (run_pipx_cli(["runpip", "pycowsay", "list"]), "pipx reinstall pycowsay" in capsys.readouterr().err) == ( 1, True, ) def test_backend_override_warning_dedups_per_venv( tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: # `upgrade-all --backend uv` over many pip-backed venvs would otherwise log a multi-line block per Venv (venv_dir := tmp_path / "demo").mkdir() metadata_payload: Final[CurrentMetadata] = { "main_package": LEGACY_MAIN_PACKAGE, "python_version": "3.12", "source_interpreter": None, "venv_args": [], "injected_packages": {}, "backend": "pip", "pipx_metadata_version": "0.6", } (venv_dir / pipx_metadata_file.PIPX_INFO_FILENAME).write_text(json.dumps(metadata_payload)) reset_backend_override_warnings() caplog.set_level("WARNING", logger="pipx.venv") Venv(venv_dir, backend=BackendOptions(cli="uv")) Venv(venv_dir, backend=BackendOptions(cli="uv")) Venv(venv_dir, backend=BackendOptions(cli="uv")) assert len([rec for rec in caplog.records if "Ignoring --backend=uv" in rec.message]) == 1 @pytest.mark.parametrize( ("backend", "env_backend", "expected"), [ pytest.param("uv", None, (UV, "cli"), id="cli"), pytest.param(None, "uv", (UV, "env"), id="env"), ], ) def test_venv_without_record_resolves_like_a_fresh_one( tmp_path: Path, backend: str | None, env_backend: str | None, expected: tuple[str, str] ) -> None: (venv_dir := tmp_path / "demo").mkdir() (venv_dir / "pyvenv.cfg").touch() venv: Final[Venv] = Venv(venv_dir, backend=BackendOptions(cli=backend, env=env_backend)) assert (venv.backend_name, venv.backend_source) == expected def test_package_name_determination_preserves_install_error(mocker: MockerFixture, tmp_path: Path) -> None: (tmp_path / "pyvenv.cfg").touch() venv: Final[Venv] = Venv(tmp_path) backend: Final[MagicMock] = mocker.create_autospec(Backend, instance=True) # Python before 3.14 ignores a keyword argument naming a method of an autospec backend.cooldown_args.return_value = [] backend.install.return_value = subprocess.CompletedProcess( ["pip", "install", "requires-newer-python"], 1, stdout="", stderr="ERROR: Package 'requires-newer-python' requires a different Python\n", ) mocker.patch.object(venv, "_backend", backend) mocker.patch.object(venv, "list_installed_packages", return_value=set()) mocker.patch("pipx.venv.get_site_packages", return_value=[]) with pytest.raises(PipxError) as excinfo: venv.install_package_no_deps("requires-newer-python", [], None) error: Final[str] = str(excinfo.value) assert ("requires a different Python" in error, "Cannot determine package name from spec" in error) == ( True, False, ) @pytest.mark.parametrize( "dist_info_dirs", [ pytest.param(["cooltool-1.0.dist-info"], id="rewritten-metadata"), pytest.param(["cooltool-1.0.dist-info", "blacken-9.0.dist-info"], id="added-distribution"), ], ) def test_package_name_determination_rejects_name_not_matching_dist_info( mocker: MockerFixture, tmp_path: Path, dist_info_dirs: list[str] ) -> None: venv: Final[Venv] = _probe_venv(mocker, tmp_path, "blacken", dist_info_dirs) with pytest.raises(PipxError, match=r"declares\s+the\s+name\s+'blacken'"): venv.install_package_no_deps("https://example.com/cooltool-1.0-py3-none-any.whl", [], None) def _probe_venv(mocker: MockerFixture, site_packages: Path, listed_name: str, dist_info_dirs: list[str]) -> Venv: def install(**_kwargs: object) -> subprocess.CompletedProcess[str]: for dist_info_dir in dist_info_dirs: (site_packages / dist_info_dir).mkdir() return subprocess.CompletedProcess(["pip", "install"], 0, stdout="", stderr="") (root := site_packages / "probe-venv").mkdir() (root / "pyvenv.cfg").touch() venv: Final[Venv] = Venv(root) backend: Final[MagicMock] = mocker.create_autospec(Backend, instance=True, **{"cooldown_args.return_value": []}) # Python 3.11's create_autospec drops a side_effect passed as a keyword for a child method backend.install.side_effect = install mocker.patch.object(venv, "_backend", backend) mocker.patch.object(venv, "list_installed_packages", side_effect=[{"pip"}, {"pip", listed_name}]) mocker.patch("pipx.venv.get_site_packages", return_value=[site_packages]) return venv def test_package_name_determination_accepts_normalized_dist_info_name(mocker: MockerFixture, tmp_path: Path) -> None: venv: Final[Venv] = _probe_venv(mocker, tmp_path, "Cool.Tool", ["cool_tool-1.0.dist-info"]) assert venv.install_package_no_deps("https://example.com/cool_tool-1.0-py3-none-any.whl", [], None) == "Cool.Tool" @pytest.mark.parametrize( "symlinks", [ pytest.param( True, id="symlinks", marks=pytest.mark.skipif(sys.platform == "win32", reason="venv copies on Windows") ), pytest.param(False, id="copies"), ], ) @pytest.mark.usefixtures("pipx_temp_env") def test_install_from_bootstrap_venv_records_interpreter_outside_it(tmp_path: Path, symlinks: bool) -> None: EnvBuilder(symlinks=symlinks).create(bootstrap := tmp_path / "bootstrap") _, bootstrap_python, _ = get_venv_paths(bootstrap) assert not run_pipx_cli(["install", "--python", str(bootstrap_python), PKG["pycowsay"]["spec"]]) recorded: Final[Path | None] = PipxMetadata(paths.CTX.venvs / "pycowsay").source_interpreter assert recorded is not None assert (recorded.is_file(), recorded.is_relative_to(bootstrap)) == (True, False) pipx-1.18.16/tests/test_venv_inspect.py000066400000000000000000000332351527355705200201470ustar00rootroot00000000000000from __future__ import annotations import json import os from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from packaging.utils import canonicalize_name from helpers import SKIP_IF_WINDOWS, write_dist_info from pipx import venv_inspect from pipx.venv_inspect import list_not_required_packages if TYPE_CHECKING: from collections.abc import Mapping from pytest_mock import MockerFixture def test_inspect_venv_indexes_distribution_names_once( tmp_path: Path, venv_sys_path: list[str], mocker: MockerFixture ) -> None: site_packages: Final[Path] = tmp_path / "site-packages" _write_dist_info(site_packages, "Root_Package", ("dependency-package",)) _write_dist_info(site_packages, "Dependency_Package") (nameless_dist_info := site_packages / "nameless-1.0.dist-info").mkdir() (nameless_dist_info / "METADATA").write_text("Version: 1.0\n", encoding="utf-8") venv_sys_path.append(str(site_packages)) canonicalize: Final = mocker.spy(venv_inspect, "canonicalize_name") result: Final = venv_inspect.inspect_venv( "root-package", set(), tmp_path / "bin", tmp_path / "python", tmp_path / "man" ) canonicalized: Final[list[str]] = [call.args[0] for call in canonicalize.call_args_list] assert (result.package_version, canonicalized.count("Root_Package"), canonicalized.count("Dependency_Package")) == ( "1.0", 1, 1, ) @pytest.fixture def venv_sys_path(mocker: MockerFixture) -> list[str]: sys_path: Final[list[str]] = [] mocker.patch.object( venv_inspect, "fetch_info_in_venv", autospec=True, return_value=(sys_path, {"python_full_version": "3.10.0"}, "3.10.0"), ) return sys_path def _write_dist_info(site_packages: Path, name: str, requirements: tuple[str, ...] = ()) -> None: (dist_info := site_packages / f"{canonicalize_name(name)}-1.0.dist-info").mkdir(parents=True) metadata: Final[list[str]] = [f"Name: {name}", "Version: 1.0"] metadata.extend(f"Requires-Dist: {requirement}" for requirement in requirements) (dist_info / "METADATA").write_text("\n".join(metadata), encoding="utf-8") @pytest.mark.parametrize( "name", [ pytest.param("-t/some/dir", id="target-option"), pytest.param("-rdrop", id="requirement-option"), pytest.param("../escape", id="path"), pytest.param("a b", id="space"), ], ) def test_not_required_packages_skips_invalid_distribution_names(tmp_path: Path, name: str) -> None: _write_dist_info(tmp_path, "top-package") (egg_info := tmp_path / "extra.egg-info").mkdir() (egg_info / "PKG-INFO").write_text(f"Name: {name}\nVersion: 1.0\n", encoding="utf-8") assert venv_inspect.not_required_packages([str(tmp_path)], {}) == {"top-package"} def test_list_not_required_packages_treats_extra_dependencies_as_required(site_packages: Path, tmp_path: Path) -> None: # child-package is required only through top-package's `extra == "cli"` dependency, so it stays required while # top-package is installed write_dist_info(site_packages, "top-package", ['child-package>=1; extra == "cli"'], provides_extra=["cli"]) write_dist_info(site_packages, "child-package") assert list_not_required_packages(tmp_path / "bin" / "python") == {"top-package"} def test_inspect_venv_refreshes_distribution_paths(tmp_path: Path, venv_sys_path: list[str]) -> None: site_packages: Final[Path] = tmp_path / "site-packages" _write_dist_info(site_packages, "root-package") venv_sys_path.append(str(site_packages)) args: Final[tuple[str, set[str], Path, Path, Path]] = ( "root-package", set(), tmp_path / "bin", tmp_path / "python", tmp_path / "man", ) assert venv_inspect.inspect_venv(*args).package_version == "1.0" site_packages_stat: Final[os.stat_result] = site_packages.stat() _write_dist_info(site_packages, "dependency-package") (site_packages / "root-package-1.0.dist-info" / "METADATA").write_text( "Name: root-package\nVersion: 1.0\nRequires-Dist: dependency-package\n", encoding="utf-8", ) os.utime(site_packages, ns=(site_packages_stat.st_atime_ns, site_packages_stat.st_mtime_ns)) assert venv_inspect.inspect_venv(*args).package_version == "1.0" def test_inspect_venv_reads_first_record_of_a_name_on_sys_path(tmp_path: Path, venv_sys_path: list[str]) -> None: site_packages: Final[Path] = tmp_path / "site-packages" _write_dist_info(site_packages, "root-package") later: Final[Path] = tmp_path / "base-site-packages" / "root_package-99.dist-info" later.mkdir(parents=True) (later / "METADATA").write_text("Name: root-package\nVersion: 99\n", encoding="utf-8") venv_sys_path.extend([str(site_packages), str(later.parent)]) result: Final = venv_inspect.inspect_venv( "root-package", set(), tmp_path / "bin", tmp_path / "python", tmp_path / "man" ) assert result.package_version == "1.0" @pytest.mark.parametrize( ("source", "expected"), [ pytest.param("man/inside.1", [str(Path("man1") / "inside.1")], id="inside"), pytest.param("{outside}", [], id="absolute-outside"), pytest.param("../outside.1", [], id="parent-outside"), ], ) def test_inspect_venv_copies_editable_man_pages_only_from_the_project( editable_project: Path, source: str, expected: list[str] ) -> None: assert ( _inspect_editable_man_pages(editable_project, source.format(outside=editable_project.parent / "outside.1")) == expected ) @pytest.fixture def editable_project(root_package_venv: Path) -> Path: project: Final[Path] = root_package_venv / "project" (project / "man").mkdir(parents=True) (project / "man" / "inside.1").touch() (root_package_venv / "outside.1").touch() _write_root_metadata( root_package_venv, {"direct_url.json": json.dumps({"url": project.as_uri(), "dir_info": {"editable": True}})} ) return project @pytest.fixture def root_package_venv(tmp_path: Path, venv_sys_path: list[str]) -> Path: site_packages: Final[Path] = tmp_path / "lib" / "site-packages" _write_dist_info(site_packages, "root-package") (tmp_path / "bin").mkdir() venv_sys_path.append(str(site_packages)) return tmp_path def _write_root_metadata(venv: Path, files: Mapping[str, str]) -> None: for name, content in files.items(): (venv / "lib" / "site-packages" / "root-package-1.0.dist-info" / name).write_text(content, encoding="utf-8") def _inspect_editable_man_pages(project: Path, source: str) -> list[str]: (project / "setup.cfg").write_text(f"[options.data_files]\nshare/man/man1 =\n {source}\n", encoding="utf-8") venv: Final[Path] = project.parent return venv_inspect.inspect_venv( "root-package", set(), venv / "bin", venv / "python", venv / "share" / "man" ).man_pages @SKIP_IF_WINDOWS @pytest.mark.parametrize( "target", [ pytest.param("../../outside.1", id="out-of-the-project"), pytest.param("link.1", id="symlink-loop"), ], ) def test_inspect_venv_skips_editable_man_page_symlink(editable_project: Path, target: str) -> None: (editable_project / "man" / "link.1").symlink_to(target) assert _inspect_editable_man_pages(editable_project, "man/link.1") == [] @pytest.mark.parametrize( ("root_requirements", "shared_requirements", "expected"), [ pytest.param(("left", "right"), (), ["first-app", "second-app"], id="first-then-second"), pytest.param(("right", "left"), (), ["first-app", "second-app"], id="second-then-first"), pytest.param(("shared", "left", "right"), (), ["first-app", "second-app"], id="base-then-extras"), pytest.param(("left", "right"), ("root",), ["first-app", "second-app"], id="cycle-to-root"), pytest.param( ("left", "right", 'third-app; extra == "other"'), ("root[other]",), ["first-app", "second-app", "third-app"], id="cycle-enables-root-extra", ), ], ) def test_inspect_venv_follows_extras_of_revisited_dependency( extras_venv: Path, root_requirements: tuple[str, ...], shared_requirements: tuple[str, ...], expected: list[str] ) -> None: result: Final = _inspect_extras_venv(extras_venv, root_requirements, shared_requirements) assert sorted(result.apps_of_dependencies) == expected @pytest.fixture def extras_venv(tmp_path: Path, venv_sys_path: list[str]) -> Path: site_packages: Final[Path] = tmp_path / "site-packages" (tmp_path / "bin").mkdir() _write_dist_info(site_packages, "left", ("shared[first]",)) _write_dist_info(site_packages, "right", ("shared[second]",)) for name in ("first-app", "second-app", "third-app"): _write_app(tmp_path, name) for resource in ("man/man1/second-app.1", "bash-completion/completions/second-app"): (resource_path := tmp_path / "share" / resource).parent.mkdir(parents=True) resource_path.touch() (site_packages / "second-app-1.0.dist-info" / "RECORD").write_text( "second-app-1.0.dist-info/entry_points.txt,,\n../share/man/man1/second-app.1,,\n" "../share/bash-completion/completions/second-app,,\n", encoding="utf-8", ) venv_sys_path.append(str(site_packages)) return tmp_path def _write_app(venv_root: Path, name: str, requirements: tuple[str, ...] = ()) -> None: site_packages: Final[Path] = venv_root / "site-packages" _write_dist_info(site_packages, name, requirements) (site_packages / f"{name}-1.0.dist-info" / "entry_points.txt").write_text( f"[console_scripts]\n{name} = app:main\n", encoding="utf-8" ) (venv_root / "bin" / name).touch() def _inspect_extras_venv( venv_root: Path, root_requirements: tuple[str, ...], shared_requirements: tuple[str, ...] = () ) -> venv_inspect.VenvMetadata: _write_app(venv_root, "root", root_requirements) _write_dist_info( venv_root / "site-packages", "shared", ('first-app; extra == "first"', 'second-app; extra == "second"', *shared_requirements), ) return venv_inspect.inspect_venv( "root", set(), venv_root / "bin", venv_root / "python", venv_root / "share" / "man" ) def test_inspect_venv_exposes_resources_of_revisited_dependency_extra(extras_venv: Path) -> None: result: Final = _inspect_extras_venv(extras_venv, ("left", "right")) assert (result.man_paths_of_dependencies, result.completion_paths_of_dependencies) == ( {"second-app": [extras_venv / "share" / "man" / "man1" / "second-app.1"]}, {"second-app": [extras_venv / "share" / "bash-completion" / "completions" / "second-app"]}, ) @pytest.mark.parametrize( ("metadata_files", "expected"), [ pytest.param({"installed-files.txt": "../../bin/other\n"}, ["other"], id="installed-files-only"), pytest.param( {"installed-files.txt": "../../bin/other\n", "RECORD": "root_package/__init__.py,,\n"}, [], id="installed-files-beside-record", ), ], ) def test_inspect_venv_reads_installed_files_only_without_record( other_app_venv: Path, metadata_files: dict[str, str], expected: list[str] ) -> None: _write_root_metadata(other_app_venv, metadata_files) assert _inspect_root_package(other_app_venv).apps == expected @pytest.fixture def other_app_venv(root_package_venv: Path) -> Path: (root_package_venv / "bin" / "other").touch() return root_package_venv def _inspect_root_package(venv: Path) -> venv_inspect.VenvMetadata: return venv_inspect.inspect_venv("root-package", set(), venv / "bin", venv / "python", venv / "man") @SKIP_IF_WINDOWS def test_inspect_venv_skips_installed_files_entry_in_a_symlink_loop(other_app_venv: Path) -> None: _write_root_metadata(other_app_venv, {"installed-files.txt": "loop\n../../bin/other\n"}) (loop := other_app_venv / "lib" / "site-packages" / "loop").symlink_to(loop) assert _inspect_root_package(other_app_venv).apps == ["other"] @pytest.mark.parametrize( ("metadata_files", "expected"), [ pytest.param({}, ["planted"], id="legacy-without-record"), pytest.param( {"RECORD": "root-package-1.0.dist-info/entry_points.txt,,\n"}, ["planted"], id="record-lists-entry-points" ), pytest.param({"RECORD": "root_package/__init__.py,,\n"}, [], id="record-without-entry-points"), ], ) def test_inspect_venv_reads_entry_points_only_when_record_lists_them( root_package_venv: Path, metadata_files: dict[str, str], expected: list[str] ) -> None: _write_root_metadata( root_package_venv, {"entry_points.txt": "[console_scripts]\nplanted = helper:main\n", **metadata_files} ) (root_package_venv / "bin" / "planted").touch() assert _inspect_root_package(root_package_venv).apps == expected def test_inspect_venv_ignores_resources_that_name_no_file(root_package_venv: Path) -> None: sections: Final = ("bin", "share/man/man1", "share/bash-completion/completions") _write_root_metadata( root_package_venv, { "entry_points.txt": "[console_scripts]\napp = root:main\n.. = root:main\n../lib = root:main\n", "RECORD": "".join(f"../../{section}/{name},,\n" for section in sections for name in ("app", "..")), }, ) for section in sections: (root_package_venv / section).mkdir(parents=True, exist_ok=True) (root_package_venv / section / "app").touch() result: Final = venv_inspect.inspect_venv( "root-package", set(), root_package_venv / "bin", root_package_venv / "python", root_package_venv / "share" / "man", ) assert (result.apps, [Path(page) for page in result.man_pages], [Path(c) for c in result.completions]) == ( ["app"], [Path("man1/app")], [Path("bash-completion/completions/app")], ) pipx-1.18.16/tox.toml000066400000000000000000000070761527355705200144040ustar00rootroot00000000000000requires = [ "tox>=4.64.7" ] env_list = [ "3.15", "3.14", "3.13", "3.12", "3.11", "3.15t", "docs", "lint", "pkg_meta", "man", "type", ] skip_missing_interpreters = true [env_run_base] description = "run tests with {env_name}" default_base_python = [ "3.15" ] package = "wheel" dependency_groups = [ "test" ] pass_env = [ "PYTEST_ADDOPTS" ] set_env.COVERAGE_FILE = "{work_dir}{/}.coverage.{env_name}" # a hung subprocess fails its test instead of the job; slow process creation on the Windows and macOS CI runners # makes some tests take 10 to 25 s there set_env.PYTEST_TIMEOUT = { replace = "if", condition = "factor.win32 or factor.darwin", then = "60", else = "10" } commands = [ [ "pytest", { replace = "posargs", default = [ "--cov=pipx", "--cov-report=term-missing:skip-covered", "--cov-report=xml:{work_dir}{/}coverage.{env_name}.xml", "-n", "auto", "--dist", "worksteal", "tests", ], extend = true }, ], ] uv_seed = true [env_pkg_base] default_base_python = [ "3.15" ] [env.docs] description = "build documentation" package = "editable" dependency_groups = [ "docs" ] commands = [ [ "sphinx-build", "-d", "{env_tmp_dir}{/}doctree", "-b", "html", "docs", "{env:READTHEDOCS_OUTPUT:site}{/}html", "-W", "--keep-going", ], [ "python", "-c", "print('docs built to: file://{env:READTHEDOCS_OUTPUT:site}{/}html{/}index.html')" ], ] [env.lint] description = "run pre-commit on the codebase" skip_install = true dependency_groups = [ "lint" ] commands = [ [ "pre-commit", "run", "--all-files" ] ] [env.pkg_meta] description = "check package metadata and wheel contents" skip_install = true dependency_groups = [] deps = [ "check-wheel-contents>=0.6.3", "twine>=7", "uv>=0.13" ] commands = [ [ "uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", "." ], [ "twine", "check", "{env_tmp_dir}{/}*" ], [ "check-wheel-contents", "--no-config", "{env_tmp_dir}" ], ] [env.man] description = "build man page" package = "editable" dependency_groups = [ "man" ] commands = [ [ "python", "scripts/generate_man.py", "{tox_root}{/}docs{/}man{/}pipx.1.rst" ], [ "uv", "build", "--out-dir", "{env_tmp_dir}{/}dist" ], [ "python", "scripts/check_distribution_manpage.py", "{env_tmp_dir}{/}dist", "{tox_root}{/}docs{/}man{/}pipx.1.rst", ], ] allowlist_externals = [ "uv" ] [env.type] description = "run the type checker" package = "editable" dependency_groups = [ "type" ] commands = [ [ "ty", "check" ] ] [env.dev] description = "generate a DEV environment" package = "editable" dependency_groups = [ "dev" ] commands = [ [ "uv", "pip", "tree" ], [ "python", "-c", "import sys; print(sys.executable)" ], ] [env.docs-live] description = "serve documentation with live reload" package = "editable" dependency_groups = [ "docs-live" ] commands = [ [ "sphinx-autobuild", "-d", "{env_tmp_dir}{/}doctree", "-b", "html", "docs", "{env:READTHEDOCS_OUTPUT:site}{/}html", "--open-browser", ], ] [env.zipapp] description = "build zipapp via shiv" skip_install = true dependency_groups = [ "zipapp" ] # pip evaluates environment markers against the interpreter building the artifact, dropping the # marker-gated dependencies; requesting them explicitly keeps the single zipapp working on every # supported platform and Python version commands = [ [ "shiv", "-c", "pipx", "-o", "{tox_root}{/}pipx.pyz", ".", "colorama>=0.4.4", ], [ "{tox_root}{/}pipx.pyz", "--version" ], ] allowlist_externals = [ "{tox_root}{/}pipx.pyz" ]