├── .deepsource.toml ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── release.yml └── workflows │ ├── bump-changelog.yml │ ├── create_tests_package_lists.yml │ ├── exhaustive_package_test.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── .readthedocs.yml ├── LICENSE ├── changelog.d ├── .gitignore ├── 1395.feature.md ├── 1521.bugfix.md ├── 1540.doc.md ├── 1565.bugfix.md ├── 1585.feature.md ├── 1610.bugfix.md ├── 1630.bugfix.md └── 683.bugfix.md ├── docs ├── README.md ├── changelog.md ├── comparisons.md ├── contributing.md ├── examples.md ├── getting-started.md ├── how-pipx-works.md ├── installation.md ├── programs-to-try.md └── troubleshooting.md ├── get-pipx.py ├── logo.svg ├── mkdocs.yml ├── noxfile.py ├── pipx_demo.gif ├── pyproject.toml ├── scripts ├── gen_doc_pages.py ├── generate_man.py ├── list_test_packages.py ├── migrate_pipsi_to_pipx.py ├── templates │ └── docs.md ├── test_packages_support.py └── update_package_cache.py ├── src └── pipx │ ├── __init__.py │ ├── __main__.py │ ├── animate.py │ ├── colors.py │ ├── commands │ ├── __init__.py │ ├── common.py │ ├── ensure_path.py │ ├── environment.py │ ├── inject.py │ ├── install.py │ ├── interpreter.py │ ├── list_packages.py │ ├── pin.py │ ├── reinstall.py │ ├── run.py │ ├── run_pip.py │ ├── uninject.py │ ├── uninstall.py │ └── upgrade.py │ ├── constants.py │ ├── emojis.py │ ├── interpreter.py │ ├── main.py │ ├── package_specifier.py │ ├── paths.py │ ├── pipx_metadata_file.py │ ├── shared_libs.py │ ├── standalone_python.py │ ├── util.py │ ├── venv.py │ ├── venv_inspect.py │ └── version.pyi ├── testdata ├── empty_project │ ├── README.md │ ├── empty_project │ │ ├── __init__.py │ │ └── main.py │ └── pyproject.toml ├── pipx_metadata_multiple_errors.json ├── standalone_python_index_20250317.json ├── standalone_python_index_20250409.json ├── test_package_specifier │ └── local_extras │ │ ├── repeatme │ │ ├── __init__.py │ │ └── main.py │ │ └── setup.py └── tests_packages │ ├── README.md │ ├── macos23-python3.12.txt │ ├── primary_packages.txt │ ├── unix-python3.10.txt │ ├── unix-python3.11.txt │ ├── unix-python3.12.txt │ ├── unix-python3.9.txt │ └── win-python3.12.txt └── tests ├── conftest.py ├── helpers.py ├── package_info.py ├── test_animate.py ├── test_completions.py ├── test_emojis.py ├── test_environment.py ├── test_inject.py ├── test_install.py ├── test_install_all.py ├── test_install_all_packages.py ├── test_interpreter.py ├── test_list.py ├── test_main.py ├── test_package_specifier.py ├── test_pin.py ├── test_pipx_metadata_file.py ├── test_reinstall.py ├── test_reinstall_all.py ├── test_run.py ├── test_runpip.py ├── test_shared_libs.py ├── test_standalone_interpreter.py ├── test_uninject.py ├── test_uninstall.py ├── test_uninstall_all.py ├── test_unpin.py ├── test_upgrade.py ├── test_upgrade_all.py └── test_upgrade_shared.py /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | test_patterns = ["tests/**"] 4 | 5 | [[analyzers]] 6 | name = "python" 7 | enabled = true 8 | 9 | [analyzers.meta] 10 | runtime_version = "3.x.x" 11 | 12 | [[transformers]] 13 | name = "black" 14 | enabled = true 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Report a bug or unexpected behavior. 4 | --- 5 | 6 | 12 | 13 | **Describe the bug** 14 | 15 | 16 | 17 | **How to reproduce** 18 | 19 | 20 | 21 | **Expected behavior** 22 | 23 | 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea or new feature for this project 4 | --- 5 | 6 | **How would this feature be useful?** 7 | 8 | 9 | 10 | **Describe the solution you'd like** 11 | 12 | 13 | 14 | **Describe alternatives you've considered** 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | - [ ] I have added a news fragment under `changelog.d/` (if the patch affects the end users) 4 | 5 | ## Summary of changes 6 | 7 | ## Test plan 8 | 9 | 10 | 11 | Tested by running 12 | 13 | ``` 14 | # command(s) to exercise these changes 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | groups: 8 | actions: 9 | patterns: 10 | - "*" 11 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | - pre-commit-ci 6 | - github-actions 7 | -------------------------------------------------------------------------------- /.github/workflows/bump-changelog.yml: -------------------------------------------------------------------------------- 1 | name: Bump changelog before release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version to be released' 8 | required: true 9 | type: string 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | env: 15 | default-python: "3.12" 16 | minimum-supported-python: "3.9" 17 | 18 | jobs: 19 | bump-changelog: 20 | name: Bump changelog 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | steps: 26 | - name: Checkout ${{ github.ref }} 27 | uses: actions/checkout@v4 28 | - name: Set up Python ${{ env.default-python }} 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: ${{ env.default-python }} 32 | cache: "pip" 33 | - name: Get release version and construct PR branch 34 | run: | 35 | echo "RELEASE_VERSION=${{ inputs.version }}" >> $GITHUB_ENV 36 | echo "PR_BRANCH=bump-changelog-for-${{ inputs.version }}" >> $GITHUB_ENV 37 | - name: Create pull request branch 38 | run: git switch -c $PR_BRANCH 39 | - name: Install nox 40 | run: python -m pip install nox 41 | - name: Update changelog 42 | run: nox --error-on-missing-interpreters --non-interactive --session build_changelog -- $RELEASE_VERSION 43 | - name: Commit and push change 44 | run: | 45 | git config --global user.name 'github-actions[bot]' 46 | git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com' 47 | git commit -am "$RELEASE_VERSION: Bump changelog" 48 | git fetch origin 49 | git push origin $PR_BRANCH 50 | - name: Create pull request 51 | run: | 52 | git fetch origin 53 | gh pr create --base main --fill --label release-version 54 | env: 55 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | -------------------------------------------------------------------------------- /.github/workflows/create_tests_package_lists.yml: -------------------------------------------------------------------------------- 1 | name: Create tests package lists for offline tests 2 | on: 3 | workflow_dispatch: 4 | concurrency: 5 | group: create-tests-package-lists-${{ github.ref }} 6 | cancel-in-progress: true 7 | jobs: 8 | create_package_lists: 9 | name: Create package lists 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | python-version: ["3.12", "3.11", "3.10", "3.9"] 15 | include: 16 | - os: macos-latest 17 | python-version: "3.12" 18 | - os: windows-latest 19 | python-version: "3.12" 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | cache: "pip" 27 | - name: Install nox 28 | run: python -m pip install nox 29 | - name: Create lists 30 | run: 31 | nox --non-interactive --session create_test_package_list-${{ matrix.python-version }} -- ./new_tests_packages 32 | - name: Store reports as artifacts 33 | uses: actions/upload-artifact@v4 34 | with: 35 | name: lists-${{ matrix.os }}-${{ matrix.python-version }} 36 | path: ./new_tests_packages 37 | -------------------------------------------------------------------------------- /.github/workflows/exhaustive_package_test.yml: -------------------------------------------------------------------------------- 1 | name: Exhaustive Package Test (slow) 2 | on: 3 | workflow_dispatch: 4 | concurrency: 5 | group: exhaustive-package-test-${{ github.ref }} 6 | cancel-in-progress: true 7 | jobs: 8 | test_all_packages: 9 | name: Exhaustive Package Test 10 | continue-on-error: true 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, macos-latest, windows-latest] 15 | python-version: ["3.12", "3.11", "3.10", "3.9"] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | cache: "pip" 24 | - name: Install nox 25 | run: python -m pip install nox 26 | - name: Execute Tests 27 | continue-on-error: true 28 | run: nox --non-interactive --session test_all_packages-${{ matrix.python-version }} 29 | - name: Store reports as artifacts 30 | uses: actions/upload-artifact@v4 31 | with: 32 | name: reports-raw-${{ matrix.os }}-${{ matrix.python-version }} 33 | path: reports 34 | 35 | report_all_packages: 36 | name: Collate test reports 37 | needs: test_all_packages 38 | runs-on: ubuntu-latest 39 | 40 | steps: 41 | - name: Get report artifacts 42 | uses: actions/download-artifact@v4 43 | with: 44 | pattern: reports-raw-* 45 | merge-multiple: true 46 | - name: Collate reports 47 | run: | 48 | ls # DEBUG 49 | mkdir reports 50 | cat all_packages_report_legend.txt > all_nodeps_reports_lf.txt 51 | cat all_packages_nodeps_report_* >> all_nodeps_reports_lf.txt 52 | tr -d '\r' < all_nodeps_reports_lf.txt > reports/all_nodeps_reports.txt 53 | cat all_packages_nodeps_errors_* > all_nodeps_errors_lf.txt 54 | tr -d '\r' < all_nodeps_errors_lf.txt > reports/all_nodeps_errors.txt 55 | cat all_packages_report_legend.txt > all_deps_reports_lf.txt 56 | cat all_packages_deps_report_* >> all_deps_reports_lf.txt 57 | tr -d '\r' < all_deps_reports_lf.txt > reports/all_deps_reports.txt 58 | cat all_packages_deps_errors_* > all_deps_errors_lf.txt 59 | tr -d '\r' < all_deps_errors_lf.txt > reports/all_deps_errors.txt 60 | - name: Store collated and raw reports as artifacts 61 | uses: actions/upload-artifact@v4 62 | with: 63 | name: reports-final 64 | path: reports 65 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "Version to release" 8 | required: true 9 | type: string 10 | pull_request_target: 11 | types: 12 | - closed 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | env: 18 | default-python: "3.12" 19 | minimum-supported-python: "3.9" 20 | 21 | jobs: 22 | create-tag: 23 | name: Create the Git tag 24 | if: >- 25 | github.event_name == 'workflow_dispatch' || 26 | github.event.pull_request.merged == true 27 | && contains(github.event.pull_request.labels.*.name, 'release-version') 28 | runs-on: ubuntu-latest 29 | outputs: 30 | release-tag: ${{ steps.get-version.outputs.version }} 31 | permissions: 32 | contents: write 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: Extract version to be released 36 | id: get-version 37 | env: 38 | PR_TITLE: ${{ github.event.pull_request.title }} 39 | run: | 40 | if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then 41 | echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT" 42 | else 43 | echo "version=${PR_TITLE/: [[:alnum:]]*}" >> "$GITHUB_OUTPUT" 44 | fi 45 | - name: Bump version and push tag 46 | uses: mathieudutour/github-tag-action@v6.2 47 | with: 48 | custom_tag: "${{ steps.get-version.outputs.version }}" 49 | github_token: ${{ secrets.GITHUB_TOKEN }} 50 | tag_prefix: "" 51 | 52 | pypi-publish: 53 | name: Publish pipx to PyPI 54 | needs: create-tag 55 | runs-on: ubuntu-latest 56 | environment: 57 | name: release 58 | url: https://pypi.org/p/pipx 59 | permissions: 60 | id-token: write 61 | steps: 62 | - name: Checkout ${{ needs.create-tag.outputs.release-tag }} 63 | uses: actions/checkout@v4 64 | with: 65 | ref: "${{ needs.create-tag.outputs.release-tag }}" 66 | - name: Set up Python ${{ env.default-python }} 67 | uses: actions/setup-python@v5 68 | with: 69 | python-version: ${{ env.default-python }} 70 | cache: "pip" 71 | - name: Install nox 72 | run: pip install nox 73 | - name: Build sdist and wheel 74 | run: nox --error-on-missing-interpreters --non-interactive --session build 75 | - name: Publish to PyPI 76 | uses: pypa/gh-action-pypi-publish@v1.12.4 77 | 78 | create-release: 79 | name: Create a release on GitHub's UI 80 | needs: [pypi-publish, create-tag] 81 | runs-on: ubuntu-latest 82 | permissions: 83 | contents: write 84 | steps: 85 | - uses: actions/checkout@v4 86 | - name: Create release 87 | uses: softprops/action-gh-release@v2 88 | with: 89 | generate_release_notes: true 90 | tag_name: "${{ needs.create-tag.outputs.release-tag }}" 91 | 92 | upload-zipapp: 93 | name: Upload zipapp to GitHub Release 94 | needs: [create-release, create-tag] 95 | runs-on: ubuntu-latest 96 | permissions: 97 | contents: write 98 | steps: 99 | - name: Checkout ${{ needs.create-tag.outputs.release-tag }} 100 | uses: actions/checkout@v4 101 | with: 102 | ref: "${{ needs.create-tag.outputs.release-tag }}" 103 | - name: Set up Python ${{ env.minimum-supported-python }} 104 | uses: actions/setup-python@v5 105 | with: 106 | python-version: ${{ env.minimum-supported-python }} 107 | cache: "pip" 108 | - name: Install nox 109 | run: pip install nox 110 | - name: Build zipapp 111 | run: nox --error-on-missing-interpreters --non-interactive --session zipapp 112 | - name: Upload to release 113 | uses: softprops/action-gh-release@v2 114 | with: 115 | files: pipx.pyz 116 | tag_name: "${{ needs.create-tag.outputs.release-tag }}" 117 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - "main" 8 | pull_request: 9 | schedule: 10 | - cron: "0 8 * * *" 11 | concurrency: 12 | group: tests-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | env: 16 | default-python: "3.12" 17 | minimum-supported-python: "3.9" 18 | 19 | jobs: 20 | tests: 21 | runs-on: ${{ matrix.os }} 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | os: [ubuntu-latest] 26 | python-version: ["3.12", "3.11", "3.10", "3.9"] 27 | include: 28 | - os: windows-latest 29 | python-version: "3.12" 30 | - os: macos-latest 31 | python-version: "3.12" 32 | 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: Set up Python ${{ matrix.python-version }} 36 | uses: actions/setup-python@v5 37 | with: 38 | python-version: ${{ matrix.python-version }} 39 | cache: "pip" 40 | - name: Persistent .pipx_tests/package_cache 41 | uses: actions/cache@v4 42 | with: 43 | path: ${{ github.workspace }}/.pipx_tests/package_cache/${{ matrix.python-version }} 44 | key: pipx-tests-package-cache-${{ runner.os }}-${{ matrix.python-version }} 45 | - name: Install nox 46 | run: python -m pip install nox 47 | - name: Execute Tests 48 | run: nox --error-on-missing-interpreters --non-interactive --session tests-${{ matrix.python-version }} 49 | man: 50 | name: Build man page 51 | runs-on: ubuntu-latest 52 | steps: 53 | - uses: actions/checkout@v4 54 | - name: Set up Python ${{ env.default-python }} 55 | uses: actions/setup-python@v5 56 | with: 57 | python-version: ${{ env.default-python }} 58 | cache: "pip" 59 | - name: Install nox 60 | run: python -m pip install nox 61 | - name: Build man page 62 | run: nox --error-on-missing-interpreters --non-interactive --session build_man 63 | - name: Show man page 64 | run: man -l pipx.1 65 | 66 | zipapp: 67 | name: Build zipapp 68 | runs-on: ubuntu-latest 69 | steps: 70 | - name: Checkout ${{ github.ref }} 71 | uses: actions/checkout@v4 72 | - name: Set up Python ${{ env.minimum-supported-python }} 73 | uses: actions/setup-python@v5 74 | with: 75 | python-version: ${{ env.minimum-supported-python }} 76 | cache: "pip" 77 | - name: Install nox 78 | run: pip install nox 79 | - name: Build zipapp 80 | run: nox --error-on-missing-interpreters --non-interactive --session zipapp 81 | - name: Test zipapp by installing black 82 | run: python ./pipx.pyz install black 83 | - uses: actions/upload-artifact@v4 84 | with: 85 | name: pipx.pyz 86 | path: pipx.pyz 87 | retention-days: 3 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.*_cache 2 | /build 3 | /dist 4 | /src/pipx/version.py 5 | /noxfile.py 6 | /.nox 7 | *.py[co] 8 | __pycache__ 9 | /site 10 | /.coverage* 11 | /.pipx_tests 12 | /testdata/tests_packages/*.txt 13 | /pipx.pyz 14 | *.egg-info 15 | build 16 | *.whl 17 | /pipx.1 18 | /docs/_draft_changelog.md 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: check-added-large-files 7 | - id: trailing-whitespace 8 | - id: check-yaml 9 | - repo: https://github.com/tox-dev/pyproject-fmt 10 | rev: "2.2.4" 11 | hooks: 12 | - id: pyproject-fmt 13 | - repo: https://github.com/astral-sh/ruff-pre-commit 14 | rev: v0.11.8 15 | hooks: 16 | - id: ruff 17 | args: [ "--fix", "--unsafe-fixes", "--show-fixes", "--exit-non-zero-on-fix"] 18 | - id: ruff-format 19 | - repo: https://github.com/pre-commit/mirrors-mypy 20 | rev: v1.11.2 21 | hooks: 22 | - id: mypy 23 | args: ['--warn-unused-ignores', '--strict-equality','--no-implicit-optional', '--check-untyped-defs'] 24 | exclude: 'testdata/test_package_specifier/local_extras/setup.py' 25 | additional_dependencies: 26 | - "mkdocs-gen-files" 27 | - "nox" 28 | - "packaging>=20" 29 | - "platformdirs>=2.1" 30 | - "tomli; python_version < '3.11'" 31 | # Configuration for codespell is in pyproject.toml 32 | - repo: https://github.com/codespell-project/codespell 33 | rev: v2.3.0 34 | hooks: 35 | - id: codespell 36 | additional_dependencies: 37 | - tomli 38 | exclude: ^testdata 39 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: pipx 2 | name: pipx 3 | entry: pipx run 4 | require_serial: true 5 | language: python 6 | minimum_pre_commit_version: '2.9.2' 7 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: "3.12" 6 | commands: 7 | - pip install nox 8 | - nox --session build_docs -- "${READTHEDOCS_OUTPUT}"/html 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Chad Smith and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /changelog.d/.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | -------------------------------------------------------------------------------- /changelog.d/1395.feature.md: -------------------------------------------------------------------------------- 1 | Rename environmental variable `USE_EMOJI` to `PIPX_USE_EMOJI`. 2 | -------------------------------------------------------------------------------- /changelog.d/1521.bugfix.md: -------------------------------------------------------------------------------- 1 | Update the logic of finding python interpreter such that `--fetch-missing-python` works on Windows 2 | -------------------------------------------------------------------------------- /changelog.d/1540.doc.md: -------------------------------------------------------------------------------- 1 | Fix `/changelog/` and `/contributing/` docs URLs 2 | -------------------------------------------------------------------------------- /changelog.d/1565.bugfix.md: -------------------------------------------------------------------------------- 1 | Fix no message displayed when no packages are upgraded with `upgrade-all`. 2 | -------------------------------------------------------------------------------- /changelog.d/1585.feature.md: -------------------------------------------------------------------------------- 1 | Add `--all-shells` flag to `pipx ensurepath`. 2 | -------------------------------------------------------------------------------- /changelog.d/1610.bugfix.md: -------------------------------------------------------------------------------- 1 | Fix incorrect order of flags when using `pipx upgrade`. 2 | -------------------------------------------------------------------------------- /changelog.d/1630.bugfix.md: -------------------------------------------------------------------------------- 1 | Update the archive name of build of Python for Windows 2 | -------------------------------------------------------------------------------- /changelog.d/683.bugfix.md: -------------------------------------------------------------------------------- 1 | On Windows, no longer overwrite existing files on upgrade if source and destination are the same 2 | -------------------------------------------------------------------------------- /docs/comparisons.md: -------------------------------------------------------------------------------- 1 | ## pipx vs pip 2 | 3 | - pip is a general Python package installer. It can be used to install libraries or cli applications with entrypoints. 4 | - pipx is a specialized package installer. It can only be used to install packages with cli entrypoints. 5 | - pipx and pip both install packages from PyPI (or locally) 6 | - pipx relies on pip (and venv) 7 | - pipx replaces a subset of pip's functionality; it lets you install cli applications but NOT libraries that you import 8 | in your code. 9 | - you can install pipx with pip 10 | 11 | Example interaction: Install pipx with pip: `pip install --user pipx` 12 | 13 | ## pipx vs poetry and pipenv 14 | 15 | - pipx is used solely for application consumption: you install cli apps with it 16 | - pipenv and poetry are cli apps used to develop applications and libraries 17 | - all three tools wrap pip and virtual environments for more convenient workflows 18 | 19 | Example interaction: Install pipenv and poetry with pipx: `pipx install poetry` Run pipenv or poetry with pipx: 20 | `pipx run poetry --help` 21 | 22 | ## pipx vs venv 23 | 24 | - venv is part of Python's standard library in Python 3.2 and above 25 | - venv creates "virtual environments" which are sandboxed python installations 26 | - pipx heavily relies on the venv package 27 | 28 | Example interaction: pipx installs packages to environments created with venv. `pipx install black --verbose` 29 | 30 | ## pipx vs pyenv 31 | 32 | - pyenv manages python versions on your system. It helps you install versions like Python 3.6, 3.7, etc. 33 | - pipx installs packages in virtual environments and exposes their entrypoints on your PATH 34 | 35 | Example interaction: Install a Python interpreter with pyenv, then install a package using pipx and that new 36 | interpreter: `pipx install black --python=python3.11` where python3.11 was installed on the system with pyenv 37 | 38 | ## pipx vs pipsi 39 | 40 | - pipx and pipsi both install packages in a similar way 41 | - pipx is under active development. pipsi is no longer maintained. 42 | - pipx always makes sure you're using the latest version of pip 43 | - pipx has the ability to run an app in one line, leaving your system unchanged after it finishes (`pipx run APP`) where 44 | pipsi does not 45 | - pipx has the ability to recursively install binaries from dependent packages 46 | - pipx adds more useful information to its output 47 | - pipx has more CLI options such as upgrade-all, reinstall-all, uninstall-all 48 | - pipx is more modern. It uses Python 3.6+, and the `venv` package in the Python3 standard library instead of the python 49 | 2 package `virtualenv`. 50 | - pipx works with Python homebrew installations while pipsi does not (at least on my machine) 51 | - pipx defaults to less verbose output 52 | - pipx allows you to see each command it runs by passing the --verbose flag 53 | - pipx prints emojis 😀 54 | 55 | Example interaction: None. Either one or the other should be used. These tools compete for a similar workflow. 56 | 57 | ### Migrating to pipx from pipsi 58 | 59 | After you have installed pipx, run 60 | [migrate_pipsi_to_pipx.py](https://raw.githubusercontent.com/pypa/pipx/main/scripts/migrate_pipsi_to_pipx.py). Why not 61 | do this with your new pipx installation? 62 | 63 | ``` 64 | pipx run https://raw.githubusercontent.com/pypa/pipx/main/scripts/migrate_pipsi_to_pipx.py 65 | ``` 66 | 67 | ## pipx vs brew 68 | 69 | - Both brew and pipx install cli tools 70 | - They install them from different sources. brew uses a curated repository specifically for brew, and pipx generally 71 | uses PyPI. 72 | 73 | Example interaction: brew can be used to install pipx, but they generally don't interact much. 74 | 75 | ## pipx vs npx 76 | 77 | - Both can run cli tools (npx will search for them in node_modules, and if not found run in a temporary environment. 78 | `pipx run` will search in `__pypackages__` and if not found run in a temporary environment) 79 | - npx works with JavaScript and pipx works with Python 80 | - Both tools attempt to make running executables written in a dynamic language (JS/Python) as easy as possible 81 | - pipx can also install tools globally; npx cannot 82 | 83 | Example interaction: None. These tools work for different languages. 84 | 85 | ## pipx vs pip-run 86 | 87 | [pip-run](https://github.com/jaraco/pip-run) is focused on running **arbitrary Python code in ephemeral environments** 88 | while pipx is focused on running **Python binaries in ephemeral and non-ephemeral environments**. 89 | 90 | For example these two commands both install poetry to an ephemeral environment and invoke poetry with `--help`. 91 | 92 | ``` 93 | pipx run poetry --help 94 | pip-run poetry -- -m poetry --help 95 | ``` 96 | 97 | Example interaction: None. 98 | 99 | ## pipx vs fades 100 | 101 | [fades](https://github.com/PyAr/fades) is a tool to run **individual** Python scripts inside automatically provisioned 102 | virtualenvs with their dependencies installed. 103 | 104 | - Both [fades](https://github.com/PyAr/fades#how-to-mark-the-dependencies-to-be-installed) and 105 | [pipx run](examples.md#pipx-run-examples) allow specifying a script's dependencies in specially formatted comments, 106 | but the exact syntax differs. (pipx's syntax is standardized by a 107 | [provisional specification](https://packaging.python.org/en/latest/specifications/inline-script-metadata/), 108 | fades's syntax is not standardized.) 109 | - Both tools automatically set up reusable virtualenvs containing the necessary dependencies. 110 | - Both can download Python scripts/packages to execute from remote resources. 111 | - fades can only run individual script files while pipx can also run packages. 112 | 113 | Example interaction: None. 114 | 115 | ## pipx vs pae/pactivate 116 | 117 | _pae_ is a Bash command-line function distributed with [pactivate](https://github.com/cynic-net/pactivate) that uses pactivate to create non-ephemeral environments focused on general use, rather than just running command-line applications. 118 | 119 | There is [a very detailed comparison here](https://github.com/cynic-net/pactivate/blob/main/doc/vs-pipx.md), but to briefly summarize: 120 | 121 | Similarities: 122 | 123 | - Both create isolated environments without having to specify (and remember) a directory in which to store them. 124 | - Both allow you to use any Python interpreter available on your system (subject to version restrictions below). 125 | 126 | pae advantages: 127 | 128 | - Supports all versions of Python from 2.7 upward. pipx requires ≥3.9. 129 | - Fewer dependencies. (See the detailed comparison for more information.) 130 | - Easier to have multiple versions of a single program and/or use different Python versions for a single program. 131 | - Somewhat more convenient for running arbitrary command-line programs in virtual environments, installing multiple packages in a single environment, and activating virtual environments. 132 | - Integrates well with source code repos using [pactivate](https://github.com/cynic-net/pactivate). 133 | 134 | pae disadvantages: 135 | 136 | - Usable with Bash shell only. 137 | - Slightly less quick and convenient for installing/running command-line programs from single Python packages. 138 | - Can be slower than pipx at creating virtual environments. 139 | 140 | Example interaction: None. Either one or the other should be used. These tools compete for a similar workflow. 141 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | ## `pipx install` examples 2 | 3 | ``` 4 | pipx install pycowsay 5 | pipx install --python python3.10 pycowsay 6 | pipx install --python 3.12 pycowsay 7 | pipx install --fetch-missing-python --python 3.12 pycowsay 8 | pipx install git+https://github.com/psf/black 9 | pipx install git+https://github.com/psf/black.git@branch-name 10 | pipx install git+https://github.com/psf/black.git@git-hash 11 | pipx install git+ssh://@/ 12 | pipx install https://github.com/psf/black/archive/18.9b0.zip 13 | pipx install black[d] 14 | pipx install --preinstall ansible-lint --preinstall mitogen ansible-core 15 | pipx install 'black[d] @ git+https://github.com/psf/black.git@branch-name' 16 | pipx install --suffix @branch-name 'black[d] @ git+https://github.com/psf/black.git@branch-name' 17 | pipx install --include-deps jupyter 18 | pipx install --pip-args='--pre' poetry 19 | pipx install --pip-args='--index-url=: --trusted-host=:' private-repo-package 20 | pipx install --index-url https://test.pypi.org/simple/ --pip-args='--extra-index-url https://pypi.org/simple/' some-package 21 | pipx --global install pycowsay 22 | pipx install . 23 | pipx install path/to/some-project 24 | ``` 25 | 26 | ## `pipx run` examples 27 | 28 | pipx enables you to test various combinations of Python versions and package versions in ephemeral environments: 29 | 30 | ``` 31 | pipx run BINARY # latest version of binary is run with python3 32 | pipx run --spec PACKAGE==2.0.0 BINARY # specific version of package is run 33 | pipx run --python python3.10 BINARY # Installed and invoked with specific Python version 34 | pipx run --python python3.9 --spec PACKAGE=1.7.3 BINARY 35 | pipx run --spec git+https://url.git BINARY # latest version on default branch is run 36 | pipx run --spec git+https://url.git@branch BINARY 37 | pipx run --spec git+https://url.git@hash BINARY 38 | pipx run pycowsay moo 39 | pipx --version # prints pipx version 40 | pipx run pycowsay --version # prints pycowsay version 41 | pipx run --python pythonX pycowsay 42 | pipx run pycowsay==2.0 --version 43 | pipx run pycowsay[dev] --version 44 | pipx run --spec git+https://github.com/psf/black.git black 45 | pipx run --spec git+https://github.com/psf/black.git@branch-name black 46 | pipx run --spec git+https://github.com/psf/black.git@git-hash black 47 | pipx run --spec https://github.com/psf/black/archive/18.9b0.zip black --help 48 | pipx run https://gist.githubusercontent.com/cs01/fa721a17a326e551ede048c5088f9e0f/raw/6bdfbb6e9c1132b1c38fdd2f195d4a24c540c324/pipx-demo.py 49 | ``` 50 | 51 | You can run local files, or scripts hosted on the internet, and you can run them with arguments: 52 | 53 | ``` 54 | pipx run test.py 55 | pipx run test.py 1 2 3 56 | pipx run https://example.com/test.py 57 | pipx run https://example.com/test.py 1 2 3 58 | ``` 59 | 60 | A simple filename is ambiguous - it could be a file, or a package on PyPI. It will be treated as a filename if the file 61 | exists, or as a package if not. To force interpretation as a local path, use `--path`, and to force interpretation as a 62 | package name, use `--spec` (with the PyPI name of the package). 63 | 64 | ``` 65 | pipx run myscript.py # Local file, if myscript.py exists 66 | pipx run doesnotexist.py # Package, because doesnotexist.py is not a local file 67 | pipx run --path test.py # Always a local file 68 | pipx run --spec test-py test.py # Always a package on PyPI 69 | ``` 70 | 71 | You can also run scripts that have dependencies: 72 | 73 | If you have a script `test.py` that needs 3rd party libraries, you can add [inline script metadata](https://packaging.python.org/en/latest/specifications/inline-script-metadata/) in the style of PEP 723. 74 | 75 | ``` 76 | # test.py 77 | 78 | # /// script 79 | # dependencies = ["requests"] 80 | # /// 81 | 82 | import sys 83 | import requests 84 | project = sys.argv[1] 85 | pipx_data = requests.get(f"https://pypi.org/pypi/{project}/json").json() 86 | print(pipx_data["info"]["version"]) 87 | ``` 88 | 89 | Then you can run it as follows: 90 | 91 | ``` 92 | > pipx run test.py pipx 93 | 1.1.0 94 | ``` 95 | 96 | ## `pipx inject` example 97 | 98 | One use of the inject command is setting up a REPL with some useful extra packages. 99 | 100 | ``` 101 | > pipx install ptpython 102 | > pipx inject ptpython requests pendulum 103 | ``` 104 | 105 | After running the above commands, you will be able to import and use the `requests` and `pendulum` packages inside a 106 | `ptpython` repl. 107 | 108 | Equivalently, the extra packages can be listed in a text file (e.g. `useful-packages.txt`). 109 | Each line is a separate package specifier with the same syntax as the command line. 110 | Comments are supported with a `#` prefix. 111 | Hence, the syntax is a strict subset of the pip [requirements file format][pip-requirements] syntax. 112 | 113 | [pip-requirements]: https://pip.pypa.io/en/stable/reference/requirements-file-format/ 114 | 115 | ``` 116 | # Additional packages 117 | requests 118 | 119 | pendulum # for easier datetimes 120 | ``` 121 | 122 | This file can then be given to `pipx inject` on the command line: 123 | 124 | ```shell 125 | > pipx inject ptpython --requirement useful-packages.txt 126 | # or: 127 | > pipx inject ptpython -r useful-packages.txt 128 | ``` 129 | 130 | Note that these options can be repeated and used together, e.g. 131 | 132 | ``` 133 | > pipx inject ptpython package-1 -r extra-packages-1.txt -r extra-packages-2.txt package-2 134 | ``` 135 | 136 | If you require full pip functionality, then use the `runpip` command instead; 137 | however, the installed packages won't be recognised as "injected". 138 | 139 | ## `pipx list` example 140 | 141 | ``` 142 | > pipx list 143 | venvs are in /Users/user/.local/pipx/venvs 144 | binaries are exposed on your $PATH at /Users/user/.local/bin 145 | package black 18.9b0, Python 3.10.0 146 | - black 147 | - blackd 148 | package pipx 0.10.0, Python 3.10.0 149 | - pipx 150 | 151 | > pipx list --short 152 | black 18.9b0 153 | pipx 0.10.0 154 | ``` 155 | 156 | ## `pipx install-all` example 157 | 158 | ```shell 159 | > pipx list --json > pipx.json 160 | > pipx install-all pipx.json 161 | 'black' already seems to be installed. Not modifying existing installation in '/usr/local/pipx/venvs/black'. Pass '--force' to force installation. 162 | 'pipx' already seems to be installed. Not modifying existing installation in '/usr/local/pipx/venvs/black'. Pass '--force' to force installation. 163 | > pipx install-all pipx.json --force 164 | Installing to existing venv 'black' 165 | installed package black 24.3.0, installed using Python 3.10.12 166 | These apps are now globally available 167 | - black 168 | - blackd 169 | done! ✨ 🌟 ✨ 170 | Installing to existing venv 'pipx' 171 | installed package pipx 1.4.3, installed using Python 3.10.12 172 | These apps are now globally available 173 | - pipx 174 | done! ✨ 🌟 ✨ 175 | ``` 176 | 177 | ## `pipx upgrade-shared` examples 178 | 179 | One use of the upgrade-shared command is to force a `pip` upgrade. 180 | 181 | ```shell 182 | > pipx upgrade-shared 183 | ``` 184 | 185 | This example pins `pip` (temporarily, until the next automatic upgrade, if that is not explicitly turned off) to a specific version. 186 | 187 | ```shell 188 | > pipx upgrade-shared --pip-args=pip==24.0 189 | ``` 190 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | Now that you have pipx installed, you can install a program: 2 | 3 | ``` 4 | pipx install PACKAGE 5 | ``` 6 | 7 | for example 8 | 9 | ``` 10 | pipx install pycowsay 11 | ``` 12 | 13 | You can list programs installed: 14 | 15 | ``` 16 | pipx list 17 | ``` 18 | 19 | Or you can run a program without installing it: 20 | 21 | ``` 22 | pipx run pycowsay moooo! 23 | ``` 24 | 25 | You can view documentation for all commands by running `pipx --help`. 26 | -------------------------------------------------------------------------------- /docs/how-pipx-works.md: -------------------------------------------------------------------------------- 1 | ## How it Works 2 | 3 | When installing a package and its binaries on linux (`pipx install package`) pipx will 4 | 5 | - create directory `~/.local/share/pipx/venvs/PACKAGE` 6 | - create or reuse a shared virtual environment that contains shared packaging library `pip` in 7 | `~/.local/share/pipx/shared/` 8 | - ensure the library is updated to its latest version 9 | - create a Virtual Environment in `~/.local/share/pipx/venvs/PACKAGE` that uses the shared pip mentioned above but 10 | otherwise is isolated (pipx uses a [.pth file](https://docs.python.org/3/library/site.html) to do this) 11 | - install the desired package in the Virtual Environment 12 | - expose binaries at `~/.local/bin` that point to new binaries in `~/.local/share/pipx/venvs/PACKAGE/bin` (such as 13 | `~/.local/bin/black` -> `~/.local/share/pipx/venvs/black/bin/black`) 14 | - expose manual pages at `~/.local/share/man/man[1-9]` that point to new manual pages in 15 | `~/.local/pipx/venvs/PACKAGE/share/man/man[1-9]` 16 | - as long as `~/.local/bin/` is on your PATH, you can now invoke the new binaries globally 17 | - on operating systems which have the `man` command, as long as `~/.local/share/man` is a recognized search path of man, 18 | you can now view the new manual pages globally 19 | - adding `--global` flag to any `pipx` command will execute the action in global scope which will expose app to all 20 | users - [reference](installation.md#global-installation). Note that this is not available on Windows. 21 | 22 | When running a binary (`pipx run BINARY`), pipx will 23 | 24 | - create or reuse a shared virtual environment that contains the shared packaging library `pip` 25 | - ensure the library is updated to its latest version 26 | - create a temporary directory (or reuse a cached virtual environment for this package) with a name based on a hash of 27 | the attributes that make the run reproducible. This includes things like the package name, spec, python version, and 28 | pip arguments. 29 | - create a Virtual Environment inside it with `python -m venv` 30 | - install the desired package in the Virtual Environment 31 | - invoke the binary 32 | 33 | These are all things you can do yourself, but pipx automates them for you. If you are curious as to what pipx is doing 34 | behind the scenes, you can always pass the `--verbose` flag to see every single command and argument being run. 35 | 36 | ## Developing for pipx 37 | 38 | If you are a developer and want to be able to run 39 | 40 | ``` 41 | pipx install MY_PACKAGE 42 | ``` 43 | 44 | make sure you include `scripts` and, optionally for Windows GUI applications `gui-scripts`, sections under your main table[^1] in `pyproject.toml` or their legacy equivalents for `setup.cfg` and `setup.py`. 45 | 46 | [^1]: This is often the `[project]` table, but might also be differently named. Read more in the [PyPUG](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#writing-your-pyproject-toml). 47 | 48 | === "pyproject.toml" 49 | 50 | ```ini 51 | [project.scripts] 52 | foo = "my_package.some_module:main_func" 53 | bar = "other_module:some_func" 54 | 55 | [project.gui-scripts] 56 | baz = "my_package_gui:start_func" 57 | ``` 58 | 59 | === "setup.cfg" 60 | 61 | ```ini 62 | [options.entry_points] 63 | console_scripts = 64 | foo = my_package.some_module:main_func 65 | bar = other_module:some_func 66 | gui_scripts = 67 | baz = my_package_gui:start_func 68 | ``` 69 | 70 | === "setup.py" 71 | 72 | ```python 73 | setup( 74 | # other arguments here... 75 | entry_points={ 76 | 'console_scripts': [ 77 | 'foo = my_package.some_module:main_func', 78 | 'bar = other_module:some_func', 79 | ], 80 | 'gui_scripts': [ 81 | 'baz = my_package_gui:start_func', 82 | ] 83 | }, 84 | ) 85 | ``` 86 | 87 | In this case `foo` and `bar` (and `baz` on Windows) would be available as "applications" to pipx after installing the above example package, invoking their corresponding entry point functions. 88 | 89 | ### Manual pages 90 | 91 | If you wish to provide documentation via `man` pages on UNIX-like systems then these can be added as data files: 92 | 93 | === "setuptools" 94 | 95 | ```toml title="pyproject.toml" 96 | [tool.setuptools.data-files] 97 | "share/man/man1" = [ 98 | "manpage.1", 99 | ] 100 | ``` 101 | 102 | ```ini title="setup.cfg" 103 | [options.data_files] 104 | share/man/man1 = 105 | manpage.1 106 | ``` 107 | 108 | ```python title="setup.py" 109 | setup( 110 | # other arguments here... 111 | data_files=[('share/man/man1', ['manpage.1'])] 112 | ) 113 | ``` 114 | 115 | > [!WARNING] 116 | > 117 | > The `data-files` keyword is "discouraged" in the [setuptools documentation](https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html#setuptools-specific-configuration) but there is no alternative if `man` pages are a requirement. 118 | 119 | === "pdm-backend" 120 | 121 | ```toml title="pyproject.toml" 122 | [tool.pdm.build] 123 | source-includes = ["share"] 124 | 125 | [tool.pdm.build.wheel-data] 126 | data = [ 127 | {path = "share/man/man1/*", relative-to = "."}, 128 | ] 129 | ``` 130 | 131 | In this case the manual page `manpage.1` could be accessed by the user after installing the above example package. 132 | 133 | For a real-world example, see [pycowsay](https://github.com/cs01/pycowsay/blob/master/setup.py)'s `setup.py` source code. 134 | 135 | You can read more about entry points [here](https://setuptools.pypa.io/en/latest/userguide/quickstart.html#entry-points-and-automatic-script-creation). 136 | -------------------------------------------------------------------------------- /docs/programs-to-try.md: -------------------------------------------------------------------------------- 1 | ## Programs 2 | 3 | Here are some programs you can try out. If you've never used the program before, make sure you add the `--help` flag so 4 | it doesn't do something you don't expect. If you decide you want to install, you can run `pipx install PACKAGE` instead. 5 | 6 | ### ansible 7 | 8 | IT automation 9 | 10 | ``` 11 | pipx install --include-deps ansible 12 | ``` 13 | 14 | ### asciinema 15 | 16 | Record and share your terminal sessions, the right way. 17 | 18 | ``` 19 | pipx run asciinema 20 | ``` 21 | 22 | ### black 23 | 24 | uncompromising Python code formatter 25 | 26 | ``` 27 | pipx run black 28 | ``` 29 | 30 | ### pybabel 31 | 32 | internationalizing and localizing Python applications 33 | 34 | ``` 35 | pipx run --spec=babel pybabel --help 36 | ``` 37 | 38 | ### chardetect 39 | 40 | detect file encoding 41 | 42 | ``` 43 | pipx run --spec=chardet chardetect --help 44 | ``` 45 | 46 | ### cookiecutter 47 | 48 | creates projects from project templates 49 | 50 | ``` 51 | pipx run cookiecutter 52 | ``` 53 | 54 | ### create-python-package 55 | 56 | easily create and publish new Python packages 57 | 58 | ``` 59 | pipx run create-python-package 60 | ``` 61 | 62 | ### flake8 63 | 64 | tool for style guide enforcement 65 | 66 | ``` 67 | pipx run flake8 68 | ``` 69 | 70 | ### gdbgui 71 | 72 | browser-based gdb debugger 73 | 74 | ``` 75 | pipx run gdbgui 76 | ``` 77 | 78 | ### hatch 79 | 80 | Python project manager that lets you build & publish packages, run tasks in environments and more 81 | 82 | ``` 83 | pipx run hatch 84 | ``` 85 | 86 | ### hexsticker 87 | 88 | create hexagon stickers automatically 89 | 90 | ``` 91 | pipx run hexsticker 92 | ``` 93 | 94 | ### ipython 95 | 96 | powerful interactive Python shell 97 | 98 | ``` 99 | pipx run ipython 100 | ``` 101 | 102 | ### jupyter 103 | 104 | web-based notebook environment for interactive computing 105 | 106 | ``` 107 | pipx run jupyter 108 | ``` 109 | 110 | ### pipenv 111 | 112 | python dependency/environment management 113 | 114 | ``` 115 | pipx run pipenv 116 | ``` 117 | 118 | ### poetry 119 | 120 | python dependency/environment/packaging management 121 | 122 | ``` 123 | pipx run poetry 124 | ``` 125 | 126 | ### pylint 127 | 128 | source code analyzer 129 | 130 | ``` 131 | pipx run pylint 132 | ``` 133 | 134 | ### pyinstaller 135 | 136 | bundles a Python application and all its dependencies into a single package 137 | 138 | ``` 139 | pipx run pyinstaller 140 | ``` 141 | 142 | ### pyxtermjs 143 | 144 | fully functional terminal in the browser 145 | 146 | ``` 147 | pipx run pyxtermjs 148 | ``` 149 | 150 | ### ruff 151 | 152 | An extremely fast Python linter 153 | 154 | ``` 155 | pipx run ruff 156 | ``` 157 | 158 | ### shell-functools 159 | 160 | Functional programming tools for the shell 161 | 162 | ``` 163 | pipx install shell-functools 164 | ``` 165 | -------------------------------------------------------------------------------- /get-pipx.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | 4 | 5 | def fail(msg): 6 | sys.stderr.write(msg + "\n") 7 | sys.stderr.flush() 8 | sys.exit(1) 9 | 10 | 11 | def main(): 12 | fail( 13 | "This installation method has been deprecated. " 14 | "See https://github.com/pypa/pipx for current installation " 15 | "instructions." 16 | ) 17 | 18 | 19 | if __name__ == "__main__": 20 | main() 21 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: pipx 2 | site_description: execute binaries from Python packages in isolated environments 3 | 4 | theme: 5 | name: "material" 6 | palette: 7 | - media: "(prefers-color-scheme: light)" 8 | scheme: default 9 | toggle: 10 | icon: material/brightness-7 11 | name: Switch to dark mode 12 | 13 | - media: "(prefers-color-scheme: dark)" 14 | scheme: slate 15 | toggle: 16 | icon: material/brightness-4 17 | name: Switch to light mode 18 | 19 | repo_name: pypa/pipx 20 | repo_url: https://github.com/pypa/pipx 21 | edit_uri: edit/main/docs/ 22 | extra: 23 | analytics: 24 | provider: 'google' 25 | property: 'UA-90243909-2' 26 | 27 | 28 | nav: 29 | - Home: "README.md" 30 | - Installation: "installation.md" 31 | - Getting Started: "getting-started.md" 32 | - Docs: "docs.md" 33 | - Troubleshooting: "troubleshooting.md" 34 | - Examples: "examples.md" 35 | - Comparison to Other Tools: "comparisons.md" 36 | - How pipx works: "how-pipx-works.md" 37 | - Programs to Try: "programs-to-try.md" 38 | - Contributing: "contributing.md" 39 | - Changelog: "changelog.md" 40 | 41 | markdown_extensions: 42 | - markdown_gfm_admonition # GitHub's admonition (alert) syntax 43 | - footnotes 44 | - pymdownx.superfences 45 | - pymdownx.tabbed: 46 | alternate_style: true 47 | 48 | plugins: 49 | - search: 50 | lang: en 51 | - gen-files: 52 | scripts: 53 | - scripts/gen_doc_pages.py 54 | - macros 55 | -------------------------------------------------------------------------------- /pipx_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/pipx/11f3262d90ead8995ce1c40f2a37828a49ee611a/pipx_demo.gif -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs>=0.4", 5 | "hatchling>=1.18", 6 | ] 7 | 8 | [project] 9 | name = "pipx" 10 | description = "Install and Run Python Applications in Isolated Environments" 11 | readme = "docs/README.md" 12 | keywords = [ 13 | "cli", 14 | "install", 15 | "pip", 16 | "Virtual Environment", 17 | "workflow", 18 | ] 19 | license = "MIT" 20 | authors = [ 21 | { name = "Chad Smith", email = "chadsmith.software@gmail.com" }, 22 | ] 23 | requires-python = ">=3.9" 24 | classifiers = [ 25 | "License :: OSI Approved :: MIT License", 26 | "Operating System :: OS Independent", 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 3 :: Only", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3.12", 33 | ] 34 | dynamic = [ 35 | "version", 36 | ] 37 | dependencies = [ 38 | "argcomplete>=1.9.4", 39 | "colorama>=0.4.4; sys_platform=='win32'", 40 | "packaging>=20", 41 | "platformdirs>=2.1", 42 | "tomli; python_version<'3.11'", 43 | "userpath!=1.9,>=1.6", 44 | ] 45 | urls."Bug Tracker" = "https://github.com/pypa/pipx/issues" 46 | urls.Documentation = "https://pipx.pypa.io" 47 | urls.Homepage = "https://pipx.pypa.io" 48 | urls."Release Notes" = "https://pipx.pypa.io/latest/changelog/" 49 | urls."Source Code" = "https://github.com/pypa/pipx" 50 | scripts.pipx = "pipx.main:cli" 51 | 52 | [tool.hatch] 53 | build.hooks.vcs.version-file = "src/pipx/version.py" 54 | build.targets.sdist.include = [ 55 | "/src", 56 | "/logo.png", 57 | "/pipx_demo.gif", 58 | "/*.md", 59 | ] 60 | version.source = "vcs" 61 | 62 | [tool.ruff] 63 | line-length = 121 64 | src = [ 65 | "src", 66 | ] 67 | lint.extend-select = [ 68 | "A", 69 | "B", 70 | "C4", 71 | "C9", 72 | "I", 73 | "ISC", 74 | "PERF", 75 | "PGH", 76 | "PLC", 77 | "PLE", 78 | "PLW", 79 | "RSE", 80 | "RUF012", 81 | "RUF100", 82 | "TC", 83 | "W", 84 | ] 85 | lint.ignore = [ 86 | "PERF203", 87 | "PLW1508", 88 | ] 89 | lint.isort = { known-first-party = [ 90 | "helpers", 91 | "package_info", 92 | "pipx", 93 | ] } 94 | lint.mccabe.max-complexity = 15 95 | 96 | [tool.codespell] 97 | # Ref: https://github.com/codespell-project/codespell#using-a-config-file 98 | skip = '.git,*.pdf,*.svg,.nox,testdata,.mypy_cache' 99 | check-hidden = true 100 | # case sensitive etc 101 | ignore-regex = '\b(UE|path/doesnt/exist)\b' 102 | 103 | [tool.pytest.ini_options] 104 | minversion = "8" 105 | log_cli_level = "INFO" 106 | markers = [ 107 | "all_packages: test install with maximum number of packages", 108 | ] 109 | testpaths = [ "tests" ] 110 | addopts = [ "-ra", "--strict-config", "--strict-markers" ] 111 | 112 | [tool.towncrier] 113 | directory = "changelog.d" 114 | filename = "docs/changelog.md" 115 | start_string = "\n" 116 | underlines = [ 117 | "", 118 | "", 119 | "", 120 | ] 121 | title_format = "## [{version}](https://github.com/pypa/pipx/tree/{version}) - {project_date}" 122 | issue_format = "[#{issue}](https://github.com/pypa/pipx/issues/{issue})" 123 | package = "pipx" 124 | 125 | [[tool.mypy.overrides]] 126 | module = [ 127 | "pycowsay.*", 128 | ] 129 | ignore_missing_imports = true 130 | -------------------------------------------------------------------------------- /scripts/gen_doc_pages.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | from subprocess import check_output 5 | from typing import Optional 6 | 7 | import mkdocs_gen_files 8 | from jinja2 import Environment, FileSystemLoader 9 | 10 | 11 | def get_help(cmd: Optional[str]) -> str: 12 | base = ["pipx"] 13 | args = base + ([cmd] if cmd else []) + ["--help"] 14 | env_patch = os.environ.copy() 15 | env_patch["PATH"] = os.pathsep.join([str(Path(sys.executable).parent)] + env_patch["PATH"].split(os.pathsep)) 16 | content = check_output(args, text=True, env=env_patch) 17 | content = content.replace(str(Path("~").expanduser()), "~") 18 | return f""" 19 | ``` 20 | {content} 21 | ``` 22 | """ 23 | 24 | 25 | params = { 26 | "install": get_help("install"), 27 | "installall": get_help("install-all"), 28 | "uninject": get_help("uninject"), 29 | "inject": get_help("inject"), 30 | "upgrade": get_help("upgrade"), 31 | "upgradeall": get_help("upgrade-all"), 32 | "upgradeshared": get_help("upgrade-shared"), 33 | "uninstall": get_help("uninstall"), 34 | "uninstallall": get_help("uninstall-all"), 35 | "reinstall": get_help("reinstall"), 36 | "reinstallall": get_help("reinstall-all"), 37 | "list": get_help("list"), 38 | "interpreter": get_help("interpreter"), 39 | "run": get_help("run"), 40 | "runpip": get_help("runpip"), 41 | "ensurepath": get_help("ensurepath"), 42 | "environment": get_help("environment"), 43 | "completions": get_help("completions"), 44 | "usage": get_help(None), 45 | } 46 | 47 | 48 | env = Environment(loader=FileSystemLoader(Path(__file__).parent / "templates")) 49 | 50 | with mkdocs_gen_files.open("docs.md", "wt") as file_handler: 51 | file_handler.write(env.get_template("docs.md").render(**params)) 52 | file_handler.write("\n") 53 | -------------------------------------------------------------------------------- /scripts/generate_man.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os.path 4 | import sys 5 | import textwrap 6 | from typing import cast 7 | 8 | from build_manpages.manpage import Manpage # type: ignore[import-not-found] 9 | 10 | from pipx.main import get_command_parser 11 | 12 | 13 | def main(): 14 | sys.argv[0] = "pipx" 15 | parser, _ = get_command_parser() 16 | parser.man_short_description = cast("str", parser.description).splitlines()[1] # type: ignore[attr-defined] 17 | 18 | manpage = Manpage(parser) 19 | body = str(manpage) 20 | 21 | # Avoid hardcoding build paths in manpages (and improve readability) 22 | body = body.replace(os.path.expanduser("~").replace("-", "\\-"), "~") 23 | 24 | # Add a credit section 25 | body += textwrap.dedent( 26 | """ 27 | .SH AUTHORS 28 | .IR pipx (1) 29 | was written by Chad Smith and contributors. 30 | The project can be found online at 31 | .UR https://pipx.pypa.io 32 | .UE 33 | .SH SEE ALSO 34 | .IR pip (1), 35 | .IR virtualenv (1) 36 | """ 37 | ) 38 | 39 | with open("pipx.1", "w") as f: 40 | f.write(body) 41 | 42 | 43 | if __name__ == "__main__": 44 | main() 45 | -------------------------------------------------------------------------------- /scripts/list_test_packages.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import re 4 | import subprocess 5 | import sys 6 | import tempfile 7 | from concurrent.futures import ThreadPoolExecutor, as_completed 8 | from pathlib import Path 9 | from typing import Any, Dict, List, Set 10 | 11 | from test_packages_support import get_platform_list_path 12 | 13 | 14 | def process_command_line(argv: List[str]) -> argparse.Namespace: 15 | """Process command line invocation arguments and switches. 16 | 17 | Args: 18 | argv: list of arguments, or `None` from ``sys.argv[1:]``. 19 | 20 | Returns: 21 | argparse.Namespace: named attributes of arguments and switches 22 | """ 23 | # script_name = argv[0] 24 | argv = argv[1:] 25 | 26 | # initialize the parser object: 27 | parser = argparse.ArgumentParser( 28 | description="Create list of needed test packages for pipx tests and local pypiserver." 29 | ) 30 | 31 | # specifying nargs= puts outputs of parser in list (even if nargs=1) 32 | 33 | # required arguments 34 | parser.add_argument( 35 | "primary_package_list", 36 | help="Main packages to examine, getting list of matching distribution files and dependencies.", 37 | ) 38 | parser.add_argument("package_list_dir", help="Directory to output package distribution lists.") 39 | 40 | # switches/options: 41 | parser.add_argument("-v", "--verbose", action="store_true", help="Maximum verbosity, especially for pip operations.") 42 | 43 | return parser.parse_args(argv) 44 | 45 | 46 | def parse_package_list(package_list_file: Path) -> List[Dict[str, Any]]: 47 | output_list: List[Dict[str, Any]] = [] 48 | try: 49 | with package_list_file.open("r") as package_list_fh: 50 | for line in package_list_fh: 51 | line_parsed = re.sub(r"#.+$", "", line) 52 | if not re.search(r"\S", line_parsed): 53 | continue 54 | line_list = line_parsed.strip().split() 55 | if len(line_list) == 1: 56 | output_list.append({"spec": line_list[0]}) 57 | elif len(line_list) == 2: 58 | output_list.append({"spec": line_list[0], "no-deps": line_list[1].lower() == "true"}) 59 | else: 60 | print(f"ERROR: Unable to parse primary package list line:\n {line.strip()}") 61 | return [] 62 | except OSError: 63 | print("ERROR: File problem reading primary package list.") 64 | return [] 65 | return output_list 66 | 67 | 68 | def create_test_packages_list(package_list_dir_path: Path, primary_package_list_path: Path, verbose: bool) -> int: 69 | exit_code = 0 70 | package_list_dir_path.mkdir(exist_ok=True) 71 | platform_package_list_path = get_platform_list_path(package_list_dir_path) 72 | 73 | primary_test_packages = parse_package_list(primary_package_list_path) 74 | if not primary_test_packages: 75 | print(f"ERROR: Problem reading {primary_package_list_path}. Exiting.", file=sys.stderr) 76 | return 1 77 | 78 | with ThreadPoolExecutor(max_workers=12) as pool: 79 | futures = {pool.submit(download, pkg, verbose) for pkg in primary_test_packages} 80 | downloaded_list = set() 81 | for future in as_completed(futures): 82 | downloaded_list.update(future.result()) 83 | 84 | all_packages = [] 85 | for downloaded_path in downloaded_list: 86 | wheel_re = re.search(r"([^-]+)-([^-]+)-([^-]+)\-([^-]+)-([^-]+)(-[^-]+)?\.whl$", downloaded_path) 87 | src_re = re.search(r"(.+)-([^-]+)\.(?:tar.gz|zip)$", downloaded_path) 88 | if wheel_re: 89 | package_name = wheel_re.group(1) 90 | package_version = wheel_re.group(2) 91 | elif src_re: 92 | package_name = src_re.group(1) 93 | package_version = src_re.group(2) 94 | else: 95 | print(f"ERROR: cannot parse: {downloaded_path}", file=sys.stderr) 96 | continue 97 | 98 | all_packages.append(f"{package_name}=={package_version}") 99 | 100 | with platform_package_list_path.open("w") as package_list_fh: 101 | for package in sorted(all_packages): 102 | print(package, file=package_list_fh) 103 | 104 | return exit_code 105 | 106 | 107 | def download(test_package: Dict[str, str], verbose: bool) -> Set[str]: 108 | no_deps = test_package.get("no-deps", False) 109 | test_package_option_string = " (no-deps)" if no_deps else "" 110 | verbose_this_iteration = False 111 | with tempfile.TemporaryDirectory() as download_dir: 112 | cmd_list = ["pip", "download"] + (["--no-deps"] if no_deps else []) + [test_package["spec"], "-d", download_dir] 113 | if verbose: 114 | print(f"CMD: {' '.join(cmd_list)}") 115 | pip_download_process = subprocess.run(cmd_list, capture_output=True, text=True, check=False) 116 | if pip_download_process.returncode == 0: 117 | print(f"Examined {test_package['spec']}{test_package_option_string}") 118 | else: 119 | print(f"ERROR with {test_package['spec']}{test_package_option_string}", file=sys.stderr) 120 | verbose_this_iteration = True 121 | if verbose or verbose_this_iteration: 122 | print(pip_download_process.stdout) 123 | print(pip_download_process.stderr) 124 | return {i.name for i in Path(download_dir).iterdir()} 125 | 126 | 127 | def main(argv: List[str]) -> int: 128 | args = process_command_line(argv) 129 | 130 | return create_test_packages_list(Path(args.package_list_dir), Path(args.primary_package_list), args.verbose) 131 | 132 | 133 | if __name__ == "__main__": 134 | try: 135 | status = main(sys.argv) 136 | except KeyboardInterrupt: 137 | print("Stopped by Keyboard Interrupt", file=sys.stderr) 138 | status = 130 139 | 140 | sys.exit(status) 141 | -------------------------------------------------------------------------------- /scripts/migrate_pipsi_to_pipx.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Script to migrate from pipsi to pipx 5 | """ 6 | 7 | import os 8 | import subprocess 9 | import sys 10 | from pathlib import Path 11 | from shutil import which 12 | 13 | 14 | def main(): 15 | if not which("pipx"): 16 | sys.exit("pipx must be installed to migrate from pipsi to pipx") 17 | 18 | if not sys.stdout.isatty(): 19 | sys.exit("Must be run from a terminal, not a script") 20 | 21 | pipsi_home = os.environ.get("PIPSI_HOME", os.path.expanduser("~/.local/venvs/")) 22 | packages = [p.name for p in Path(pipsi_home).iterdir()] 23 | 24 | if not packages: 25 | print("No packages installed with pipsi") 26 | sys.exit(0) 27 | 28 | print("Attempting to migrate the following packages from pipsi to pipx:") 29 | for package in packages: 30 | print(f" - {package}") 31 | 32 | answer = None 33 | while answer not in ["y", "n"]: 34 | answer = input("Continue? [y/n] ") 35 | 36 | if answer == "n": 37 | sys.exit(0) 38 | 39 | error = False 40 | for package in packages: 41 | ret = subprocess.run(["pipsi", "uninstall", "--yes", package], check=False) 42 | if ret.returncode: 43 | error = True 44 | print(f"Failed to uninstall {package!r} with pipsi. Not attempting to install with pipx.") 45 | else: 46 | print(f"uninstalled {package!r} with pipsi. Now attempting to install with pipx.") 47 | ret = subprocess.run(["pipx", "install", package], check=False) 48 | if ret.returncode: 49 | error = True 50 | print(f"Failed to install {package!r} with pipx.") 51 | else: 52 | print(f"Successfully installed {package} with pipx") 53 | 54 | print(f"Done migrating {len(packages)} packages!") 55 | print("You may still need to run `pipsi uninstall pipsi` or `pip uninstall pipsi`. Refer to pipsi's documentation.") 56 | 57 | if error: 58 | print("Note: Finished with errors. Review output to manually complete migration.") 59 | 60 | 61 | if __name__ == "__main__": 62 | main() 63 | -------------------------------------------------------------------------------- /scripts/templates/docs.md: -------------------------------------------------------------------------------- 1 | {{usage}} 2 | 3 | ### pipx install 4 | 5 | {{install}} 6 | 7 | ### pipx install-all 8 | 9 | {{installall}} 10 | 11 | ### pipx uninject 12 | 13 | {{uninject}} 14 | 15 | ### pipx inject 16 | 17 | {{inject}} 18 | 19 | ### pipx upgrade 20 | 21 | {{upgrade}} 22 | 23 | ### pipx upgrade-all 24 | 25 | {{upgradeall}} 26 | 27 | ### pipx upgrade-shared 28 | 29 | {{upgradeshared}} 30 | 31 | ### pipx uninstall 32 | 33 | {{uninstall}} 34 | 35 | ### pipx uninstall-all 36 | 37 | {{uninstallall}} 38 | 39 | ### pipx reinstall 40 | 41 | {{reinstall}} 42 | 43 | ### pipx reinstall-all 44 | 45 | {{reinstallall}} 46 | 47 | ### pipx list 48 | 49 | {{list}} 50 | 51 | ### pipx interpreter 52 | 53 | {{interpreter}} 54 | 55 | ### pipx run 56 | 57 | {{run}} 58 | 59 | ### pipx runpip 60 | 61 | {{runpip}} 62 | 63 | ### pipx ensurepath 64 | 65 | {{ensurepath}} 66 | 67 | ### pipx environment 68 | 69 | {{environment}} 70 | 71 | ### pipx completions 72 | 73 | {{completions}} 74 | -------------------------------------------------------------------------------- /scripts/test_packages_support.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import sys 3 | from pathlib import Path 4 | 5 | PYTHON_VERSION_STR = f"{sys.version_info[0]}.{sys.version_info[1]}" 6 | 7 | # Platform logic 8 | if sys.platform == "darwin": 9 | FULL_PLATFORM = "macos" + platform.release().split(".")[0] 10 | elif sys.platform == "win32": 11 | FULL_PLATFORM = "win" 12 | else: 13 | FULL_PLATFORM = "unix" 14 | 15 | 16 | def get_platform_list_path(package_list_dir_path: Path) -> Path: 17 | return package_list_dir_path / f"{FULL_PLATFORM}-python{PYTHON_VERSION_STR}.txt" 18 | 19 | 20 | def get_platform_packages_dir_path(pipx_package_cache_path: Path) -> Path: 21 | return pipx_package_cache_path / f"{PYTHON_VERSION_STR}" 22 | -------------------------------------------------------------------------------- /src/pipx/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info < (3, 9, 0): 4 | sys.exit("Python 3.9 or later is required. See https://github.com/pypa/pipx for installation instructions.") 5 | -------------------------------------------------------------------------------- /src/pipx/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | if not __package__: 5 | # Running from source. Add pipx's source code to the system 6 | # path to allow direct invocation, such as: 7 | # python src/pipx --help 8 | pipx_package_source_path = os.path.dirname(os.path.dirname(__file__)) 9 | sys.path.insert(0, pipx_package_source_path) 10 | 11 | from pipx.main import cli 12 | 13 | if __name__ == "__main__": 14 | sys.exit(cli()) 15 | -------------------------------------------------------------------------------- /src/pipx/animate.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import sys 3 | from contextlib import contextmanager 4 | from threading import Event, Thread 5 | from typing import Generator, List 6 | 7 | from pipx.constants import WINDOWS 8 | from pipx.emojis import EMOJI_SUPPORT 9 | 10 | stderr_is_tty = sys.stderr.isatty() 11 | 12 | CLEAR_LINE = "\033[K" 13 | EMOJI_ANIMATION_FRAMES = ["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"] 14 | NONEMOJI_ANIMATION_FRAMES = ["", ".", "..", "..."] 15 | EMOJI_FRAME_PERIOD = 0.1 16 | NONEMOJI_FRAME_PERIOD = 1 17 | MINIMUM_COLS_ALLOW_ANIMATION = 16 18 | 19 | 20 | if WINDOWS: 21 | import ctypes 22 | 23 | class _CursorInfo(ctypes.Structure): 24 | _fields_ = (("size", ctypes.c_int), ("visible", ctypes.c_byte)) 25 | 26 | 27 | def _env_supports_animation() -> bool: 28 | (term_cols, _) = shutil.get_terminal_size(fallback=(0, 0)) 29 | return stderr_is_tty and term_cols > MINIMUM_COLS_ALLOW_ANIMATION 30 | 31 | 32 | @contextmanager 33 | def animate(message: str, do_animation: bool, *, delay: float = 0) -> Generator[None, None, None]: 34 | if not do_animation or not _env_supports_animation(): 35 | # No animation, just a single print of message 36 | sys.stderr.write(f"{message}...\n") 37 | yield 38 | return 39 | 40 | event = Event() 41 | 42 | if EMOJI_SUPPORT: 43 | animate_at_beginning_of_line = True 44 | symbols = EMOJI_ANIMATION_FRAMES 45 | period = EMOJI_FRAME_PERIOD 46 | else: 47 | animate_at_beginning_of_line = False 48 | symbols = NONEMOJI_ANIMATION_FRAMES 49 | period = NONEMOJI_FRAME_PERIOD 50 | 51 | thread_kwargs = { 52 | "message": message, 53 | "event": event, 54 | "symbols": symbols, 55 | "delay": delay, 56 | "period": period, 57 | "animate_at_beginning_of_line": animate_at_beginning_of_line, 58 | } 59 | 60 | t = Thread(target=print_animation, kwargs=thread_kwargs) 61 | t.start() 62 | 63 | try: 64 | yield 65 | finally: 66 | event.set() 67 | clear_line() 68 | 69 | 70 | def print_animation( 71 | *, 72 | message: str, 73 | event: Event, 74 | symbols: List[str], 75 | delay: float, 76 | period: float, 77 | animate_at_beginning_of_line: bool, 78 | ) -> None: 79 | (term_cols, _) = shutil.get_terminal_size(fallback=(9999, 24)) 80 | event.wait(delay) 81 | while not event.wait(0): 82 | for s in symbols: 83 | if animate_at_beginning_of_line: 84 | max_message_len = term_cols - len(f"{s} ... ") 85 | cur_line = f"{s} {message:.{max_message_len}}" 86 | if len(message) > max_message_len: 87 | cur_line += "..." 88 | else: 89 | max_message_len = term_cols - len("... ") 90 | cur_line = f"{message:.{max_message_len}}{s}" 91 | 92 | clear_line() 93 | sys.stderr.write(cur_line) 94 | sys.stderr.flush() 95 | if event.wait(period): 96 | break 97 | 98 | 99 | # for Windows pre-ANSI-terminal-support (before Windows 10 TH2 (v1511)) 100 | # https://stackoverflow.com/a/10455937 101 | def win_cursor(visible: bool) -> None: 102 | if sys.platform != "win32": # hello mypy 103 | return 104 | ci = _CursorInfo() 105 | handle = ctypes.windll.kernel32.GetStdHandle(-11) 106 | ctypes.windll.kernel32.GetConsoleCursorInfo(handle, ctypes.byref(ci)) 107 | ci.visible = visible 108 | ctypes.windll.kernel32.SetConsoleCursorInfo(handle, ctypes.byref(ci)) 109 | 110 | 111 | def hide_cursor() -> None: 112 | if stderr_is_tty: 113 | if WINDOWS: 114 | win_cursor(visible=False) 115 | else: 116 | sys.stderr.write("\033[?25l") 117 | sys.stderr.flush() 118 | 119 | 120 | def show_cursor() -> None: 121 | if stderr_is_tty: 122 | if WINDOWS: 123 | win_cursor(visible=True) 124 | else: 125 | sys.stderr.write("\033[?25h") 126 | sys.stderr.flush() 127 | 128 | 129 | def clear_line() -> None: 130 | """Clears current line and positions cursor at start of line""" 131 | sys.stderr.write(f"\r{CLEAR_LINE}") 132 | sys.stdout.write(f"\r{CLEAR_LINE}") 133 | -------------------------------------------------------------------------------- /src/pipx/colors.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Callable 3 | 4 | try: 5 | import colorama # type: ignore[import-untyped] 6 | except ImportError: # Colorama is Windows only package 7 | colorama = None 8 | 9 | PRINT_COLOR = sys.stdout.isatty() 10 | 11 | if PRINT_COLOR and colorama: 12 | colorama.init() 13 | 14 | 15 | class c: 16 | header = "\033[95m" 17 | blue = "\033[94m" 18 | green = "\033[92m" 19 | yellow = "\033[93m" 20 | red = "\033[91m" 21 | bold = "\033[1m" 22 | cyan = "\033[96m" 23 | underline = "\033[4m" 24 | end = "\033[0m" 25 | 26 | 27 | def mkcolorfunc(style: str) -> Callable[[str], str]: 28 | def stylize_text(x: str) -> str: 29 | if PRINT_COLOR: 30 | return f"{style}{x}{c.end}" 31 | else: 32 | return x 33 | 34 | return stylize_text 35 | 36 | 37 | bold = mkcolorfunc(c.bold) 38 | red = mkcolorfunc(c.red) 39 | blue = mkcolorfunc(c.cyan) 40 | cyan = mkcolorfunc(c.blue) 41 | green = mkcolorfunc(c.green) 42 | -------------------------------------------------------------------------------- /src/pipx/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from pipx.commands.ensure_path import ensure_pipx_paths 2 | from pipx.commands.environment import environment 3 | from pipx.commands.inject import inject 4 | from pipx.commands.install import install, install_all 5 | from pipx.commands.interpreter import list_interpreters, prune_interpreters, upgrade_interpreters 6 | from pipx.commands.list_packages import list_packages 7 | from pipx.commands.pin import pin, unpin 8 | from pipx.commands.reinstall import reinstall, reinstall_all 9 | from pipx.commands.run import run 10 | from pipx.commands.run_pip import run_pip 11 | from pipx.commands.uninject import uninject 12 | from pipx.commands.uninstall import uninstall, uninstall_all 13 | from pipx.commands.upgrade import upgrade, upgrade_all, upgrade_shared 14 | 15 | __all__ = [ 16 | "upgrade", 17 | "upgrade_all", 18 | "upgrade_shared", 19 | "run", 20 | "install", 21 | "install_all", 22 | "inject", 23 | "uninject", 24 | "uninstall", 25 | "uninstall_all", 26 | "reinstall", 27 | "reinstall_all", 28 | "list_packages", 29 | "run_pip", 30 | "ensure_pipx_paths", 31 | "environment", 32 | "list_interpreters", 33 | "prune_interpreters", 34 | "pin", 35 | "unpin", 36 | "upgrade_interpreters", 37 | ] 38 | -------------------------------------------------------------------------------- /src/pipx/commands/ensure_path.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import site 3 | import sys 4 | from pathlib import Path 5 | from typing import Optional, Tuple 6 | 7 | import userpath # type: ignore[import-not-found] 8 | 9 | from pipx import paths 10 | from pipx.constants import EXIT_CODE_OK, ExitCode 11 | from pipx.emojis import hazard, stars 12 | from pipx.util import pipx_wrap 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def get_pipx_user_bin_path() -> Optional[Path]: 18 | """Returns None if pipx is not installed using `pip --user` 19 | Otherwise returns parent dir of pipx binary 20 | """ 21 | # NOTE: using this method to detect pip user-installed pipx will return 22 | # None if pipx was installed as editable using `pip install --user -e` 23 | 24 | # https://docs.python.org/3/install/index.html#inst-alt-install-user 25 | # Linux + Mac: 26 | # scripts in /bin 27 | # Windows: 28 | # scripts in /Python/Scripts 29 | # modules in /Python/site-packages 30 | 31 | pipx_bin_path = None 32 | 33 | script_path = Path(__file__).resolve() 34 | userbase_path = Path(site.getuserbase()).resolve() 35 | try: 36 | _ = script_path.relative_to(userbase_path) 37 | except ValueError: 38 | pip_user_installed = False 39 | else: 40 | pip_user_installed = True 41 | if pip_user_installed: 42 | test_paths = ( 43 | userbase_path / "bin" / "pipx", 44 | Path(site.getusersitepackages()).resolve().parent / "Scripts" / "pipx.exe", 45 | ) 46 | for test_path in test_paths: 47 | if test_path.exists(): 48 | pipx_bin_path = test_path.parent 49 | break 50 | 51 | return pipx_bin_path 52 | 53 | 54 | def ensure_path(location: Path, *, force: bool, prepend: bool = False, all_shells: bool = False) -> Tuple[bool, bool]: 55 | """Ensure location is in user's PATH or add it to PATH. 56 | If prepend is True, location will be prepended to PATH, else appended. 57 | Returns True if location was added to PATH 58 | """ 59 | location_str = str(location) 60 | path_added = False 61 | need_shell_restart = userpath.need_shell_restart(location_str) 62 | in_current_path = userpath.in_current_path(location_str) 63 | 64 | if force or (not in_current_path and not need_shell_restart): 65 | if prepend: 66 | path_added = userpath.prepend(location_str, "pipx", all_shells=all_shells) 67 | else: 68 | path_added = userpath.append(location_str, "pipx", all_shells=all_shells) 69 | if not path_added: 70 | print( 71 | pipx_wrap( 72 | f"{hazard} {location_str} is not added to the PATH environment variable successfully. " 73 | f"You may need to add it to PATH manually.", 74 | subsequent_indent=" " * 4, 75 | ) 76 | ) 77 | else: 78 | print( 79 | pipx_wrap( 80 | f"Success! Added {location_str} to the PATH environment variable.", 81 | subsequent_indent=" " * 4, 82 | ) 83 | ) 84 | need_shell_restart = userpath.need_shell_restart(location_str) 85 | elif not in_current_path and need_shell_restart: 86 | print( 87 | pipx_wrap( 88 | f""" 89 | {location_str} has been been added to PATH, but you need to 90 | open a new terminal or re-login for this PATH change to take 91 | effect. Alternatively, you can source your shell's config file 92 | with e.g. 'source ~/.bashrc'. 93 | """, 94 | subsequent_indent=" " * 4, 95 | ) 96 | ) 97 | else: 98 | print(pipx_wrap(f"{location_str} is already in PATH.", subsequent_indent=" " * 4)) 99 | 100 | return (path_added, need_shell_restart) 101 | 102 | 103 | def ensure_pipx_paths(force: bool, prepend: bool = False, all_shells: bool = False) -> ExitCode: 104 | """Returns pipx exit code.""" 105 | bin_paths = {paths.ctx.bin_dir} 106 | 107 | pipx_user_bin_path = get_pipx_user_bin_path() 108 | if pipx_user_bin_path is not None: 109 | bin_paths.add(pipx_user_bin_path) 110 | 111 | path_added = False 112 | need_shell_restart = False 113 | path_action_str = "prepended to" if prepend else "appended to" 114 | 115 | for bin_path in bin_paths: 116 | (path_added_current, need_shell_restart_current) = ensure_path( 117 | bin_path, prepend=prepend, force=force, all_shells=all_shells 118 | ) 119 | path_added |= path_added_current 120 | need_shell_restart |= need_shell_restart_current 121 | 122 | print() 123 | 124 | if path_added: 125 | print( 126 | pipx_wrap( 127 | """ 128 | Consider adding shell completions for pipx. Run 'pipx 129 | completions' for instructions. 130 | """ 131 | ) 132 | + "\n" 133 | ) 134 | elif not need_shell_restart: 135 | sys.stdout.flush() 136 | logger.warning( 137 | pipx_wrap( 138 | f""" 139 | {hazard} All pipx binary directories have been {path_action_str} PATH. If you 140 | are sure you want to proceed, try again with the '--force' 141 | flag. 142 | """ 143 | ) 144 | + "\n" 145 | ) 146 | 147 | if need_shell_restart: 148 | print( 149 | pipx_wrap( 150 | """ 151 | You will need to open a new terminal or re-login for the PATH 152 | changes to take effect. Alternatively, you can source your shell's 153 | config file with e.g. 'source ~/.bashrc'. 154 | """ 155 | ) 156 | + "\n" 157 | ) 158 | 159 | print(f"Otherwise pipx is ready to go! {stars}") 160 | 161 | return EXIT_CODE_OK 162 | -------------------------------------------------------------------------------- /src/pipx/commands/environment.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from pipx import paths 4 | from pipx.constants import EXIT_CODE_OK, ExitCode 5 | from pipx.emojis import EMOJI_SUPPORT 6 | from pipx.interpreter import DEFAULT_PYTHON 7 | from pipx.util import PipxError 8 | 9 | ENVIRONMENT_VARIABLES = [ 10 | "PIPX_HOME", 11 | "PIPX_GLOBAL_HOME", 12 | "PIPX_BIN_DIR", 13 | "PIPX_GLOBAL_BIN_DIR", 14 | "PIPX_MAN_DIR", 15 | "PIPX_GLOBAL_MAN_DIR", 16 | "PIPX_SHARED_LIBS", 17 | "PIPX_DEFAULT_PYTHON", 18 | "PIPX_FETCH_MISSING_PYTHON", 19 | "PIPX_USE_EMOJI", 20 | "PIPX_HOME_ALLOW_SPACE", 21 | ] 22 | 23 | 24 | def environment(value: str) -> ExitCode: 25 | """Print a list of environment variables and paths used by pipx""" 26 | derived_values = { 27 | "PIPX_HOME": paths.ctx.home, 28 | "PIPX_BIN_DIR": paths.ctx.bin_dir, 29 | "PIPX_MAN_DIR": paths.ctx.man_dir, 30 | "PIPX_SHARED_LIBS": paths.ctx.shared_libs, 31 | "PIPX_LOCAL_VENVS": paths.ctx.venvs, 32 | "PIPX_LOG_DIR": paths.ctx.logs, 33 | "PIPX_TRASH_DIR": paths.ctx.trash, 34 | "PIPX_VENV_CACHEDIR": paths.ctx.venv_cache, 35 | "PIPX_STANDALONE_PYTHON_CACHEDIR": paths.ctx.standalone_python_cachedir, 36 | "PIPX_DEFAULT_PYTHON": DEFAULT_PYTHON, 37 | "PIPX_USE_EMOJI": str(EMOJI_SUPPORT).lower(), 38 | "PIPX_HOME_ALLOW_SPACE": str(paths.ctx.allow_spaces_in_home_path).lower(), 39 | } 40 | if value is None: 41 | print("Environment variables (set by user):") 42 | print("") 43 | for env_variable in ENVIRONMENT_VARIABLES: 44 | env_value = os.getenv(env_variable, "") 45 | print(f"{env_variable}={env_value}") 46 | print("") 47 | print("Derived values (computed by pipx):") 48 | print("") 49 | for env_variable, derived_value in derived_values.items(): 50 | print(f"{env_variable}={derived_value}") 51 | elif value in derived_values: 52 | print(derived_values[value]) 53 | else: 54 | raise PipxError("Variable not found.") 55 | 56 | return EXIT_CODE_OK 57 | -------------------------------------------------------------------------------- /src/pipx/commands/inject.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | import sys 5 | from pathlib import Path 6 | from typing import Generator, Iterable, List, Optional, Union 7 | 8 | from pipx import paths 9 | from pipx.colors import bold 10 | from pipx.commands.common import package_name_from_spec, run_post_install_actions 11 | from pipx.constants import EXIT_CODE_INJECT_ERROR, EXIT_CODE_OK, ExitCode 12 | from pipx.emojis import hazard, stars 13 | from pipx.util import PipxError, pipx_wrap 14 | from pipx.venv import Venv 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | COMMENT_RE = re.compile(r"(^|\s+)#.*$") 19 | 20 | 21 | def inject_dep( 22 | venv_dir: Path, 23 | package_name: Optional[str], 24 | package_spec: str, 25 | pip_args: List[str], 26 | *, 27 | verbose: bool, 28 | include_apps: bool, 29 | include_dependencies: bool, 30 | force: bool, 31 | suffix: bool = False, 32 | ) -> bool: 33 | logger.debug("Injecting package %s", package_spec) 34 | 35 | if not venv_dir.exists() or not next(venv_dir.iterdir()): 36 | raise PipxError( 37 | f""" 38 | Can't inject {package_spec!r} into nonexistent Virtual Environment 39 | {venv_dir.name!r}. Be sure to install the package first with 'pipx 40 | install {venv_dir.name}' before injecting into it. 41 | """ 42 | ) 43 | 44 | venv = Venv(venv_dir, verbose=verbose) 45 | venv.check_upgrade_shared_libs(pip_args=pip_args, verbose=verbose) 46 | 47 | if not venv.package_metadata: 48 | raise PipxError( 49 | f""" 50 | Can't inject {package_spec!r} into Virtual Environment 51 | {venv.name!r}. {venv.name!r} has missing internal pipx metadata. It 52 | was likely installed using a pipx version before 0.15.0.0. Please 53 | uninstall and install {venv.name!r}, or reinstall-all to fix. 54 | """ 55 | ) 56 | 57 | # package_spec is anything pip-installable, including package_name, vcs spec, 58 | # zip file, or tar.gz file. 59 | if package_name is None: 60 | package_name = package_name_from_spec( 61 | package_spec, 62 | os.fspath(venv.python_path), 63 | pip_args=pip_args, 64 | verbose=verbose, 65 | ) 66 | 67 | if not force and venv.has_package(package_name): 68 | logger.info("Package %s has already been injected", package_name) 69 | print( 70 | pipx_wrap( 71 | f""" 72 | {hazard} {package_name} already seems to be injected in {venv.name!r}. 73 | Not modifying existing installation in '{venv_dir}'. 74 | Pass '--force' to force installation. 75 | """ 76 | ) 77 | ) 78 | return True 79 | 80 | if suffix: 81 | venv_suffix = venv.package_metadata[venv.main_package_name].suffix 82 | else: 83 | venv_suffix = "" 84 | venv.install_package( 85 | package_name=package_name, 86 | package_or_url=package_spec, 87 | pip_args=pip_args, 88 | include_dependencies=include_dependencies, 89 | include_apps=include_apps, 90 | is_main_package=False, 91 | suffix=venv_suffix, 92 | ) 93 | if include_apps: 94 | run_post_install_actions( 95 | venv, 96 | package_name, 97 | paths.ctx.bin_dir, 98 | paths.ctx.man_dir, 99 | venv_dir, 100 | include_dependencies, 101 | force=force, 102 | ) 103 | 104 | print(f" injected package {bold(package_name)} into venv {bold(venv.name)}") 105 | print(f"done! {stars}", file=sys.stderr) 106 | 107 | # Any failure to install will raise PipxError, otherwise success 108 | return True 109 | 110 | 111 | def inject( 112 | venv_dir: Path, 113 | package_name: Optional[str], 114 | package_specs: Iterable[str], 115 | requirement_files: Iterable[str], 116 | pip_args: List[str], 117 | *, 118 | verbose: bool, 119 | include_apps: bool, 120 | include_dependencies: bool, 121 | force: bool, 122 | suffix: bool = False, 123 | ) -> ExitCode: 124 | """Returns pipx exit code.""" 125 | # Combined collection of package specifications 126 | packages = list(package_specs) 127 | for filename in requirement_files: 128 | packages.extend(parse_requirements(filename)) 129 | 130 | # Remove duplicates and order deterministically 131 | packages = sorted(set(packages)) 132 | 133 | if not packages: 134 | raise PipxError("No packages have been specified.") 135 | logger.info("Injecting packages: %r", packages) 136 | 137 | # Inject packages 138 | if not include_apps and include_dependencies: 139 | include_apps = True 140 | all_success = True 141 | for dep in packages: 142 | all_success &= inject_dep( 143 | venv_dir, 144 | package_name=None, 145 | package_spec=dep, 146 | pip_args=pip_args, 147 | verbose=verbose, 148 | include_apps=include_apps, 149 | include_dependencies=include_dependencies, 150 | force=force, 151 | suffix=suffix, 152 | ) 153 | 154 | # Any failure to install will raise PipxError, otherwise success 155 | return EXIT_CODE_OK if all_success else EXIT_CODE_INJECT_ERROR 156 | 157 | 158 | def parse_requirements(filename: Union[str, os.PathLike]) -> Generator[str, None, None]: 159 | """ 160 | Extract package specifications from requirements file. 161 | 162 | Return all of the non-empty lines with comments removed. 163 | """ 164 | # Based on https://github.com/pypa/pip/blob/main/src/pip/_internal/req/req_file.py 165 | with open(filename) as f: 166 | for line in f: 167 | # Strip comments and filter empty lines 168 | if pkgspec := COMMENT_RE.sub("", line).strip(): 169 | yield pkgspec 170 | -------------------------------------------------------------------------------- /src/pipx/commands/interpreter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | from pathlib import Path 4 | from typing import List 5 | 6 | from packaging import version 7 | 8 | from pipx import commands, constants, paths, standalone_python 9 | from pipx.animate import animate 10 | from pipx.pipx_metadata_file import PipxMetadata 11 | from pipx.util import is_paths_relative, rmdir 12 | from pipx.venv import Venv, VenvContainer 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def get_installed_standalone_interpreters() -> List[Path]: 18 | return [python_dir for python_dir in paths.ctx.standalone_python_cachedir.iterdir() if python_dir.is_dir()] 19 | 20 | 21 | def get_venvs_using_standalone_interpreter(venv_container: VenvContainer) -> List[Venv]: 22 | venvs: list[Venv] = [] 23 | for venv_dir in venv_container.iter_venv_dirs(): 24 | venv = Venv(venv_dir) 25 | if venv.pipx_metadata.source_interpreter: 26 | venvs.append(venv) 27 | return venvs 28 | 29 | 30 | def get_interpreter_users(interpreter: Path, venvs: List[Venv]) -> List[PipxMetadata]: 31 | return [ 32 | venv.pipx_metadata 33 | for venv in venvs 34 | if venv.pipx_metadata.source_interpreter 35 | and is_paths_relative(venv.pipx_metadata.source_interpreter, interpreter) 36 | ] 37 | 38 | 39 | def list_interpreters( 40 | venv_container: VenvContainer, 41 | ): 42 | interpreters = get_installed_standalone_interpreters() 43 | venvs = get_venvs_using_standalone_interpreter(venv_container) 44 | output: list[str] = [] 45 | output.append(f"Standalone interpreters are in {paths.ctx.standalone_python_cachedir}") 46 | for interpreter in interpreters: 47 | output.append(f"Python {interpreter.name}") 48 | used_in = get_interpreter_users(interpreter, venvs) 49 | if used_in: 50 | output.append(" Used in:") 51 | output.extend(f" - {p.main_package.package} {p.main_package.package_version}" for p in used_in) 52 | else: 53 | output.append(" Unused") 54 | 55 | print("\n".join(output)) 56 | return constants.EXIT_CODE_OK 57 | 58 | 59 | def prune_interpreters( 60 | venv_container: VenvContainer, 61 | ): 62 | interpreters = get_installed_standalone_interpreters() 63 | venvs = get_venvs_using_standalone_interpreter(venv_container) 64 | removed = [] 65 | for interpreter in interpreters: 66 | if get_interpreter_users(interpreter, venvs): 67 | continue 68 | rmdir(interpreter, safe_rm=True) 69 | removed.append(interpreter.name) 70 | if removed: 71 | print("Successfully removed:") 72 | for interpreter_name in removed: 73 | print(f" - Python {interpreter_name}") 74 | else: 75 | print("Nothing to remove") 76 | return constants.EXIT_CODE_OK 77 | 78 | 79 | def get_latest_micro_version( 80 | current_version: version.Version, latest_python_versions: List[version.Version] 81 | ) -> version.Version: 82 | for latest_python_version in latest_python_versions: 83 | if current_version.major == latest_python_version.major and current_version.minor == latest_python_version.minor: 84 | return latest_python_version 85 | return current_version 86 | 87 | 88 | def upgrade_interpreters(venv_container: VenvContainer, verbose: bool): 89 | with animate("Getting the index of the latest standalone python builds", not verbose): 90 | latest_pythons = standalone_python.list_pythons(use_cache=False) 91 | 92 | parsed_latest_python_versions = [] 93 | for latest_python_version in latest_pythons: 94 | try: 95 | parsed_latest_python_versions.append(version.parse(latest_python_version)) 96 | except version.InvalidVersion: 97 | logger.info(f"Invalid version found in latest pythons: {latest_python_version}. Skipping.") 98 | 99 | upgraded = [] 100 | 101 | for interpreter_dir in paths.ctx.standalone_python_cachedir.iterdir(): 102 | if not interpreter_dir.is_dir(): 103 | continue 104 | 105 | interpreter_python = interpreter_dir / "python.exe" if constants.WINDOWS else interpreter_dir / "bin" / "python3" 106 | interpreter_full_version = ( 107 | subprocess.run([str(interpreter_python), "--version"], stdout=subprocess.PIPE, check=True, text=True) 108 | .stdout.strip() 109 | .split()[1] 110 | ) 111 | try: 112 | parsed_interpreter_full_version = version.parse(interpreter_full_version) 113 | except version.InvalidVersion: 114 | logger.info(f"Invalid version found in interpreter at {interpreter_dir}. Skipping.") 115 | continue 116 | latest_micro_version = get_latest_micro_version(parsed_interpreter_full_version, parsed_latest_python_versions) 117 | if latest_micro_version > parsed_interpreter_full_version: 118 | standalone_python.download_python_build_standalone( 119 | f"{latest_micro_version.major}.{latest_micro_version.minor}", 120 | override=True, 121 | ) 122 | 123 | for venv_dir in venv_container.iter_venv_dirs(): 124 | venv = Venv(venv_dir) 125 | if venv.pipx_metadata.source_interpreter is not None and is_paths_relative( 126 | venv.pipx_metadata.source_interpreter, interpreter_dir 127 | ): 128 | print( 129 | f"Upgrade the interpreter of {venv.name} from {interpreter_full_version} to {latest_micro_version}" 130 | ) 131 | commands.reinstall( 132 | venv_dir=venv_dir, 133 | local_bin_dir=paths.ctx.bin_dir, 134 | local_man_dir=paths.ctx.man_dir, 135 | python=str(interpreter_python), 136 | verbose=verbose, 137 | ) 138 | upgraded.append((venv.name, interpreter_full_version, latest_micro_version)) 139 | 140 | if upgraded: 141 | print("Successfully upgraded the interpreter(s):") 142 | for venv_name, old_version, new_version in upgraded: 143 | print(f" - {venv_name}: {old_version} -> {new_version}") 144 | else: 145 | print("Nothing to upgrade") 146 | 147 | # Any failure to upgrade will raise PipxError, otherwise success 148 | return constants.EXIT_CODE_OK 149 | -------------------------------------------------------------------------------- /src/pipx/commands/list_packages.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import sys 4 | from pathlib import Path 5 | from typing import Any, Collection, Dict, Tuple 6 | 7 | from pipx import paths 8 | from pipx.colors import bold 9 | from pipx.commands.common import VenvProblems, get_venv_summary, venv_health_check 10 | from pipx.constants import EXIT_CODE_LIST_PROBLEM, EXIT_CODE_OK, ExitCode 11 | from pipx.emojis import sleep 12 | from pipx.pipx_metadata_file import JsonEncoderHandlesPath, PipxMetadata 13 | from pipx.venv import Venv, VenvContainer 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | PIPX_SPEC_VERSION = "0.1" 18 | 19 | 20 | def get_venv_metadata_summary(venv_dir: Path) -> Tuple[PipxMetadata, VenvProblems, str]: 21 | venv = Venv(venv_dir) 22 | 23 | (venv_problems, warning_message) = venv_health_check(venv) 24 | if venv_problems.any_(): 25 | return (PipxMetadata(venv_dir, read=False), venv_problems, warning_message) 26 | 27 | return (venv.pipx_metadata, venv_problems, "") 28 | 29 | 30 | def list_short(venv_dirs: Collection[Path]) -> VenvProblems: 31 | all_venv_problems = VenvProblems() 32 | for venv_dir in venv_dirs: 33 | venv_metadata, venv_problems, warning_str = get_venv_metadata_summary(venv_dir) 34 | if venv_problems.any_(): 35 | logger.warning(warning_str) 36 | else: 37 | print( 38 | venv_metadata.main_package.package, 39 | venv_metadata.main_package.package_version, 40 | ) 41 | all_venv_problems.or_(venv_problems) 42 | 43 | return all_venv_problems 44 | 45 | 46 | def list_text(venv_dirs: Collection[Path], include_injected: bool, venv_root_dir: str) -> VenvProblems: 47 | print(f"venvs are in {bold(venv_root_dir)}") 48 | print(f"apps are exposed on your $PATH at {bold(str(paths.ctx.bin_dir))}") 49 | print(f"manual pages are exposed at {bold(str(paths.ctx.man_dir))}") 50 | 51 | all_venv_problems = VenvProblems() 52 | for venv_dir in venv_dirs: 53 | package_summary, venv_problems = get_venv_summary(venv_dir, include_injected=include_injected) 54 | if venv_problems.any_(): 55 | logger.warning(package_summary) 56 | else: 57 | print(package_summary) 58 | all_venv_problems.or_(venv_problems) 59 | 60 | return all_venv_problems 61 | 62 | 63 | def list_json(venv_dirs: Collection[Path]) -> VenvProblems: 64 | warning_messages = [] 65 | spec_metadata: Dict[str, Any] = { 66 | "pipx_spec_version": PIPX_SPEC_VERSION, 67 | "venvs": {}, 68 | } 69 | all_venv_problems = VenvProblems() 70 | for venv_dir in venv_dirs: 71 | (venv_metadata, venv_problems, warning_str) = get_venv_metadata_summary(venv_dir) 72 | all_venv_problems.or_(venv_problems) 73 | if venv_problems.any_(): 74 | warning_messages.append(warning_str) 75 | continue 76 | 77 | spec_metadata["venvs"][venv_dir.name] = {} 78 | spec_metadata["venvs"][venv_dir.name]["metadata"] = venv_metadata.to_dict() 79 | 80 | print(json.dumps(spec_metadata, indent=4, sort_keys=True, cls=JsonEncoderHandlesPath)) 81 | for warning_message in warning_messages: 82 | logger.warning(warning_message) 83 | 84 | return all_venv_problems 85 | 86 | 87 | def list_pinned(venv_dirs: Collection[Path], include_injected: bool) -> VenvProblems: 88 | all_venv_problems = VenvProblems() 89 | for venv_dir in venv_dirs: 90 | venv_metadata, venv_problems, warning_str = get_venv_metadata_summary(venv_dir) 91 | if venv_problems.any_(): 92 | logger.warning(warning_str) 93 | else: 94 | if venv_metadata.main_package.pinned: 95 | print( 96 | venv_metadata.main_package.package, 97 | venv_metadata.main_package.package_version, 98 | ) 99 | if include_injected: 100 | for pkg, info in venv_metadata.injected_packages.items(): 101 | if info.pinned: 102 | print(pkg, info.package_version, f"(injected in venv {venv_dir.name})") 103 | all_venv_problems.or_(venv_problems) 104 | 105 | return all_venv_problems 106 | 107 | 108 | def list_packages( 109 | venv_container: VenvContainer, 110 | include_injected: bool, 111 | json_format: bool, 112 | short_format: bool, 113 | pinned_only: bool, 114 | ) -> ExitCode: 115 | """Returns pipx exit code.""" 116 | venv_dirs: Collection[Path] = sorted(venv_container.iter_venv_dirs()) 117 | if not venv_dirs: 118 | print(f"nothing has been installed with pipx {sleep}", file=sys.stderr) 119 | 120 | if json_format: 121 | all_venv_problems = list_json(venv_dirs) 122 | elif short_format: 123 | all_venv_problems = list_short(venv_dirs) 124 | elif pinned_only: 125 | all_venv_problems = list_pinned(venv_dirs, include_injected) 126 | else: 127 | if not venv_dirs: 128 | return EXIT_CODE_OK 129 | all_venv_problems = list_text(venv_dirs, include_injected, str(venv_container)) 130 | 131 | if all_venv_problems.bad_venv_name: 132 | logger.warning( 133 | "\nOne or more packages contain out-of-date internal data installed from a\n" 134 | "previous pipx version and need to be updated.\n" 135 | " To fix, execute: pipx reinstall-all" 136 | ) 137 | if all_venv_problems.invalid_interpreter: 138 | logger.warning( 139 | "\nOne or more packages have a missing python interpreter.\n To fix, execute: pipx reinstall-all" 140 | ) 141 | if all_venv_problems.missing_metadata: 142 | logger.warning( 143 | "\nOne or more packages have a missing internal pipx metadata.\n" 144 | " They were likely installed using a pipx version before 0.15.0.0.\n" 145 | " Please uninstall and install these package(s) to fix." 146 | ) 147 | if all_venv_problems.not_installed: 148 | logger.warning( 149 | "\nOne or more packages are not installed properly.\n" 150 | " Please uninstall and install these package(s) to fix." 151 | ) 152 | 153 | if all_venv_problems.any_(): 154 | print("", file=sys.stderr) 155 | return EXIT_CODE_LIST_PROBLEM 156 | 157 | return EXIT_CODE_OK 158 | -------------------------------------------------------------------------------- /src/pipx/commands/pin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from typing import Sequence 4 | 5 | from pipx.colors import bold 6 | from pipx.constants import ExitCode 7 | from pipx.emojis import sleep 8 | from pipx.util import PipxError 9 | from pipx.venv import Venv 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def _update_pin_info(venv: Venv, package_name: str, is_main_package: bool, unpin: bool) -> int: 15 | package_metadata = venv.package_metadata[package_name] 16 | venv.update_package_metadata( 17 | package_name=str(package_metadata.package), 18 | package_or_url=str(package_metadata.package_or_url), 19 | pip_args=package_metadata.pip_args, 20 | include_dependencies=package_metadata.include_dependencies, 21 | include_apps=package_metadata.include_apps, 22 | is_main_package=is_main_package, 23 | suffix=package_metadata.suffix, 24 | pinned=not unpin, 25 | ) 26 | return 1 27 | 28 | 29 | def pin( 30 | venv_dir: Path, 31 | verbose: bool, 32 | skip: Sequence[str], 33 | injected_only: bool = False, 34 | ) -> ExitCode: 35 | venv = Venv(venv_dir, verbose=verbose) 36 | try: 37 | main_package_metadata = venv.package_metadata[venv.main_package_name] 38 | except KeyError as e: 39 | raise PipxError(f"Package {venv.name} is not installed") from e 40 | 41 | if main_package_metadata.pinned: 42 | logger.warning(f"Package {main_package_metadata.package} already pinned {sleep}") 43 | elif injected_only or skip: 44 | pinned_packages_count = 0 45 | pinned_packages_list = [] 46 | for package_name in venv.package_metadata: 47 | if package_name == venv.main_package_name or package_name in skip: 48 | continue 49 | 50 | if venv.package_metadata[package_name].pinned: 51 | print(f"{package_name} was pinned. Not modifying.") 52 | continue 53 | 54 | pinned_packages_count += _update_pin_info(venv, package_name, is_main_package=False, unpin=False) 55 | pinned_packages_list.append(f"{package_name} {venv.package_metadata[package_name].package_version}") 56 | 57 | if pinned_packages_count != 0: 58 | print(bold(f"Pinned {pinned_packages_count} packages in venv {venv.name}")) 59 | for package in pinned_packages_list: 60 | print(" -", package) 61 | else: 62 | for package_name in venv.package_metadata: 63 | if package_name == venv.main_package_name: 64 | _update_pin_info(venv, venv.main_package_name, is_main_package=True, unpin=False) 65 | else: 66 | _update_pin_info(venv, package_name, is_main_package=False, unpin=False) 67 | 68 | return ExitCode(0) 69 | 70 | 71 | def unpin(venv_dir: Path, verbose: bool) -> ExitCode: 72 | venv = Venv(venv_dir, verbose=verbose) 73 | try: 74 | main_package_metadata = venv.package_metadata[venv.main_package_name] 75 | except KeyError as e: 76 | raise PipxError(f"Package {venv.name} is not installed") from e 77 | 78 | unpinned_packages_count = 0 79 | unpinned_packages_list = [] 80 | 81 | for package_name in venv.package_metadata: 82 | if package_name == main_package_metadata.package and main_package_metadata.pinned: 83 | unpinned_packages_count += _update_pin_info(venv, package_name, is_main_package=True, unpin=True) 84 | elif venv.package_metadata[package_name].pinned: 85 | unpinned_packages_count += _update_pin_info(venv, package_name, is_main_package=False, unpin=True) 86 | unpinned_packages_list.append(package_name) 87 | 88 | if unpinned_packages_count != 0: 89 | print(bold(f"Unpinned {unpinned_packages_count} packages in venv {venv.name}")) 90 | for package in unpinned_packages_list: 91 | print(" -", package) 92 | else: 93 | logger.warning(f"No packages to unpin in venv {venv.name}") 94 | 95 | return ExitCode(0) 96 | -------------------------------------------------------------------------------- /src/pipx/commands/reinstall.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | from typing import List, Sequence 4 | 5 | from packaging.utils import canonicalize_name 6 | 7 | from pipx.commands.inject import inject_dep 8 | from pipx.commands.install import install 9 | from pipx.commands.uninstall import uninstall 10 | from pipx.constants import ( 11 | EXIT_CODE_OK, 12 | EXIT_CODE_REINSTALL_INVALID_PYTHON, 13 | EXIT_CODE_REINSTALL_VENV_NONEXISTENT, 14 | ExitCode, 15 | ) 16 | from pipx.emojis import error, sleep 17 | from pipx.util import PipxError 18 | from pipx.venv import Venv, VenvContainer 19 | 20 | 21 | def reinstall( 22 | *, 23 | venv_dir: Path, 24 | local_bin_dir: Path, 25 | local_man_dir: Path, 26 | python: str, 27 | verbose: bool, 28 | force_reinstall_shared_libs: bool = False, 29 | python_flag_passed: bool = False, 30 | ) -> ExitCode: 31 | """Returns pipx exit code.""" 32 | if not venv_dir.exists(): 33 | print(f"Nothing to reinstall for {venv_dir.name} {sleep}") 34 | return EXIT_CODE_REINSTALL_VENV_NONEXISTENT 35 | 36 | try: 37 | Path(python).relative_to(venv_dir) 38 | except ValueError: 39 | pass 40 | else: 41 | print( 42 | f"{error} Error, the python executable would be deleted!", 43 | "Change it using the --python option or PIPX_DEFAULT_PYTHON environment variable.", 44 | ) 45 | return EXIT_CODE_REINSTALL_INVALID_PYTHON 46 | 47 | venv = Venv(venv_dir, verbose=verbose) 48 | venv.check_upgrade_shared_libs( 49 | pip_args=venv.pipx_metadata.main_package.pip_args, verbose=verbose, force_upgrade=force_reinstall_shared_libs 50 | ) 51 | 52 | if venv.pipx_metadata.main_package.package_or_url is not None: 53 | package_or_url = venv.pipx_metadata.main_package.package_or_url 54 | else: 55 | package_or_url = venv.main_package_name 56 | 57 | uninstall(venv_dir, local_bin_dir, local_man_dir, verbose) 58 | 59 | # in case legacy original dir name 60 | venv_dir = venv_dir.with_name(canonicalize_name(venv_dir.name)) 61 | 62 | # install main package first 63 | install( 64 | venv_dir, 65 | [venv.main_package_name], 66 | [package_or_url], 67 | local_bin_dir, 68 | local_man_dir, 69 | python, 70 | venv.pipx_metadata.main_package.pip_args, 71 | venv.pipx_metadata.venv_args, 72 | verbose, 73 | force=True, 74 | reinstall=True, 75 | include_dependencies=venv.pipx_metadata.main_package.include_dependencies, 76 | preinstall_packages=[], 77 | suffix=venv.pipx_metadata.main_package.suffix, 78 | python_flag_passed=python_flag_passed, 79 | ) 80 | 81 | # now install injected packages 82 | for injected_name, injected_package in venv.pipx_metadata.injected_packages.items(): 83 | if injected_package.package_or_url is None: 84 | # This should never happen, but package_or_url is type 85 | # Optional[str] so mypy thinks it could be None 86 | raise PipxError(f"Internal Error injecting package {injected_package} into {venv.name}") 87 | inject_dep( 88 | venv_dir, 89 | injected_name, 90 | injected_package.package_or_url, 91 | injected_package.pip_args, 92 | verbose=verbose, 93 | include_apps=injected_package.include_apps, 94 | include_dependencies=injected_package.include_dependencies, 95 | force=True, 96 | ) 97 | 98 | # Any failure to install will raise PipxError, otherwise success 99 | return EXIT_CODE_OK 100 | 101 | 102 | def reinstall_all( 103 | venv_container: VenvContainer, 104 | local_bin_dir: Path, 105 | local_man_dir: Path, 106 | python: str, 107 | verbose: bool, 108 | *, 109 | skip: Sequence[str], 110 | python_flag_passed: bool = False, 111 | ) -> ExitCode: 112 | """Returns pipx exit code.""" 113 | failed: List[str] = [] 114 | reinstalled: List[str] = [] 115 | 116 | # iterate on all packages and reinstall them 117 | # for the first one, we also trigger 118 | # a reinstall of shared libs beforehand 119 | first_reinstall = True 120 | for venv_dir in venv_container.iter_venv_dirs(): 121 | if venv_dir.name in skip: 122 | continue 123 | try: 124 | reinstall( 125 | venv_dir=venv_dir, 126 | local_bin_dir=local_bin_dir, 127 | local_man_dir=local_man_dir, 128 | python=python, 129 | verbose=verbose, 130 | force_reinstall_shared_libs=first_reinstall, 131 | python_flag_passed=python_flag_passed, 132 | ) 133 | except PipxError as e: 134 | print(e, file=sys.stderr) 135 | failed.append(venv_dir.name) 136 | else: 137 | first_reinstall = False 138 | reinstalled.append(venv_dir.name) 139 | if len(reinstalled) == 0: 140 | print(f"No packages reinstalled after running 'pipx reinstall-all' {sleep}") 141 | if len(failed) > 0: 142 | raise PipxError(f"The following package(s) failed to reinstall: {', '.join(failed)}") 143 | # Any failure to install will raise PipxError, otherwise success 144 | return EXIT_CODE_OK 145 | -------------------------------------------------------------------------------- /src/pipx/commands/run_pip.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List 3 | 4 | from pipx.constants import ExitCode 5 | from pipx.util import PipxError 6 | from pipx.venv import Venv 7 | 8 | 9 | def run_pip(package: str, venv_dir: Path, pip_args: List[str], verbose: bool) -> ExitCode: 10 | """Returns pipx exit code.""" 11 | venv = Venv(venv_dir, verbose=verbose) 12 | if not venv.python_path.exists(): 13 | raise PipxError(f"venv for {package!r} was not found. Was {package!r} installed with pipx?") 14 | venv.verbose = True 15 | return venv.run_pip_get_exit_code(pip_args) 16 | -------------------------------------------------------------------------------- /src/pipx/commands/uninject.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from pathlib import Path 4 | from typing import List, Set 5 | 6 | from packaging.utils import canonicalize_name 7 | 8 | from pipx.colors import bold 9 | from pipx.commands.uninstall import ( 10 | _get_package_bin_dir_app_paths, 11 | _get_package_man_paths, 12 | ) 13 | from pipx.constants import ( 14 | EXIT_CODE_OK, 15 | EXIT_CODE_UNINJECT_ERROR, 16 | MAN_SECTIONS, 17 | ExitCode, 18 | ) 19 | from pipx.emojis import stars 20 | from pipx.util import PipxError, pipx_wrap 21 | from pipx.venv import Venv 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | def get_include_resource_paths(package_name: str, venv: Venv, local_bin_dir: Path, local_man_dir: Path) -> Set[Path]: 27 | bin_dir_app_paths = _get_package_bin_dir_app_paths( 28 | venv, venv.package_metadata[package_name], venv.bin_path, local_bin_dir 29 | ) 30 | man_paths = set() 31 | for man_section in MAN_SECTIONS: 32 | man_paths |= _get_package_man_paths( 33 | venv, 34 | venv.package_metadata[package_name], 35 | venv.man_path / man_section, 36 | local_man_dir / man_section, 37 | ) 38 | 39 | need_to_remove = set() 40 | for bin_dir_app_path in bin_dir_app_paths: 41 | if bin_dir_app_path.name in venv.package_metadata[package_name].apps: 42 | need_to_remove.add(bin_dir_app_path) 43 | for man_path in man_paths: 44 | path = Path(man_path.parent.name) / man_path.name 45 | if str(path) in venv.package_metadata[package_name].man_pages: 46 | need_to_remove.add(path) 47 | 48 | return need_to_remove 49 | 50 | 51 | def uninject_dep( 52 | venv: Venv, 53 | package_name: str, 54 | *, 55 | local_bin_dir: Path, 56 | local_man_dir: Path, 57 | leave_deps: bool = False, 58 | ) -> bool: 59 | package_name = canonicalize_name(package_name) 60 | 61 | if package_name == venv.pipx_metadata.main_package.package: 62 | logger.warning( 63 | pipx_wrap( 64 | f""" 65 | {package_name} is the main package of {venv.root.name} 66 | venv. Use `pipx uninstall {venv.root.name}` to uninstall instead of uninject. 67 | """, 68 | subsequent_indent=" " * 4, 69 | ) 70 | ) 71 | return False 72 | 73 | if package_name not in venv.pipx_metadata.injected_packages: 74 | logger.warning(f"{package_name} is not in the {venv.root.name} venv. Skipping.") 75 | return False 76 | 77 | need_app_uninstall = venv.package_metadata[package_name].include_apps 78 | 79 | new_resource_paths = get_include_resource_paths(package_name, venv, local_bin_dir, local_man_dir) 80 | 81 | if not leave_deps: 82 | orig_not_required_packages = venv.list_installed_packages(not_required=True) 83 | logger.info(f"Original not required packages: {orig_not_required_packages}") 84 | 85 | venv.uninstall_package(package=package_name, was_injected=True) 86 | 87 | if not leave_deps: 88 | new_not_required_packages = venv.list_installed_packages(not_required=True) 89 | logger.info(f"New not required packages: {new_not_required_packages}") 90 | 91 | deps_of_uninstalled = new_not_required_packages - orig_not_required_packages 92 | if len(deps_of_uninstalled) == 0: 93 | pass 94 | else: 95 | logger.info(f"Dependencies of uninstalled package: {deps_of_uninstalled}") 96 | 97 | for dep_package_name in deps_of_uninstalled: 98 | venv.uninstall_package(package=dep_package_name, was_injected=False) 99 | 100 | deps_string = " and its dependencies" 101 | else: 102 | deps_string = "" 103 | 104 | if need_app_uninstall: 105 | for path in new_resource_paths: 106 | try: 107 | os.unlink(path) 108 | except FileNotFoundError: 109 | logger.info(f"tried to remove but couldn't find {path}") 110 | else: 111 | logger.info(f"removed file {path}") 112 | 113 | print(f"Uninjected package {bold(package_name)}{deps_string} from venv {bold(venv.root.name)} {stars}") 114 | return True 115 | 116 | 117 | def uninject( 118 | venv_dir: Path, 119 | dependencies: List[str], 120 | *, 121 | local_bin_dir: Path, 122 | local_man_dir: Path, 123 | leave_deps: bool, 124 | verbose: bool, 125 | ) -> ExitCode: 126 | """Returns pipx exit code""" 127 | 128 | if not venv_dir.exists() or not next(venv_dir.iterdir()): 129 | raise PipxError(f"Virtual environment {venv_dir.name} does not exist.") 130 | 131 | venv = Venv(venv_dir, verbose=verbose) 132 | 133 | if not venv.package_metadata: 134 | raise PipxError( 135 | f""" 136 | Can't uninject from Virtual Environment {venv_dir.name!r}. 137 | {venv_dir.name!r} has missing internal pipx metadata. 138 | It was likely installed using a pipx version before 0.15.0.0. 139 | Please uninstall and install {venv_dir.name!r} manually to fix. 140 | """ 141 | ) 142 | 143 | all_success = True 144 | for dep in dependencies: 145 | all_success &= uninject_dep( 146 | venv, 147 | dep, 148 | local_bin_dir=local_bin_dir, 149 | local_man_dir=local_man_dir, 150 | leave_deps=leave_deps, 151 | ) 152 | 153 | if all_success: 154 | return EXIT_CODE_OK 155 | else: 156 | return EXIT_CODE_UNINJECT_ERROR 157 | -------------------------------------------------------------------------------- /src/pipx/commands/uninstall.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from shutil import which 4 | from typing import TYPE_CHECKING, List, Optional, Set 5 | 6 | if TYPE_CHECKING: 7 | from collections.abc import Callable 8 | 9 | from pipx.commands.common import ( 10 | add_suffix, 11 | can_symlink, 12 | get_exposed_man_paths_for_package, 13 | get_exposed_paths_for_package, 14 | ) 15 | from pipx.constants import ( 16 | EXIT_CODE_OK, 17 | EXIT_CODE_UNINSTALL_ERROR, 18 | EXIT_CODE_UNINSTALL_VENV_NONEXISTENT, 19 | MAN_SECTIONS, 20 | ExitCode, 21 | ) 22 | from pipx.emojis import hazard, sleep, stars 23 | from pipx.pipx_metadata_file import PackageInfo 24 | from pipx.util import rmdir, safe_unlink 25 | from pipx.venv import Venv, VenvContainer 26 | from pipx.venv_inspect import VenvMetadata 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | def _venv_metadata_to_package_info( 32 | venv_metadata: VenvMetadata, 33 | package_name: str, 34 | package_or_url: str = "", 35 | pip_args: Optional[List[str]] = None, 36 | include_apps: bool = True, 37 | include_dependencies: bool = False, 38 | suffix: str = "", 39 | ) -> PackageInfo: 40 | if pip_args is None: 41 | pip_args = [] 42 | 43 | return PackageInfo( 44 | package=package_name, 45 | package_or_url=package_or_url, 46 | pip_args=pip_args, 47 | include_apps=include_apps, 48 | include_dependencies=include_dependencies, 49 | apps=venv_metadata.apps, 50 | app_paths=venv_metadata.app_paths, 51 | apps_of_dependencies=venv_metadata.apps_of_dependencies, 52 | app_paths_of_dependencies=venv_metadata.app_paths_of_dependencies, 53 | man_pages=venv_metadata.man_pages, 54 | man_paths=venv_metadata.man_paths, 55 | man_pages_of_dependencies=venv_metadata.man_pages_of_dependencies, 56 | man_paths_of_dependencies=venv_metadata.man_paths_of_dependencies, 57 | package_version=venv_metadata.package_version, 58 | suffix=suffix, 59 | ) 60 | 61 | 62 | def _get_package_bin_dir_app_paths( 63 | venv: Venv, package_info: PackageInfo, venv_bin_path: Path, local_bin_dir: Path 64 | ) -> Set[Path]: 65 | suffix = package_info.suffix 66 | apps = [] 67 | if package_info.include_apps: 68 | apps += package_info.apps 69 | if package_info.include_dependencies: 70 | apps += package_info.apps_of_dependencies 71 | return get_exposed_paths_for_package(venv_bin_path, local_bin_dir, [add_suffix(app, suffix) for app in apps]) 72 | 73 | 74 | def _get_package_man_paths(venv: Venv, package_info: PackageInfo, venv_man_path: Path, local_man_dir: Path) -> Set[Path]: 75 | man_pages = [] 76 | if package_info.include_apps: 77 | man_pages += package_info.man_pages 78 | if package_info.include_dependencies: 79 | man_pages += package_info.man_pages_of_dependencies 80 | return get_exposed_man_paths_for_package(venv_man_path, local_man_dir, man_pages) 81 | 82 | 83 | def _get_venv_resource_paths( 84 | resource_type: str, venv: Venv, venv_resource_path: Path, local_resource_dir: Path 85 | ) -> Set[Path]: 86 | resource_paths = set() 87 | assert resource_type in ("app", "man"), "invalid resource type" 88 | get_package_resource_paths: Callable[[Venv, PackageInfo, Path, Path], Set[Path]] 89 | get_package_resource_paths = { 90 | "app": _get_package_bin_dir_app_paths, 91 | "man": _get_package_man_paths, 92 | }[resource_type] 93 | 94 | if venv.pipx_metadata.main_package.package is not None: 95 | # Valid metadata for venv 96 | for package_info in venv.package_metadata.values(): 97 | resource_paths |= get_package_resource_paths(venv, package_info, venv_resource_path, local_resource_dir) 98 | elif venv.python_path.is_file(): 99 | # No metadata from pipx_metadata.json, but valid python interpreter. 100 | # In pre-metadata-pipx venv.root.name is name of main package 101 | # In pre-metadata-pipx there is no suffix 102 | # We make the conservative assumptions: no injected packages, 103 | # not include_dependencies. Other PackageInfo fields are irrelevant 104 | # here. 105 | venv_metadata = venv.get_venv_metadata_for_package(venv.root.name, set()) 106 | main_package_info = _venv_metadata_to_package_info(venv_metadata, venv.root.name) 107 | resource_paths = get_package_resource_paths(venv, main_package_info, venv_resource_path, local_resource_dir) 108 | else: 109 | # No metadata and no valid python interpreter. 110 | # We'll take our best guess on what to uninstall here based on symlink 111 | # location for symlink-capable systems. 112 | # The heuristic here is any symlink in ~/.local/bin pointing to 113 | # .local/share/pipx/venvs/VENV_NAME/{bin,Scripts} should be uninstalled. 114 | 115 | # For non-symlink systems we give up and return an empty set. 116 | if not local_resource_dir.is_dir() or not can_symlink(local_resource_dir): 117 | return set() 118 | 119 | resource_paths = get_exposed_paths_for_package(venv_resource_path, local_resource_dir) 120 | 121 | return resource_paths 122 | 123 | 124 | def uninstall(venv_dir: Path, local_bin_dir: Path, local_man_dir: Path, verbose: bool) -> ExitCode: 125 | """Uninstall entire venv_dir, including main package and all injected 126 | packages. 127 | 128 | Returns pipx exit code. 129 | """ 130 | if not venv_dir.exists(): 131 | print(f"Nothing to uninstall for {venv_dir.name} {sleep}") 132 | app = which(venv_dir.name) 133 | if app: 134 | print(f"{hazard} Note: '{app}' still exists on your system and is on your PATH") 135 | return EXIT_CODE_UNINSTALL_VENV_NONEXISTENT 136 | 137 | venv = Venv(venv_dir, verbose=verbose) 138 | 139 | bin_dir_app_paths = _get_venv_resource_paths("app", venv, venv.bin_path, local_bin_dir) 140 | man_dir_paths = set() 141 | for man_section in MAN_SECTIONS: 142 | man_dir_paths |= _get_venv_resource_paths("man", venv, venv.man_path / man_section, local_man_dir / man_section) 143 | 144 | for path in bin_dir_app_paths | man_dir_paths: 145 | try: 146 | safe_unlink(path) 147 | except FileNotFoundError: 148 | logger.info(f"tried to remove but couldn't find {path}") 149 | else: 150 | logger.info(f"removed file {path}") 151 | 152 | rmdir(venv_dir) 153 | print(f"uninstalled {venv.name}! {stars}") 154 | return EXIT_CODE_OK 155 | 156 | 157 | def uninstall_all( 158 | venv_container: VenvContainer, 159 | local_bin_dir: Path, 160 | local_man_dir: Path, 161 | verbose: bool, 162 | ) -> ExitCode: 163 | """Returns pipx exit code.""" 164 | all_success = True 165 | for venv_dir in venv_container.iter_venv_dirs(): 166 | return_val = uninstall(venv_dir, local_bin_dir, local_man_dir, verbose) 167 | all_success &= return_val == 0 168 | 169 | return EXIT_CODE_OK if all_success else EXIT_CODE_UNINSTALL_ERROR 170 | -------------------------------------------------------------------------------- /src/pipx/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import sysconfig 4 | from textwrap import dedent 5 | from typing import NewType 6 | 7 | PIPX_SHARED_PTH = "pipx_shared.pth" 8 | TEMP_VENV_EXPIRATION_THRESHOLD_DAYS = 14 9 | MINIMUM_PYTHON_VERSION = "3.9" 10 | MAN_SECTIONS = ["man%d" % i for i in range(1, 10)] 11 | FETCH_MISSING_PYTHON = os.environ.get("PIPX_FETCH_MISSING_PYTHON", False) 12 | 13 | 14 | ExitCode = NewType("ExitCode", int) 15 | # pipx shell exit codes 16 | EXIT_CODE_OK = ExitCode(0) 17 | EXIT_CODE_INJECT_ERROR = ExitCode(1) 18 | EXIT_CODE_UNINJECT_ERROR = ExitCode(1) 19 | EXIT_CODE_INSTALL_VENV_EXISTS = ExitCode(0) 20 | EXIT_CODE_LIST_PROBLEM = ExitCode(1) 21 | EXIT_CODE_UNINSTALL_VENV_NONEXISTENT = ExitCode(1) 22 | EXIT_CODE_UNINSTALL_ERROR = ExitCode(1) 23 | EXIT_CODE_REINSTALL_VENV_NONEXISTENT = ExitCode(1) 24 | EXIT_CODE_REINSTALL_INVALID_PYTHON = ExitCode(1) 25 | EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND = ExitCode(1) 26 | 27 | 28 | def is_windows() -> bool: 29 | return platform.system() == "Windows" 30 | 31 | 32 | def is_macos() -> bool: 33 | return platform.system() == "Darwin" 34 | 35 | 36 | def is_linux() -> bool: 37 | return platform.system() == "Linux" 38 | 39 | 40 | def is_mingw() -> bool: 41 | return sysconfig.get_platform().startswith("mingw") 42 | 43 | 44 | WINDOWS: bool = is_windows() 45 | MACOS: bool = is_macos() 46 | LINUX: bool = is_linux() 47 | MINGW: bool = is_mingw() 48 | 49 | completion_instructions = dedent( 50 | """ 51 | If you encountered register-python-argcomplete command not found error, 52 | or if you are using zipapp, run 53 | 54 | pipx install argcomplete 55 | 56 | before running any of the following commands. 57 | 58 | Add the appropriate command to your shell's config file 59 | so that it is run on startup. You will likely have to restart 60 | or re-login for the autocompletion to start working. 61 | 62 | bash: 63 | eval "$(register-python-argcomplete pipx)" 64 | 65 | zsh: 66 | To activate completions in zsh, first make sure compinit is marked for 67 | autoload and run autoload: 68 | 69 | autoload -U compinit && compinit 70 | 71 | Afterwards you can enable completions for pipx: 72 | 73 | eval "$(register-python-argcomplete pipx)" 74 | 75 | NOTE: If your version of argcomplete is earlier than v3, you may need to 76 | have bashcompinit enabled in zsh by running: 77 | 78 | autoload -U bashcompinit 79 | bashcompinit 80 | 81 | 82 | tcsh: 83 | eval `register-python-argcomplete --shell tcsh pipx` 84 | 85 | fish: 86 | # Not required to be in the config file, only run once 87 | register-python-argcomplete --shell fish pipx >~/.config/fish/completions/pipx.fish 88 | 89 | """ 90 | ) 91 | -------------------------------------------------------------------------------- /src/pipx/emojis.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | def strtobool(val: str) -> bool: 6 | val = val.lower() 7 | if val in ("y", "yes", "t", "true", "on", "1"): 8 | return True 9 | elif val in ("n", "no", "f", "false", "off", "0"): 10 | return False 11 | else: 12 | return False 13 | 14 | 15 | def use_emojis() -> bool: 16 | # All emojis that pipx might possibly use 17 | emoji_test_str = "✨🌟⚠️😴⣷⣯⣟⡿⢿⣻⣽⣾" 18 | try: 19 | emoji_test_str.encode(sys.stderr.encoding) 20 | platform_emoji_support = True 21 | except UnicodeEncodeError: 22 | platform_emoji_support = False 23 | use_emoji = os.getenv("PIPX_USE_EMOJI") 24 | if use_emoji is None: 25 | use_emoji = str(os.getenv("USE_EMOJI", platform_emoji_support)) 26 | return strtobool(use_emoji) 27 | 28 | 29 | EMOJI_SUPPORT = use_emojis() 30 | 31 | if EMOJI_SUPPORT: 32 | stars = "✨ 🌟 ✨" 33 | hazard = "⚠️" 34 | error = "⛔" 35 | sleep = "😴" 36 | else: 37 | stars = "" 38 | hazard = "" 39 | error = "" 40 | sleep = "" 41 | -------------------------------------------------------------------------------- /src/pipx/paths.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | from platformdirs import user_cache_path, user_data_path, user_log_path 7 | 8 | from pipx.constants import LINUX, WINDOWS 9 | from pipx.emojis import hazard, strtobool 10 | from pipx.util import pipx_wrap 11 | 12 | if LINUX: 13 | DEFAULT_PIPX_HOME = Path(user_data_path("pipx")) 14 | FALLBACK_PIPX_HOMES = [Path.home() / ".local/pipx"] 15 | elif WINDOWS: 16 | DEFAULT_PIPX_HOME = Path.home() / "pipx" 17 | FALLBACK_PIPX_HOMES = [Path.home() / ".local/pipx", Path(user_data_path("pipx"))] 18 | else: 19 | DEFAULT_PIPX_HOME = Path.home() / ".local/pipx" 20 | FALLBACK_PIPX_HOMES = [Path(user_data_path("pipx"))] 21 | 22 | DEFAULT_PIPX_BIN_DIR = Path.home() / ".local/bin" 23 | DEFAULT_PIPX_MAN_DIR = Path.home() / ".local/share/man" 24 | DEFAULT_PIPX_GLOBAL_HOME = Path("/opt/pipx") 25 | DEFAULT_PIPX_GLOBAL_BIN_DIR = Path("/usr/local/bin") 26 | DEFAULT_PIPX_GLOBAL_MAN_DIR = Path("/usr/local/share/man") 27 | 28 | # Overrides for testing 29 | OVERRIDE_PIPX_HOME = None 30 | OVERRIDE_PIPX_BIN_DIR = None 31 | OVERRIDE_PIPX_MAN_DIR = None 32 | OVERRIDE_PIPX_SHARED_LIBS = None 33 | OVERRIDE_PIPX_GLOBAL_HOME = None 34 | OVERRIDE_PIPX_GLOBAL_BIN_DIR = None 35 | OVERRIDE_PIPX_GLOBAL_MAN_DIR = None 36 | 37 | logger = logging.getLogger(__name__) 38 | 39 | 40 | def get_expanded_environ(env_name: str) -> Optional[Path]: 41 | val = os.environ.get(env_name) 42 | if val is not None: 43 | return Path(val).expanduser().resolve() 44 | return val 45 | 46 | 47 | class _PathContext: 48 | _base_home: Optional[Path] 49 | _default_home: Path 50 | _base_bin: Optional[Path] 51 | _default_bin: Path 52 | _base_man: Optional[Path] 53 | _default_man: Path 54 | _default_log: Path 55 | _default_cache: Path 56 | _default_trash: Path 57 | _base_shared_libs: Optional[Path] 58 | _fallback_home: Optional[Path] 59 | _home_exists: bool 60 | log_file: Optional[Path] = None 61 | 62 | def __init__(self): 63 | self.make_local() 64 | 65 | @property 66 | def venvs(self) -> Path: 67 | return self.home / "venvs" 68 | 69 | @property 70 | def logs(self) -> Path: 71 | if self._home_exists or not LINUX: 72 | return self.home / "logs" 73 | return self._default_log 74 | 75 | @property 76 | def trash(self) -> Path: 77 | if self._home_exists: 78 | return self.home / ".trash" 79 | return self._default_trash 80 | 81 | @property 82 | def venv_cache(self) -> Path: 83 | if self._home_exists or not LINUX: 84 | return self.home / ".cache" 85 | return self._default_cache 86 | 87 | @property 88 | def bin_dir(self) -> Path: 89 | return (self._base_bin or self._default_bin).resolve() 90 | 91 | @property 92 | def man_dir(self) -> Path: 93 | return (self._base_man or self._default_man).resolve() 94 | 95 | @property 96 | def home(self) -> Path: 97 | if self._base_home: 98 | home = Path(self._base_home) 99 | elif self._fallback_home: 100 | home = self._fallback_home 101 | else: 102 | home = self._default_home 103 | return home.resolve() 104 | 105 | @property 106 | def shared_libs(self) -> Path: 107 | return (self._base_shared_libs or self.home / "shared").resolve() 108 | 109 | def make_local(self) -> None: 110 | self._base_home = OVERRIDE_PIPX_HOME or get_expanded_environ("PIPX_HOME") 111 | self._default_home = DEFAULT_PIPX_HOME 112 | self._base_bin = OVERRIDE_PIPX_BIN_DIR or get_expanded_environ("PIPX_BIN_DIR") 113 | self._default_bin = DEFAULT_PIPX_BIN_DIR 114 | self._base_man = OVERRIDE_PIPX_MAN_DIR or get_expanded_environ("PIPX_MAN_DIR") 115 | self._default_man = DEFAULT_PIPX_MAN_DIR 116 | self._base_shared_libs = OVERRIDE_PIPX_SHARED_LIBS or get_expanded_environ("PIPX_SHARED_LIBS") 117 | self._default_log = Path(user_log_path("pipx")) 118 | self._default_cache = Path(user_cache_path("pipx")) 119 | self._default_trash = self._default_home / "trash" 120 | self._fallback_home = next(iter([fallback for fallback in FALLBACK_PIPX_HOMES if fallback.exists()]), None) 121 | self._home_exists = self._base_home is not None or any(fallback.exists() for fallback in FALLBACK_PIPX_HOMES) 122 | 123 | def make_global(self) -> None: 124 | self._base_home = OVERRIDE_PIPX_GLOBAL_HOME or get_expanded_environ("PIPX_GLOBAL_HOME") 125 | self._default_home = DEFAULT_PIPX_GLOBAL_HOME 126 | self._base_bin = OVERRIDE_PIPX_GLOBAL_BIN_DIR or get_expanded_environ("PIPX_GLOBAL_BIN_DIR") 127 | self._default_bin = DEFAULT_PIPX_GLOBAL_BIN_DIR 128 | self._base_man = OVERRIDE_PIPX_GLOBAL_MAN_DIR or get_expanded_environ("PIPX_GLOBAL_MAN_DIR") 129 | self._default_man = DEFAULT_PIPX_GLOBAL_MAN_DIR 130 | self._default_log = self._default_home / "logs" 131 | self._default_cache = self._default_home / ".cache" 132 | self._default_trash = self._default_home / "trash" 133 | self._base_shared_libs = None 134 | self._fallback_home = None 135 | self._home_exists = self._base_home is not None 136 | 137 | @property 138 | def standalone_python_cachedir(self) -> Path: 139 | return self.home / "py" 140 | 141 | @property 142 | def allow_spaces_in_home_path(self) -> bool: 143 | return strtobool(os.getenv("PIPX_HOME_ALLOW_SPACE", "0")) 144 | 145 | def log_warnings(self): 146 | if " " in str(self.home) and not self.allow_spaces_in_home_path: 147 | logger.warning( 148 | pipx_wrap( 149 | ( 150 | f"{hazard} Found a space in the pipx home path. We heavily discourage this, due to " 151 | "multiple incompatibilities. Please check our docs for more information on this, " 152 | "as well as some pointers on how to migrate to a different home path." 153 | ), 154 | subsequent_indent=" " * 4, 155 | ) 156 | ) 157 | 158 | fallback_home_exists = self._fallback_home is not None and self._fallback_home.exists() 159 | specific_home_exists = self.home != self._fallback_home 160 | if fallback_home_exists and specific_home_exists: 161 | logger.info( 162 | pipx_wrap( 163 | ( 164 | f"Both a specific pipx home folder ({self.home}) and the fallback " 165 | f"pipx home folder ({self._fallback_home}) exist. If you are done migrating from the" 166 | "fallback to the new location, it is safe to delete the fallback location." 167 | ), 168 | subsequent_indent=" " * 4, 169 | ) 170 | ) 171 | 172 | 173 | ctx = _PathContext() 174 | ctx.log_warnings() 175 | -------------------------------------------------------------------------------- /src/pipx/shared_libs.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import time 4 | from pathlib import Path 5 | from typing import Dict, List 6 | 7 | from pipx import paths 8 | from pipx.animate import animate 9 | from pipx.constants import WINDOWS 10 | from pipx.interpreter import DEFAULT_PYTHON 11 | from pipx.util import ( 12 | get_site_packages, 13 | get_venv_paths, 14 | run_subprocess, 15 | subprocess_post_check, 16 | ) 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | SHARED_LIBS_MAX_AGE_SEC = datetime.timedelta(days=30).total_seconds() 22 | 23 | 24 | class _SharedLibs: 25 | def __init__(self) -> None: 26 | self._site_packages: Dict[Path, Path] = {} 27 | self.has_been_updated_this_run = False 28 | self.has_been_logged_this_run = False 29 | 30 | @property 31 | def root(self) -> Path: 32 | return paths.ctx.shared_libs 33 | 34 | @property 35 | def bin_path(self) -> Path: 36 | bin_path, _, _ = get_venv_paths(self.root) 37 | return bin_path 38 | 39 | @property 40 | def python_path(self) -> Path: 41 | _, python_path, _ = get_venv_paths(self.root) 42 | return python_path 43 | 44 | @property 45 | def man_path(self) -> Path: 46 | _, _, man_path = get_venv_paths(self.root) 47 | return man_path 48 | 49 | @property 50 | def pip_path(self) -> Path: 51 | return self.bin_path / ("pip" if not WINDOWS else "pip.exe") 52 | 53 | @property 54 | def site_packages(self) -> Path: 55 | if self.python_path not in self._site_packages: 56 | self._site_packages[self.python_path] = get_site_packages(self.python_path) 57 | 58 | return self._site_packages[self.python_path] 59 | 60 | def create(self, pip_args: List[str], verbose: bool = False) -> None: 61 | if not self.is_valid: 62 | with animate("creating shared libraries", not verbose): 63 | create_process = run_subprocess( 64 | [DEFAULT_PYTHON, "-m", "venv", "--clear", self.root], run_dir=str(self.root) 65 | ) 66 | subprocess_post_check(create_process) 67 | 68 | # ignore installed packages to ensure no unexpected patches from the OS vendor 69 | # are used 70 | pip_args = pip_args or [] 71 | pip_args.append("--force-reinstall") 72 | self.upgrade(pip_args=pip_args, verbose=verbose, raises=True) 73 | 74 | @property 75 | def is_valid(self) -> bool: 76 | if self.python_path.is_file(): 77 | check_pip = "import importlib.util; print(importlib.util.find_spec('pip'))" 78 | out = run_subprocess( 79 | [self.python_path, "-c", check_pip], 80 | capture_stderr=False, 81 | log_cmd_str="", 82 | ).stdout.strip() 83 | 84 | return self.pip_path.is_file() and out != "None" 85 | else: 86 | return False 87 | 88 | @property 89 | def needs_upgrade(self) -> bool: 90 | if self.has_been_updated_this_run: 91 | return False 92 | 93 | if not self.pip_path.is_file(): 94 | return True 95 | 96 | now = time.time() 97 | time_since_last_update_sec = now - self.pip_path.stat().st_mtime 98 | if not self.has_been_logged_this_run: 99 | logger.info( 100 | f"Time since last upgrade of shared libs, in seconds: {time_since_last_update_sec:.0f}. " 101 | f"Upgrade will be run by pipx if greater than {SHARED_LIBS_MAX_AGE_SEC:.0f}." 102 | ) 103 | self.has_been_logged_this_run = True 104 | return time_since_last_update_sec > SHARED_LIBS_MAX_AGE_SEC 105 | 106 | def upgrade(self, *, pip_args: List[str], verbose: bool = False, raises: bool = False) -> None: 107 | if not self.is_valid: 108 | self.create(verbose=verbose, pip_args=pip_args) 109 | return 110 | 111 | # Don't try to upgrade multiple times per run 112 | if self.has_been_updated_this_run: 113 | logger.info(f"Already upgraded libraries in {self.root}") 114 | return 115 | 116 | if pip_args is None: 117 | pip_args = [] 118 | 119 | logger.info(f"Upgrading shared libraries in {self.root}") 120 | 121 | ignored_args = ["--editable"] 122 | _pip_args = [arg for arg in pip_args if arg not in ignored_args] 123 | if not verbose: 124 | _pip_args.append("-q") 125 | try: 126 | with animate("upgrading shared libraries", not verbose): 127 | upgrade_process = run_subprocess( 128 | [ 129 | self.python_path, 130 | "-m", 131 | "pip", 132 | "--no-input", 133 | "--disable-pip-version-check", 134 | "install", 135 | *_pip_args, 136 | "--upgrade", 137 | "pip >= 23.1", 138 | ] 139 | ) 140 | subprocess_post_check(upgrade_process) 141 | 142 | self.has_been_updated_this_run = True 143 | self.pip_path.touch() 144 | 145 | except Exception: 146 | logger.error("Failed to upgrade shared libraries", exc_info=not raises) 147 | if raises: 148 | raise 149 | 150 | 151 | shared_libs = _SharedLibs() 152 | -------------------------------------------------------------------------------- /src/pipx/version.pyi: -------------------------------------------------------------------------------- 1 | version: str 2 | version_tuple: tuple[int, int, int, str, str] | tuple[int, int, int] 3 | 4 | # Note that newer versions of setuptools_scm also add __version__, but we are 5 | # not forcing new versions of setuptools_scm, so only these imports are allowed. 6 | -------------------------------------------------------------------------------- /testdata/empty_project/README.md: -------------------------------------------------------------------------------- 1 | Empty project used for testing only 2 | -------------------------------------------------------------------------------- /testdata/empty_project/empty_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/pipx/11f3262d90ead8995ce1c40f2a37828a49ee611a/testdata/empty_project/empty_project/__init__.py -------------------------------------------------------------------------------- /testdata/empty_project/empty_project/main.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | pass 3 | 4 | 5 | if __name__ == "__main__": 6 | main() 7 | -------------------------------------------------------------------------------- /testdata/empty_project/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | 4 | requires = [ 5 | "setuptools", 6 | ] 7 | 8 | [project] 9 | name = "empty-project" 10 | version = "0.1.0" 11 | description = "Empty Python Project" 12 | authors = [ 13 | { name = "My Name", email = "me@example.com" }, 14 | ] 15 | requires-python = ">=3.9" 16 | classifiers = [ 17 | "Programming Language :: Python :: 3 :: Only", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | ] 23 | scripts.empty-project = "empty_project.main:cli" 24 | entry-points."pipx.run".empty-project = "empty_project.main:cli" 25 | -------------------------------------------------------------------------------- /testdata/pipx_metadata_multiple_errors.json: -------------------------------------------------------------------------------- 1 | { 2 | "pipx_spec_version": "0.1", 3 | "venvs": { 4 | "dotenv": { 5 | "metadata": { 6 | "injected_packages": {}, 7 | "main_package": { 8 | "app_paths": [ 9 | ], 10 | "app_paths_of_dependencies": {}, 11 | "apps": [ 12 | ], 13 | "apps_of_dependencies": [], 14 | "include_apps": true, 15 | "include_dependencies": false, 16 | "man_pages": [], 17 | "man_pages_of_dependencies": [], 18 | "man_paths": [], 19 | "man_paths_of_dependencies": {}, 20 | "package": "dotenv", 21 | "package_or_url": "dotenv", 22 | "package_version": "0.0.5", 23 | "pip_args": [], 24 | "suffix": "" 25 | }, 26 | "pipx_metadata_version": "0.4", 27 | "python_version": "Python 3.10.12", 28 | "source_interpreter": { 29 | }, 30 | "venv_args": [] 31 | } 32 | }, 33 | "weblate": { 34 | "metadata": { 35 | "injected_packages": {}, 36 | "main_package": { 37 | "app_paths": [ 38 | ], 39 | "app_paths_of_dependencies": {}, 40 | "apps": [ 41 | ], 42 | "apps_of_dependencies": [], 43 | "include_apps": true, 44 | "include_dependencies": false, 45 | "man_pages": [ 46 | ], 47 | "man_pages_of_dependencies": [], 48 | "man_paths": [ 49 | ], 50 | "man_paths_of_dependencies": {}, 51 | "package": "weblate", 52 | "package_or_url": "weblate", 53 | "package_version": "4.3.1", 54 | "pip_args": [], 55 | "suffix": "" 56 | }, 57 | "pipx_metadata_version": "0.4", 58 | "python_version": "Python 3.10.12", 59 | "source_interpreter": { 60 | }, 61 | "venv_args": [] 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /testdata/test_package_specifier/local_extras/repeatme/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/pipx/11f3262d90ead8995ce1c40f2a37828a49ee611a/testdata/test_package_specifier/local_extras/repeatme/__init__.py -------------------------------------------------------------------------------- /testdata/test_package_specifier/local_extras/repeatme/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | try: 4 | import pycowsay.main 5 | 6 | has_pycowsay = True 7 | except ImportError: 8 | has_pycowsay = False 9 | 10 | 11 | def main(): 12 | print(f"You said:\n {' '.join(sys.argv[1:])}") 13 | 14 | if has_pycowsay: 15 | print() 16 | print("In cow, you said:") 17 | pycowsay.main.main() 18 | -------------------------------------------------------------------------------- /testdata/test_package_specifier/local_extras/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="repeatme", 5 | version=0.1, 6 | description="Repeat arguments.", 7 | packages=["repeatme"], 8 | extras_require={"cow": ["pycowsay==0.0.0.2"]}, 9 | entry_points={"console_scripts": ["repeatme=repeatme.main:main"]}, 10 | ) 11 | -------------------------------------------------------------------------------- /testdata/tests_packages/README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | `primary_packages.txt` is the master list, containing all packages 4 | installed or injected in the pipx tests `tests`. Platform-specific list files 5 | listing both these primary packages and their dependencies are generated from 6 | it. These platform-specific list files are used to populate the directory 7 | `.pipx_tests/package_cache`. 8 | 9 | # Generating the platform-specific lists from the master list 10 | 11 | Using the Github Workflow 12 | * Make sure that the file in this directory `primary_packages.txt` is up to date for every package & version that is installed or injected in the tests. 13 | * Manually activate the Github workflow: Create tests package lists for offline tests 14 | * Download the artifact `lists` and put the files from it into this directory. 15 | 16 | Or to locally generate these lists, on the target platform execute: 17 | * `nox -s create_test_package_list` 18 | 19 | # Updating / Populating the directory `.pipx_tests/package_cache` before running the tests 20 | Pre-populating this directory allows the pipx `tests` to run completely offline. 21 | 22 | Nox instructions 23 | * execute `nox -s refresh_packages_cache` 24 | 25 | Or manually execute from the top-level pipx repo directory: 26 | * `mkdir -p .pipx_tests/package_cache` 27 | * `python3 scripts/update_package_cache.py testdata/tests_packages .pipx_tests/package_cache` 28 | -------------------------------------------------------------------------------- /testdata/tests_packages/primary_packages.txt: -------------------------------------------------------------------------------- 1 | # Modify this list if the packages pipx installs in 'tests' changes 2 | 3 | # Comments ignored after # 4 | # Space-separated values on same row. 5 | 6 | # spec no-deps 7 | Cython # in 'setup_requires' of jupyter dep pywinpty on Win 8 | ansible==6.7.0 9 | awscli==1.18.168 10 | black==22.8.0 11 | black==22.10.0 12 | cloudtoken==2.1.0 13 | ipython==7.16.1 14 | isort==5.6.4 15 | jaraco-clipboard==2.0.1 16 | zest-releaser==9.1.2 17 | jupyter==1.0.0 18 | kaggle==1.6.11 19 | nox==2022.1.7 20 | nox[tox_to_nox]==2023.4.22 21 | pbr==5.6.0 22 | pip==23.3.2 23 | pip==24.0 24 | pip 25 | pycowsay==0.0.0.2 26 | pygdbmi==0.10.0.0 27 | pylint 28 | pylint==3.0.4 29 | setuptools-scm 30 | setuptools>=41.0 31 | shell-functools==0.3.0 32 | tox 33 | tox-ini-fmt==0.5.0 34 | weblate==4.3.1 True # expected fail in tests 35 | wheel 36 | -------------------------------------------------------------------------------- /testdata/tests_packages/unix-python3.10.txt: -------------------------------------------------------------------------------- 1 | Babel==2.14.0 2 | Cython==3.0.10 3 | Flask==1.1.4 4 | Jinja2==2.11.3 5 | Jinja2==3.1.3 6 | MarkupSafe==2.1.5 7 | PyYAML==5.3.1 8 | PyYAML==6.0.1 9 | QtPy==2.4.1 10 | SecretStorage==3.3.3 11 | Send2Trash==1.8.3 12 | Weblate==4.3.1 13 | Werkzeug==1.0.1 14 | ansible==6.7.0 15 | ansible_core==2.13.13 16 | anyio==4.3.0 17 | appdirs==1.4.4 18 | argcomplete==1.12.3 19 | argcomplete==3.3.0 20 | argon2_cffi==23.1.0 21 | argon2_cffi_bindings==21.2.0 22 | arrow==1.3.0 23 | astroid==3.0.3 24 | astroid==3.1.0 25 | asttokens==2.4.1 26 | async_lru==2.0.4 27 | attrs==23.2.0 28 | awscli==1.18.168 29 | backcall==0.2.0 30 | backports.tarfile==1.1.1 31 | beautifulsoup4==4.12.3 32 | black==22.10.0 33 | black==22.8.0 34 | bleach==6.1.0 35 | boto3==1.34.92 36 | botocore==1.19.8 37 | botocore==1.34.92 38 | build==1.2.1 39 | cachetools==5.3.3 40 | certifi==2024.2.2 41 | cffi==1.16.0 42 | chardet==5.2.0 43 | charset_normalizer==3.3.2 44 | click==7.1.2 45 | click==8.1.7 46 | cloudtoken==2.1.0 47 | cmarkgfm==2024.1.14 48 | colorama==0.4.3 49 | colorama==0.4.6 50 | colorlog==6.8.2 51 | comm==0.2.2 52 | cryptography==42.0.5 53 | debugpy==1.8.1 54 | decorator==5.1.1 55 | deepdiff==5.8.1 56 | defusedxml==0.7.1 57 | delegator.py==0.1.1 58 | dill==0.3.8 59 | distlib==0.3.8 60 | docutils==0.15.2 61 | docutils==0.21.2 62 | exceptiongroup==1.2.1 63 | executing==2.0.1 64 | fastjsonschema==2.19.1 65 | filelock==3.13.4 66 | fqdn==1.5.1 67 | h11==0.14.0 68 | halo==0.0.31 69 | httpcore==1.0.5 70 | httpx==0.27.0 71 | idna==3.7 72 | ifaddr==0.1.7 73 | importlib_metadata==7.1.0 74 | iniconfig==2.0.0 75 | ipykernel==6.29.4 76 | ipython==7.16.1 77 | ipython==8.23.0 78 | ipywidgets==8.1.2 79 | isoduration==20.11.0 80 | isort==5.13.2 81 | isort==5.6.4 82 | itsdangerous==1.1.0 83 | jaraco.classes==3.4.0 84 | jaraco.clipboard==2.0.1 85 | jaraco.context==5.3.0 86 | jaraco.functools==4.0.1 87 | jedi==0.19.1 88 | jeepney==0.8.0 89 | jmespath==0.10.0 90 | jmespath==1.0.1 91 | json5==0.9.25 92 | jsonpointer==2.4 93 | jsonschema==4.21.1 94 | jsonschema_specifications==2023.12.1 95 | jupyter==1.0.0 96 | jupyter_client==8.6.1 97 | jupyter_console==6.6.3 98 | jupyter_core==5.7.2 99 | jupyter_events==0.10.0 100 | jupyter_lsp==2.2.5 101 | jupyter_server==2.14.0 102 | jupyter_server_terminals==0.5.3 103 | jupyterlab==4.1.6 104 | jupyterlab_pygments==0.3.0 105 | jupyterlab_server==2.27.1 106 | jupyterlab_widgets==3.0.10 107 | kaggle==1.6.11 108 | keyring==21.8.0 109 | keyring==25.1.0 110 | log_symbols==0.0.14 111 | lxml==4.9.4 112 | markdown_it_py==3.0.0 113 | matplotlib_inline==0.1.7 114 | mccabe==0.7.0 115 | mdurl==0.1.2 116 | mistune==3.0.2 117 | more_itertools==10.2.0 118 | mypy_extensions==1.0.0 119 | nbclient==0.10.0 120 | nbconvert==7.16.3 121 | nbformat==5.10.4 122 | nest_asyncio==1.6.0 123 | nh3==0.2.17 124 | notebook==7.1.3 125 | notebook_shim==0.2.4 126 | nox==2022.1.7 127 | nox==2023.4.22 128 | ordered_set==4.1.0 129 | overrides==7.7.0 130 | packaging==20.9 131 | packaging==24.0 132 | pandocfilters==1.5.1 133 | parso==0.8.4 134 | pathspec==0.12.1 135 | pbr==5.6.0 136 | pexpect==4.9.0 137 | pickleshare==0.7.5 138 | pip==23.3.2 139 | pip==24.0 140 | pkginfo==1.10.0 141 | platformdirs==4.2.1 142 | pluggy==1.5.0 143 | prometheus_client==0.20.0 144 | prompt_toolkit==3.0.43 145 | psutil==5.9.8 146 | ptyprocess==0.7.0 147 | pure_eval==0.2.2 148 | py==1.11.0 149 | pyasn1==0.6.0 150 | pycowsay==0.0.0.2 151 | pycparser==2.22 152 | pygdbmi==0.10.0.0 153 | pygments==2.17.2 154 | pylint==3.0.4 155 | pylint==3.1.0 156 | pyparsing==3.1.2 157 | pyperclip==1.8.2 158 | pyproject_api==1.6.1 159 | pyproject_hooks==1.0.0 160 | pytest==8.1.1 161 | python_dateutil==2.9.0.post0 162 | python_json_logger==2.0.7 163 | python_slugify==8.0.4 164 | pyzmq==26.0.2 165 | qtconsole==5.5.1 166 | readme_renderer==43.0 167 | referencing==0.35.0 168 | requests==2.31.0 169 | requests_toolbelt==1.0.0 170 | resolvelib==0.8.1 171 | rfc3339_validator==0.1.4 172 | rfc3986==2.0.0 173 | rfc3986_validator==0.1.1 174 | rich==13.7.1 175 | rpds_py==0.18.0 176 | rsa==4.5 177 | ruamel.yaml.clib==0.2.8 178 | ruamel.yaml==0.17.40 179 | s3transfer==0.10.1 180 | s3transfer==0.3.7 181 | setuptools==69.5.1 182 | setuptools_scm==8.0.4 183 | shell-functools==0.3.0 184 | six==1.16.0 185 | sniffio==1.3.1 186 | soupsieve==2.5 187 | spinners==0.0.24 188 | stack_data==0.6.3 189 | termcolor==2.4.0 190 | terminado==0.18.1 191 | text_unidecode==1.3 192 | tinycss2==1.3.0 193 | tomli==2.0.1 194 | tomlkit==0.12.4 195 | tornado==6.4 196 | tox==3.28.0 197 | tox==4.14.2 198 | tox_ini_fmt==0.5.0 199 | tqdm==4.66.2 200 | traitlets==5.14.3 201 | twine==5.0.0 202 | types_python_dateutil==2.9.0.20240316 203 | typing_extensions==4.11.0 204 | uri_template==1.3.0 205 | urllib3==1.25.11 206 | urllib3==2.2.1 207 | virtualenv==20.26.0 208 | wcwidth==0.2.13 209 | webcolors==1.13 210 | webencodings==0.5.1 211 | websocket_client==1.8.0 212 | wheel==0.43.0 213 | widgetsnbextension==4.0.10 214 | xdg==5.1.1 215 | zest.releaser==9.1.2 216 | zipp==3.18.1 217 | -------------------------------------------------------------------------------- /testdata/tests_packages/unix-python3.11.txt: -------------------------------------------------------------------------------- 1 | Babel==2.14.0 2 | Cython==3.0.10 3 | Flask==1.1.4 4 | Jinja2==2.11.3 5 | Jinja2==3.1.3 6 | MarkupSafe==2.1.5 7 | PyYAML==5.3.1 8 | PyYAML==6.0.1 9 | QtPy==2.4.1 10 | SecretStorage==3.3.3 11 | Send2Trash==1.8.3 12 | Weblate==4.3.1 13 | Werkzeug==1.0.1 14 | ansible==6.7.0 15 | ansible_core==2.13.13 16 | anyio==4.3.0 17 | appdirs==1.4.4 18 | argcomplete==1.12.3 19 | argcomplete==3.3.0 20 | argon2_cffi==23.1.0 21 | argon2_cffi_bindings==21.2.0 22 | arrow==1.3.0 23 | astroid==3.0.3 24 | astroid==3.1.0 25 | asttokens==2.4.1 26 | async_lru==2.0.4 27 | attrs==23.2.0 28 | awscli==1.18.168 29 | backcall==0.2.0 30 | backports.tarfile==1.1.1 31 | beautifulsoup4==4.12.3 32 | black==22.10.0 33 | black==22.8.0 34 | bleach==6.1.0 35 | boto3==1.34.92 36 | botocore==1.19.8 37 | botocore==1.34.92 38 | build==1.2.1 39 | cachetools==5.3.3 40 | certifi==2024.2.2 41 | cffi==1.16.0 42 | chardet==5.2.0 43 | charset_normalizer==3.3.2 44 | click==7.1.2 45 | click==8.1.7 46 | cloudtoken==2.1.0 47 | cmarkgfm==2024.1.14 48 | colorama==0.4.3 49 | colorama==0.4.6 50 | colorlog==6.8.2 51 | comm==0.2.2 52 | cryptography==42.0.5 53 | debugpy==1.8.1 54 | decorator==5.1.1 55 | deepdiff==5.8.1 56 | defusedxml==0.7.1 57 | delegator.py==0.1.1 58 | dill==0.3.8 59 | distlib==0.3.8 60 | docutils==0.15.2 61 | docutils==0.21.2 62 | executing==2.0.1 63 | fastjsonschema==2.19.1 64 | filelock==3.13.4 65 | fqdn==1.5.1 66 | h11==0.14.0 67 | halo==0.0.31 68 | httpcore==1.0.5 69 | httpx==0.27.0 70 | idna==3.7 71 | ifaddr==0.1.7 72 | importlib_metadata==7.1.0 73 | iniconfig==2.0.0 74 | ipykernel==6.29.4 75 | ipython==7.16.1 76 | ipython==8.23.0 77 | ipywidgets==8.1.2 78 | isoduration==20.11.0 79 | isort==5.13.2 80 | isort==5.6.4 81 | itsdangerous==1.1.0 82 | jaraco.classes==3.4.0 83 | jaraco.clipboard==2.0.1 84 | jaraco.context==5.3.0 85 | jaraco.functools==4.0.1 86 | jedi==0.19.1 87 | jeepney==0.8.0 88 | jmespath==0.10.0 89 | jmespath==1.0.1 90 | json5==0.9.25 91 | jsonpointer==2.4 92 | jsonschema==4.21.1 93 | jsonschema_specifications==2023.12.1 94 | jupyter==1.0.0 95 | jupyter_client==8.6.1 96 | jupyter_console==6.6.3 97 | jupyter_core==5.7.2 98 | jupyter_events==0.10.0 99 | jupyter_lsp==2.2.5 100 | jupyter_server==2.14.0 101 | jupyter_server_terminals==0.5.3 102 | jupyterlab==4.1.6 103 | jupyterlab_pygments==0.3.0 104 | jupyterlab_server==2.27.1 105 | jupyterlab_widgets==3.0.10 106 | kaggle==1.6.11 107 | keyring==21.8.0 108 | keyring==25.1.0 109 | log_symbols==0.0.14 110 | lxml==4.9.4 111 | markdown_it_py==3.0.0 112 | matplotlib_inline==0.1.7 113 | mccabe==0.7.0 114 | mdurl==0.1.2 115 | mistune==3.0.2 116 | more_itertools==10.2.0 117 | mypy_extensions==1.0.0 118 | nbclient==0.10.0 119 | nbconvert==7.16.3 120 | nbformat==5.10.4 121 | nest_asyncio==1.6.0 122 | nh3==0.2.17 123 | notebook==7.1.3 124 | notebook_shim==0.2.4 125 | nox==2022.1.7 126 | nox==2023.4.22 127 | ordered_set==4.1.0 128 | overrides==7.7.0 129 | packaging==20.9 130 | packaging==24.0 131 | pandocfilters==1.5.1 132 | parso==0.8.4 133 | pathspec==0.12.1 134 | pbr==5.6.0 135 | pexpect==4.9.0 136 | pickleshare==0.7.5 137 | pip==23.3.2 138 | pip==24.0 139 | pkginfo==1.10.0 140 | platformdirs==4.2.1 141 | pluggy==1.5.0 142 | prometheus_client==0.20.0 143 | prompt_toolkit==3.0.43 144 | psutil==5.9.8 145 | ptyprocess==0.7.0 146 | pure_eval==0.2.2 147 | py==1.11.0 148 | pyasn1==0.6.0 149 | pycowsay==0.0.0.2 150 | pycparser==2.22 151 | pygdbmi==0.10.0.0 152 | pygments==2.17.2 153 | pylint==3.0.4 154 | pylint==3.1.0 155 | pyparsing==3.1.2 156 | pyperclip==1.8.2 157 | pyproject_api==1.6.1 158 | pyproject_hooks==1.0.0 159 | pytest==8.1.1 160 | python_dateutil==2.9.0.post0 161 | python_json_logger==2.0.7 162 | python_slugify==8.0.4 163 | pyzmq==26.0.2 164 | qtconsole==5.5.1 165 | readme_renderer==43.0 166 | referencing==0.35.0 167 | requests==2.31.0 168 | requests_toolbelt==1.0.0 169 | resolvelib==0.8.1 170 | rfc3339_validator==0.1.4 171 | rfc3986==2.0.0 172 | rfc3986_validator==0.1.1 173 | rich==13.7.1 174 | rpds_py==0.18.0 175 | rsa==4.5 176 | ruamel.yaml.clib==0.2.8 177 | ruamel.yaml==0.17.40 178 | s3transfer==0.10.1 179 | s3transfer==0.3.7 180 | setuptools==69.5.1 181 | setuptools_scm==8.0.4 182 | shell-functools==0.3.0 183 | six==1.16.0 184 | sniffio==1.3.1 185 | soupsieve==2.5 186 | spinners==0.0.24 187 | stack_data==0.6.3 188 | termcolor==2.4.0 189 | terminado==0.18.1 190 | text_unidecode==1.3 191 | tinycss2==1.3.0 192 | tomlkit==0.12.4 193 | tornado==6.4 194 | tox==3.28.0 195 | tox==4.14.2 196 | tox_ini_fmt==0.5.0 197 | tqdm==4.66.2 198 | traitlets==5.14.3 199 | twine==5.0.0 200 | types_python_dateutil==2.9.0.20240316 201 | typing_extensions==4.11.0 202 | uri_template==1.3.0 203 | urllib3==1.25.11 204 | urllib3==2.2.1 205 | virtualenv==20.26.0 206 | wcwidth==0.2.13 207 | webcolors==1.13 208 | webencodings==0.5.1 209 | websocket_client==1.8.0 210 | wheel==0.43.0 211 | widgetsnbextension==4.0.10 212 | xdg==5.1.1 213 | zest.releaser==9.1.2 214 | zipp==3.18.1 215 | -------------------------------------------------------------------------------- /testdata/tests_packages/unix-python3.12.txt: -------------------------------------------------------------------------------- 1 | Babel==2.14.0 2 | Cython==3.0.10 3 | Flask==1.1.4 4 | Jinja2==2.11.3 5 | Jinja2==3.1.3 6 | MarkupSafe==2.1.5 7 | PyYAML==5.3.1 8 | PyYAML==6.0.1 9 | QtPy==2.4.1 10 | SecretStorage==3.3.3 11 | Send2Trash==1.8.3 12 | Weblate==4.3.1 13 | Werkzeug==1.0.1 14 | ansible==6.7.0 15 | ansible_core==2.13.13 16 | anyio==4.3.0 17 | appdirs==1.4.4 18 | argcomplete==1.12.3 19 | argcomplete==3.3.0 20 | argon2_cffi==23.1.0 21 | argon2_cffi_bindings==21.2.0 22 | arrow==1.3.0 23 | astroid==3.0.3 24 | astroid==3.1.0 25 | asttokens==2.4.1 26 | async_lru==2.0.4 27 | attrs==23.2.0 28 | awscli==1.18.168 29 | backcall==0.2.0 30 | beautifulsoup4==4.12.3 31 | black==22.10.0 32 | black==22.8.0 33 | bleach==6.1.0 34 | boto3==1.34.92 35 | botocore==1.19.8 36 | botocore==1.34.92 37 | build==1.2.1 38 | cachetools==5.3.3 39 | certifi==2024.2.2 40 | cffi==1.16.0 41 | chardet==5.2.0 42 | charset_normalizer==3.3.2 43 | click==7.1.2 44 | click==8.1.7 45 | cloudtoken==2.1.0 46 | cmarkgfm==2024.1.14 47 | colorama==0.4.3 48 | colorama==0.4.6 49 | colorlog==6.8.2 50 | comm==0.2.2 51 | cryptography==42.0.5 52 | debugpy==1.8.1 53 | decorator==5.1.1 54 | deepdiff==5.8.1 55 | defusedxml==0.7.1 56 | delegator.py==0.1.1 57 | dill==0.3.8 58 | distlib==0.3.8 59 | docutils==0.15.2 60 | docutils==0.21.2 61 | executing==2.0.1 62 | fastjsonschema==2.19.1 63 | filelock==3.13.4 64 | fqdn==1.5.1 65 | h11==0.14.0 66 | halo==0.0.31 67 | httpcore==1.0.5 68 | httpx==0.27.0 69 | idna==3.7 70 | ifaddr==0.1.7 71 | importlib_metadata==7.1.0 72 | iniconfig==2.0.0 73 | ipykernel==6.29.4 74 | ipython==7.16.1 75 | ipython==8.23.0 76 | ipywidgets==8.1.2 77 | isoduration==20.11.0 78 | isort==5.13.2 79 | isort==5.6.4 80 | itsdangerous==1.1.0 81 | jaraco.classes==3.4.0 82 | jaraco.clipboard==2.0.1 83 | jaraco.context==5.3.0 84 | jaraco.functools==4.0.1 85 | jedi==0.19.1 86 | jeepney==0.8.0 87 | jmespath==0.10.0 88 | jmespath==1.0.1 89 | json5==0.9.25 90 | jsonpointer==2.4 91 | jsonschema==4.21.1 92 | jsonschema_specifications==2023.12.1 93 | jupyter==1.0.0 94 | jupyter_client==8.6.1 95 | jupyter_console==6.6.3 96 | jupyter_core==5.7.2 97 | jupyter_events==0.10.0 98 | jupyter_lsp==2.2.5 99 | jupyter_server==2.14.0 100 | jupyter_server_terminals==0.5.3 101 | jupyterlab==4.1.6 102 | jupyterlab_pygments==0.3.0 103 | jupyterlab_server==2.27.1 104 | jupyterlab_widgets==3.0.10 105 | kaggle==1.6.11 106 | keyring==21.8.0 107 | keyring==25.1.0 108 | log_symbols==0.0.14 109 | lxml==4.9.4 110 | markdown_it_py==3.0.0 111 | matplotlib_inline==0.1.7 112 | mccabe==0.7.0 113 | mdurl==0.1.2 114 | mistune==3.0.2 115 | more_itertools==10.2.0 116 | mypy_extensions==1.0.0 117 | nbclient==0.10.0 118 | nbconvert==7.16.3 119 | nbformat==5.10.4 120 | nest_asyncio==1.6.0 121 | nh3==0.2.17 122 | notebook==7.1.3 123 | notebook_shim==0.2.4 124 | nox==2022.1.7 125 | nox==2023.4.22 126 | ordered_set==4.1.0 127 | overrides==7.7.0 128 | packaging==20.9 129 | packaging==24.0 130 | pandocfilters==1.5.1 131 | parso==0.8.4 132 | pathspec==0.12.1 133 | pbr==5.6.0 134 | pexpect==4.9.0 135 | pickleshare==0.7.5 136 | pip==23.3.2 137 | pip==24.0 138 | pkginfo==1.10.0 139 | platformdirs==4.2.1 140 | pluggy==1.5.0 141 | prometheus_client==0.20.0 142 | prompt_toolkit==3.0.43 143 | psutil==5.9.8 144 | ptyprocess==0.7.0 145 | pure_eval==0.2.2 146 | py==1.11.0 147 | pyasn1==0.6.0 148 | pycowsay==0.0.0.2 149 | pycparser==2.22 150 | pygdbmi==0.10.0.0 151 | pygments==2.17.2 152 | pylint==3.0.4 153 | pylint==3.1.0 154 | pyparsing==3.1.2 155 | pyperclip==1.8.2 156 | pyproject_api==1.6.1 157 | pyproject_hooks==1.0.0 158 | pytest==8.1.1 159 | python_dateutil==2.9.0.post0 160 | python_json_logger==2.0.7 161 | python_slugify==8.0.4 162 | pyzmq==26.0.2 163 | qtconsole==5.5.1 164 | readme_renderer==43.0 165 | referencing==0.35.0 166 | requests==2.31.0 167 | requests_toolbelt==1.0.0 168 | resolvelib==0.8.1 169 | rfc3339_validator==0.1.4 170 | rfc3986==2.0.0 171 | rfc3986_validator==0.1.1 172 | rich==13.7.1 173 | rpds_py==0.18.0 174 | rsa==4.5 175 | ruamel.yaml.clib==0.2.8 176 | ruamel.yaml==0.17.40 177 | s3transfer==0.10.1 178 | s3transfer==0.3.7 179 | setuptools==69.5.1 180 | setuptools_scm==8.0.4 181 | shell-functools==0.3.0 182 | six==1.16.0 183 | sniffio==1.3.1 184 | soupsieve==2.5 185 | spinners==0.0.24 186 | stack_data==0.6.3 187 | termcolor==2.4.0 188 | terminado==0.18.1 189 | text_unidecode==1.3 190 | tinycss2==1.3.0 191 | tomlkit==0.12.4 192 | tornado==6.4 193 | tox==3.28.0 194 | tox==4.14.2 195 | tox_ini_fmt==0.5.0 196 | tqdm==4.66.2 197 | traitlets==5.14.3 198 | twine==5.0.0 199 | types_python_dateutil==2.9.0.20240316 200 | typing_extensions==4.11.0 201 | uri_template==1.3.0 202 | urllib3==1.25.11 203 | urllib3==2.2.1 204 | virtualenv==20.26.0 205 | wcwidth==0.2.13 206 | webcolors==1.13 207 | webencodings==0.5.1 208 | websocket_client==1.8.0 209 | wheel==0.43.0 210 | widgetsnbextension==4.0.10 211 | xdg==5.1.1 212 | zest.releaser==9.1.2 213 | zipp==3.18.1 214 | -------------------------------------------------------------------------------- /testdata/tests_packages/unix-python3.9.txt: -------------------------------------------------------------------------------- 1 | Babel==2.14.0 2 | Cython==3.0.10 3 | Flask==1.1.4 4 | Jinja2==2.11.3 5 | Jinja2==3.1.3 6 | MarkupSafe==2.1.5 7 | PyYAML==5.3.1 8 | PyYAML==6.0.1 9 | QtPy==2.4.1 10 | SecretStorage==3.3.3 11 | Send2Trash==1.8.3 12 | Weblate==4.3.1 13 | Werkzeug==1.0.1 14 | ansible==6.7.0 15 | ansible_core==2.13.13 16 | anyio==4.3.0 17 | appdirs==1.4.4 18 | argcomplete==1.12.3 19 | argcomplete==3.3.0 20 | argon2_cffi==23.1.0 21 | argon2_cffi_bindings==21.2.0 22 | arrow==1.3.0 23 | astroid==3.0.3 24 | astroid==3.1.0 25 | asttokens==2.4.1 26 | async_lru==2.0.4 27 | attrs==23.2.0 28 | awscli==1.18.168 29 | backcall==0.2.0 30 | backports.tarfile==1.1.1 31 | beautifulsoup4==4.12.3 32 | black==22.10.0 33 | black==22.8.0 34 | bleach==6.1.0 35 | boto3==1.34.92 36 | botocore==1.19.8 37 | botocore==1.34.92 38 | build==1.2.1 39 | cachetools==5.3.3 40 | certifi==2024.2.2 41 | cffi==1.16.0 42 | chardet==5.2.0 43 | charset_normalizer==3.3.2 44 | click==7.1.2 45 | click==8.1.7 46 | cloudtoken==2.1.0 47 | cmarkgfm==2024.1.14 48 | colorama==0.4.3 49 | colorama==0.4.6 50 | colorlog==6.8.2 51 | comm==0.2.2 52 | cryptography==42.0.5 53 | debugpy==1.8.1 54 | decorator==5.1.1 55 | deepdiff==5.8.1 56 | defusedxml==0.7.1 57 | delegator.py==0.1.1 58 | dill==0.3.8 59 | distlib==0.3.8 60 | docutils==0.15.2 61 | docutils==0.21.2 62 | exceptiongroup==1.2.1 63 | executing==2.0.1 64 | fastjsonschema==2.19.1 65 | filelock==3.13.4 66 | fqdn==1.5.1 67 | h11==0.14.0 68 | halo==0.0.31 69 | httpcore==1.0.5 70 | httpx==0.27.0 71 | idna==3.7 72 | ifaddr==0.1.7 73 | importlib_metadata==7.1.0 74 | iniconfig==2.0.0 75 | ipykernel==6.29.4 76 | ipython==7.16.1 77 | ipython==8.18.1 78 | ipywidgets==8.1.2 79 | isoduration==20.11.0 80 | isort==5.13.2 81 | isort==5.6.4 82 | itsdangerous==1.1.0 83 | jaraco.classes==3.4.0 84 | jaraco.clipboard==2.0.1 85 | jaraco.context==5.3.0 86 | jaraco.functools==4.0.1 87 | jedi==0.19.1 88 | jeepney==0.8.0 89 | jmespath==0.10.0 90 | jmespath==1.0.1 91 | json5==0.9.25 92 | jsonpointer==2.4 93 | jsonschema==4.21.1 94 | jsonschema_specifications==2023.12.1 95 | jupyter==1.0.0 96 | jupyter_client==8.6.1 97 | jupyter_console==6.6.3 98 | jupyter_core==5.7.2 99 | jupyter_events==0.10.0 100 | jupyter_lsp==2.2.5 101 | jupyter_server==2.14.0 102 | jupyter_server_terminals==0.5.3 103 | jupyterlab==4.1.6 104 | jupyterlab_pygments==0.3.0 105 | jupyterlab_server==2.27.1 106 | jupyterlab_widgets==3.0.10 107 | kaggle==1.6.11 108 | keyring==21.8.0 109 | keyring==25.1.0 110 | log_symbols==0.0.14 111 | lxml==4.9.4 112 | markdown_it_py==3.0.0 113 | matplotlib_inline==0.1.7 114 | mccabe==0.7.0 115 | mdurl==0.1.2 116 | mistune==3.0.2 117 | more_itertools==10.2.0 118 | mypy_extensions==1.0.0 119 | nbclient==0.10.0 120 | nbconvert==7.16.3 121 | nbformat==5.10.4 122 | nest_asyncio==1.6.0 123 | nh3==0.2.17 124 | notebook==7.1.3 125 | notebook_shim==0.2.4 126 | nox==2022.1.7 127 | nox==2023.4.22 128 | ordered_set==4.1.0 129 | overrides==7.7.0 130 | packaging==20.9 131 | packaging==24.0 132 | pandocfilters==1.5.1 133 | parso==0.8.4 134 | pathspec==0.12.1 135 | pbr==5.6.0 136 | pexpect==4.9.0 137 | pickleshare==0.7.5 138 | pip==23.3.2 139 | pip==24.0 140 | pkginfo==1.10.0 141 | platformdirs==4.2.1 142 | pluggy==1.5.0 143 | prometheus_client==0.20.0 144 | prompt_toolkit==3.0.43 145 | psutil==5.9.8 146 | ptyprocess==0.7.0 147 | pure_eval==0.2.2 148 | py==1.11.0 149 | pyasn1==0.6.0 150 | pycowsay==0.0.0.2 151 | pycparser==2.22 152 | pygdbmi==0.10.0.0 153 | pygments==2.17.2 154 | pylint==3.0.4 155 | pylint==3.1.0 156 | pyparsing==3.1.2 157 | pyperclip==1.8.2 158 | pyproject_api==1.6.1 159 | pyproject_hooks==1.0.0 160 | pytest==8.1.1 161 | python_dateutil==2.9.0.post0 162 | python_json_logger==2.0.7 163 | python_slugify==8.0.4 164 | pyzmq==26.0.2 165 | qtconsole==5.5.1 166 | readme_renderer==43.0 167 | referencing==0.35.0 168 | requests==2.31.0 169 | requests_toolbelt==1.0.0 170 | resolvelib==0.8.1 171 | rfc3339_validator==0.1.4 172 | rfc3986==2.0.0 173 | rfc3986_validator==0.1.1 174 | rich==13.7.1 175 | rpds_py==0.18.0 176 | rsa==4.5 177 | ruamel.yaml.clib==0.2.8 178 | ruamel.yaml==0.17.40 179 | s3transfer==0.10.1 180 | s3transfer==0.3.7 181 | setuptools==69.5.1 182 | setuptools_scm==8.0.4 183 | shell-functools==0.3.0 184 | six==1.16.0 185 | sniffio==1.3.1 186 | soupsieve==2.5 187 | spinners==0.0.24 188 | stack_data==0.6.3 189 | termcolor==2.4.0 190 | terminado==0.18.1 191 | text_unidecode==1.3 192 | tinycss2==1.3.0 193 | tomli==2.0.1 194 | tomlkit==0.12.4 195 | tornado==6.4 196 | tox==3.28.0 197 | tox==4.14.2 198 | tox_ini_fmt==0.5.0 199 | tqdm==4.66.2 200 | traitlets==5.14.3 201 | twine==5.0.0 202 | types_python_dateutil==2.9.0.20240316 203 | typing_extensions==4.11.0 204 | uri_template==1.3.0 205 | urllib3==1.25.11 206 | urllib3==1.26.18 207 | urllib3==2.2.1 208 | virtualenv==20.26.0 209 | wcwidth==0.2.13 210 | webcolors==1.13 211 | webencodings==0.5.1 212 | websocket_client==1.8.0 213 | wheel==0.43.0 214 | widgetsnbextension==4.0.10 215 | xdg==5.1.1 216 | zest.releaser==9.1.2 217 | zipp==3.18.1 218 | -------------------------------------------------------------------------------- /testdata/tests_packages/win-python3.12.txt: -------------------------------------------------------------------------------- 1 | Babel==2.14.0 2 | Cython==3.0.10 3 | Flask==1.1.4 4 | Jinja2==2.11.3 5 | Jinja2==3.1.3 6 | MarkupSafe==2.1.5 7 | PyYAML==5.3.1 8 | PyYAML==6.0.1 9 | QtPy==2.4.1 10 | Send2Trash==1.8.3 11 | Weblate==4.3.1 12 | Werkzeug==1.0.1 13 | ansible==6.7.0 14 | ansible_core==2.13.13 15 | anyio==4.3.0 16 | appdirs==1.4.4 17 | argcomplete==1.12.3 18 | argcomplete==3.3.0 19 | argon2_cffi==23.1.0 20 | argon2_cffi_bindings==21.2.0 21 | arrow==1.3.0 22 | astroid==3.0.3 23 | astroid==3.1.0 24 | asttokens==2.4.1 25 | async_lru==2.0.4 26 | attrs==23.2.0 27 | autocommand==2.2.2 28 | awscli==1.18.168 29 | backcall==0.2.0 30 | beautifulsoup4==4.12.3 31 | black==22.10.0 32 | black==22.8.0 33 | bleach==6.1.0 34 | boto3==1.34.92 35 | botocore==1.19.8 36 | botocore==1.34.92 37 | build==1.2.1 38 | cachetools==5.3.3 39 | certifi==2024.2.2 40 | cffi==1.16.0 41 | chardet==5.2.0 42 | charset_normalizer==3.3.2 43 | click==7.1.2 44 | click==8.1.7 45 | cloudtoken==2.1.0 46 | cmarkgfm==2024.1.14 47 | colorama==0.4.3 48 | colorama==0.4.6 49 | colorlog==6.8.2 50 | comm==0.2.2 51 | cryptography==42.0.5 52 | debugpy==1.8.1 53 | decorator==5.1.1 54 | deepdiff==5.8.1 55 | defusedxml==0.7.1 56 | delegator.py==0.1.1 57 | dill==0.3.8 58 | distlib==0.3.8 59 | docutils==0.15.2 60 | docutils==0.21.2 61 | executing==2.0.1 62 | fastjsonschema==2.19.1 63 | filelock==3.13.4 64 | fqdn==1.5.1 65 | h11==0.14.0 66 | halo==0.0.31 67 | httpcore==1.0.5 68 | httpx==0.27.0 69 | idna==3.7 70 | ifaddr==0.1.7 71 | importlib_metadata==7.1.0 72 | inflect==7.2.1 73 | iniconfig==2.0.0 74 | ipykernel==6.29.4 75 | ipython==7.16.1 76 | ipython==8.23.0 77 | ipywidgets==8.1.2 78 | isoduration==20.11.0 79 | isort==5.13.2 80 | isort==5.6.4 81 | itsdangerous==1.1.0 82 | jaraco.classes==3.4.0 83 | jaraco.clipboard==2.0.1 84 | jaraco.collections==5.0.1 85 | jaraco.context==5.3.0 86 | jaraco.functools==4.0.1 87 | jaraco.structures==2.2.0 88 | jaraco.text==3.12.0 89 | jaraco.ui==2.3.0 90 | jaraco.windows==5.8.0 91 | jedi==0.19.1 92 | jmespath==0.10.0 93 | jmespath==1.0.1 94 | json5==0.9.25 95 | jsonpointer==2.4 96 | jsonschema==4.21.1 97 | jsonschema_specifications==2023.12.1 98 | jupyter==1.0.0 99 | jupyter_client==8.6.1 100 | jupyter_console==6.6.3 101 | jupyter_core==5.7.2 102 | jupyter_events==0.10.0 103 | jupyter_lsp==2.2.5 104 | jupyter_server==2.14.0 105 | jupyter_server_terminals==0.5.3 106 | jupyterlab==4.1.6 107 | jupyterlab_pygments==0.3.0 108 | jupyterlab_server==2.27.1 109 | jupyterlab_widgets==3.0.10 110 | kaggle==1.6.11 111 | keyring==21.8.0 112 | keyring==25.1.0 113 | log_symbols==0.0.14 114 | lxml==4.9.4 115 | markdown_it_py==3.0.0 116 | matplotlib_inline==0.1.7 117 | mccabe==0.7.0 118 | mdurl==0.1.2 119 | mistune==3.0.2 120 | more_itertools==10.2.0 121 | mypy_extensions==1.0.0 122 | nbclient==0.10.0 123 | nbconvert==7.16.3 124 | nbformat==5.10.4 125 | nest_asyncio==1.6.0 126 | nh3==0.2.17 127 | notebook==7.1.3 128 | notebook_shim==0.2.4 129 | nox==2022.1.7 130 | nox==2023.4.22 131 | ordered_set==4.1.0 132 | overrides==7.7.0 133 | packaging==20.9 134 | packaging==24.0 135 | pandocfilters==1.5.1 136 | parso==0.8.4 137 | path==16.14.0 138 | pathspec==0.12.1 139 | pbr==5.6.0 140 | pexpect==4.9.0 141 | pickleshare==0.7.5 142 | pip==23.3.2 143 | pip==24.0 144 | pkginfo==1.10.0 145 | platformdirs==4.2.1 146 | pluggy==1.5.0 147 | prometheus_client==0.20.0 148 | prompt_toolkit==3.0.43 149 | psutil==5.9.8 150 | ptyprocess==0.7.0 151 | pure_eval==0.2.2 152 | py==1.11.0 153 | pyasn1==0.6.0 154 | pycowsay==0.0.0.2 155 | pycparser==2.22 156 | pygdbmi==0.10.0.0 157 | pygments==2.17.2 158 | pylint==3.0.4 159 | pylint==3.1.0 160 | pyparsing==3.1.2 161 | pyproject_api==1.6.1 162 | pyproject_hooks==1.0.0 163 | pytest==8.1.1 164 | python_dateutil==2.9.0.post0 165 | python_json_logger==2.0.7 166 | python_slugify==8.0.4 167 | pywin32==306 168 | pywin32_ctypes==0.2.2 169 | pywinpty==2.0.13 170 | pyzmq==26.0.2 171 | qtconsole==5.5.1 172 | readme_renderer==43.0 173 | referencing==0.35.0 174 | requests==2.31.0 175 | requests_toolbelt==1.0.0 176 | resolvelib==0.8.1 177 | rfc3339_validator==0.1.4 178 | rfc3986==2.0.0 179 | rfc3986_validator==0.1.1 180 | rich==13.7.1 181 | rpds_py==0.18.0 182 | rsa==4.5 183 | ruamel.yaml.clib==0.2.8 184 | ruamel.yaml==0.17.40 185 | s3transfer==0.10.1 186 | s3transfer==0.3.7 187 | setuptools==69.5.1 188 | setuptools_scm==8.0.4 189 | shell-functools==0.3.0 190 | six==1.16.0 191 | sniffio==1.3.1 192 | soupsieve==2.5 193 | spinners==0.0.24 194 | stack_data==0.6.3 195 | termcolor==2.4.0 196 | terminado==0.18.1 197 | text_unidecode==1.3 198 | tinycss2==1.3.0 199 | tomlkit==0.12.4 200 | tornado==6.4 201 | tox==3.28.0 202 | tox==4.14.2 203 | tox_ini_fmt==0.5.0 204 | tqdm==4.66.2 205 | traitlets==5.14.3 206 | twine==5.0.0 207 | typeguard==4.2.1 208 | types_python_dateutil==2.9.0.20240316 209 | typing_extensions==4.11.0 210 | uri_template==1.3.0 211 | urllib3==1.25.11 212 | urllib3==2.2.1 213 | virtualenv==20.26.0 214 | wcwidth==0.2.13 215 | webcolors==1.13 216 | webencodings==0.5.1 217 | websocket_client==1.8.0 218 | wheel==0.43.0 219 | widgetsnbextension==4.0.10 220 | xdg==5.1.1 221 | zest.releaser==9.1.2 222 | zipp==3.18.1 223 | -------------------------------------------------------------------------------- /tests/test_animate.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest # type: ignore[import-not-found] 4 | 5 | import pipx.animate 6 | from pipx.animate import ( 7 | CLEAR_LINE, 8 | EMOJI_ANIMATION_FRAMES, 9 | EMOJI_FRAME_PERIOD, 10 | NONEMOJI_ANIMATION_FRAMES, 11 | NONEMOJI_FRAME_PERIOD, 12 | ) 13 | 14 | # 40-char test_string counts columns e.g.: "0204060810 ... 363840" 15 | TEST_STRING_40_CHAR = "".join([f"{x:02}" for x in range(2, 41, 2)]) 16 | 17 | 18 | def check_animate_output( 19 | capsys, 20 | test_string, 21 | frame_strings, 22 | frame_period, 23 | frames_to_test, 24 | extra_animate_time=0.4, 25 | extra_after_thread_time=0.1, 26 | ): 27 | # github workflow history (2020-07-27): 28 | # extra_animate_time <= 0.3 failed on macos 29 | # extra_after_thread_time <= 0.0 failed on macos 30 | expected_string = "".join(frame_strings) 31 | 32 | chars_to_test = 1 + len("".join(frame_strings[:frames_to_test])) 33 | 34 | with pipx.animate.animate(test_string, do_animation=True): 35 | time.sleep(frame_period * (frames_to_test - 1) + extra_animate_time) 36 | # Wait before capturing stderr to ensure animate thread is finished 37 | # and to capture all its characters. 38 | time.sleep(extra_after_thread_time) 39 | captured = capsys.readouterr() 40 | 41 | print("check_animate_output() Test Debug Output:") 42 | if len(captured.err) < chars_to_test: 43 | print("Not enough captured characters--Likely need to increase extra_animate_time") 44 | print(f"captured characters: {len(captured.err)}") 45 | print(f"chars_to_test: {chars_to_test}") 46 | for i in range(0, chars_to_test, 40): 47 | i_end = min(i + 40, chars_to_test) 48 | print(f"expected_string[{i}:{i_end}]: {expected_string[i:i_end]!r}") 49 | print(f"captured.err[{i}:{i_end}] : {captured.err[i:i_end]!r}") 50 | 51 | assert captured.err[:chars_to_test] == expected_string[:chars_to_test] 52 | 53 | 54 | def test_delay_suppresses_output(capsys, monkeypatch): 55 | monkeypatch.setattr(pipx.animate, "stderr_is_tty", True) 56 | monkeypatch.setenv("COLUMNS", "80") 57 | 58 | test_string = "asdf" 59 | 60 | with pipx.animate.animate(test_string, do_animation=True, delay=0.9): 61 | time.sleep(0.5) 62 | captured = capsys.readouterr() 63 | assert test_string not in captured.err 64 | 65 | 66 | @pytest.mark.parametrize( 67 | "env_columns,expected_frame_message", 68 | [ 69 | (45, f"{TEST_STRING_40_CHAR:.{45 - 6}}..."), 70 | (46, f"{TEST_STRING_40_CHAR}"), 71 | (47, f"{TEST_STRING_40_CHAR}"), 72 | ], 73 | ) 74 | def test_line_lengths_emoji(capsys, monkeypatch, env_columns, expected_frame_message): 75 | # EMOJI_SUPPORT and stderr_is_tty is set only at import animate.py 76 | # since we are already after that, we must override both here 77 | monkeypatch.setattr(pipx.animate, "stderr_is_tty", True) 78 | monkeypatch.setattr(pipx.animate, "EMOJI_SUPPORT", True) 79 | 80 | monkeypatch.setenv("COLUMNS", str(env_columns)) 81 | 82 | frames_to_test = 4 83 | frame_strings = [f"\r{CLEAR_LINE}{x} {expected_frame_message}" for x in EMOJI_ANIMATION_FRAMES] 84 | check_animate_output(capsys, TEST_STRING_40_CHAR, frame_strings, EMOJI_FRAME_PERIOD, frames_to_test) 85 | 86 | 87 | @pytest.mark.parametrize( 88 | "env_columns,expected_frame_message", 89 | [ 90 | (43, f"{TEST_STRING_40_CHAR:.{43 - 4}}"), 91 | (44, f"{TEST_STRING_40_CHAR}"), 92 | (45, f"{TEST_STRING_40_CHAR}"), 93 | ], 94 | ) 95 | def test_line_lengths_no_emoji(capsys, monkeypatch, env_columns, expected_frame_message): 96 | # EMOJI_SUPPORT and stderr_is_tty is set only at import animate.py 97 | # since we are already after that, we must override both here 98 | monkeypatch.setattr(pipx.animate, "stderr_is_tty", True) 99 | monkeypatch.setattr(pipx.animate, "EMOJI_SUPPORT", False) 100 | 101 | monkeypatch.setenv("COLUMNS", str(env_columns)) 102 | 103 | frames_to_test = 2 104 | frame_strings = [f"\r{CLEAR_LINE}{expected_frame_message}{x}" for x in NONEMOJI_ANIMATION_FRAMES] 105 | 106 | check_animate_output( 107 | capsys, 108 | TEST_STRING_40_CHAR, 109 | frame_strings, 110 | NONEMOJI_FRAME_PERIOD, 111 | frames_to_test, 112 | ) 113 | 114 | 115 | @pytest.mark.parametrize("env_columns,stderr_is_tty", [(0, True), (8, True), (16, True), (17, False)]) 116 | def test_env_no_animate(capsys, monkeypatch, env_columns, stderr_is_tty): 117 | monkeypatch.setattr(pipx.animate, "stderr_is_tty", stderr_is_tty) 118 | monkeypatch.setenv("COLUMNS", str(env_columns)) 119 | 120 | frames_to_test = 4 121 | expected_string = f"{TEST_STRING_40_CHAR}...\n" 122 | extra_animate_time = 0.4 123 | extra_after_thread_time = 0.1 124 | 125 | with pipx.animate.animate(TEST_STRING_40_CHAR, do_animation=True): 126 | time.sleep(EMOJI_FRAME_PERIOD * (frames_to_test - 1) + extra_animate_time) 127 | time.sleep(extra_after_thread_time) 128 | captured = capsys.readouterr() 129 | 130 | assert captured.out == "" 131 | assert captured.err == expected_string 132 | -------------------------------------------------------------------------------- /tests/test_completions.py: -------------------------------------------------------------------------------- 1 | from helpers import run_pipx_cli 2 | 3 | 4 | def test_cli(monkeypatch, capsys): 5 | assert not run_pipx_cli(["completions"]) 6 | captured = capsys.readouterr() 7 | assert "Add the appropriate command" in captured.out 8 | -------------------------------------------------------------------------------- /tests/test_emojis.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from io import BytesIO, TextIOWrapper 3 | from unittest import mock 4 | 5 | import pytest # type: ignore[import-not-found] 6 | 7 | from pipx.emojis import use_emojis 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "PIPX_USE_EMOJI, encoding, expected", 12 | [ 13 | # utf-8 14 | (None, "utf-8", True), 15 | ("", "utf-8", False), 16 | ("0", "utf-8", False), 17 | ("1", "utf-8", True), 18 | ("true", "utf-8", True), 19 | ("tru", "utf-8", False), # codespell:ignore tru 20 | ("True", "utf-8", True), 21 | ("false", "utf-8", False), 22 | # latin_1 (alias: iso-8859-1) 23 | (None, "latin_1", False), 24 | ("", "latin_1", False), 25 | ("0", "latin_1", False), 26 | ("1", "latin_1", True), 27 | ("true", "latin_1", True), 28 | ("tru", "latin_1", False), # codespell:ignore tru 29 | ("True", "latin_1", True), 30 | ("false", "latin_1", False), 31 | # cp1252 32 | (None, "cp1252", False), 33 | ("", "cp1252", False), 34 | ("0", "cp1252", False), 35 | ("1", "cp1252", True), 36 | ("true", "cp1252", True), 37 | ("tru", "cp1252", False), # codespell:ignore tru 38 | ("True", "cp1252", True), 39 | ("false", "cp1252", False), 40 | ], 41 | ) 42 | def test_use_emojis(monkeypatch, PIPX_USE_EMOJI, encoding, expected): 43 | with mock.patch.object(sys, "stderr", TextIOWrapper(BytesIO(), encoding=encoding)): 44 | if PIPX_USE_EMOJI is not None: 45 | monkeypatch.setenv("PIPX_USE_EMOJI", PIPX_USE_EMOJI) 46 | assert use_emojis() is expected 47 | -------------------------------------------------------------------------------- /tests/test_environment.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | from pathlib import Path 3 | 4 | from helpers import run_pipx_cli, skip_if_windows 5 | from pipx import paths 6 | from pipx.commands.environment import ENVIRONMENT_VARIABLES 7 | from pipx.paths import get_expanded_environ 8 | 9 | 10 | def test_cli(pipx_temp_env, monkeypatch, capsys): 11 | assert not run_pipx_cli(["environment"]) 12 | captured = capsys.readouterr() 13 | assert fnmatch.fnmatch(captured.out, "*PIPX_HOME=*subdir/pipxhome*") 14 | assert fnmatch.fnmatch(captured.out, "*PIPX_BIN_DIR=*otherdir/pipxbindir*") 15 | assert fnmatch.fnmatch(captured.out, "*PIPX_MAN_DIR=*otherdir/pipxmandir*") 16 | assert "PIPX_SHARED_LIBS" in captured.out 17 | assert fnmatch.fnmatch(captured.out, "*PIPX_LOCAL_VENVS=*subdir/pipxhome/venvs*") 18 | assert fnmatch.fnmatch(captured.out, "*PIPX_LOG_DIR=*subdir/pipxhome/logs*") 19 | assert fnmatch.fnmatch(captured.out, "*PIPX_TRASH_DIR=*subdir/pipxhome/.trash*") 20 | assert fnmatch.fnmatch(captured.out, "*PIPX_VENV_CACHEDIR=*subdir/pipxhome/.cache*") 21 | # Checking just for the sake of completeness 22 | for env_var in ENVIRONMENT_VARIABLES: 23 | assert env_var in captured.out 24 | 25 | 26 | def test_cli_with_args(monkeypatch, capsys): 27 | assert not run_pipx_cli(["environment", "--value", "PIPX_HOME"]) 28 | assert not run_pipx_cli(["environment", "--value", "PIPX_BIN_DIR"]) 29 | assert not run_pipx_cli(["environment", "--value", "PIPX_MAN_DIR"]) 30 | assert not run_pipx_cli(["environment", "--value", "PIPX_SHARED_LIBS"]) 31 | assert not run_pipx_cli(["environment", "--value", "PIPX_LOCAL_VENVS"]) 32 | assert not run_pipx_cli(["environment", "--value", "PIPX_LOG_DIR"]) 33 | assert not run_pipx_cli(["environment", "--value", "PIPX_TRASH_DIR"]) 34 | assert not run_pipx_cli(["environment", "--value", "PIPX_VENV_CACHEDIR"]) 35 | assert not run_pipx_cli(["environment", "--value", "PIPX_DEFAULT_PYTHON"]) 36 | assert not run_pipx_cli(["environment", "--value", "PIPX_USE_EMOJI"]) 37 | assert not run_pipx_cli(["environment", "--value", "PIPX_HOME_ALLOW_SPACE"]) 38 | 39 | assert run_pipx_cli(["environment", "--value", "SSS"]) 40 | captured = capsys.readouterr() 41 | assert "Variable not found." in captured.err 42 | 43 | 44 | def test_resolve_user_dir_in_env_paths(monkeypatch): 45 | monkeypatch.setenv("TEST_DIR", "~/test") 46 | home = Path.home() 47 | env_dir = get_expanded_environ("TEST_DIR") 48 | assert "~" not in str(env_dir) 49 | assert env_dir == home / "test" 50 | env_dir = get_expanded_environ("THIS_SHOULD_NOT_EXIST") 51 | assert env_dir is None 52 | 53 | 54 | def test_allow_space_in_pipx_home( 55 | monkeypatch, 56 | capsys, 57 | tmp_path, 58 | ): 59 | home_dir = Path(tmp_path) / "path with space" 60 | monkeypatch.setattr(paths.ctx, "_base_home", home_dir) 61 | assert not run_pipx_cli(["environment", "--value", "PIPX_HOME_ALLOW_SPACE"]) 62 | paths.ctx.log_warnings() 63 | captured = capsys.readouterr() 64 | assert "Found a space" in captured.err 65 | assert "false" in captured.out 66 | 67 | monkeypatch.setenv("PIPX_HOME_ALLOW_SPACE", "1") 68 | assert not run_pipx_cli(["environment", "--value", "PIPX_HOME_ALLOW_SPACE"]) 69 | paths.ctx.log_warnings() 70 | captured = capsys.readouterr() 71 | assert "Found a space" not in captured.err 72 | assert "true" in captured.out 73 | 74 | paths.ctx.make_local() 75 | 76 | 77 | @skip_if_windows 78 | def test_cli_global(pipx_temp_env, monkeypatch, capsys): 79 | assert not run_pipx_cli(["environment", "--global"]) 80 | captured = capsys.readouterr() 81 | assert fnmatch.fnmatch(captured.out, "*PIPX_HOME=*global/pipxhome*") 82 | assert fnmatch.fnmatch(captured.out, "*PIPX_BIN_DIR=*global_otherdir/pipxbindir*") 83 | assert fnmatch.fnmatch(captured.out, "*PIPX_MAN_DIR=*global_otherdir/pipxmandir*") 84 | assert "PIPX_SHARED_LIBS" in captured.out 85 | assert fnmatch.fnmatch(captured.out, "*PIPX_LOCAL_VENVS=*global/pipxhome/venvs*") 86 | assert fnmatch.fnmatch(captured.out, "*PIPX_LOG_DIR=*global/pipxhome/logs*") 87 | assert fnmatch.fnmatch(captured.out, "*PIPX_TRASH_DIR=*global/pipxhome/.trash*") 88 | assert fnmatch.fnmatch(captured.out, "*PIPX_VENV_CACHEDIR=*global/pipxhome/.cache*") 89 | # Checking just for the sake of completeness 90 | for env_var in ENVIRONMENT_VARIABLES: 91 | assert env_var in captured.out 92 | -------------------------------------------------------------------------------- /tests/test_inject.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import textwrap 4 | 5 | import pytest # type: ignore[import-not-found] 6 | 7 | from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli, skip_if_windows 8 | from package_info import PKG 9 | 10 | 11 | # Note that this also checks that packages used in other tests can be injected individually 12 | @pytest.mark.parametrize( 13 | "pkg_spec,", 14 | [ 15 | PKG["black"]["spec"], 16 | PKG["nox"]["spec"], 17 | PKG["pylint"]["spec"], 18 | PKG["ipython"]["spec"], 19 | "jaraco.clipboard==2.0.1", # tricky character 20 | ], 21 | ) 22 | def test_inject_single_package(pipx_temp_env, capsys, caplog, pkg_spec): 23 | assert not run_pipx_cli(["install", "pycowsay"]) 24 | assert not run_pipx_cli(["inject", "pycowsay", pkg_spec]) 25 | 26 | # Check arguments have been parsed correctly 27 | assert f"Injecting packages: {[pkg_spec]!r}" in caplog.text 28 | 29 | # Check it's actually being installed and into correct venv 30 | captured = capsys.readouterr() 31 | injected = re.findall(r"injected package (.+?) into venv pycowsay", captured.out) 32 | pkg_name = pkg_spec.split("=", 1)[0].replace(".", "-") # assuming spec is always of the form == 33 | assert set(injected) == {pkg_name} 34 | 35 | 36 | @skip_if_windows 37 | def test_inject_simple_global(pipx_temp_env, capsys): 38 | assert not run_pipx_cli(["install", "--global", "pycowsay"]) 39 | assert not run_pipx_cli(["inject", "--global", "pycowsay", PKG["black"]["spec"]]) 40 | 41 | 42 | @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) 43 | def test_inject_simple_legacy_venv(pipx_temp_env, capsys, metadata_version): 44 | assert not run_pipx_cli(["install", "pycowsay"]) 45 | mock_legacy_venv("pycowsay", metadata_version=metadata_version) 46 | if metadata_version is not None: 47 | assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) 48 | else: 49 | # no metadata in venv should result in PipxError with message 50 | assert run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) 51 | assert "Please uninstall and install" in capsys.readouterr().err 52 | 53 | 54 | @pytest.mark.parametrize("with_suffix,", [(False,), (True,)]) 55 | def test_inject_include_apps(pipx_temp_env, capsys, with_suffix): 56 | install_args = [] 57 | suffix = "" 58 | 59 | if with_suffix: 60 | suffix = "_x" 61 | install_args = [f"--suffix={suffix}"] 62 | 63 | assert not run_pipx_cli(["install", "pycowsay", *install_args]) 64 | assert not run_pipx_cli(["inject", f"pycowsay{suffix}", PKG["black"]["spec"], "--include-deps"]) 65 | 66 | if suffix: 67 | assert run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"], "--include-deps"]) 68 | 69 | assert not run_pipx_cli(["inject", f"pycowsay{suffix}", PKG["black"]["spec"], "--include-deps"]) 70 | 71 | 72 | @pytest.mark.parametrize( 73 | "with_packages,", 74 | [ 75 | (), # no extra packages 76 | ("black",), # duplicate from requirements file 77 | ("ipython",), # additional package 78 | ], 79 | ) 80 | def test_inject_with_req_file(pipx_temp_env, capsys, caplog, tmp_path, with_packages): 81 | caplog.set_level(logging.INFO) 82 | 83 | req_file = tmp_path / "inject-requirements.txt" 84 | req_file.write_text( 85 | textwrap.dedent( 86 | f""" 87 | {PKG["black"]["spec"]} # a comment inline 88 | {PKG["nox"]["spec"]} 89 | 90 | {PKG["pylint"]["spec"]} 91 | # comment on separate line 92 | """ 93 | ).strip() 94 | ) 95 | assert not run_pipx_cli(["install", "pycowsay"]) 96 | 97 | assert not run_pipx_cli( 98 | ["inject", "pycowsay", *(PKG[pkg]["spec"] for pkg in with_packages), "--requirement", str(req_file)] 99 | ) 100 | 101 | packages = [ 102 | ("black", PKG["black"]["spec"]), 103 | ("nox", PKG["nox"]["spec"]), 104 | ("pylint", PKG["pylint"]["spec"]), 105 | ] 106 | packages.extend((pkg, PKG[pkg]["spec"]) for pkg in with_packages) 107 | packages = sorted(set(packages)) 108 | 109 | # Check arguments and files have been parsed correctly 110 | assert f"Injecting packages: {[p for _, p in packages]!r}" in caplog.text 111 | 112 | # Check they're actually being installed and into correct venv 113 | captured = capsys.readouterr() 114 | injected = re.findall(r"injected package (.+?) into venv pycowsay", captured.out) 115 | assert set(injected) == {pkg for pkg, _ in packages} 116 | -------------------------------------------------------------------------------- /tests/test_install_all.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from helpers import run_pipx_cli 4 | from pipx import paths 5 | 6 | 7 | def test_install_all(pipx_temp_env, tmp_path, capsys): 8 | assert not run_pipx_cli(["install", "pycowsay"]) 9 | assert not run_pipx_cli(["install", "black"]) 10 | _ = capsys.readouterr() 11 | 12 | assert not run_pipx_cli(["list", "--json"]) 13 | captured = capsys.readouterr() 14 | 15 | pipx_list_path = Path(tmp_path) / "pipx_list.json" 16 | with open(pipx_list_path, "w") as pipx_list_fh: 17 | pipx_list_fh.write(captured.out) 18 | 19 | assert not run_pipx_cli(["install-all", str(pipx_list_path)]) 20 | 21 | captured = capsys.readouterr() 22 | assert "black" in captured.out 23 | assert "pycowsay" in captured.out 24 | 25 | 26 | def test_install_all_multiple_errors(pipx_temp_env, root, capsys): 27 | pipx_metadata_path = root / "testdata" / "pipx_metadata_multiple_errors.json" 28 | assert run_pipx_cli(["install-all", str(pipx_metadata_path)]) 29 | captured = capsys.readouterr() 30 | assert "The following package(s) failed to install: dotenv, weblate" in captured.err 31 | assert f"No packages installed after running 'pipx install-all {pipx_metadata_path}'" in captured.out 32 | if paths.ctx.log_file: 33 | with open(paths.ctx.log_file.parent / (paths.ctx.log_file.stem + "_pip_errors.log")) as log_fh: 34 | log_contents = log_fh.read() 35 | assert "dotenv" in log_contents 36 | assert "weblate" in log_contents 37 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import mock 3 | 4 | import pytest # type: ignore[import-not-found] 5 | 6 | from helpers import run_pipx_cli 7 | from pipx import main 8 | 9 | 10 | def test_help_text(monkeypatch, capsys): 11 | mock_exit = mock.Mock(side_effect=ValueError("raised in test to exit early")) 12 | with mock.patch.object(sys, "exit", mock_exit), pytest.raises(ValueError, match="raised in test to exit early"): 13 | assert not run_pipx_cli(["--help"]) 14 | captured = capsys.readouterr() 15 | assert "usage: pipx" in captured.out 16 | 17 | 18 | def test_version(monkeypatch, capsys): 19 | mock_exit = mock.Mock(side_effect=ValueError("raised in test to exit early")) 20 | with mock.patch.object(sys, "exit", mock_exit), pytest.raises(ValueError, match="raised in test to exit early"): 21 | assert not run_pipx_cli(["--version"]) 22 | captured = capsys.readouterr() 23 | mock_exit.assert_called_with(0) 24 | assert main.__version__ in captured.out.strip() 25 | 26 | 27 | @pytest.mark.parametrize( 28 | ("argv", "executable", "expected"), 29 | [ 30 | ("/usr/bin/pipx", "", "pipx"), 31 | ("__main__.py", "/usr/bin/python", "/usr/bin/python -m pipx"), 32 | ], 33 | ) 34 | def test_prog_name(monkeypatch, argv, executable, expected): 35 | monkeypatch.setattr("pipx.main.sys.argv", [argv]) 36 | monkeypatch.setattr("pipx.main.sys.executable", executable) 37 | assert main.prog_name() == expected 38 | 39 | 40 | def test_limit_verbosity(): 41 | assert not run_pipx_cli(["list", "-qqq"]) 42 | assert not run_pipx_cli(["list", "-vvvv"]) 43 | -------------------------------------------------------------------------------- /tests/test_pin.py: -------------------------------------------------------------------------------- 1 | from helpers import run_pipx_cli 2 | from package_info import PKG 3 | 4 | 5 | def test_pin(capsys, pipx_temp_env, caplog): 6 | assert not run_pipx_cli(["install", "pycowsay"]) 7 | assert not run_pipx_cli(["pin", "pycowsay"]) 8 | assert not run_pipx_cli(["upgrade", "pycowsay"]) 9 | 10 | assert "Not upgrading pinned package pycowsay" in caplog.text 11 | 12 | 13 | def test_pin_with_suffix(capsys, pipx_temp_env, caplog): 14 | assert not run_pipx_cli(["install", PKG["black"]["spec"], "--suffix", "@1"]) 15 | assert not run_pipx_cli(["pin", "black@1"]) 16 | assert not run_pipx_cli(["upgrade", "black@1"]) 17 | 18 | assert "Not upgrading pinned package black@1" in caplog.text 19 | 20 | 21 | def test_pin_warning(capsys, pipx_temp_env, caplog): 22 | assert not run_pipx_cli(["install", PKG["nox"]["spec"]]) 23 | assert not run_pipx_cli(["pin", "nox"]) 24 | assert not run_pipx_cli(["pin", "nox"]) 25 | 26 | assert "Package nox already pinned 😴" in caplog.text 27 | 28 | 29 | def test_pin_not_installed_package(capsys, pipx_temp_env): 30 | assert run_pipx_cli(["pin", "abc"]) 31 | 32 | captured = capsys.readouterr() 33 | assert "Package abc is not installed" in captured.err 34 | 35 | 36 | def test_pin_injected_packages_only(capsys, pipx_temp_env, caplog): 37 | assert not run_pipx_cli(["install", "pycowsay"]) 38 | assert not run_pipx_cli(["inject", "pycowsay", "black", PKG["pylint"]["spec"]]) 39 | 40 | assert not run_pipx_cli(["pin", "pycowsay", "--injected-only"]) 41 | 42 | captured = capsys.readouterr() 43 | 44 | assert "Pinned 2 packages in venv pycowsay" in captured.out 45 | assert "black" in captured.out 46 | assert "pylint" in captured.out 47 | 48 | assert not run_pipx_cli(["upgrade", "pycowsay", "--include-injected"]) 49 | 50 | assert "Not upgrading pinned package black in venv pycowsay" in caplog.text 51 | assert "Not upgrading pinned package pylint in venv pycowsay" in caplog.text 52 | 53 | 54 | def test_pin_injected_packages_with_skip(capsys, pipx_temp_env): 55 | assert not run_pipx_cli(["install", "black"]) 56 | assert not run_pipx_cli(["inject", "black", PKG["pylint"]["spec"], PKG["isort"]["spec"]]) 57 | 58 | _ = capsys.readouterr() 59 | 60 | assert not run_pipx_cli(["pin", "black", "--injected-only", "--skip", "isort"]) 61 | 62 | captured = capsys.readouterr() 63 | 64 | assert "pylint" in captured.out 65 | assert "isort" not in captured.out 66 | -------------------------------------------------------------------------------- /tests/test_pipx_metadata_file.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from dataclasses import replace 3 | from pathlib import Path 4 | 5 | import pytest # type: ignore[import-not-found] 6 | 7 | from helpers import assert_package_metadata, create_package_info_ref, run_pipx_cli 8 | from package_info import PKG 9 | from pipx import paths 10 | from pipx.pipx_metadata_file import PackageInfo, PipxMetadata 11 | from pipx.util import PipxError 12 | 13 | TEST_PACKAGE1 = PackageInfo( 14 | package="test_package", 15 | package_or_url="test_package_url", 16 | pip_args=[], 17 | include_apps=True, 18 | include_dependencies=False, 19 | apps=["testapp"], 20 | app_paths=[Path("/usr/bin")], 21 | apps_of_dependencies=["dep1"], 22 | app_paths_of_dependencies={"dep1": [Path("bin")]}, 23 | man_pages=[str(Path("man1/testapp.1"))], 24 | man_pages_of_dependencies=[str(Path("man1/dep1.1"))], 25 | man_paths_of_dependencies={"dep1": [Path("man1/dep1.1")]}, 26 | package_version="0.1.2", 27 | ) 28 | TEST_PACKAGE2 = PackageInfo( 29 | package="inj_package", 30 | package_or_url="inj_package_url", 31 | pip_args=["-e"], 32 | include_apps=True, 33 | include_dependencies=False, 34 | apps=["injapp"], 35 | app_paths=[Path("/usr/bin")], 36 | apps_of_dependencies=["dep2"], 37 | app_paths_of_dependencies={"dep2": [Path("bin")]}, 38 | man_pages=[str(Path("man1/injapp.1"))], 39 | man_pages_of_dependencies=[str(Path("man1/dep2.1"))], 40 | man_paths_of_dependencies={"dep2": [Path("man1/dep2.1")]}, 41 | package_version="6.7.8", 42 | ) 43 | 44 | 45 | def test_pipx_metadata_file_create(tmp_path): 46 | venv_dir = tmp_path / TEST_PACKAGE1.package 47 | venv_dir.mkdir() 48 | 49 | pipx_metadata = PipxMetadata(venv_dir) 50 | pipx_metadata.main_package = TEST_PACKAGE1 51 | pipx_metadata.python_version = "3.4.5" 52 | pipx_metadata.source_interpreter = Path(sys.executable) 53 | pipx_metadata.venv_args = ["--system-site-packages"] 54 | pipx_metadata.injected_packages = {"injected": TEST_PACKAGE2} 55 | pipx_metadata.write() 56 | 57 | pipx_metadata2 = PipxMetadata(venv_dir) 58 | 59 | for attribute in [ 60 | "venv_dir", 61 | "main_package", 62 | "python_version", 63 | "venv_args", 64 | "injected_packages", 65 | ]: 66 | assert getattr(pipx_metadata, attribute) == getattr(pipx_metadata2, attribute) 67 | 68 | 69 | @pytest.mark.parametrize( 70 | "test_package", 71 | [ 72 | replace(TEST_PACKAGE1, include_apps=False), 73 | replace(TEST_PACKAGE1, package=None), 74 | replace(TEST_PACKAGE1, package_or_url=None), 75 | ], 76 | ) 77 | def test_pipx_metadata_file_validation(tmp_path, test_package): 78 | venv_dir = tmp_path / "venv" 79 | venv_dir.mkdir() 80 | 81 | pipx_metadata = PipxMetadata(venv_dir) 82 | pipx_metadata.main_package = test_package 83 | pipx_metadata.python_version = "3.4.5" 84 | pipx_metadata.source_interpreter = Path(sys.executable) 85 | pipx_metadata.venv_args = ["--system-site-packages"] 86 | pipx_metadata.injected_packages = {} 87 | 88 | with pytest.raises(PipxError): 89 | pipx_metadata.write() 90 | 91 | 92 | def test_package_install(monkeypatch, tmp_path, pipx_temp_env): 93 | pipx_venvs_dir = paths.ctx.home / "venvs" 94 | 95 | run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) 96 | assert (pipx_venvs_dir / "pycowsay" / "pipx_metadata.json").is_file() 97 | 98 | pipx_metadata = PipxMetadata(pipx_venvs_dir / "pycowsay") 99 | pycowsay_package_ref = create_package_info_ref("pycowsay", "pycowsay", pipx_venvs_dir) 100 | assert_package_metadata(pipx_metadata.main_package, pycowsay_package_ref) 101 | assert pipx_metadata.injected_packages == {} 102 | 103 | 104 | def test_package_inject(monkeypatch, tmp_path, pipx_temp_env): 105 | pipx_venvs_dir = paths.ctx.home / "venvs" 106 | 107 | run_pipx_cli(["install", PKG["pycowsay"]["spec"]]) 108 | run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) 109 | 110 | assert (pipx_venvs_dir / "pycowsay" / "pipx_metadata.json").is_file() 111 | pipx_metadata = PipxMetadata(pipx_venvs_dir / "pycowsay") 112 | 113 | assert pipx_metadata.injected_packages.keys() == {"black"} 114 | black_package_ref = create_package_info_ref("pycowsay", "black", pipx_venvs_dir, include_apps=False) 115 | assert_package_metadata(pipx_metadata.injected_packages["black"], black_package_ref) 116 | -------------------------------------------------------------------------------- /tests/test_reinstall.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest # type: ignore[import-not-found] 4 | 5 | from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli, skip_if_windows 6 | 7 | 8 | def test_reinstall(pipx_temp_env, capsys): 9 | assert not run_pipx_cli(["install", "pycowsay"]) 10 | assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) 11 | 12 | 13 | @skip_if_windows 14 | def test_reinstall_global(pipx_temp_env, capsys): 15 | assert not run_pipx_cli(["install", "--global", "pycowsay"]) 16 | assert not run_pipx_cli(["reinstall", "--global", "--python", sys.executable, "pycowsay"]) 17 | 18 | 19 | def test_reinstall_nonexistent(pipx_temp_env, capsys): 20 | assert run_pipx_cli(["reinstall", "--python", sys.executable, "nonexistent"]) 21 | assert "Nothing to reinstall for nonexistent" in capsys.readouterr().out 22 | 23 | 24 | @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) 25 | def test_reinstall_legacy_venv(pipx_temp_env, capsys, metadata_version): 26 | assert not run_pipx_cli(["install", "pycowsay"]) 27 | mock_legacy_venv("pycowsay", metadata_version=metadata_version) 28 | 29 | assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pycowsay"]) 30 | 31 | 32 | def test_reinstall_suffix(pipx_temp_env, capsys): 33 | suffix = "_x" 34 | assert not run_pipx_cli(["install", "pycowsay", f"--suffix={suffix}"]) 35 | 36 | assert not run_pipx_cli(["reinstall", "--python", sys.executable, f"pycowsay{suffix}"]) 37 | 38 | 39 | @pytest.mark.parametrize("metadata_version", ["0.1"]) 40 | def test_reinstall_suffix_legacy_venv(pipx_temp_env, capsys, metadata_version): 41 | suffix = "_x" 42 | assert not run_pipx_cli(["install", "pycowsay", f"--suffix={suffix}"]) 43 | mock_legacy_venv(f"pycowsay{suffix}", metadata_version=metadata_version) 44 | 45 | assert not run_pipx_cli(["reinstall", "--python", sys.executable, f"pycowsay{suffix}"]) 46 | 47 | 48 | def test_reinstall_specifier(pipx_temp_env, capsys): 49 | assert not run_pipx_cli(["install", "pylint==3.0.4"]) 50 | 51 | # clear capsys before reinstall 52 | captured = capsys.readouterr() 53 | 54 | assert not run_pipx_cli(["reinstall", "--python", sys.executable, "pylint"]) 55 | captured = capsys.readouterr() 56 | assert "installed package pylint 3.0.4" in captured.out 57 | 58 | 59 | def test_reinstall_with_path(pipx_temp_env, capsys, tmp_path): 60 | path = tmp_path / "some" / "path" 61 | 62 | assert run_pipx_cli(["reinstall", str(path)]) 63 | captured = capsys.readouterr() 64 | 65 | assert "Expected the name of an installed package" in captured.err.replace("\n", " ") 66 | 67 | assert run_pipx_cli(["reinstall", str(path.resolve())]) 68 | captured = capsys.readouterr() 69 | 70 | assert "Expected the name of an installed package" in captured.err.replace("\n", " ") 71 | -------------------------------------------------------------------------------- /tests/test_reinstall_all.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest # type: ignore[import-not-found] 4 | 5 | from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli 6 | from pipx import shared_libs 7 | 8 | 9 | def test_reinstall_all(pipx_temp_env, capsys): 10 | assert not run_pipx_cli(["install", "pycowsay"]) 11 | assert not run_pipx_cli(["reinstall-all", "--python", sys.executable]) 12 | 13 | 14 | def test_reinstall_all_none(pipx_temp_env, capsys): 15 | assert not run_pipx_cli(["reinstall-all"]) 16 | captured = capsys.readouterr() 17 | assert "No packages reinstalled after running 'pipx reinstall-all'" in captured.out 18 | 19 | 20 | @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) 21 | def test_reinstall_all_legacy_venv(pipx_temp_env, capsys, metadata_version): 22 | assert not run_pipx_cli(["install", "pycowsay"]) 23 | mock_legacy_venv("pycowsay", metadata_version=metadata_version) 24 | 25 | assert not run_pipx_cli(["reinstall-all", "--python", sys.executable]) 26 | 27 | 28 | def test_reinstall_all_suffix(pipx_temp_env, capsys): 29 | suffix = "_x" 30 | assert not run_pipx_cli(["install", "pycowsay", f"--suffix={suffix}"]) 31 | 32 | assert not run_pipx_cli(["reinstall-all", "--python", sys.executable]) 33 | 34 | 35 | @pytest.mark.parametrize("metadata_version", ["0.1"]) 36 | def test_reinstall_all_suffix_legacy_venv(pipx_temp_env, capsys, metadata_version): 37 | suffix = "_x" 38 | assert not run_pipx_cli(["install", "pycowsay", f"--suffix={suffix}"]) 39 | mock_legacy_venv(f"pycowsay{suffix}", metadata_version=metadata_version) 40 | 41 | assert not run_pipx_cli(["reinstall-all", "--python", sys.executable]) 42 | 43 | 44 | def test_reinstall_all_triggers_shared_libs_upgrade(pipx_temp_env, caplog, capsys): 45 | assert not run_pipx_cli(["install", "pycowsay"]) 46 | 47 | shared_libs.shared_libs.has_been_updated_this_run = False 48 | caplog.clear() 49 | 50 | assert not run_pipx_cli(["reinstall-all"]) 51 | assert "Upgrading shared libraries in" in caplog.text 52 | -------------------------------------------------------------------------------- /tests/test_runpip.py: -------------------------------------------------------------------------------- 1 | from helpers import run_pipx_cli, skip_if_windows 2 | 3 | 4 | def test_runpip(pipx_temp_env, monkeypatch, capsys): 5 | assert not run_pipx_cli(["install", "pycowsay"]) 6 | assert not run_pipx_cli(["runpip", "pycowsay", "list"]) 7 | 8 | 9 | @skip_if_windows 10 | def test_runpip_global(pipx_temp_env, monkeypatch, capsys): 11 | assert not run_pipx_cli(["install", "--global", "pycowsay"]) 12 | assert not run_pipx_cli(["runpip", "--global", "pycowsay", "list"]) 13 | -------------------------------------------------------------------------------- /tests/test_shared_libs.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | import pytest # type: ignore[import-not-found] 5 | 6 | from pipx import shared_libs 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "mtime_minus_now,needs_upgrade", 11 | [ 12 | (-shared_libs.SHARED_LIBS_MAX_AGE_SEC - 5 * 60, True), 13 | (-shared_libs.SHARED_LIBS_MAX_AGE_SEC + 5 * 60, False), 14 | ], 15 | ) 16 | def test_auto_update_shared_libs(capsys, pipx_ultra_temp_env, mtime_minus_now, needs_upgrade): 17 | now = time.time() 18 | shared_libs.shared_libs.create(verbose=True, pip_args=[]) 19 | shared_libs.shared_libs.has_been_updated_this_run = False 20 | 21 | access_time = now # this can be anything 22 | os.utime(shared_libs.shared_libs.pip_path, (access_time, mtime_minus_now + now)) 23 | 24 | assert shared_libs.shared_libs.needs_upgrade is needs_upgrade 25 | -------------------------------------------------------------------------------- /tests/test_standalone_interpreter.py: -------------------------------------------------------------------------------- 1 | import json 2 | import shutil 3 | import sys 4 | 5 | from helpers import ( 6 | run_pipx_cli, 7 | ) 8 | from package_info import PKG 9 | from pipx import standalone_python 10 | 11 | MAJOR_PYTHON_VERSION = sys.version_info.major 12 | MINOR_PYTHON_VERSION = sys.version_info.minor 13 | TARGET_PYTHON_VERSION = f"{MAJOR_PYTHON_VERSION}.{MINOR_PYTHON_VERSION}" 14 | 15 | original_which = shutil.which 16 | 17 | 18 | def mock_which(name): 19 | if name == TARGET_PYTHON_VERSION: 20 | return None 21 | return original_which(name) 22 | 23 | 24 | def test_list_no_standalone_interpreters(pipx_temp_env, monkeypatch, capsys): 25 | assert not run_pipx_cli(["interpreter", "list"]) 26 | 27 | captured = capsys.readouterr() 28 | assert "Standalone interpreters" in captured.out 29 | assert len(captured.out.splitlines()) == 1 30 | 31 | 32 | def test_list_used_standalone_interpreters(pipx_temp_env, monkeypatch, mocked_github_api, capsys): 33 | monkeypatch.setattr(shutil, "which", mock_which) 34 | 35 | assert not run_pipx_cli( 36 | [ 37 | "install", 38 | "--fetch-missing-python", 39 | "--python", 40 | TARGET_PYTHON_VERSION, 41 | PKG["pycowsay"]["spec"], 42 | ] 43 | ) 44 | 45 | capsys.readouterr() 46 | assert not run_pipx_cli(["interpreter", "list"]) 47 | 48 | captured = capsys.readouterr() 49 | assert TARGET_PYTHON_VERSION in captured.out 50 | assert "pycowsay" in captured.out 51 | 52 | 53 | def test_list_unused_standalone_interpreters(pipx_temp_env, monkeypatch, mocked_github_api, capsys): 54 | monkeypatch.setattr(shutil, "which", mock_which) 55 | 56 | assert not run_pipx_cli( 57 | [ 58 | "install", 59 | "--fetch-missing-python", 60 | "--python", 61 | TARGET_PYTHON_VERSION, 62 | PKG["pycowsay"]["spec"], 63 | ] 64 | ) 65 | 66 | assert not run_pipx_cli(["uninstall", "pycowsay"]) 67 | capsys.readouterr() 68 | assert not run_pipx_cli(["interpreter", "list"]) 69 | 70 | captured = capsys.readouterr() 71 | assert TARGET_PYTHON_VERSION in captured.out 72 | assert "pycowsay" not in captured.out 73 | assert "Unused" in captured.out 74 | 75 | 76 | def test_prune_unused_standalone_interpreters(pipx_temp_env, monkeypatch, mocked_github_api, capsys): 77 | monkeypatch.setattr(shutil, "which", mock_which) 78 | 79 | assert not run_pipx_cli( 80 | [ 81 | "install", 82 | "--fetch-missing-python", 83 | "--python", 84 | TARGET_PYTHON_VERSION, 85 | PKG["pycowsay"]["spec"], 86 | ] 87 | ) 88 | 89 | capsys.readouterr() 90 | assert not run_pipx_cli(["interpreter", "prune"]) 91 | captured = capsys.readouterr() 92 | assert "Nothing to remove" in captured.out 93 | 94 | assert not run_pipx_cli(["uninstall", "pycowsay"]) 95 | capsys.readouterr() 96 | 97 | assert not run_pipx_cli(["interpreter", "prune"]) 98 | captured = capsys.readouterr() 99 | assert "Successfully removed:" in captured.out 100 | assert f"- Python {TARGET_PYTHON_VERSION}" in captured.out 101 | 102 | assert not run_pipx_cli(["interpreter", "list"]) 103 | captured = capsys.readouterr() 104 | assert "Standalone interpreters" in captured.out 105 | assert len(captured.out.splitlines()) == 1 106 | 107 | assert not run_pipx_cli(["interpreter", "prune"]) 108 | captured = capsys.readouterr() 109 | assert "Nothing to remove" in captured.out 110 | 111 | 112 | def test_upgrade_standalone_interpreter(pipx_temp_env, root, monkeypatch, capsys): 113 | monkeypatch.setattr(shutil, "which", mock_which) 114 | 115 | with open(root / "testdata" / "standalone_python_index_20250317.json") as f: 116 | new_index = json.load(f) 117 | monkeypatch.setattr(standalone_python, "get_or_update_index", lambda _: new_index) 118 | 119 | assert not run_pipx_cli( 120 | [ 121 | "install", 122 | "--fetch-missing-python", 123 | "--python", 124 | TARGET_PYTHON_VERSION, 125 | PKG["pycowsay"]["spec"], 126 | ] 127 | ) 128 | 129 | with open(root / "testdata" / "standalone_python_index_20250409.json") as f: 130 | new_index = json.load(f) 131 | monkeypatch.setattr(standalone_python, "get_or_update_index", lambda _: new_index) 132 | 133 | assert not run_pipx_cli(["interpreter", "upgrade"]) 134 | 135 | 136 | def test_upgrade_standalone_interpreter_nothing_to_upgrade(pipx_temp_env, capsys, mocked_github_api): 137 | assert not run_pipx_cli(["interpreter", "upgrade"]) 138 | captured = capsys.readouterr() 139 | assert "Nothing to upgrade" in captured.out 140 | -------------------------------------------------------------------------------- /tests/test_uninject.py: -------------------------------------------------------------------------------- 1 | from helpers import run_pipx_cli, skip_if_windows 2 | from package_info import PKG 3 | 4 | 5 | def test_uninject_simple(pipx_temp_env, capsys): 6 | assert not run_pipx_cli(["install", "pycowsay"]) 7 | assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) 8 | assert not run_pipx_cli(["uninject", "pycowsay", "black"]) 9 | captured = capsys.readouterr() 10 | assert "Uninjected package black" in captured.out 11 | assert not run_pipx_cli(["list", "--include-injected"]) 12 | captured = capsys.readouterr() 13 | assert "black" not in captured.out 14 | 15 | 16 | @skip_if_windows 17 | def test_uninject_simple_global(pipx_temp_env, capsys): 18 | assert not run_pipx_cli(["install", "--global", "pycowsay"]) 19 | assert not run_pipx_cli(["inject", "--global", "pycowsay", PKG["black"]["spec"]]) 20 | assert not run_pipx_cli(["uninject", "--global", "pycowsay", "black"]) 21 | captured = capsys.readouterr() 22 | assert "Uninjected package black" in captured.out 23 | assert not run_pipx_cli(["list", "--global", "--include-injected"]) 24 | captured = capsys.readouterr() 25 | assert "black" not in captured.out 26 | 27 | 28 | def test_uninject_with_include_apps(pipx_temp_env, capsys, caplog): 29 | assert not run_pipx_cli(["install", "pycowsay"]) 30 | assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"], "--include-deps", "--include-apps"]) 31 | assert not run_pipx_cli(["uninject", "pycowsay", "black", "--verbose"]) 32 | assert "removed file" in caplog.text 33 | 34 | 35 | def test_uninject_leave_deps(pipx_temp_env, capsys, caplog): 36 | assert not run_pipx_cli(["install", "pycowsay"]) 37 | assert not run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]]) 38 | assert not run_pipx_cli(["uninject", "pycowsay", "black", "--leave-deps", "--verbose"]) 39 | captured = capsys.readouterr() 40 | assert "Uninjected package black from venv pycowsay" in captured.out 41 | assert "Dependencies of uninstalled package:" not in caplog.text 42 | -------------------------------------------------------------------------------- /tests/test_uninstall_all.py: -------------------------------------------------------------------------------- 1 | import pytest # type: ignore[import-not-found] 2 | 3 | from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli 4 | 5 | 6 | def test_uninstall_all(pipx_temp_env, capsys): 7 | assert not run_pipx_cli(["install", "pycowsay"]) 8 | assert not run_pipx_cli(["uninstall-all"]) 9 | 10 | 11 | @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) 12 | def test_uninstall_all_legacy_venv(pipx_temp_env, capsys, metadata_version): 13 | assert not run_pipx_cli(["install", "pycowsay"]) 14 | mock_legacy_venv("pycowsay", metadata_version=metadata_version) 15 | assert not run_pipx_cli(["uninstall-all"]) 16 | -------------------------------------------------------------------------------- /tests/test_unpin.py: -------------------------------------------------------------------------------- 1 | from helpers import run_pipx_cli 2 | from package_info import PKG 3 | 4 | 5 | def test_unpin(capsys, pipx_temp_env, caplog): 6 | assert not run_pipx_cli(["install", PKG["nox"]["spec"]]) 7 | assert not run_pipx_cli(["pin", "nox"]) 8 | 9 | assert not run_pipx_cli(["unpin", "nox"]) 10 | assert not run_pipx_cli(["upgrade", "nox"]) 11 | 12 | captured = capsys.readouterr() 13 | assert "nox is already at latest version" in captured.out 14 | 15 | 16 | def test_unpin_with_suffix(capsys, pipx_temp_env): 17 | assert not run_pipx_cli(["install", PKG["black"]["spec"], "--suffix", "@1"]) 18 | assert not run_pipx_cli(["pin", "black@1"]) 19 | assert not run_pipx_cli(["unpin", "black@1"]) 20 | 21 | captured = capsys.readouterr() 22 | assert "Unpinned 1 packages in venv black@1" in captured.out 23 | 24 | assert not run_pipx_cli(["upgrade", "black@1"]) 25 | 26 | captured = capsys.readouterr() 27 | assert "upgraded package black@1 from 22.8.0 to 22.10.0" in captured.out 28 | 29 | 30 | def test_unpin_warning(capsys, pipx_temp_env, caplog): 31 | assert not run_pipx_cli(["install", PKG["nox"]["spec"]]) 32 | assert not run_pipx_cli(["pin", "nox"]) 33 | assert not run_pipx_cli(["unpin", "nox"]) 34 | assert not run_pipx_cli(["unpin", "nox"]) 35 | 36 | assert "No packages to unpin in venv nox" in caplog.text 37 | 38 | 39 | def test_unpin_not_installed_package(capsys, pipx_temp_env): 40 | assert run_pipx_cli(["unpin", "abc"]) 41 | 42 | captured = capsys.readouterr() 43 | assert "Package abc is not installed" in captured.err 44 | 45 | 46 | def test_unpin_injected_packages(capsys, pipx_temp_env): 47 | assert not run_pipx_cli(["install", "black"]) 48 | assert not run_pipx_cli(["inject", "black", "nox", "pylint"]) 49 | assert not run_pipx_cli(["pin", "black"]) 50 | assert not run_pipx_cli(["unpin", "black"]) 51 | 52 | captured = capsys.readouterr() 53 | assert "Unpinned 3 packages in venv black" in captured.out 54 | -------------------------------------------------------------------------------- /tests/test_upgrade.py: -------------------------------------------------------------------------------- 1 | import pytest # type: ignore[import-not-found] 2 | 3 | from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli, skip_if_windows 4 | from package_info import PKG 5 | 6 | 7 | def test_upgrade(pipx_temp_env, capsys): 8 | assert run_pipx_cli(["upgrade", "pycowsay"]) 9 | captured = capsys.readouterr() 10 | assert "Package is not installed" in captured.err 11 | 12 | assert not run_pipx_cli(["install", "pycowsay"]) 13 | captured = capsys.readouterr() 14 | assert "installed package pycowsay" in captured.out 15 | 16 | assert not run_pipx_cli(["upgrade", "pycowsay"]) 17 | captured = capsys.readouterr() 18 | assert "pycowsay is already at latest version" in captured.out 19 | 20 | 21 | @skip_if_windows 22 | def test_upgrade_global(pipx_temp_env, capsys): 23 | assert run_pipx_cli(["upgrade", "--global", "pycowsay"]) 24 | captured = capsys.readouterr() 25 | assert "Package is not installed" in captured.err 26 | 27 | assert not run_pipx_cli(["install", "--global", "pycowsay"]) 28 | captured = capsys.readouterr() 29 | assert "installed package pycowsay" in captured.out 30 | 31 | assert not run_pipx_cli(["upgrade", "--global", "pycowsay"]) 32 | captured = capsys.readouterr() 33 | assert "pycowsay is already at latest version" in captured.out 34 | 35 | 36 | @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) 37 | def test_upgrade_legacy_venv(pipx_temp_env, capsys, metadata_version): 38 | assert not run_pipx_cli(["install", "pycowsay"]) 39 | mock_legacy_venv("pycowsay", metadata_version=metadata_version) 40 | captured = capsys.readouterr() 41 | if metadata_version is None: 42 | assert run_pipx_cli(["upgrade", "pycowsay"]) 43 | captured = capsys.readouterr() 44 | assert "Not upgrading pycowsay. It has missing internal pipx metadata." in captured.err 45 | else: 46 | assert not run_pipx_cli(["upgrade", "pycowsay"]) 47 | captured = capsys.readouterr() 48 | 49 | 50 | def test_upgrade_suffix(pipx_temp_env, capsys): 51 | name = "pycowsay" 52 | suffix = "_a" 53 | 54 | assert not run_pipx_cli(["install", name, f"--suffix={suffix}"]) 55 | assert run_pipx_cli(["upgrade", f"{name}"]) 56 | assert not run_pipx_cli(["upgrade", f"{name}{suffix}"]) 57 | 58 | 59 | @pytest.mark.parametrize("metadata_version", ["0.1"]) 60 | def test_upgrade_suffix_legacy_venv(pipx_temp_env, capsys, metadata_version): 61 | name = "pycowsay" 62 | suffix = "_a" 63 | 64 | assert not run_pipx_cli(["install", name, f"--suffix={suffix}"]) 65 | mock_legacy_venv(f"{name}{suffix}", metadata_version=metadata_version) 66 | assert run_pipx_cli(["upgrade", f"{name}"]) 67 | assert not run_pipx_cli(["upgrade", f"{name}{suffix}"]) 68 | 69 | 70 | def test_upgrade_specifier(pipx_temp_env, capsys): 71 | name = "pylint" 72 | pkg_spec = PKG[name]["spec"] 73 | initial_version = pkg_spec.split("==")[-1] 74 | 75 | assert not run_pipx_cli(["install", f"{pkg_spec}"]) 76 | assert not run_pipx_cli(["upgrade", f"{name}"]) 77 | captured = capsys.readouterr() 78 | assert f"upgraded package {name} from {initial_version} to" in captured.out 79 | 80 | 81 | def test_upgrade_editable(pipx_temp_env, capsys, root): 82 | empty_project_path_as_string = (root / "testdata" / "empty_project").as_posix() 83 | assert not run_pipx_cli(["install", "--editable", empty_project_path_as_string, "--force"]) 84 | assert not run_pipx_cli(["upgrade", "--editable", "empty_project"]) 85 | captured = capsys.readouterr() 86 | assert "empty-project is already at latest version" in captured.out 87 | 88 | 89 | def test_upgrade_include_injected(pipx_temp_env, capsys): 90 | assert not run_pipx_cli(["install", PKG["pylint"]["spec"]]) 91 | assert not run_pipx_cli(["inject", "pylint", PKG["black"]["spec"]]) 92 | captured = capsys.readouterr() 93 | assert not run_pipx_cli(["upgrade", "--include-injected", "pylint"]) 94 | captured = capsys.readouterr() 95 | assert "upgraded package pylint" in captured.out 96 | assert "upgraded package black" in captured.out 97 | 98 | 99 | def test_upgrade_no_include_injected(pipx_temp_env, capsys): 100 | assert not run_pipx_cli(["install", PKG["pylint"]["spec"]]) 101 | assert not run_pipx_cli(["inject", "pylint", PKG["black"]["spec"]]) 102 | captured = capsys.readouterr() 103 | assert not run_pipx_cli(["upgrade", "pylint"]) 104 | captured = capsys.readouterr() 105 | assert "upgraded package pylint" in captured.out 106 | assert "upgraded package black" not in captured.out 107 | 108 | 109 | def test_upgrade_install_missing(pipx_temp_env, capsys): 110 | assert not run_pipx_cli(["upgrade", "pycowsay", "--install"]) 111 | captured = capsys.readouterr() 112 | assert "installed package pycowsay" in captured.out 113 | 114 | 115 | def test_upgrade_multiple(pipx_temp_env, capsys): 116 | name = "pylint" 117 | pkg_spec = PKG[name]["spec"] 118 | initial_version = pkg_spec.split("==")[-1] 119 | assert not run_pipx_cli(["install", pkg_spec]) 120 | 121 | assert not run_pipx_cli(["install", "pycowsay"]) 122 | 123 | assert not run_pipx_cli(["upgrade", name, "pycowsay"]) 124 | captured = capsys.readouterr() 125 | assert f"upgraded package {name} from {initial_version} to" in captured.out 126 | assert "pycowsay is already at latest version" in captured.out 127 | 128 | 129 | def test_upgrade_absolute_path(pipx_temp_env, capsys, root): 130 | assert run_pipx_cli(["upgrade", "--verbose", str((root / "testdata" / "empty_project").resolve())]) 131 | captured = capsys.readouterr() 132 | assert "Package cannot be a URL" not in captured.err 133 | -------------------------------------------------------------------------------- /tests/test_upgrade_all.py: -------------------------------------------------------------------------------- 1 | import pytest # type: ignore[import-not-found] 2 | 3 | from helpers import PIPX_METADATA_LEGACY_VERSIONS, mock_legacy_venv, run_pipx_cli 4 | 5 | 6 | def test_upgrade_all(pipx_temp_env, capsys): 7 | assert run_pipx_cli(["upgrade", "pycowsay"]) 8 | assert not run_pipx_cli(["install", "pycowsay"]) 9 | assert not run_pipx_cli(["upgrade-all"]) 10 | 11 | 12 | def test_upgrade_all_none(pipx_temp_env, capsys): 13 | assert not run_pipx_cli(["install", "pycowsay"]) 14 | assert not run_pipx_cli(["upgrade-all"]) 15 | captured = capsys.readouterr() 16 | assert "No packages upgraded after running 'pipx upgrade-all'" in captured.out 17 | 18 | 19 | @pytest.mark.parametrize("metadata_version", PIPX_METADATA_LEGACY_VERSIONS) 20 | def test_upgrade_all_legacy_venv(pipx_temp_env, capsys, caplog, metadata_version): 21 | assert run_pipx_cli(["upgrade", "pycowsay"]) 22 | assert not run_pipx_cli(["install", "pycowsay"]) 23 | mock_legacy_venv("pycowsay", metadata_version=metadata_version) 24 | if metadata_version is None: 25 | capsys.readouterr() 26 | assert run_pipx_cli(["upgrade-all"]) 27 | assert "The following package(s) failed to upgrade: pycowsay" in caplog.text 28 | else: 29 | assert not run_pipx_cli(["upgrade-all"]) 30 | -------------------------------------------------------------------------------- /tests/test_upgrade_shared.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | import pytest # type: ignore[import-not-found] 4 | 5 | from helpers import run_pipx_cli 6 | 7 | 8 | @pytest.fixture 9 | def shared_libs(pipx_ultra_temp_env): 10 | # local import to get the shared_libs object patched by fixtures 11 | from pipx.shared_libs import shared_libs as _shared_libs 12 | 13 | yield _shared_libs 14 | 15 | 16 | def test_upgrade_shared(shared_libs, capsys, caplog): 17 | assert shared_libs.has_been_updated_this_run is False 18 | assert shared_libs.is_valid is False 19 | assert run_pipx_cli(["upgrade-shared", "-v"]) == 0 20 | captured = capsys.readouterr() 21 | assert "creating shared libraries" in captured.err 22 | assert "upgrading shared libraries" in captured.err 23 | assert "Upgrading shared libraries in" in caplog.text 24 | assert "Already upgraded libraries in" not in caplog.text 25 | assert shared_libs.has_been_updated_this_run is True 26 | assert shared_libs.is_valid is True 27 | shared_libs.has_been_updated_this_run = False 28 | assert run_pipx_cli(["upgrade-shared", "-v"]) == 0 29 | captured = capsys.readouterr() 30 | assert "creating shared libraries" not in captured.err 31 | assert "upgrading shared libraries" in captured.err 32 | assert "Upgrading shared libraries in" in caplog.text 33 | assert "Already upgraded libraries in" not in caplog.text 34 | assert shared_libs.has_been_updated_this_run is True 35 | assert run_pipx_cli(["upgrade-shared", "-v"]) == 0 36 | assert "Already upgraded libraries in" in caplog.text 37 | 38 | 39 | def test_upgrade_shared_pip_args(shared_libs, capsys, caplog): 40 | assert shared_libs.has_been_updated_this_run is False 41 | assert shared_libs.is_valid is False 42 | assert run_pipx_cli(["upgrade-shared", "-v", "--pip-args='--no-index'"]) == 1 43 | captured = capsys.readouterr() 44 | assert "creating shared libraries" in captured.err 45 | assert "upgrading shared libraries" in captured.err 46 | assert "Upgrading shared libraries in" in caplog.text 47 | assert "Already upgraded libraries in" not in caplog.text 48 | assert shared_libs.has_been_updated_this_run is False 49 | assert shared_libs.is_valid is True 50 | 51 | 52 | def test_upgrade_shared_pin_pip(shared_libs): 53 | def pip_version(): 54 | cmd = "from importlib.metadata import version; print(version('pip'))" 55 | ret = subprocess.run([shared_libs.python_path, "-c", cmd], check=True, capture_output=True, text=True) 56 | return ret.stdout.strip() 57 | 58 | assert shared_libs.has_been_updated_this_run is False 59 | assert shared_libs.is_valid is False 60 | assert run_pipx_cli(["upgrade-shared", "-v", "--pip-args=pip==24.0"]) == 0 61 | assert shared_libs.is_valid is True 62 | assert pip_version() == "24.0" 63 | shared_libs.has_been_updated_this_run = False # reset for next run 64 | assert run_pipx_cli(["upgrade-shared", "-v", "--pip-args=pip==23.3.2"]) == 0 65 | assert shared_libs.is_valid is True 66 | assert pip_version() == "23.3.2" 67 | --------------------------------------------------------------------------------