├── .github ├── dependabot.yml ├── pipask-demo.gif └── workflows │ ├── integration-tests.yaml │ ├── release.yaml │ ├── repo-scan.yaml │ └── tests.yaml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── poetry.lock ├── pyproject.toml ├── run-checks.sh ├── src ├── __init__.py └── pipask │ ├── __init__.py │ ├── _vendor │ ├── __init__.py │ └── pip │ │ ├── AUTHORS.txt │ │ ├── LICENSE.txt │ │ ├── README.rst │ │ ├── SECURITY.md │ │ ├── __init__.py │ │ ├── _internal │ │ ├── __init__.py │ │ ├── build_env.py │ │ ├── cache.py │ │ ├── cli │ │ │ ├── __init__.py │ │ │ ├── base_command.py │ │ │ ├── cmdoptions.py │ │ │ ├── command_context.py │ │ │ ├── main_parser.py │ │ │ ├── parser.py │ │ │ ├── progress_bars.py │ │ │ ├── req_command.py │ │ │ ├── spinners.py │ │ │ └── status_codes.py │ │ ├── commands │ │ │ ├── __init__.py │ │ │ └── install.py │ │ ├── configuration.py │ │ ├── distributions │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── installed.py │ │ │ ├── sdist.py │ │ │ └── wheel.py │ │ ├── exceptions.py │ │ ├── index │ │ │ ├── __init__.py │ │ │ ├── collector.py │ │ │ ├── package_finder.py │ │ │ └── sources.py │ │ ├── locations │ │ │ ├── __init__.py │ │ │ ├── _distutils.py │ │ │ ├── _sysconfig.py │ │ │ └── base.py │ │ ├── metadata │ │ │ ├── __init__.py │ │ │ ├── _json.py │ │ │ ├── base.py │ │ │ ├── importlib │ │ │ │ ├── __init__.py │ │ │ │ ├── _compat.py │ │ │ │ ├── _dists.py │ │ │ │ └── _envs.py │ │ │ └── pkg_resources.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── candidate.py │ │ │ ├── direct_url.py │ │ │ ├── format_control.py │ │ │ ├── index.py │ │ │ ├── installation_report.py │ │ │ ├── link.py │ │ │ ├── scheme.py │ │ │ ├── search_scope.py │ │ │ ├── selection_prefs.py │ │ │ ├── target_python.py │ │ │ └── wheel.py │ │ ├── network │ │ │ ├── __init__.py │ │ │ ├── auth.py │ │ │ ├── cache.py │ │ │ ├── download.py │ │ │ ├── lazy_wheel.py │ │ │ ├── session.py │ │ │ └── utils.py │ │ ├── operations │ │ │ ├── __init__.py │ │ │ ├── build │ │ │ │ ├── __init__.py │ │ │ │ ├── build_tracker.py │ │ │ │ ├── metadata.py │ │ │ │ ├── metadata_editable.py │ │ │ │ ├── metadata_legacy.py │ │ │ │ ├── wheel.py │ │ │ │ ├── wheel_editable.py │ │ │ │ └── wheel_legacy.py │ │ │ ├── check.py │ │ │ └── prepare.py │ │ ├── pyproject.py │ │ ├── req │ │ │ ├── __init__.py │ │ │ ├── constructors.py │ │ │ ├── req_file.py │ │ │ ├── req_install.py │ │ │ ├── req_set.py │ │ │ └── req_uninstall.py │ │ ├── resolution │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── legacy │ │ │ │ ├── __init__.py │ │ │ │ └── resolver.py │ │ │ └── resolvelib │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ ├── candidates.py │ │ │ │ ├── factory.py │ │ │ │ ├── found_candidates.py │ │ │ │ ├── provider.py │ │ │ │ ├── reporter.py │ │ │ │ ├── requirements.py │ │ │ │ └── resolver.py │ │ ├── utils │ │ │ ├── __init__.py │ │ │ ├── _jaraco_text.py │ │ │ ├── _log.py │ │ │ ├── appdirs.py │ │ │ ├── compat.py │ │ │ ├── compatibility_tags.py │ │ │ ├── deprecation.py │ │ │ ├── direct_url_helpers.py │ │ │ ├── egg_link.py │ │ │ ├── encoding.py │ │ │ ├── filesystem.py │ │ │ ├── filetypes.py │ │ │ ├── glibc.py │ │ │ ├── hashes.py │ │ │ ├── logging.py │ │ │ ├── misc.py │ │ │ ├── models.py │ │ │ ├── packaging.py │ │ │ ├── setuptools_build.py │ │ │ ├── subprocess.py │ │ │ ├── temp_dir.py │ │ │ ├── unpacking.py │ │ │ ├── urls.py │ │ │ ├── virtualenv.py │ │ │ └── wheel.py │ │ └── vcs │ │ │ ├── __init__.py │ │ │ ├── bazaar.py │ │ │ ├── git.py │ │ │ ├── mercurial.py │ │ │ ├── subversion.py │ │ │ └── versioncontrol.py │ │ └── _vendor │ │ ├── pkg_resources │ │ ├── LICENSE │ │ └── __init__.py │ │ ├── typing_extensions.LICENSE │ │ ├── typing_extensions.py │ │ └── typing_extensions.pyi │ ├── checks │ ├── __init__.py │ ├── base_checker.py │ ├── checks_executor.py │ ├── license.py │ ├── package_age.py │ ├── package_downloads.py │ ├── release_metadata.py │ ├── repo_popularity.py │ ├── types.py │ └── vulnerabilities.py │ ├── cli_args.py │ ├── cli_helpers.py │ ├── code_execution_guard.py │ ├── exception.py │ ├── infra │ ├── executables.py │ ├── metadata.py │ ├── pip.py │ ├── pip_types.py │ ├── pypi.py │ ├── pypistats.py │ ├── repo_client.py │ ├── sys_values.py │ └── vulnerability_details.py │ ├── main.py │ ├── py.typed │ ├── report.py │ └── utils.py ├── tests ├── __init__.py ├── checks │ ├── test_license.py │ ├── test_package_age.py │ ├── test_package_downloads.py │ ├── test_release_metadata.py │ ├── test_repo_popularity.py │ └── test_vulnerabilities.py ├── conftest.py ├── infra │ ├── data │ │ ├── pyfluent_iterables-2.0.1-py3-none-any.whl │ │ ├── pyfluent_iterables-2.0.1.tar.gz │ │ └── test-package │ │ │ ├── module.py │ │ │ └── setup.py │ ├── test_metadata.py │ ├── test_pip.py │ ├── test_pypi.py │ ├── test_pypistats.py │ ├── test_repo_client.py │ ├── test_sys_values.py │ └── test_vulnerability_details.py └── test_main.py └── trivy-config.yaml /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/pipask-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/.github/pipask-demo.gif -------------------------------------------------------------------------------- /.github/workflows/integration-tests.yaml: -------------------------------------------------------------------------------- 1 | name: Integration Tests 2 | on: 3 | workflow_dispatch: # on demand: 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | tests: 9 | name: 'Integration tests' 10 | strategy: 11 | matrix: 12 | python_version: [ '3.10', '3.11', '3.12' ] #'3.13' ] 13 | os: [ ubuntu-latest, windows-latest ] 14 | runs-on: ${{ matrix.os }} 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python_version }} 23 | 24 | - name: Install dependencies 25 | run: | 26 | pipx install poetry 27 | poetry install --no-interaction 28 | 29 | - name: Run tests 30 | run: | 31 | poetry run pytest -vv --integration 32 | 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [ published ] 5 | 6 | jobs: 7 | tests: 8 | name: 'Tests & static checks' 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: '3.10' 20 | 21 | - name: Install dependencies 22 | run: | 23 | pipx install poetry 24 | poetry install --no-interaction 25 | 26 | - name: Run tests 27 | run: | 28 | poetry run pytest . 29 | 30 | - name: Run integration tests 31 | run: | 32 | poetry run pytest -m integration . 33 | 34 | - name: Run type check 35 | run: | 36 | poetry run pyright . 37 | 38 | - name: Run linters 39 | run: | 40 | poetry run ruff check . 41 | 42 | - name: Run bandit static analyzer 43 | run: | 44 | poetry run bandit -c pyproject.toml -r . 45 | 46 | build: 47 | name: 'Build' 48 | runs-on: ubuntu-latest 49 | permissions: 50 | contents: read 51 | steps: 52 | - name: Checkout code 53 | uses: actions/checkout@v4 54 | 55 | - name: Set up Python 56 | uses: actions/setup-python@v5 57 | with: 58 | python-version: '3.10' 59 | 60 | - name: Install dependencies 61 | run: | 62 | pipx install poetry 63 | poetry install --no-interaction 64 | 65 | - name: Build package 66 | run: poetry build 67 | 68 | - name: Upload artifact 69 | uses: actions/upload-artifact@v4 70 | with: 71 | name: python-package-distributions 72 | path: dist/ 73 | 74 | release: 75 | name: 'Release' 76 | runs-on: ubuntu-latest 77 | needs: 78 | - build 79 | - tests 80 | environment: 81 | name: pypi 82 | url: https://pypi.org/p/pipask 83 | permissions: 84 | id-token: write 85 | contents: write 86 | steps: 87 | - name: Download artifact 88 | uses: actions/download-artifact@v4 89 | with: 90 | name: python-package-distributions 91 | path: dist/ 92 | 93 | - name: Sign the dists with Sigstore 94 | uses: sigstore/gh-action-sigstore-python@v3.0.0 95 | with: 96 | inputs: >- 97 | ./dist/*.tar.gz 98 | ./dist/*.whl 99 | 100 | - name: Upload artifact signatures to GitHub Release 101 | if: github.event_name == 'release' 102 | env: 103 | GITHUB_TOKEN: ${{ github.token }} 104 | run: | 105 | # Commented out: although this comes from the pypa documentation, 106 | # it seems to conflict with the gh-action-sigstore-python action 107 | # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 108 | # gh release upload --repo '${{ github.repository }}' '${{ github.ref_name }}' dist/* 109 | 110 | # Remove the Sigstore signatures from the dist folder - they would not be accepted by PyPI 111 | rm dist/*.sigstore.json 112 | 113 | - name: Publish distribution 📦 to PyPI 114 | uses: pypa/gh-action-pypi-publish@release/v1 115 | if: github.event_name == 'release' 116 | -------------------------------------------------------------------------------- /.github/workflows/repo-scan.yaml: -------------------------------------------------------------------------------- 1 | name: Vulnerability scan of the repo 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | repo-scan: 11 | name: 'Trivy repo scan' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Run Trivy vulnerability scanner 18 | uses: aquasecurity/trivy-action@master 19 | with: 20 | scan-type: 'fs' 21 | trivy-config: "trivy-config.yaml" 22 | env: 23 | # env variables seems to be a more reliable way to configure trivy than inputs 24 | TRIVY_FORMAT: table # we cannot use the GitHub-integrated sarif format without GitHub advanced security 25 | TRIVY_OUTPUT: trivy-report.txt 26 | TRIVY_EXIT_CODE: 1 27 | 28 | - shell: bash 29 | if: always() 30 | # Print result so that one doesn't need to download zip files to see the result 31 | run: | 32 | cat trivy-report.txt 33 | 34 | echo "Trivy report:" >> $GITHUB_STEP_SUMMARY 35 | echo '~~~' >> $GITHUB_STEP_SUMMARY 36 | cat trivy-report.txt >> $GITHUB_STEP_SUMMARY 37 | echo '~~~' >> $GITHUB_STEP_SUMMARY 38 | 39 | if echo "$REPORT_WITHOUT_TABLES" | grep -E '(HIGH|CRITICAL): [1-9]' > /dev/null; then 40 | echo "::error::Found HIGH or CRITICAL vulnerabilities" 41 | fi 42 | 43 | - name: Upload report artifact 44 | uses: actions/upload-artifact@v4 45 | if: always() 46 | with: 47 | name: trivy-report.txt 48 | path: trivy-report.txt 49 | 50 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests & Static Checks 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | tests: 11 | name: 'Unit tests & static checks' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.10' 21 | 22 | - name: Install dependencies 23 | run: | 24 | pipx install poetry 25 | poetry install --no-interaction 26 | 27 | - name: Run tests 28 | run: | 29 | poetry run pytest . 30 | 31 | - name: Run type check 32 | run: | 33 | poetry run pyright . 34 | 35 | - name: Run linters 36 | run: | 37 | poetry run ruff check . 38 | 39 | - name: Run bandit static analyzer 40 | run: | 41 | poetry run bandit -c pyproject.toml -r . 42 | 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Other 132 | *.iml 133 | .idea/ 134 | 135 | # Project specific: 136 | !src/pipask/_vendor/pip/_internal/operations/build -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to pipask 2 | 3 | This document provides guidelines and instructions for contributing to the project. 4 | 5 | ## Development Setup 6 | 7 | 1. Ensure you have Python 3.10 or higher installed 8 | 2. Install [Poetry](https://python-poetry.org/docs/#installation) for dependency management 9 | 3. Clone the repository: 10 | ```bash 11 | git clone https://github.com/feynmanix/pipask.git 12 | cd pipask 13 | ``` 14 | 4. (If needed) activate the desired python version 15 | 5. Install dependencies: 16 | ```bash 17 | poetry install 18 | ``` 19 | 20 | 21 | ## Development Guidance 22 | 23 | - Write tests for new functionality 24 | - Run tests with `poetry run pytest` 25 | - Run **integration** tests with `poetry run pytest -m integration` (requires internet access) 26 | - Run static checks and formatting using `./run-checks.sh` 27 | 28 | ## Compatibility testing: 29 | 30 | To ensure pipask works across different environments, test various installation methods and platforms: 31 | 32 | | Installation Method of `pipask` | Execution environment | Unix | Windows | 33 | |---------------------------------|-----------------------|------|---------| 34 | | pipx | Global | ✅ | | 35 | | pipx | venv | ✅ | | 36 | | Global pip | Global | ✅ | | 37 | | Global pip | venv | ✅ | | 38 | | venv pip | venv | ✅ | | 39 | 40 | Other edge cases to consider (some may not be currently supported): 41 | - installing extras (e.g. `pipask install black[d]`) 42 | - installing from a requirements file (e.g. `pipask install -r requirements.txt`) 43 | - installing from 44 | - local directory (e.g. `pipask install .`) 45 | - git (e.g. `pipask install git+https://github.com/feynmanix/pipask.git`) 46 | - a file (e.g. `pipask install pipask-0.1.0-py3-none-any.whl`) 47 | - alternative package repository (e.g. `pipask install --index-url https://pypi.org/simple/ requests`) 48 | - editable installs 49 | - `#egg=name` syntax 50 | - installation flags 51 | - `--user` 52 | - `--upgrade` 53 | - `--force-reinstall` 54 | - installing an already installed package 55 | 56 | 57 | ## Releasing 58 | 1. Bump version before releasing with `bumpver`, e.g.: 59 | ```bash 60 | poetry run bumpver update --minor 61 | ``` 62 | 2. Push the created tag to the repository, e.g.: 63 | ```bash 64 | git push origin tag 1.0.0 65 | # OR 66 | # git push --tags 67 | ``` 68 | 2. Create a [new release](https://github.com/feynmanix/pipask/releases/new) with the newly created tag. The name of the release should correspond to the version number (e.g., `v1.0.0`). 69 | 70 | ## Reporting Issues 71 | 72 | When reporting issues, please include: 73 | - A clear description of the problem 74 | - Steps to reproduce 75 | - Expected vs actual behavior 76 | - Python version and environment details 77 | 78 | Feel free to open an issue for any questions or if you need help with your contribution. 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Feynmanix 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pipask: Know What You're Installing Before It's Too Late 2 | A safer way to install Python packages without compromising convenience. 3 | ![pipask-demo](https://github.com/feynmanix/pipask/blob/main/.github/pipask-demo.gif?raw=true) 4 | 5 | Pipask is a drop-in replacement for pip that performs security checks before installing a package. 6 | Unlike `pip`, which needs to download and execute code from source distribution first to get dependency metadata, 7 | pipask relies on metadata from PyPI whenever possible. If 3rd party code execution is necessary, pipask asks for consent first. 8 | The actual installation is handed over to `pip` if installation is approved. 9 | 10 | See the **[introductory blog post](https://medium.com/data-science-collective/pipask-know-what-youre-installing-before-it-s-too-late-2a6afce80987)** for more information. 11 | 12 | ## Installation 13 | 14 | The recommended way to install `pipask` is with [pipx](https://pipx.pypa.io/stable/#install-pipx) to isolate dependencies: 15 | ```bash 16 | pipx install pipask 17 | ``` 18 | 19 | Alternatively, you can install it using `pip`: 20 | ```bash 21 | pip install pipask 22 | ``` 23 | 24 | ## Usage 25 | 26 | Use `pipask` exactly as you would use `pip`: 27 | ```bash 28 | pipask install requests 29 | pipask install 'fastapi>=0.100.0' 30 | pipask install -r requirements.txt 31 | ``` 32 | 33 | For maximum convenience, alias pip to point to pipask: 34 | ```bash 35 | alias pip='pipask' 36 | ``` 37 | 38 | Add this to your shell configuration file (`~/.bashrc`, `~/.bash_profile`, `~/.zshrc`, etc.). You can always fall back to native pip with `python -m pip` if needed. 39 | 40 | To run checks without installing, use the `--dry-run` flag: 41 | ```bash 42 | pipask install requests --dry-run 43 | ``` 44 | 45 | ## Security Checks 46 | 47 | Pipask performs these checks before allowing installation: 48 | 49 | * **Repository popularity** - verification of links from PyPI to repositories, number of stars on GitHub or GitLab source repo (warning below 1000 stars) 50 | * **Package and release age** - warning for new packages (less than 22 days old) or stale releases (older than 365 days) 51 | * **Known vulnerabilities** in the package available in PyPI (failure for HIGH or CRITICAL vulnerabilities, warning for MODERATE vulnerabilities) 52 | * **Number of downloads** from PyPI in the last month (warning below 1000 downloads) 53 | * **Metadata verification**: Checks for license availability, development status, and yanked packages 54 | 55 | All checks are executed for requested (i.e., explicitly specified) packages. Only the known vulnerabilities check is executed for transitive dependencies. 56 | 57 | ## How pipask works 58 | 59 | Under the hood, pipask: 60 | 61 | 1. Uses PyPI's JSON API to retrieve metadata without downloading or executing code 62 | 2. When code execution is unavoidable, asks for confirmation first 63 | 3. Collects security information from multiple sources: 64 | - Download statistics from pypistats.org 65 | - Repository popularity from GitHub or GitLab 66 | - Vulnerability details from OSV.dev 67 | - Attestation metadata from PyPI integrity API 68 | 4. Presents a formatted report and asks for consent 69 | - _Tip: You may notice some parts of the report are underlined on supported terminals. These are hyperlinks you can open (e.g., with Cmd+click in iTerm)_ 70 | 6. Hands over to standard pip for the actual installation if approved 71 | 72 | ## Development 73 | See [CONTRIBUTING.md](https://github.com/feynmanix/pipask/blob/main/CONTRIBUTING.md) for development guidance. 74 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pipask" 3 | version = "0.9.6" 4 | description = "Safer python package installation with audit and consent before install" 5 | authors = [ 6 | { name = "Feynmanix", email = "feynmanix@users.noreply.github.com" }, 7 | ] 8 | readme = "README.md" 9 | requires-python = ">=3.10" 10 | keywords = ["pip", "security"] 11 | license = "MIT" 12 | dependencies = [ 13 | "pydantic (>=2.10.6,<3.0.0)", 14 | "httpx (>=0.28.1,<0.29.0)", 15 | "rich (>=13.9.4,<14.0.0)", 16 | "cvss (>=3.4,<4.0)", 17 | "packaging (>=21.3,<22.0)", # same major version as the corresponding fork of pip uses 18 | "platformdirs (>=3.8.1,<4.0.0)", # same major version as the corresponding fork of pip uses 19 | "urllib3 (>=1.26.17,<2.0.0)", # same major version as the corresponding fork of pip uses 20 | "requests (>=2.32.3,<3.0.0)", # same major version as the corresponding fork of pip uses 21 | "pyproject-hooks (>=1.2.0,<2.0.0)", # same major version as the corresponding fork of pip uses 22 | "tenacity (>=9.1.2,<10.0.0)", # same major version as the corresponding fork of pip uses 23 | "cachecontrol (>=0.14.2,<0.15.0)", # same major version as the corresponding fork of pip uses 24 | "resolvelib (>=1.1.0,<2.0.0)", # same major version as the corresponding fork of pip uses 25 | "tomli (>=2.2.1,<3.0.0)", # same major version as the corresponding fork of pip uses 26 | "truststore (>=0.10.1,<0.11.0)", # same major version as the corresponding fork of pip uses 27 | "distro (>=1.9.0,<2.0.0)", # same major version as the corresponding fork of pip uses 28 | ] 29 | classifiers = [ 30 | "License :: OSI Approved :: MIT License", 31 | "Intended Audience :: Developers", 32 | "Environment :: Console", 33 | "Topic :: Software Development", 34 | "Programming Language :: Python :: 3", 35 | "Programming Language :: Python :: 3.10", 36 | "Programming Language :: Python :: 3.11", 37 | "Programming Language :: Python :: 3.12", 38 | ] 39 | 40 | [project.urls] 41 | #homepage = "" 42 | repository = "https://github.com/feynmanix/pipask" 43 | documentation = "https://github.com/feynmanix/pipask/blob/main/README.md" 44 | 45 | [project.scripts] 46 | pipask = "pipask.main:main" 47 | 48 | [tool.poetry] 49 | packages = [{ include = "pipask", from = "src" }] 50 | 51 | [tool.poetry.group.dev.dependencies] 52 | pyright = {extras = ["nodejs"], version = "^1.1.401"} 53 | pytest = ">=8.3.4" 54 | bumpver = "^2024.1130" 55 | pytest-asyncio = ">=0.25.0" 56 | bandit = ">=1.8.0" 57 | ruff = ">=0.8.4" 58 | pypiserver = "==2.0.1" 59 | 60 | [build-system] 61 | requires = ["poetry-core>=2.0.0,<3.0.0"] 62 | build-backend = "poetry.core.masonry.api" 63 | 64 | [tool.bumpver] 65 | current_version = "0.9.6" 66 | version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]" 67 | commit_message = "bump version {old_version} -> {new_version}" 68 | commit = true 69 | tag = true 70 | push = false 71 | 72 | [tool.bumpver.file_patterns] 73 | "pyproject.toml" = [ 74 | 'current_version = "{version}"', 75 | 'version = "{version}"', 76 | ] 77 | "src/pipask/__init__.py" = ["{version}"] 78 | 79 | [tool.pyright] 80 | include = ["pipask"] 81 | exclude = [ 82 | "**/__pycache__", 83 | "**/tests", 84 | "src/pipask/_vendor" 85 | ] 86 | ignore = [] 87 | 88 | reportMissingImports = true 89 | reportMissingTypeStubs = false 90 | reportPrivateImportUsage = false 91 | pythonVersion = "3.10" 92 | 93 | [tool.pytest.ini_options] 94 | minversion = "8.0" 95 | addopts = "--import-mode=importlib -rA --color=yes" 96 | testpaths = ["tests"] 97 | log_level = "warning" 98 | 99 | # asyncio: 100 | asyncio_default_fixture_loop_scope = "function" 101 | asyncio_mode = "auto" 102 | 103 | markers = [ 104 | "integration: marks tests as integration tests" 105 | ] 106 | 107 | [tool.ruff] 108 | line-length = 120 109 | extend-exclude = ["src/pipask/_vendor"] 110 | 111 | [tool.bandit] 112 | exclude_dirs = ["tests", "src/pipask/_vendor"] 113 | skips = ["B404", "B603"] 114 | 115 | -------------------------------------------------------------------------------- /run-checks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | cd "$(dirname "${BASH_SOURCE[0]}")" 5 | 6 | echo -e "\n== Running pytest (tests) ==" 7 | poetry run pytest . 8 | 9 | echo -e "\n== Running pyright (type check) ==" 10 | poetry run pyright . 11 | 12 | echo -e "\n== Running ruff check (linter) ==" 13 | poetry run ruff check . 14 | 15 | echo -e "\n== Running ruff format (code formatter) ==" 16 | poetry run ruff format . 17 | 18 | echo -e "\n== Running bandit (static analyzer) ==" 19 | poetry run bandit -c pyproject.toml -r . 20 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/src/__init__.py -------------------------------------------------------------------------------- /src/pipask/__init__.py: -------------------------------------------------------------------------------- 1 | """pipask - Safer python package installations with audit and consent before install 2 | See https://github.com/feynmanix/pipask/blob/main/README.md for more information. 3 | """ 4 | 5 | # Version of the package 6 | __version__ = "0.9.6" 7 | -------------------------------------------------------------------------------- /src/pipask/_vendor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/src/pipask/_vendor/__init__.py -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008-present The pip developers (see AUTHORS.txt file) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/README.rst: -------------------------------------------------------------------------------- 1 | pip - The Python Package Installer 2 | ================================== 3 | 4 | .. image:: https://img.shields.io/pypi/v/pip.svg 5 | :target: https://pypi.org/project/pip/ 6 | :alt: PyPI 7 | 8 | .. image:: https://img.shields.io/pypi/pyversions/pip 9 | :target: https://pypi.org/project/pip 10 | :alt: PyPI - Python Version 11 | 12 | .. image:: https://readthedocs.org/projects/pip/badge/?version=latest 13 | :target: https://pip.pypa.io/en/latest 14 | :alt: Documentation 15 | 16 | pip is the `package installer`_ for Python. You can use pip to install packages from the `Python Package Index`_ and other indexes. 17 | 18 | Please take a look at our documentation for how to install and use pip: 19 | 20 | * `Installation`_ 21 | * `Usage`_ 22 | 23 | We release updates regularly, with a new version every 3 months. Find more details in our documentation: 24 | 25 | * `Release notes`_ 26 | * `Release process`_ 27 | 28 | If you find bugs, need help, or want to talk to the developers, please use our mailing lists or chat rooms: 29 | 30 | * `Issue tracking`_ 31 | * `Discourse channel`_ 32 | * `User IRC`_ 33 | 34 | If you want to get involved head over to GitHub to get the source code, look at our development documentation and feel free to jump on the developer mailing lists and chat rooms: 35 | 36 | * `GitHub page`_ 37 | * `Development documentation`_ 38 | * `Development IRC`_ 39 | 40 | Code of Conduct 41 | --------------- 42 | 43 | Everyone interacting in the pip project's codebases, issue trackers, chat 44 | rooms, and mailing lists is expected to follow the `PSF Code of Conduct`_. 45 | 46 | .. _package installer: https://packaging.python.org/guides/tool-recommendations/ 47 | .. _Python Package Index: https://pypi.org 48 | .. _Installation: https://pip.pypa.io/en/stable/installation/ 49 | .. _Usage: https://pip.pypa.io/en/stable/ 50 | .. _Release notes: https://pip.pypa.io/en/stable/news.html 51 | .. _Release process: https://pip.pypa.io/en/latest/development/release-process/ 52 | .. _GitHub page: https://github.com/pypa/pip 53 | .. _Development documentation: https://pip.pypa.io/en/latest/development 54 | .. _Issue tracking: https://github.com/pypa/pip/issues 55 | .. _Discourse channel: https://discuss.python.org/c/packaging 56 | .. _User IRC: https://kiwiirc.com/nextclient/#ircs://irc.libera.chat:+6697/pypa 57 | .. _Development IRC: https://kiwiirc.com/nextclient/#ircs://irc.libera.chat:+6697/pypa-dev 58 | .. _PSF Code of Conduct: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md 59 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please read the guidelines on reporting security issues [on the 6 | official website](https://www.python.org/dev/security/) for 7 | instructions on how to report a security-related problem to 8 | the Python Security Response Team responsibly. 9 | 10 | To reach the response team, email `security at python dot org`. 11 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "24.0" 2 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pipask._vendor.pip._internal.utils import _log 4 | 5 | # init_logging() must be called before any call to logging.getLogger() 6 | # which happens at import of most modules. 7 | _log.init_logging() 8 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """Subpackage containing all of pip's command line interface related code 2 | """ 3 | 4 | # This file intentionally does not import submodules 5 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/cli/command_context.py: -------------------------------------------------------------------------------- 1 | from contextlib import ExitStack, contextmanager 2 | from typing import ContextManager, Generator, TypeVar 3 | 4 | _T = TypeVar("_T", covariant=True) 5 | 6 | 7 | class CommandContextMixIn: 8 | def __init__(self) -> None: 9 | super().__init__() 10 | self._in_main_context = False 11 | self._main_context = ExitStack() 12 | 13 | @contextmanager 14 | def main_context(self) -> Generator[None, None, None]: 15 | assert not self._in_main_context 16 | 17 | self._in_main_context = True 18 | try: 19 | with self._main_context: 20 | yield 21 | finally: 22 | self._in_main_context = False 23 | 24 | def enter_context(self, context_provider: ContextManager[_T]) -> _T: 25 | assert self._in_main_context 26 | 27 | return self._main_context.enter_context(context_provider) 28 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/cli/main_parser.py: -------------------------------------------------------------------------------- 1 | """A single place for constructing and exposing the main parser 2 | """ 3 | 4 | import os 5 | import sys 6 | from typing import List, Optional, Tuple 7 | 8 | from pipask._vendor.pip._internal.cli import cmdoptions 9 | from pipask._vendor.pip._internal.cli.parser import ConfigOptionParser, UpdatingDefaultsHelpFormatter 10 | from pipask._vendor.pip._internal.commands import commands_dict, get_similar_commands 11 | from pipask._vendor.pip._internal.exceptions import CommandError 12 | from pipask._vendor.pip._internal.utils.misc import get_pip_version, get_prog 13 | 14 | __all__ = ["create_main_parser", "parse_command"] 15 | 16 | 17 | def create_main_parser() -> ConfigOptionParser: 18 | """Creates and returns the main parser for pip's CLI""" 19 | 20 | parser = ConfigOptionParser( 21 | usage="\n%prog [options]", 22 | add_help_option=False, 23 | formatter=UpdatingDefaultsHelpFormatter(), 24 | name="global", 25 | prog=get_prog(), 26 | ) 27 | parser.disable_interspersed_args() 28 | 29 | parser.version = get_pip_version() 30 | 31 | # add the general options 32 | gen_opts = cmdoptions.make_option_group(cmdoptions.general_group, parser) 33 | parser.add_option_group(gen_opts) 34 | 35 | # so the help formatter knows 36 | parser.main = True # type: ignore 37 | 38 | # create command listing for description 39 | description = [""] + [ 40 | f"{name:27} {command_info.summary}" 41 | for name, command_info in commands_dict.items() 42 | ] 43 | parser.description = "\n".join(description) 44 | 45 | return parser 46 | 47 | 48 | def identify_python_interpreter(python: str) -> Optional[str]: 49 | # If the named file exists, use it. 50 | # If it's a directory, assume it's a virtual environment and 51 | # look for the environment's Python executable. 52 | if os.path.exists(python): 53 | if os.path.isdir(python): 54 | # bin/python for Unix, Scripts/python.exe for Windows 55 | # Try both in case of odd cases like cygwin. 56 | for exe in ("bin/python", "Scripts/python.exe"): 57 | py = os.path.join(python, exe) 58 | if os.path.exists(py): 59 | return py 60 | else: 61 | return python 62 | 63 | # Could not find the interpreter specified 64 | return None 65 | 66 | 67 | def parse_command(args: List[str]) -> Tuple[str, List[str]]: 68 | parser = create_main_parser() 69 | 70 | # Note: parser calls disable_interspersed_args(), so the result of this 71 | # call is to split the initial args into the general options before the 72 | # subcommand and everything else. 73 | # For example: 74 | # args: ['--timeout=5', 'install', '--user', 'INITools'] 75 | # general_options: ['--timeout==5'] 76 | # args_else: ['install', '--user', 'INITools'] 77 | general_options, args_else = parser.parse_args(args) 78 | 79 | # MODIFIED for pipask: commented out this branch 80 | # --python 81 | # if general_options.python and "_PIP_RUNNING_IN_SUBPROCESS" not in os.environ: 82 | # # Re-invoke pip using the specified Python interpreter 83 | # interpreter = identify_python_interpreter(general_options.python) 84 | # if interpreter is None: 85 | # raise CommandError( 86 | # f"Could not locate Python interpreter {general_options.python}" 87 | # ) 88 | # 89 | # pip_cmd = [ 90 | # interpreter, 91 | # get_runnable_pip(), 92 | # ] 93 | # pip_cmd.extend(args) 94 | # 95 | # # Set a flag so the child doesn't re-invoke itself, causing 96 | # # an infinite loop. 97 | # os.environ["_PIP_RUNNING_IN_SUBPROCESS"] = "1" 98 | # returncode = 0 99 | # try: 100 | # proc = subprocess.run(pip_cmd) 101 | # returncode = proc.returncode 102 | # except (subprocess.SubprocessError, OSError) as exc: 103 | # raise CommandError(f"Failed to run pip under {interpreter}: {exc}") 104 | # sys.exit(returncode) 105 | 106 | # --version 107 | if general_options.version: 108 | sys.stdout.write(parser.version) 109 | sys.stdout.write(os.linesep) 110 | sys.exit() 111 | 112 | # pip || pip help -> print_help() 113 | if not args_else or (args_else[0] == "help" and len(args_else) == 1): 114 | parser.print_help() 115 | sys.exit() 116 | 117 | # the subcommand name 118 | cmd_name = args_else[0] 119 | 120 | if cmd_name not in commands_dict: 121 | guess = get_similar_commands(cmd_name) 122 | 123 | msg = [f'unknown command "{cmd_name}"'] 124 | if guess: 125 | msg.append(f'maybe you meant "{guess}"') 126 | 127 | raise CommandError(" - ".join(msg)) 128 | 129 | # all the args without the subcommand 130 | cmd_args = args[:] 131 | cmd_args.remove(cmd_name) 132 | 133 | return cmd_name, cmd_args 134 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/cli/progress_bars.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Callable, Generator, Iterable, Iterator, Optional, Tuple 3 | 4 | from rich.progress import ( 5 | BarColumn, 6 | DownloadColumn, 7 | FileSizeColumn, 8 | Progress, 9 | ProgressColumn, 10 | SpinnerColumn, 11 | TextColumn, 12 | TimeElapsedColumn, 13 | TimeRemainingColumn, 14 | TransferSpeedColumn, 15 | ) 16 | 17 | from pipask._vendor.pip._internal.utils.logging import get_indentation 18 | 19 | DownloadProgressRenderer = Callable[[Iterable[bytes]], Iterator[bytes]] 20 | 21 | 22 | def _rich_progress_bar( 23 | iterable: Iterable[bytes], 24 | *, 25 | bar_type: str, 26 | size: int, 27 | ) -> Generator[bytes, None, None]: 28 | assert bar_type == "on", "This should only be used in the default mode." 29 | 30 | if not size: 31 | total = float("inf") 32 | columns: Tuple[ProgressColumn, ...] = ( 33 | TextColumn("[progress.description]{task.description}"), 34 | SpinnerColumn("line", speed=1.5), 35 | FileSizeColumn(), 36 | TransferSpeedColumn(), 37 | TimeElapsedColumn(), 38 | ) 39 | else: 40 | total = size 41 | columns = ( 42 | TextColumn("[progress.description]{task.description}"), 43 | BarColumn(), 44 | DownloadColumn(), 45 | TransferSpeedColumn(), 46 | TextColumn("eta"), 47 | TimeRemainingColumn(), 48 | ) 49 | 50 | progress = Progress(*columns, refresh_per_second=30) 51 | task_id = progress.add_task(" " * (get_indentation() + 2), total=total) 52 | with progress: 53 | for chunk in iterable: 54 | yield chunk 55 | progress.update(task_id, advance=len(chunk)) 56 | 57 | 58 | def get_download_progress_renderer( 59 | *, bar_type: str, size: Optional[int] = None 60 | ) -> DownloadProgressRenderer: 61 | """Get an object that can be used to render the download progress. 62 | 63 | Returns a callable, that takes an iterable to "wrap". 64 | """ 65 | if bar_type == "on": 66 | return functools.partial(_rich_progress_bar, bar_type=bar_type, size=size) 67 | else: 68 | return iter # no-op, when passed an iterator 69 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/cli/status_codes.py: -------------------------------------------------------------------------------- 1 | SUCCESS = 0 2 | ERROR = 1 3 | UNKNOWN_ERROR = 2 4 | VIRTUALENV_NOT_FOUND = 3 5 | PREVIOUS_BUILD_DIR_ERROR = 4 6 | NO_MATCHES_FOUND = 23 7 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Package containing all pip commands 3 | """ 4 | 5 | from collections import namedtuple 6 | from typing import Any, Dict, Optional 7 | 8 | 9 | CommandInfo = namedtuple("CommandInfo", "module_path, class_name, summary") 10 | 11 | # This dictionary does a bunch of heavy lifting for help output: 12 | # - Enables avoiding additional (costly) imports for presenting `--help`. 13 | # - The ordering matters for help display. 14 | # 15 | # Even though the module path starts with the same "pip._internal.commands" 16 | # prefix, the full path makes testing easier (specifically when modifying 17 | # `commands_dict` in test setup / teardown). 18 | # MODIFIED for pipask - changed package path to pipask._vendor... 19 | commands_dict: Dict[str, CommandInfo] = { 20 | "install": CommandInfo( 21 | "pipask._vendor.pip._internal.commands.install", 22 | "InstallCommand", 23 | "Install packages.", 24 | ), 25 | "download": CommandInfo( 26 | "pipask._vendor.pip._internal.commands.download", 27 | "DownloadCommand", 28 | "Download packages.", 29 | ), 30 | "uninstall": CommandInfo( 31 | "pipask._vendor.pip._internal.commands.uninstall", 32 | "UninstallCommand", 33 | "Uninstall packages.", 34 | ), 35 | "freeze": CommandInfo( 36 | "pipask._vendor.pip._internal.commands.freeze", 37 | "FreezeCommand", 38 | "Output installed packages in requirements format.", 39 | ), 40 | "inspect": CommandInfo( 41 | "pipask._vendor.pip._internal.commands.inspect", 42 | "InspectCommand", 43 | "Inspect the python environment.", 44 | ), 45 | "list": CommandInfo( 46 | "pipask._vendor.pip._internal.commands.list", 47 | "ListCommand", 48 | "List installed packages.", 49 | ), 50 | "show": CommandInfo( 51 | "pipask._vendor.pip._internal.commands.show", 52 | "ShowCommand", 53 | "Show information about installed packages.", 54 | ), 55 | "check": CommandInfo( 56 | "pipask._vendor.pip._internal.commands.check", 57 | "CheckCommand", 58 | "Verify installed packages have compatible dependencies.", 59 | ), 60 | "config": CommandInfo( 61 | "pipask._vendor.pip._internal.commands.configuration", 62 | "ConfigurationCommand", 63 | "Manage local and global configuration.", 64 | ), 65 | "search": CommandInfo( 66 | "pipask._vendor.pip._internal.commands.search", 67 | "SearchCommand", 68 | "Search PyPI for packages.", 69 | ), 70 | "cache": CommandInfo( 71 | "pipask._vendor.pip._internal.commands.cache", 72 | "CacheCommand", 73 | "Inspect and manage pip's wheel cache.", 74 | ), 75 | "index": CommandInfo( 76 | "pipask._vendor.pip._internal.commands.index", 77 | "IndexCommand", 78 | "Inspect information available from package indexes.", 79 | ), 80 | "wheel": CommandInfo( 81 | "pipask._vendor.pip._internal.commands.wheel", 82 | "WheelCommand", 83 | "Build wheels from your requirements.", 84 | ), 85 | "hash": CommandInfo( 86 | "pipask._vendor.pip._internal.commands.hash", 87 | "HashCommand", 88 | "Compute hashes of package archives.", 89 | ), 90 | "completion": CommandInfo( 91 | "pipask._vendor.pip._internal.commands.completion", 92 | "CompletionCommand", 93 | "A helper command used for command completion.", 94 | ), 95 | "debug": CommandInfo( 96 | "pipask._vendor.pip._internal.commands.debug", 97 | "DebugCommand", 98 | "Show information useful for debugging.", 99 | ), 100 | "help": CommandInfo( 101 | "pipask._vendor.pip._internal.commands.help", 102 | "HelpCommand", 103 | "Show help for commands.", 104 | ), 105 | } 106 | 107 | 108 | def get_similar_commands(name: str) -> Optional[str]: 109 | """Command name auto-correct.""" 110 | from difflib import get_close_matches 111 | 112 | name = name.lower() 113 | 114 | close_commands = get_close_matches(name, commands_dict.keys()) 115 | 116 | if close_commands: 117 | return close_commands[0] 118 | else: 119 | return None 120 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/distributions/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pipask._vendor.pip._internal.distributions.base import AbstractDistribution 4 | from pipask._vendor.pip._internal.distributions.sdist import SourceDistribution 5 | from pipask._vendor.pip._internal.distributions.wheel import WheelDistribution 6 | from pipask._vendor.pip._internal.index.package_finder import PackageFinder 7 | from pipask._vendor.pip._internal.metadata import BaseDistribution 8 | from pipask._vendor.pip._internal.network.session import PipSession 9 | from pipask._vendor.pip._internal.req.req_install import InstallRequirement 10 | from pipask.infra.metadata import ( 11 | fetch_metadata_from_pypi_is_available, 12 | ) 13 | 14 | 15 | def make_distribution_for_install_requirement( 16 | install_req: InstallRequirement, 17 | session: PipSession, 18 | ) -> AbstractDistribution: 19 | """Returns a Distribution for the given InstallRequirement""" 20 | # Editable requirements will always be source distributions. They use the 21 | # legacy logic until we create a modern standard for them. 22 | if install_req.editable: 23 | return SourceDistribution(install_req) 24 | 25 | # If it's a wheel, it's a WheelDistribution 26 | if install_req.is_wheel: 27 | return WheelDistribution(install_req) 28 | 29 | # Otherwise, a SourceDistribution. 30 | # MODIFIED for pipask: We can do with metadata obtained from PyPI if available 31 | # instead of building the source distribution. 32 | if distribution := VirtualMetadataOnlyDistribution.create_if_metadata_available(install_req, session): 33 | return distribution 34 | return SourceDistribution(install_req) 35 | 36 | 37 | # MODIFIED for pipask: A new class that wraps around just the metadata 38 | class VirtualMetadataOnlyDistribution(AbstractDistribution): 39 | """Not a real distribution, but a wrapper around a metadata fetched from a remote index.""" 40 | 41 | def __init__(self, req: InstallRequirement, metadata_distribution: BaseDistribution) -> None: 42 | super().__init__(req) 43 | self._metadata_distribution = metadata_distribution 44 | 45 | @classmethod 46 | def create_if_metadata_available(cls, req: InstallRequirement, session: PipSession) -> Optional["VirtualMetadataOnlyDistribution"]: 47 | """Create a distribution if metadata is available""" 48 | metadata_distribution = fetch_metadata_from_pypi_is_available(req, session) 49 | if metadata_distribution is None: 50 | return None 51 | return cls(req, metadata_distribution) 52 | 53 | @property 54 | def build_tracker_id(self) -> str | None: 55 | return None 56 | 57 | def get_metadata_distribution(self) -> BaseDistribution: 58 | """Returns metadata from the memory""" 59 | return self._metadata_distribution 60 | 61 | def prepare_distribution_metadata( 62 | self, 63 | finder: PackageFinder, 64 | build_isolation: bool, 65 | check_build_deps: bool, 66 | ) -> None: 67 | pass 68 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/distributions/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Optional 3 | 4 | from pipask._vendor.pip._internal.index.package_finder import PackageFinder 5 | from pipask._vendor.pip._internal.metadata.base import BaseDistribution 6 | from pipask._vendor.pip._internal.req import InstallRequirement 7 | 8 | 9 | class AbstractDistribution(metaclass=abc.ABCMeta): 10 | """A base class for handling installable artifacts. 11 | 12 | The requirements for anything installable are as follows: 13 | 14 | - we must be able to determine the requirement name 15 | (or we can't correctly handle the non-upgrade case). 16 | 17 | - for packages with setup requirements, we must also be able 18 | to determine their requirements without installing additional 19 | packages (for the same reason as run-time dependencies) 20 | 21 | - we must be able to create a Distribution object exposing the 22 | above metadata. 23 | 24 | - if we need to do work in the build tracker, we must be able to generate a unique 25 | string to identify the requirement in the build tracker. 26 | """ 27 | 28 | def __init__(self, req: InstallRequirement) -> None: 29 | super().__init__() 30 | self.req = req 31 | 32 | @abc.abstractproperty 33 | def build_tracker_id(self) -> Optional[str]: 34 | """A string that uniquely identifies this requirement to the build tracker. 35 | 36 | If None, then this dist has no work to do in the build tracker, and 37 | ``.prepare_distribution_metadata()`` will not be called.""" 38 | raise NotImplementedError() 39 | 40 | @abc.abstractmethod 41 | def get_metadata_distribution(self) -> BaseDistribution: 42 | raise NotImplementedError() 43 | 44 | @abc.abstractmethod 45 | def prepare_distribution_metadata( 46 | self, 47 | finder: PackageFinder, 48 | build_isolation: bool, 49 | check_build_deps: bool, 50 | ) -> None: 51 | raise NotImplementedError() 52 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/distributions/installed.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pipask._vendor.pip._internal.distributions.base import AbstractDistribution 4 | from pipask._vendor.pip._internal.index.package_finder import PackageFinder 5 | from pipask._vendor.pip._internal.metadata import BaseDistribution 6 | 7 | 8 | class InstalledDistribution(AbstractDistribution): 9 | """Represents an installed package. 10 | 11 | This does not need any preparation as the required information has already 12 | been computed. 13 | """ 14 | 15 | @property 16 | def build_tracker_id(self) -> Optional[str]: 17 | return None 18 | 19 | def get_metadata_distribution(self) -> BaseDistribution: 20 | assert self.req.satisfied_by is not None, "not actually installed" 21 | return self.req.satisfied_by 22 | 23 | def prepare_distribution_metadata( 24 | self, 25 | finder: PackageFinder, 26 | build_isolation: bool, 27 | check_build_deps: bool, 28 | ) -> None: 29 | pass 30 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/distributions/wheel.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from packaging.utils import canonicalize_name 4 | 5 | from pipask._vendor.pip._internal.distributions.base import AbstractDistribution 6 | from pipask._vendor.pip._internal.index.package_finder import PackageFinder 7 | from pipask._vendor.pip._internal.metadata import ( 8 | BaseDistribution, 9 | FilesystemWheel, 10 | get_wheel_distribution, 11 | ) 12 | 13 | 14 | class WheelDistribution(AbstractDistribution): 15 | """Represents a wheel distribution. 16 | 17 | This does not need any preparation as wheels can be directly unpacked. 18 | """ 19 | 20 | @property 21 | def build_tracker_id(self) -> Optional[str]: 22 | return None 23 | 24 | def get_metadata_distribution(self) -> BaseDistribution: 25 | """Loads the metadata from the wheel file into memory and returns a 26 | Distribution that uses it, not relying on the wheel file or 27 | requirement. 28 | """ 29 | assert self.req.local_file_path, "Set as part of preparation during download" 30 | assert self.req.name, "Wheels are never unnamed" 31 | wheel = FilesystemWheel(self.req.local_file_path) 32 | return get_wheel_distribution(wheel, canonicalize_name(self.req.name)) 33 | 34 | def prepare_distribution_metadata( 35 | self, 36 | finder: PackageFinder, 37 | build_isolation: bool, 38 | check_build_deps: bool, 39 | ) -> None: 40 | pass 41 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/index/__init__.py: -------------------------------------------------------------------------------- 1 | """Index interaction code 2 | """ 3 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/locations/base.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import os 3 | import site 4 | import sys 5 | import sysconfig 6 | import typing 7 | 8 | from pipask._vendor.pip._internal.exceptions import InstallationError 9 | from pipask._vendor.pip._internal.utils import appdirs 10 | from pipask._vendor.pip._internal.utils.virtualenv import running_under_virtualenv 11 | from pipask.infra.sys_values import get_pip_sys_values 12 | 13 | # Application Directories 14 | USER_CACHE_DIR = appdirs.user_cache_dir("pip") 15 | 16 | # FIXME doesn't account for venv linked to global site-packages 17 | site_packages: str = sysconfig.get_path("purelib") 18 | 19 | 20 | def get_major_minor_version() -> str: 21 | """ 22 | Return the major-minor version of the current Python as a string, e.g. 23 | "3.7" or "3.10". 24 | """ 25 | return "{}.{}".format(*sys.version_info) 26 | 27 | 28 | def change_root(new_root: str, pathname: str) -> str: 29 | """Return 'pathname' with 'new_root' prepended. 30 | 31 | If 'pathname' is relative, this is equivalent to os.path.join(new_root, pathname). 32 | Otherwise, it requires making 'pathname' relative and then joining the 33 | two, which is tricky on DOS/Windows and Mac OS. 34 | 35 | This is borrowed from Python's standard library's distutils module. 36 | """ 37 | if os.name == "posix": 38 | if not os.path.isabs(pathname): 39 | return os.path.join(new_root, pathname) 40 | else: 41 | return os.path.join(new_root, pathname[1:]) 42 | 43 | elif os.name == "nt": 44 | (drive, path) = os.path.splitdrive(pathname) 45 | if path[0] == "\\": 46 | path = path[1:] 47 | return os.path.join(new_root, path) 48 | 49 | else: 50 | raise InstallationError( 51 | f"Unknown platform: {os.name}\n" 52 | "Can not change root path prefix on unknown platform." 53 | ) 54 | 55 | 56 | def get_src_prefix() -> str: 57 | if running_under_virtualenv(): 58 | src_prefix = os.path.join(get_pip_sys_values().prefix, "src") # MODIFIED for pipask 59 | else: 60 | # FIXME: keep src in cwd for now (it is not a temporary folder) 61 | try: 62 | src_prefix = os.path.join(os.getcwd(), "src") 63 | except OSError: 64 | # In case the current working directory has been renamed or deleted 65 | sys.exit("The folder you are executing pip from can no longer be found.") 66 | 67 | # under macOS + virtualenv sys.prefix is not properly resolved 68 | # it is something like /path/to/python/bin/.. 69 | return os.path.abspath(src_prefix) 70 | 71 | 72 | try: 73 | # Use getusersitepackages if this is present, as it ensures that the 74 | # value is initialised properly. 75 | user_site: typing.Optional[str] = site.getusersitepackages() 76 | except AttributeError: 77 | user_site = site.USER_SITE 78 | 79 | 80 | @functools.lru_cache(maxsize=None) 81 | def is_osx_framework() -> bool: 82 | return bool(sysconfig.get_config_var("PYTHONFRAMEWORK")) 83 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/metadata/__init__.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import functools 3 | import os 4 | import sys 5 | from typing import TYPE_CHECKING, List, Optional, Type, cast 6 | 7 | from pipask._vendor.pip._internal.utils.misc import strtobool 8 | 9 | from .base import BaseDistribution, BaseEnvironment, FilesystemWheel, MemoryWheel, Wheel 10 | 11 | if TYPE_CHECKING: 12 | from typing import Literal, Protocol 13 | else: 14 | Protocol = object 15 | 16 | __all__ = [ 17 | "BaseDistribution", 18 | "BaseEnvironment", 19 | "FilesystemWheel", 20 | "MemoryWheel", 21 | "Wheel", 22 | "get_default_environment", 23 | "get_environment", 24 | "get_wheel_distribution", 25 | "select_backend", 26 | ] 27 | 28 | 29 | def _should_use_importlib_metadata() -> bool: 30 | """Whether to use the ``importlib.metadata`` or ``pkg_resources`` backend. 31 | 32 | By default, pip uses ``importlib.metadata`` on Python 3.11+, and 33 | ``pkg_resourcess`` otherwise. This can be overridden by a couple of ways: 34 | 35 | * If environment variable ``_PIP_USE_IMPORTLIB_METADATA`` is set, it 36 | dictates whether ``importlib.metadata`` is used, regardless of Python 37 | version. 38 | * On Python 3.11+, Python distributors can patch ``importlib.metadata`` 39 | to add a global constant ``_PIP_USE_IMPORTLIB_METADATA = False``. This 40 | makes pip use ``pkg_resources`` (unless the user set the aforementioned 41 | environment variable to *True*). 42 | """ 43 | with contextlib.suppress(KeyError, ValueError): 44 | return bool(strtobool(os.environ["_PIP_USE_IMPORTLIB_METADATA"])) 45 | if sys.version_info < (3, 11): 46 | return False 47 | import importlib.metadata 48 | 49 | return bool(getattr(importlib.metadata, "_PIP_USE_IMPORTLIB_METADATA", True)) 50 | 51 | 52 | class Backend(Protocol): 53 | NAME: 'Literal["importlib", "pkg_resources"]' 54 | Distribution: Type[BaseDistribution] 55 | Environment: Type[BaseEnvironment] 56 | 57 | 58 | @functools.lru_cache(maxsize=None) 59 | def select_backend() -> Backend: 60 | if _should_use_importlib_metadata(): 61 | from . import importlib 62 | 63 | return cast(Backend, importlib) 64 | from . import pkg_resources 65 | 66 | return cast(Backend, pkg_resources) 67 | 68 | 69 | def get_default_environment() -> BaseEnvironment: 70 | """Get the default representation for the current environment. 71 | 72 | This returns an Environment instance from the chosen backend. The default 73 | Environment instance should be built from ``sys.path`` and may use caching 74 | to share instance state accorss calls. 75 | """ 76 | return select_backend().Environment.default() 77 | 78 | 79 | def get_environment(paths: Optional[List[str]]) -> BaseEnvironment: 80 | """Get a representation of the environment specified by ``paths``. 81 | 82 | This returns an Environment instance from the chosen backend based on the 83 | given import paths. The backend must build a fresh instance representing 84 | the state of installed distributions when this function is called. 85 | """ 86 | return select_backend().Environment.from_paths(paths) 87 | 88 | 89 | def get_directory_distribution(directory: str) -> BaseDistribution: 90 | """Get the distribution metadata representation in the specified directory. 91 | 92 | This returns a Distribution instance from the chosen backend based on 93 | the given on-disk ``.dist-info`` directory. 94 | """ 95 | return select_backend().Distribution.from_directory(directory) 96 | 97 | 98 | def get_wheel_distribution(wheel: Wheel, canonical_name: str) -> BaseDistribution: 99 | """Get the representation of the specified wheel's distribution metadata. 100 | 101 | This returns a Distribution instance from the chosen backend based on 102 | the given wheel's ``.dist-info`` directory. 103 | 104 | :param canonical_name: Normalized project name of the given wheel. 105 | """ 106 | return select_backend().Distribution.from_wheel(wheel, canonical_name) 107 | 108 | 109 | def get_metadata_distribution( 110 | metadata_contents: bytes, 111 | filename: Optional[str], # MODIFIED for pipask 112 | canonical_name: str, 113 | ) -> BaseDistribution: 114 | """Get the dist representation of the specified METADATA file contents. 115 | 116 | This returns a Distribution instance from the chosen backend sourced from the data 117 | in `metadata_contents`. 118 | 119 | :param metadata_contents: Contents of a METADATA file within a dist, or one served 120 | via PEP 658. 121 | :param filename: Filename for the dist this metadata represents. 122 | :param canonical_name: Normalized project name of the given dist. 123 | """ 124 | return select_backend().Distribution.from_metadata_file_contents( 125 | metadata_contents, 126 | filename, 127 | canonical_name, 128 | ) 129 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/metadata/_json.py: -------------------------------------------------------------------------------- 1 | # Extracted from https://github.com/pfmoore/pkg_metadata 2 | 3 | from email.header import Header, decode_header, make_header 4 | from email.message import Message 5 | from typing import Any, Dict, List, Union 6 | 7 | METADATA_FIELDS = [ 8 | # Name, Multiple-Use 9 | ("Metadata-Version", False), 10 | ("Name", False), 11 | ("Version", False), 12 | ("Dynamic", True), 13 | ("Platform", True), 14 | ("Supported-Platform", True), 15 | ("Summary", False), 16 | ("Description", False), 17 | ("Description-Content-Type", False), 18 | ("Keywords", False), 19 | ("Home-page", False), 20 | ("Download-URL", False), 21 | ("Author", False), 22 | ("Author-email", False), 23 | ("Maintainer", False), 24 | ("Maintainer-email", False), 25 | ("License", False), 26 | ("Classifier", True), 27 | ("Requires-Dist", True), 28 | ("Requires-Python", False), 29 | ("Requires-External", True), 30 | ("Project-URL", True), 31 | ("Provides-Extra", True), 32 | ("Provides-Dist", True), 33 | ("Obsoletes-Dist", True), 34 | ] 35 | 36 | 37 | def json_name(field: str) -> str: 38 | return field.lower().replace("-", "_") 39 | 40 | 41 | def msg_to_json(msg: Message) -> Dict[str, Any]: 42 | """Convert a Message object into a JSON-compatible dictionary.""" 43 | 44 | def sanitise_header(h: Union[Header, str]) -> str: 45 | if isinstance(h, Header): 46 | chunks = [] 47 | for bytes, encoding in decode_header(h): 48 | if encoding == "unknown-8bit": 49 | try: 50 | # See if UTF-8 works 51 | bytes.decode("utf-8") 52 | encoding = "utf-8" 53 | except UnicodeDecodeError: 54 | # If not, latin1 at least won't fail 55 | encoding = "latin1" 56 | chunks.append((bytes, encoding)) 57 | return str(make_header(chunks)) 58 | return str(h) 59 | 60 | result = {} 61 | for field, multi in METADATA_FIELDS: 62 | if field not in msg: 63 | continue 64 | key = json_name(field) 65 | if multi: 66 | value: Union[str, List[str]] = [ 67 | sanitise_header(v) for v in msg.get_all(field) # type: ignore 68 | ] 69 | else: 70 | value = sanitise_header(msg.get(field)) # type: ignore 71 | if key == "keywords": 72 | # Accept both comma-separated and space-separated 73 | # forms, for better compatibility with old data. 74 | if "," in value: 75 | value = [v.strip() for v in value.split(",")] 76 | else: 77 | value = value.split() 78 | result[key] = value 79 | 80 | payload = msg.get_payload() 81 | if payload: 82 | result["description"] = payload 83 | 84 | return result 85 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/metadata/importlib/__init__.py: -------------------------------------------------------------------------------- 1 | from ._dists import Distribution 2 | from ._envs import Environment 3 | 4 | __all__ = ["NAME", "Distribution", "Environment"] 5 | 6 | NAME = "importlib" 7 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/metadata/importlib/_compat.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | from typing import Any, Optional, Protocol, cast 3 | 4 | 5 | class BadMetadata(ValueError): 6 | def __init__(self, dist: importlib.metadata.Distribution, *, reason: str) -> None: 7 | self.dist = dist 8 | self.reason = reason 9 | 10 | def __str__(self) -> str: 11 | return f"Bad metadata in {self.dist} ({self.reason})" 12 | 13 | 14 | class BasePath(Protocol): 15 | """A protocol that various path objects conform. 16 | 17 | This exists because importlib.metadata uses both ``pathlib.Path`` and 18 | ``zipfile.Path``, and we need a common base for type hints (Union does not 19 | work well since ``zipfile.Path`` is too new for our linter setup). 20 | 21 | This does not mean to be exhaustive, but only contains things that present 22 | in both classes *that we need*. 23 | """ 24 | 25 | @property 26 | def name(self) -> str: 27 | raise NotImplementedError() 28 | 29 | @property 30 | def parent(self) -> "BasePath": 31 | raise NotImplementedError() 32 | 33 | 34 | def get_info_location(d: importlib.metadata.Distribution) -> Optional[BasePath]: 35 | """Find the path to the distribution's metadata directory. 36 | 37 | HACK: This relies on importlib.metadata's private ``_path`` attribute. Not 38 | all distributions exist on disk, so importlib.metadata is correct to not 39 | expose the attribute as public. But pip's code base is old and not as clean, 40 | so we do this to avoid having to rewrite too many things. Hopefully we can 41 | eliminate this some day. 42 | """ 43 | return getattr(d, "_path", None) 44 | 45 | 46 | def get_dist_name(dist: importlib.metadata.Distribution) -> str: 47 | """Get the distribution's project name. 48 | 49 | The ``name`` attribute is only available in Python 3.10 or later. We are 50 | targeting exactly that, but Mypy does not know this. 51 | """ 52 | name = cast(Any, dist).name 53 | if not isinstance(name, str): 54 | raise BadMetadata(dist, reason="invalid metadata entry 'name'") 55 | return name 56 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/models/__init__.py: -------------------------------------------------------------------------------- 1 | """A package that contains models that represent entities. 2 | """ 3 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/models/candidate.py: -------------------------------------------------------------------------------- 1 | from packaging.version import parse as parse_version 2 | 3 | from pipask._vendor.pip._internal.models.link import Link 4 | from pipask._vendor.pip._internal.utils.models import KeyBasedCompareMixin 5 | 6 | 7 | class InstallationCandidate(KeyBasedCompareMixin): 8 | """Represents a potential "candidate" for installation.""" 9 | 10 | __slots__ = ["name", "version", "link"] 11 | 12 | def __init__(self, name: str, version: str, link: Link) -> None: 13 | self.name = name 14 | self.version = parse_version(version) 15 | self.link = link 16 | 17 | super().__init__( 18 | key=(self.name, self.version, self.link), 19 | defining_class=InstallationCandidate, 20 | ) 21 | 22 | def __repr__(self) -> str: 23 | return "".format( 24 | self.name, 25 | self.version, 26 | self.link, 27 | ) 28 | 29 | def __str__(self) -> str: 30 | return f"{self.name!r} candidate (version {self.version} at {self.link})" 31 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/models/format_control.py: -------------------------------------------------------------------------------- 1 | from typing import FrozenSet, Optional, Set 2 | 3 | from packaging.utils import canonicalize_name 4 | 5 | from pipask._vendor.pip._internal.exceptions import CommandError 6 | 7 | 8 | class FormatControl: 9 | """Helper for managing formats from which a package can be installed.""" 10 | 11 | __slots__ = ["no_binary", "only_binary"] 12 | 13 | def __init__( 14 | self, 15 | no_binary: Optional[Set[str]] = None, 16 | only_binary: Optional[Set[str]] = None, 17 | ) -> None: 18 | if no_binary is None: 19 | no_binary = set() 20 | if only_binary is None: 21 | only_binary = set() 22 | 23 | self.no_binary = no_binary 24 | self.only_binary = only_binary 25 | 26 | def __eq__(self, other: object) -> bool: 27 | if not isinstance(other, self.__class__): 28 | return NotImplemented 29 | 30 | if self.__slots__ != other.__slots__: 31 | return False 32 | 33 | return all(getattr(self, k) == getattr(other, k) for k in self.__slots__) 34 | 35 | def __repr__(self) -> str: 36 | return f"{self.__class__.__name__}({self.no_binary}, {self.only_binary})" 37 | 38 | @staticmethod 39 | def handle_mutual_excludes(value: str, target: Set[str], other: Set[str]) -> None: 40 | if value.startswith("-"): 41 | raise CommandError( 42 | "--no-binary / --only-binary option requires 1 argument." 43 | ) 44 | new = value.split(",") 45 | while ":all:" in new: 46 | other.clear() 47 | target.clear() 48 | target.add(":all:") 49 | del new[: new.index(":all:") + 1] 50 | # Without a none, we want to discard everything as :all: covers it 51 | if ":none:" not in new: 52 | return 53 | for name in new: 54 | if name == ":none:": 55 | target.clear() 56 | continue 57 | name = canonicalize_name(name) 58 | other.discard(name) 59 | target.add(name) 60 | 61 | def get_allowed_formats(self, canonical_name: str) -> FrozenSet[str]: 62 | result = {"binary", "source"} 63 | if canonical_name in self.only_binary: 64 | result.discard("source") 65 | elif canonical_name in self.no_binary: 66 | result.discard("binary") 67 | elif ":all:" in self.only_binary: 68 | result.discard("source") 69 | elif ":all:" in self.no_binary: 70 | result.discard("binary") 71 | return frozenset(result) 72 | 73 | def disallow_binaries(self) -> None: 74 | self.handle_mutual_excludes( 75 | ":all:", 76 | self.no_binary, 77 | self.only_binary, 78 | ) 79 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/models/index.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | 4 | class PackageIndex: 5 | """Represents a Package Index and provides easier access to endpoints""" 6 | 7 | __slots__ = ["url", "netloc", "simple_url", "pypi_url", "file_storage_domain"] 8 | 9 | def __init__(self, url: str, file_storage_domain: str) -> None: 10 | super().__init__() 11 | self.url = url 12 | self.netloc = urllib.parse.urlsplit(url).netloc 13 | self.simple_url = self._url_for_path("simple") 14 | self.pypi_url = self._url_for_path("pypi") 15 | 16 | # This is part of a temporary hack used to block installs of PyPI 17 | # packages which depend on external urls only necessary until PyPI can 18 | # block such packages themselves 19 | self.file_storage_domain = file_storage_domain 20 | 21 | def _url_for_path(self, path: str) -> str: 22 | return urllib.parse.urljoin(self.url, path) 23 | 24 | 25 | PyPI = PackageIndex("https://pypi.org/", file_storage_domain="files.pythonhosted.org") 26 | TestPyPI = PackageIndex( 27 | "https://test.pypi.org/", file_storage_domain="test-files.pythonhosted.org" 28 | ) 29 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/models/installation_report.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Sequence 2 | 3 | from packaging.markers import default_environment 4 | 5 | from pipask._vendor.pip import __version__ 6 | from pipask._vendor.pip._internal.req.req_install import InstallRequirement 7 | 8 | 9 | class InstallationReport: 10 | def __init__(self, install_requirements: Sequence[InstallRequirement]): 11 | self._install_requirements = install_requirements 12 | 13 | @classmethod 14 | def _install_req_to_dict(cls, ireq: InstallRequirement) -> Dict[str, Any]: 15 | assert ireq.download_info, f"No download_info for {ireq}" 16 | res = { 17 | # PEP 610 json for the download URL. download_info.archive_info.hashes may 18 | # be absent when the requirement was installed from the wheel cache 19 | # and the cache entry was populated by an older pip version that did not 20 | # record origin.json. 21 | "download_info": ireq.download_info.to_dict(), 22 | # is_direct is true if the requirement was a direct URL reference (which 23 | # includes editable requirements), and false if the requirement was 24 | # downloaded from a PEP 503 index or --find-links. 25 | "is_direct": ireq.is_direct, 26 | # is_yanked is true if the requirement was yanked from the index, but 27 | # was still selected by pip to conform to PEP 592. 28 | "is_yanked": ireq.link.is_yanked if ireq.link else False, 29 | # requested is true if the requirement was specified by the user (aka 30 | # top level requirement), and false if it was installed as a dependency of a 31 | # requirement. https://peps.python.org/pep-0376/#requested 32 | "requested": ireq.user_supplied, 33 | # PEP 566 json encoding for metadata 34 | # https://www.python.org/dev/peps/pep-0566/#json-compatible-metadata 35 | "metadata": ireq.get_dist().metadata_dict, 36 | } 37 | if ireq.user_supplied and ireq.extras: 38 | # For top level requirements, the list of requested extras, if any. 39 | res["requested_extras"] = sorted(ireq.extras) 40 | return res 41 | 42 | def to_dict(self) -> Dict[str, Any]: 43 | return { 44 | "version": "1", 45 | "pip_version": __version__, 46 | "install": [ 47 | self._install_req_to_dict(ireq) for ireq in self._install_requirements 48 | ], 49 | # https://peps.python.org/pep-0508/#environment-markers 50 | # TODO: currently, the resolver uses the default environment to evaluate 51 | # environment markers, so that is what we report here. In the future, it 52 | # should also take into account options such as --python-version or 53 | # --platform, perhaps under the form of an environment_override field? 54 | # https://github.com/pypa/pip/issues/11198 55 | "environment": default_environment(), 56 | } 57 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/models/scheme.py: -------------------------------------------------------------------------------- 1 | """ 2 | For types associated with installation schemes. 3 | 4 | For a general overview of available schemes and their context, see 5 | https://docs.python.org/3/install/index.html#alternate-installation. 6 | """ 7 | 8 | 9 | SCHEME_KEYS = ["platlib", "purelib", "headers", "scripts", "data"] 10 | 11 | 12 | class Scheme: 13 | """A Scheme holds paths which are used as the base directories for 14 | artifacts associated with a Python package. 15 | """ 16 | 17 | __slots__ = SCHEME_KEYS 18 | 19 | def __init__( 20 | self, 21 | platlib: str, 22 | purelib: str, 23 | headers: str, 24 | scripts: str, 25 | data: str, 26 | ) -> None: 27 | self.platlib = platlib 28 | self.purelib = purelib 29 | self.headers = headers 30 | self.scripts = scripts 31 | self.data = data 32 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/models/selection_prefs.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pipask._vendor.pip._internal.models.format_control import FormatControl 4 | 5 | 6 | class SelectionPreferences: 7 | """ 8 | Encapsulates the candidate selection preferences for downloading 9 | and installing files. 10 | """ 11 | 12 | __slots__ = [ 13 | "allow_yanked", 14 | "allow_all_prereleases", 15 | "format_control", 16 | "prefer_binary", 17 | "ignore_requires_python", 18 | ] 19 | 20 | # Don't include an allow_yanked default value to make sure each call 21 | # site considers whether yanked releases are allowed. This also causes 22 | # that decision to be made explicit in the calling code, which helps 23 | # people when reading the code. 24 | def __init__( 25 | self, 26 | allow_yanked: bool, 27 | allow_all_prereleases: bool = False, 28 | format_control: Optional[FormatControl] = None, 29 | prefer_binary: bool = False, 30 | ignore_requires_python: Optional[bool] = None, 31 | ) -> None: 32 | """Create a SelectionPreferences object. 33 | 34 | :param allow_yanked: Whether files marked as yanked (in the sense 35 | of PEP 592) are permitted to be candidates for install. 36 | :param format_control: A FormatControl object or None. Used to control 37 | the selection of source packages / binary packages when consulting 38 | the index and links. 39 | :param prefer_binary: Whether to prefer an old, but valid, binary 40 | dist over a new source dist. 41 | :param ignore_requires_python: Whether to ignore incompatible 42 | "Requires-Python" values in links. Defaults to False. 43 | """ 44 | if ignore_requires_python is None: 45 | ignore_requires_python = False 46 | 47 | self.allow_yanked = allow_yanked 48 | self.allow_all_prereleases = allow_all_prereleases 49 | self.format_control = format_control 50 | self.prefer_binary = prefer_binary 51 | self.ignore_requires_python = ignore_requires_python 52 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/models/target_python.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import List, Optional, Set, Tuple 3 | 4 | from packaging.tags import Tag 5 | 6 | from pipask._vendor.pip._internal.utils.compatibility_tags import get_supported, version_info_to_nodot 7 | from pipask._vendor.pip._internal.utils.misc import normalize_version_info 8 | 9 | 10 | class TargetPython: 11 | 12 | """ 13 | Encapsulates the properties of a Python interpreter one is targeting 14 | for a package install, download, etc. 15 | """ 16 | 17 | __slots__ = [ 18 | "_given_py_version_info", 19 | "abis", 20 | "implementation", 21 | "platforms", 22 | "py_version", 23 | "py_version_info", 24 | "_valid_tags", 25 | "_valid_tags_set", 26 | ] 27 | 28 | def __init__( 29 | self, 30 | platforms: Optional[List[str]] = None, 31 | py_version_info: Optional[Tuple[int, ...]] = None, 32 | abis: Optional[List[str]] = None, 33 | implementation: Optional[str] = None, 34 | ) -> None: 35 | """ 36 | :param platforms: A list of strings or None. If None, searches for 37 | packages that are supported by the current system. Otherwise, will 38 | find packages that can be built on the platforms passed in. These 39 | packages will only be downloaded for distribution: they will 40 | not be built locally. 41 | :param py_version_info: An optional tuple of ints representing the 42 | Python version information to use (e.g. `sys.version_info[:3]`). 43 | This can have length 1, 2, or 3 when provided. 44 | :param abis: A list of strings or None. This is passed to 45 | compatibility_tags.py's get_supported() function as is. 46 | :param implementation: A string or None. This is passed to 47 | compatibility_tags.py's get_supported() function as is. 48 | """ 49 | # Store the given py_version_info for when we call get_supported(). 50 | self._given_py_version_info = py_version_info 51 | 52 | if py_version_info is None: 53 | py_version_info = sys.version_info[:3] 54 | else: 55 | py_version_info = normalize_version_info(py_version_info) 56 | 57 | py_version = ".".join(map(str, py_version_info[:2])) 58 | 59 | self.abis = abis 60 | self.implementation = implementation 61 | self.platforms = platforms 62 | self.py_version = py_version 63 | self.py_version_info = py_version_info 64 | 65 | # This is used to cache the return value of get_(un)sorted_tags. 66 | self._valid_tags: Optional[List[Tag]] = None 67 | self._valid_tags_set: Optional[Set[Tag]] = None 68 | 69 | def format_given(self) -> str: 70 | """ 71 | Format the given, non-None attributes for display. 72 | """ 73 | display_version = None 74 | if self._given_py_version_info is not None: 75 | display_version = ".".join( 76 | str(part) for part in self._given_py_version_info 77 | ) 78 | 79 | key_values = [ 80 | ("platforms", self.platforms), 81 | ("version_info", display_version), 82 | ("abis", self.abis), 83 | ("implementation", self.implementation), 84 | ] 85 | return " ".join( 86 | f"{key}={value!r}" for key, value in key_values if value is not None 87 | ) 88 | 89 | def get_sorted_tags(self) -> List[Tag]: 90 | """ 91 | Return the supported PEP 425 tags to check wheel candidates against. 92 | 93 | The tags are returned in order of preference (most preferred first). 94 | """ 95 | if self._valid_tags is None: 96 | # Pass versions=None if no py_version_info was given since 97 | # versions=None uses special default logic. 98 | py_version_info = self._given_py_version_info 99 | if py_version_info is None: 100 | version = None 101 | else: 102 | version = version_info_to_nodot(py_version_info) 103 | 104 | tags = get_supported( 105 | version=version, 106 | platforms=self.platforms, 107 | abis=self.abis, 108 | impl=self.implementation, 109 | ) 110 | self._valid_tags = tags 111 | 112 | return self._valid_tags 113 | 114 | def get_unsorted_tags(self) -> Set[Tag]: 115 | """Exactly the same as get_sorted_tags, but returns a set. 116 | 117 | This is important for performance. 118 | """ 119 | if self._valid_tags_set is None: 120 | self._valid_tags_set = set(self.get_sorted_tags()) 121 | 122 | return self._valid_tags_set 123 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/models/wheel.py: -------------------------------------------------------------------------------- 1 | """Represents a wheel file and provides access to the various parts of the 2 | name that have meaning. 3 | """ 4 | import re 5 | from typing import Dict, Iterable, List 6 | 7 | from packaging.tags import Tag 8 | 9 | from pipask._vendor.pip._internal.exceptions import InvalidWheelFilename 10 | 11 | 12 | class Wheel: 13 | """A wheel file""" 14 | 15 | wheel_file_re = re.compile( 16 | r"""^(?P(?P[^\s-]+?)-(?P[^\s-]*?)) 17 | ((-(?P\d[^-]*?))?-(?P[^\s-]+?)-(?P[^\s-]+?)-(?P[^\s-]+?) 18 | \.whl|\.dist-info)$""", 19 | re.VERBOSE, 20 | ) 21 | 22 | def __init__(self, filename: str) -> None: 23 | """ 24 | :raises InvalidWheelFilename: when the filename is invalid for a wheel 25 | """ 26 | wheel_info = self.wheel_file_re.match(filename) 27 | if not wheel_info: 28 | raise InvalidWheelFilename(f"{filename} is not a valid wheel filename.") 29 | self.filename = filename 30 | self.name = wheel_info.group("name").replace("_", "-") 31 | # we'll assume "_" means "-" due to wheel naming scheme 32 | # (https://github.com/pypa/pip/issues/1150) 33 | self.version = wheel_info.group("ver").replace("_", "-") 34 | self.build_tag = wheel_info.group("build") 35 | self.pyversions = wheel_info.group("pyver").split(".") 36 | self.abis = wheel_info.group("abi").split(".") 37 | self.plats = wheel_info.group("plat").split(".") 38 | 39 | # All the tag combinations from this file 40 | self.file_tags = { 41 | Tag(x, y, z) for x in self.pyversions for y in self.abis for z in self.plats 42 | } 43 | 44 | def get_formatted_file_tags(self) -> List[str]: 45 | """Return the wheel's tags as a sorted list of strings.""" 46 | return sorted(str(tag) for tag in self.file_tags) 47 | 48 | def support_index_min(self, tags: List[Tag]) -> int: 49 | """Return the lowest index that one of the wheel's file_tag combinations 50 | achieves in the given list of supported tags. 51 | 52 | For example, if there are 8 supported tags and one of the file tags 53 | is first in the list, then return 0. 54 | 55 | :param tags: the PEP 425 tags to check the wheel against, in order 56 | with most preferred first. 57 | 58 | :raises ValueError: If none of the wheel's file tags match one of 59 | the supported tags. 60 | """ 61 | try: 62 | return next(i for i, t in enumerate(tags) if t in self.file_tags) 63 | except StopIteration: 64 | raise ValueError() 65 | 66 | def find_most_preferred_tag( 67 | self, tags: List[Tag], tag_to_priority: Dict[Tag, int] 68 | ) -> int: 69 | """Return the priority of the most preferred tag that one of the wheel's file 70 | tag combinations achieves in the given list of supported tags using the given 71 | tag_to_priority mapping, where lower priorities are more-preferred. 72 | 73 | This is used in place of support_index_min in some cases in order to avoid 74 | an expensive linear scan of a large list of tags. 75 | 76 | :param tags: the PEP 425 tags to check the wheel against. 77 | :param tag_to_priority: a mapping from tag to priority of that tag, where 78 | lower is more preferred. 79 | 80 | :raises ValueError: If none of the wheel's file tags match one of 81 | the supported tags. 82 | """ 83 | return min( 84 | tag_to_priority[tag] for tag in self.file_tags if tag in tag_to_priority 85 | ) 86 | 87 | def supported(self, tags: Iterable[Tag]) -> bool: 88 | """Return whether the wheel is compatible with one of the given tags. 89 | 90 | :param tags: the PEP 425 tags to check the wheel against. 91 | """ 92 | return not self.file_tags.isdisjoint(tags) 93 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/network/__init__.py: -------------------------------------------------------------------------------- 1 | """Contains purely network-related utilities. 2 | """ 3 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/network/cache.py: -------------------------------------------------------------------------------- 1 | """HTTP cache implementation. 2 | """ 3 | 4 | import os 5 | from contextlib import contextmanager 6 | from datetime import datetime 7 | from typing import BinaryIO, Generator, Optional, Union 8 | 9 | from cachecontrol.cache import SeparateBodyBaseCache 10 | from cachecontrol.caches import SeparateBodyFileCache 11 | from requests.models import Response 12 | 13 | from pipask._vendor.pip._internal.utils.filesystem import adjacent_tmp_file, replace 14 | from pipask._vendor.pip._internal.utils.misc import ensure_dir 15 | 16 | 17 | def is_from_cache(response: Response) -> bool: 18 | return getattr(response, "from_cache", False) 19 | 20 | 21 | @contextmanager 22 | def suppressed_cache_errors() -> Generator[None, None, None]: 23 | """If we can't access the cache then we can just skip caching and process 24 | requests as if caching wasn't enabled. 25 | """ 26 | try: 27 | yield 28 | except OSError: 29 | pass 30 | 31 | 32 | class SafeFileCache(SeparateBodyBaseCache): 33 | """ 34 | A file based cache which is safe to use even when the target directory may 35 | not be accessible or writable. 36 | 37 | There is a race condition when two processes try to write and/or read the 38 | same entry at the same time, since each entry consists of two separate 39 | files (https://github.com/psf/cachecontrol/issues/324). We therefore have 40 | additional logic that makes sure that both files to be present before 41 | returning an entry; this fixes the read side of the race condition. 42 | 43 | For the write side, we assume that the server will only ever return the 44 | same data for the same URL, which ought to be the case for files pip is 45 | downloading. PyPI does not have a mechanism to swap out a wheel for 46 | another wheel, for example. If this assumption is not true, the 47 | CacheControl issue will need to be fixed. 48 | """ 49 | 50 | def __init__(self, directory: str) -> None: 51 | assert directory is not None, "Cache directory must not be None." 52 | super().__init__() 53 | self.directory = directory 54 | 55 | def _get_cache_path(self, name: str) -> str: 56 | # From cachecontrol.caches.file_cache.FileCache._fn, brought into our 57 | # class for backwards-compatibility and to avoid using a non-public 58 | # method. 59 | hashed = SeparateBodyFileCache.encode(name) 60 | parts = list(hashed[:5]) + [hashed] 61 | return os.path.join(self.directory, *parts) 62 | 63 | def get(self, key: str) -> Optional[bytes]: 64 | # The cache entry is only valid if both metadata and body exist. 65 | metadata_path = self._get_cache_path(key) 66 | body_path = metadata_path + ".body" 67 | if not (os.path.exists(metadata_path) and os.path.exists(body_path)): 68 | return None 69 | with suppressed_cache_errors(): 70 | with open(metadata_path, "rb") as f: 71 | return f.read() 72 | 73 | def _write(self, path: str, data: bytes) -> None: 74 | with suppressed_cache_errors(): 75 | ensure_dir(os.path.dirname(path)) 76 | 77 | with adjacent_tmp_file(path) as f: 78 | f.write(data) 79 | 80 | replace(f.name, path) 81 | 82 | def set( 83 | self, key: str, value: bytes, expires: Union[int, datetime, None] = None 84 | ) -> None: 85 | path = self._get_cache_path(key) 86 | self._write(path, value) 87 | 88 | def delete(self, key: str) -> None: 89 | path = self._get_cache_path(key) 90 | with suppressed_cache_errors(): 91 | os.remove(path) 92 | with suppressed_cache_errors(): 93 | os.remove(path + ".body") 94 | 95 | def get_body(self, key: str) -> Optional[BinaryIO]: 96 | # The cache entry is only valid if both metadata and body exist. 97 | metadata_path = self._get_cache_path(key) 98 | body_path = metadata_path + ".body" 99 | if not (os.path.exists(metadata_path) and os.path.exists(body_path)): 100 | return None 101 | with suppressed_cache_errors(): 102 | return open(body_path, "rb") 103 | 104 | def set_body(self, key: str, body: bytes) -> None: 105 | path = self._get_cache_path(key) + ".body" 106 | self._write(path, body) 107 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/network/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Generator 2 | 3 | from requests.models import CONTENT_CHUNK_SIZE, Response 4 | 5 | from pipask._vendor.pip._internal.exceptions import NetworkConnectionError 6 | 7 | # The following comments and HTTP headers were originally added by 8 | # Donald Stufft in git commit 22c562429a61bb77172039e480873fb239dd8c03. 9 | # 10 | # We use Accept-Encoding: identity here because requests defaults to 11 | # accepting compressed responses. This breaks in a variety of ways 12 | # depending on how the server is configured. 13 | # - Some servers will notice that the file isn't a compressible file 14 | # and will leave the file alone and with an empty Content-Encoding 15 | # - Some servers will notice that the file is already compressed and 16 | # will leave the file alone, adding a Content-Encoding: gzip header 17 | # - Some servers won't notice anything at all and will take a file 18 | # that's already been compressed and compress it again, and set 19 | # the Content-Encoding: gzip header 20 | # By setting this to request only the identity encoding we're hoping 21 | # to eliminate the third case. Hopefully there does not exist a server 22 | # which when given a file will notice it is already compressed and that 23 | # you're not asking for a compressed file and will then decompress it 24 | # before sending because if that's the case I don't think it'll ever be 25 | # possible to make this work. 26 | HEADERS: Dict[str, str] = {"Accept-Encoding": "identity"} 27 | 28 | 29 | def raise_for_status(resp: Response) -> None: 30 | http_error_msg = "" 31 | if isinstance(resp.reason, bytes): 32 | # We attempt to decode utf-8 first because some servers 33 | # choose to localize their reason strings. If the string 34 | # isn't utf-8, we fall back to iso-8859-1 for all other 35 | # encodings. 36 | try: 37 | reason = resp.reason.decode("utf-8") 38 | except UnicodeDecodeError: 39 | reason = resp.reason.decode("iso-8859-1") 40 | else: 41 | reason = resp.reason 42 | 43 | if 400 <= resp.status_code < 500: 44 | http_error_msg = ( 45 | f"{resp.status_code} Client Error: {reason} for url: {resp.url}" 46 | ) 47 | 48 | elif 500 <= resp.status_code < 600: 49 | http_error_msg = ( 50 | f"{resp.status_code} Server Error: {reason} for url: {resp.url}" 51 | ) 52 | 53 | if http_error_msg: 54 | raise NetworkConnectionError(http_error_msg, response=resp) 55 | 56 | 57 | def response_chunks( 58 | response: Response, chunk_size: int = CONTENT_CHUNK_SIZE 59 | ) -> Generator[bytes, None, None]: 60 | """Given a requests Response, provide the data chunks.""" 61 | try: 62 | # Special case for urllib3. 63 | for chunk in response.raw.stream( 64 | chunk_size, 65 | # We use decode_content=False here because we don't 66 | # want urllib3 to mess with the raw bytes we get 67 | # from the server. If we decompress inside of 68 | # urllib3 then we cannot verify the checksum 69 | # because the checksum will be of the compressed 70 | # file. This breakage will only occur if the 71 | # server adds a Content-Encoding header, which 72 | # depends on how the server was configured: 73 | # - Some servers will notice that the file isn't a 74 | # compressible file and will leave the file alone 75 | # and with an empty Content-Encoding 76 | # - Some servers will notice that the file is 77 | # already compressed and will leave the file 78 | # alone and will add a Content-Encoding: gzip 79 | # header 80 | # - Some servers won't notice anything at all and 81 | # will take a file that's already been compressed 82 | # and compress it again and set the 83 | # Content-Encoding: gzip header 84 | # 85 | # By setting this not to decode automatically we 86 | # hope to eliminate problems with the second case. 87 | decode_content=False, 88 | ): 89 | yield chunk 90 | except AttributeError: 91 | # Standard file-like object. 92 | while True: 93 | chunk = response.raw.read(chunk_size) 94 | if not chunk: 95 | break 96 | yield chunk 97 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/operations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/src/pipask/_vendor/pip/_internal/operations/__init__.py -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/operations/build/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/src/pipask/_vendor/pip/_internal/operations/build/__init__.py -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/operations/build/metadata.py: -------------------------------------------------------------------------------- 1 | """Metadata generation logic for source distributions. 2 | """ 3 | 4 | import os 5 | 6 | from pyproject_hooks import BuildBackendHookCaller 7 | 8 | from pipask._vendor.pip._internal.build_env import BuildEnvironment 9 | from pipask._vendor.pip._internal.exceptions import ( 10 | InstallationSubprocessError, 11 | MetadataGenerationFailed, 12 | ) 13 | from pipask._vendor.pip._internal.utils.subprocess import runner_with_spinner_message 14 | from pipask._vendor.pip._internal.utils.temp_dir import TempDirectory 15 | 16 | 17 | def generate_metadata( 18 | build_env: BuildEnvironment, backend: BuildBackendHookCaller, details: str 19 | ) -> str: 20 | """Generate metadata using mechanisms described in PEP 517. 21 | 22 | Returns the generated metadata directory. 23 | """ 24 | metadata_tmpdir = TempDirectory(kind="modern-metadata", globally_managed=True) 25 | 26 | metadata_dir = metadata_tmpdir.path 27 | 28 | with build_env: 29 | # Note that BuildBackendHookCaller implements a fallback for 30 | # prepare_metadata_for_build_wheel, so we don't have to 31 | # consider the possibility that this hook doesn't exist. 32 | runner = runner_with_spinner_message("Preparing metadata (pyproject.toml)") 33 | with backend.subprocess_runner(runner): 34 | try: 35 | distinfo_dir = backend.prepare_metadata_for_build_wheel(metadata_dir) 36 | except InstallationSubprocessError as error: 37 | raise MetadataGenerationFailed(package_details=details) from error 38 | 39 | return os.path.join(metadata_dir, distinfo_dir) 40 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/operations/build/metadata_editable.py: -------------------------------------------------------------------------------- 1 | """Metadata generation logic for source distributions. 2 | """ 3 | 4 | import os 5 | 6 | from pyproject_hooks import BuildBackendHookCaller 7 | 8 | from pipask._vendor.pip._internal.build_env import BuildEnvironment 9 | from pipask._vendor.pip._internal.exceptions import ( 10 | InstallationSubprocessError, 11 | MetadataGenerationFailed, 12 | ) 13 | from pipask._vendor.pip._internal.utils.subprocess import runner_with_spinner_message 14 | from pipask._vendor.pip._internal.utils.temp_dir import TempDirectory 15 | 16 | 17 | def generate_editable_metadata( 18 | build_env: BuildEnvironment, backend: BuildBackendHookCaller, details: str 19 | ) -> str: 20 | """Generate metadata using mechanisms described in PEP 660. 21 | 22 | Returns the generated metadata directory. 23 | """ 24 | metadata_tmpdir = TempDirectory(kind="modern-metadata", globally_managed=True) 25 | 26 | metadata_dir = metadata_tmpdir.path 27 | 28 | with build_env: 29 | # Note that BuildBackendHookCaller implements a fallback for 30 | # prepare_metadata_for_build_wheel/editable, so we don't have to 31 | # consider the possibility that this hook doesn't exist. 32 | runner = runner_with_spinner_message( 33 | "Preparing editable metadata (pyproject.toml)" 34 | ) 35 | with backend.subprocess_runner(runner): 36 | try: 37 | distinfo_dir = backend.prepare_metadata_for_build_editable(metadata_dir) 38 | except InstallationSubprocessError as error: 39 | raise MetadataGenerationFailed(package_details=details) from error 40 | 41 | return os.path.join(metadata_dir, distinfo_dir) 42 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/operations/build/metadata_legacy.py: -------------------------------------------------------------------------------- 1 | """Metadata generation logic for legacy source distributions. 2 | """ 3 | 4 | import logging 5 | import os 6 | 7 | from pipask._vendor.pip._internal.build_env import BuildEnvironment 8 | from pipask._vendor.pip._internal.cli.spinners import open_spinner 9 | from pipask._vendor.pip._internal.exceptions import ( 10 | InstallationError, 11 | InstallationSubprocessError, 12 | MetadataGenerationFailed, 13 | ) 14 | from pipask._vendor.pip._internal.utils.setuptools_build import make_setuptools_egg_info_args 15 | from pipask._vendor.pip._internal.utils.subprocess import call_subprocess 16 | from pipask._vendor.pip._internal.utils.temp_dir import TempDirectory 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def _find_egg_info(directory: str) -> str: 22 | """Find an .egg-info subdirectory in `directory`.""" 23 | filenames = [f for f in os.listdir(directory) if f.endswith(".egg-info")] 24 | 25 | if not filenames: 26 | raise InstallationError(f"No .egg-info directory found in {directory}") 27 | 28 | if len(filenames) > 1: 29 | raise InstallationError( 30 | "More than one .egg-info directory found in {}".format(directory) 31 | ) 32 | 33 | return os.path.join(directory, filenames[0]) 34 | 35 | 36 | def generate_metadata( 37 | build_env: BuildEnvironment, 38 | setup_py_path: str, 39 | source_dir: str, 40 | isolated: bool, 41 | details: str, 42 | ) -> str: 43 | """Generate metadata using setup.py-based defacto mechanisms. 44 | 45 | Returns the generated metadata directory. 46 | """ 47 | logger.debug( 48 | "Running setup.py (path:%s) egg_info for package %s", 49 | setup_py_path, 50 | details, 51 | ) 52 | 53 | egg_info_dir = TempDirectory(kind="pip-egg-info", globally_managed=True).path 54 | 55 | args = make_setuptools_egg_info_args( 56 | setup_py_path, 57 | egg_info_dir=egg_info_dir, 58 | no_user_config=isolated, 59 | ) 60 | 61 | with build_env: 62 | with open_spinner("Preparing metadata (setup.py)") as spinner: 63 | try: 64 | call_subprocess( 65 | args, 66 | cwd=source_dir, 67 | command_desc="python setup.py egg_info", 68 | spinner=spinner, 69 | ) 70 | except InstallationSubprocessError as error: 71 | raise MetadataGenerationFailed(package_details=details) from error 72 | 73 | # Return the .egg-info directory. 74 | return _find_egg_info(egg_info_dir) 75 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/operations/build/wheel.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import Optional 4 | 5 | from pyproject_hooks import BuildBackendHookCaller 6 | 7 | # from pipask._vendor.pip._internal.utils.subprocess import runner_with_spinner_message 8 | from pipask.exception import PipaskException 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def build_wheel_pep517( 14 | name: str, 15 | backend: BuildBackendHookCaller, 16 | metadata_directory: str, 17 | tempd: str, 18 | ) -> Optional[str]: 19 | """Build one InstallRequirement using the PEP 517 build process. 20 | 21 | Returns path to wheel if successfully built. Otherwise, returns None. 22 | """ 23 | # MODIFIED for pipask 24 | raise PipaskException("Pipask should not need to build any wheels") 25 | # assert metadata_directory is not None 26 | # try: 27 | # logger.debug("Destination directory: %s", tempd) 28 | # 29 | # runner = runner_with_spinner_message( 30 | # f"Building wheel for {name} (pyproject.toml)" 31 | # ) 32 | # with backend.subprocess_runner(runner): 33 | # wheel_name = backend.build_wheel( 34 | # tempd, 35 | # metadata_directory=metadata_directory, 36 | # ) 37 | # except Exception: 38 | # logger.error("Failed building wheel for %s", name) 39 | # return None 40 | # return os.path.join(tempd, wheel_name) 41 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/operations/build/wheel_editable.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import Optional 4 | 5 | from pyproject_hooks import BuildBackendHookCaller, HookMissing 6 | 7 | from pipask.exception import PipaskException 8 | 9 | # from pipask._vendor.pip._internal.utils.subprocess import runner_with_spinner_message 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def build_wheel_editable( 15 | name: str, 16 | backend: BuildBackendHookCaller, 17 | metadata_directory: str, 18 | tempd: str, 19 | ) -> Optional[str]: 20 | """Build one InstallRequirement using the PEP 660 build process. 21 | 22 | Returns path to wheel if successfully built. Otherwise, returns None. 23 | """ 24 | # MODIFIED for pipask 25 | raise PipaskException("Pipask should not need to build any wheels") 26 | # assert metadata_directory is not None 27 | # try: 28 | # logger.debug("Destination directory: %s", tempd) 29 | # 30 | # runner = runner_with_spinner_message( 31 | # f"Building editable for {name} (pyproject.toml)" 32 | # ) 33 | # with backend.subprocess_runner(runner): 34 | # try: 35 | # wheel_name = backend.build_editable( 36 | # tempd, 37 | # metadata_directory=metadata_directory, 38 | # ) 39 | # except HookMissing as e: 40 | # logger.error( 41 | # "Cannot build editable %s because the build " 42 | # "backend does not have the %s hook", 43 | # name, 44 | # e, 45 | # ) 46 | # return None 47 | # except Exception: 48 | # logger.error("Failed building editable for %s", name) 49 | # return None 50 | # return os.path.join(tempd, wheel_name) 51 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/operations/build/wheel_legacy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os.path 3 | from typing import List, Optional 4 | 5 | # from pipask._vendor.pip._internal.cli.spinners import open_spinner 6 | # from pipask._vendor.pip._internal.utils.setuptools_build import make_setuptools_bdist_wheel_args 7 | from pipask._vendor.pip._internal.utils.subprocess import call_subprocess, format_command_args 8 | from pipask.exception import PipaskException 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def format_command_result( 14 | command_args: List[str], 15 | command_output: str, 16 | ) -> str: 17 | """Format command information for logging.""" 18 | command_desc = format_command_args(command_args) 19 | text = f"Command arguments: {command_desc}\n" 20 | 21 | if not command_output: 22 | text += "Command output: None" 23 | elif logger.getEffectiveLevel() > logging.DEBUG: 24 | text += "Command output: [use --verbose to show]" 25 | else: 26 | if not command_output.endswith("\n"): 27 | command_output += "\n" 28 | text += f"Command output:\n{command_output}" 29 | 30 | return text 31 | 32 | 33 | def get_legacy_build_wheel_path( 34 | names: List[str], 35 | temp_dir: str, 36 | name: str, 37 | command_args: List[str], 38 | command_output: str, 39 | ) -> Optional[str]: 40 | """Return the path to the wheel in the temporary build directory.""" 41 | # Sort for determinism. 42 | names = sorted(names) 43 | if not names: 44 | msg = ("Legacy build of wheel for {!r} created no files.\n").format(name) 45 | msg += format_command_result(command_args, command_output) 46 | logger.warning(msg) 47 | return None 48 | 49 | if len(names) > 1: 50 | msg = ( 51 | "Legacy build of wheel for {!r} created more than one file.\n" 52 | "Filenames (choosing first): {}\n" 53 | ).format(name, names) 54 | msg += format_command_result(command_args, command_output) 55 | logger.warning(msg) 56 | 57 | return os.path.join(temp_dir, names[0]) 58 | 59 | 60 | def build_wheel_legacy( 61 | name: str, 62 | setup_py_path: str, 63 | source_dir: str, 64 | global_options: List[str], 65 | build_options: List[str], 66 | tempd: str, 67 | ) -> Optional[str]: 68 | """Build one unpacked package using the "legacy" build process. 69 | 70 | Returns path to wheel if successfully built. Otherwise, returns None. 71 | """ 72 | # MODIFIED for pipask 73 | raise PipaskException("Pipask should not need to build any wheels") 74 | 75 | # wheel_args = make_setuptools_bdist_wheel_args( 76 | # setup_py_path, 77 | # global_options=global_options, 78 | # build_options=build_options, 79 | # destination_dir=tempd, 80 | # ) 81 | # 82 | # spin_message = f"Building wheel for {name} (setup.py)" 83 | # with open_spinner(spin_message) as spinner: 84 | # logger.debug("Destination directory: %s", tempd) 85 | # 86 | # try: 87 | # output = call_subprocess( 88 | # wheel_args, 89 | # command_desc="python setup.py bdist_wheel", 90 | # cwd=source_dir, 91 | # spinner=spinner, 92 | # ) 93 | # except Exception: 94 | # spinner.finish("error") 95 | # logger.error("Failed building wheel for %s", name) 96 | # return None 97 | # 98 | # names = os.listdir(tempd) 99 | # wheel_path = get_legacy_build_wheel_path( 100 | # names=names, 101 | # temp_dir=tempd, 102 | # name=name, 103 | # command_args=wheel_args, 104 | # command_output=output, 105 | # ) 106 | # return wheel_path 107 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/req/__init__.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import logging 3 | from typing import Generator, List, Optional, Sequence, Tuple 4 | 5 | from pipask._vendor.pip._internal.utils.logging import indent_log 6 | 7 | from .req_file import parse_requirements 8 | from .req_install import InstallRequirement 9 | from .req_set import RequirementSet 10 | 11 | __all__ = [ 12 | "RequirementSet", 13 | "InstallRequirement", 14 | "parse_requirements", 15 | "install_given_reqs", 16 | ] 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class InstallationResult: 22 | def __init__(self, name: str) -> None: 23 | self.name = name 24 | 25 | def __repr__(self) -> str: 26 | return f"InstallationResult(name={self.name!r})" 27 | 28 | 29 | def _validate_requirements( 30 | requirements: List[InstallRequirement], 31 | ) -> Generator[Tuple[str, InstallRequirement], None, None]: 32 | for req in requirements: 33 | assert req.name, f"invalid to-be-installed requirement: {req}" 34 | yield req.name, req 35 | 36 | 37 | def install_given_reqs( 38 | requirements: List[InstallRequirement], 39 | global_options: Sequence[str], 40 | root: Optional[str], 41 | home: Optional[str], 42 | prefix: Optional[str], 43 | warn_script_location: bool, 44 | use_user_site: bool, 45 | pycompile: bool, 46 | ) -> List[InstallationResult]: 47 | """ 48 | Install everything in the given list. 49 | 50 | (to be called after having downloaded and unpacked the packages) 51 | """ 52 | to_install = collections.OrderedDict(_validate_requirements(requirements)) 53 | 54 | if to_install: 55 | logger.info( 56 | "Installing collected packages: %s", 57 | ", ".join(to_install.keys()), 58 | ) 59 | 60 | installed = [] 61 | 62 | with indent_log(): 63 | for req_name, requirement in to_install.items(): 64 | if requirement.should_reinstall: 65 | logger.info("Attempting uninstall: %s", req_name) 66 | with indent_log(): 67 | uninstalled_pathset = requirement.uninstall(auto_confirm=True) 68 | else: 69 | uninstalled_pathset = None 70 | 71 | try: 72 | requirement.install( 73 | global_options, 74 | root=root, 75 | home=home, 76 | prefix=prefix, 77 | warn_script_location=warn_script_location, 78 | use_user_site=use_user_site, 79 | pycompile=pycompile, 80 | ) 81 | except Exception: 82 | # if install did not succeed, rollback previous uninstall 83 | if uninstalled_pathset and not requirement.install_succeeded: 84 | uninstalled_pathset.rollback() 85 | raise 86 | else: 87 | if uninstalled_pathset and requirement.install_succeeded: 88 | uninstalled_pathset.commit() 89 | 90 | installed.append(InstallationResult(req_name)) 91 | 92 | return installed 93 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/resolution/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/src/pipask/_vendor/pip/_internal/resolution/__init__.py -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/resolution/base.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Optional 2 | 3 | from pipask._vendor.pip._internal.req.req_install import InstallRequirement 4 | from pipask._vendor.pip._internal.req.req_set import RequirementSet 5 | 6 | InstallRequirementProvider = Callable[ 7 | [str, Optional[InstallRequirement]], InstallRequirement 8 | ] 9 | 10 | 11 | class BaseResolver: 12 | def resolve( 13 | self, root_reqs: List[InstallRequirement], check_supported_wheels: bool 14 | ) -> RequirementSet: 15 | raise NotImplementedError() 16 | 17 | def get_installation_order( 18 | self, req_set: RequirementSet 19 | ) -> List[InstallRequirement]: 20 | raise NotImplementedError() 21 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/resolution/legacy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/src/pipask/_vendor/pip/_internal/resolution/legacy/__init__.py -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/resolution/resolvelib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/src/pipask/_vendor/pip/_internal/resolution/resolvelib/__init__.py -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/resolution/resolvelib/reporter.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from logging import getLogger 3 | from typing import Any, DefaultDict 4 | 5 | from resolvelib.reporters import BaseReporter 6 | 7 | from .base import Candidate, Requirement 8 | 9 | logger = getLogger(__name__) 10 | 11 | 12 | class PipReporter(BaseReporter): 13 | def __init__(self) -> None: 14 | self.reject_count_by_package: DefaultDict[str, int] = defaultdict(int) 15 | 16 | self._messages_at_reject_count = { 17 | 1: ( 18 | "pip is looking at multiple versions of {package_name} to " 19 | "determine which version is compatible with other " 20 | "requirements. This could take a while." 21 | ), 22 | 8: ( 23 | "pip is still looking at multiple versions of {package_name} to " 24 | "determine which version is compatible with other " 25 | "requirements. This could take a while." 26 | ), 27 | 13: ( 28 | "This is taking longer than usual. You might need to provide " 29 | "the dependency resolver with stricter constraints to reduce " 30 | "runtime. See https://pip.pypa.io/warnings/backtracking for " 31 | "guidance. If you want to abort this run, press Ctrl + C." 32 | ), 33 | } 34 | 35 | def rejecting_candidate(self, criterion: Any, candidate: Candidate) -> None: 36 | self.reject_count_by_package[candidate.name] += 1 37 | 38 | count = self.reject_count_by_package[candidate.name] 39 | if count not in self._messages_at_reject_count: 40 | return 41 | 42 | message = self._messages_at_reject_count[count] 43 | logger.info("INFO: %s", message.format(package_name=candidate.name)) 44 | 45 | msg = "Will try a different candidate, due to conflict:" 46 | for req_info in criterion.information: 47 | req, parent = req_info.requirement, req_info.parent 48 | # Inspired by Factory.get_installation_error 49 | msg += "\n " 50 | if parent: 51 | msg += f"{parent.name} {parent.version} depends on " 52 | else: 53 | msg += "The user requested " 54 | msg += req.format_for_error() 55 | logger.debug(msg) 56 | 57 | 58 | class PipDebuggingReporter(BaseReporter): 59 | """A reporter that does an info log for every event it sees.""" 60 | 61 | def starting(self) -> None: 62 | logger.info("Reporter.starting()") 63 | 64 | def starting_round(self, index: int) -> None: 65 | logger.info("Reporter.starting_round(%r)", index) 66 | 67 | def ending_round(self, index: int, state: Any) -> None: 68 | logger.info("Reporter.ending_round(%r, state)", index) 69 | 70 | def ending(self, state: Any) -> None: 71 | logger.info("Reporter.ending(%r)", state) 72 | 73 | def adding_requirement(self, requirement: Requirement, parent: Candidate) -> None: 74 | logger.info("Reporter.adding_requirement(%r, %r)", requirement, parent) 75 | 76 | def rejecting_candidate(self, criterion: Any, candidate: Candidate) -> None: 77 | logger.info("Reporter.rejecting_candidate(%r, %r)", criterion, candidate) 78 | 79 | def pinning(self, candidate: Candidate) -> None: 80 | logger.info("Reporter.pinning(%r)", candidate) 81 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/src/pipask/_vendor/pip/_internal/utils/__init__.py -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/_jaraco_text.py: -------------------------------------------------------------------------------- 1 | """Functions brought over from jaraco.text. 2 | 3 | These functions are not supposed to be used within `pip._internal`. These are 4 | helper functions brought over from `jaraco.text` to enable vendoring newer 5 | copies of `pkg_resources` without having to vendor `jaraco.text` and its entire 6 | dependency cone; something that our vendoring setup is not currently capable of 7 | handling. 8 | 9 | License reproduced from original source below: 10 | 11 | Copyright Jason R. Coombs 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to 15 | deal in the Software without restriction, including without limitation the 16 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 17 | sell copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in 21 | all copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 28 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 29 | IN THE SOFTWARE. 30 | """ 31 | 32 | import functools 33 | import itertools 34 | 35 | 36 | def _nonblank(str): 37 | return str and not str.startswith("#") 38 | 39 | 40 | @functools.singledispatch 41 | def yield_lines(iterable): 42 | r""" 43 | Yield valid lines of a string or iterable. 44 | 45 | >>> list(yield_lines('')) 46 | [] 47 | >>> list(yield_lines(['foo', 'bar'])) 48 | ['foo', 'bar'] 49 | >>> list(yield_lines('foo\nbar')) 50 | ['foo', 'bar'] 51 | >>> list(yield_lines('\nfoo\n#bar\nbaz #comment')) 52 | ['foo', 'baz #comment'] 53 | >>> list(yield_lines(['foo\nbar', 'baz', 'bing\n\n\n'])) 54 | ['foo', 'bar', 'baz', 'bing'] 55 | """ 56 | return itertools.chain.from_iterable(map(yield_lines, iterable)) 57 | 58 | 59 | @yield_lines.register(str) 60 | def _(text): 61 | return filter(_nonblank, map(str.strip, text.splitlines())) 62 | 63 | 64 | def drop_comment(line): 65 | """ 66 | Drop comments. 67 | 68 | >>> drop_comment('foo # bar') 69 | 'foo' 70 | 71 | A hash without a space may be in a URL. 72 | 73 | >>> drop_comment('http://example.com/foo#bar') 74 | 'http://example.com/foo#bar' 75 | """ 76 | return line.partition(" #")[0] 77 | 78 | 79 | def join_continuation(lines): 80 | r""" 81 | Join lines continued by a trailing backslash. 82 | 83 | >>> list(join_continuation(['foo \\', 'bar', 'baz'])) 84 | ['foobar', 'baz'] 85 | >>> list(join_continuation(['foo \\', 'bar', 'baz'])) 86 | ['foobar', 'baz'] 87 | >>> list(join_continuation(['foo \\', 'bar \\', 'baz'])) 88 | ['foobarbaz'] 89 | 90 | Not sure why, but... 91 | The character preceeding the backslash is also elided. 92 | 93 | >>> list(join_continuation(['goo\\', 'dly'])) 94 | ['godly'] 95 | 96 | A terrible idea, but... 97 | If no line is available to continue, suppress the lines. 98 | 99 | >>> list(join_continuation(['foo', 'bar\\', 'baz\\'])) 100 | ['foo'] 101 | """ 102 | lines = iter(lines) 103 | for item in lines: 104 | while item.endswith("\\"): 105 | try: 106 | item = item[:-2].strip() + next(lines) 107 | except StopIteration: 108 | return 109 | yield item 110 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/_log.py: -------------------------------------------------------------------------------- 1 | """Customize logging 2 | 3 | Defines custom logger class for the `logger.verbose(...)` method. 4 | 5 | init_logging() must be called before any other modules that call logging.getLogger. 6 | """ 7 | 8 | import logging 9 | from typing import Any, cast 10 | 11 | # custom log level for `--verbose` output 12 | # between DEBUG and INFO 13 | VERBOSE = 15 14 | 15 | 16 | class VerboseLogger(logging.Logger): 17 | """Custom Logger, defining a verbose log-level 18 | 19 | VERBOSE is between INFO and DEBUG. 20 | """ 21 | 22 | def verbose(self, msg: str, *args: Any, **kwargs: Any) -> None: 23 | return self.log(VERBOSE, msg, *args, **kwargs) 24 | 25 | 26 | def getLogger(name: str) -> VerboseLogger: 27 | """logging.getLogger, but ensures our VerboseLogger class is returned""" 28 | return cast(VerboseLogger, logging.getLogger(name)) 29 | 30 | 31 | def init_logging() -> None: 32 | """Register our VerboseLogger and VERBOSE log level. 33 | 34 | Should be called before any calls to getLogger(), 35 | i.e. in pip._internal.__init__ 36 | """ 37 | logging.setLoggerClass(VerboseLogger) 38 | logging.addLevelName(VERBOSE, "VERBOSE") 39 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/appdirs.py: -------------------------------------------------------------------------------- 1 | """ 2 | This code wraps the vendored appdirs module to so the return values are 3 | compatible for the current pip code base. 4 | 5 | The intention is to rewrite current usages gradually, keeping the tests pass, 6 | and eventually drop this after all usages are changed. 7 | """ 8 | 9 | import os 10 | import sys 11 | from typing import List 12 | 13 | import platformdirs as _appdirs 14 | 15 | 16 | def user_cache_dir(appname: str) -> str: 17 | return _appdirs.user_cache_dir(appname, appauthor=False) 18 | 19 | 20 | def _macos_user_config_dir(appname: str, roaming: bool = True) -> str: 21 | # Use ~/Application Support/pip, if the directory exists. 22 | path = _appdirs.user_data_dir(appname, appauthor=False, roaming=roaming) 23 | if os.path.isdir(path): 24 | return path 25 | 26 | # Use a Linux-like ~/.config/pip, by default. 27 | linux_like_path = "~/.config/" 28 | if appname: 29 | linux_like_path = os.path.join(linux_like_path, appname) 30 | 31 | return os.path.expanduser(linux_like_path) 32 | 33 | 34 | def user_config_dir(appname: str, roaming: bool = True) -> str: 35 | if sys.platform == "darwin": 36 | return _macos_user_config_dir(appname, roaming) 37 | 38 | return _appdirs.user_config_dir(appname, appauthor=False, roaming=roaming) 39 | 40 | 41 | # for the discussion regarding site_config_dir locations 42 | # see 43 | def site_config_dirs(appname: str) -> List[str]: 44 | if sys.platform == "darwin": 45 | return [_appdirs.site_data_dir(appname, appauthor=False, multipath=True)] 46 | 47 | dirval = _appdirs.site_config_dir(appname, appauthor=False, multipath=True) 48 | if sys.platform == "win32": 49 | return [dirval] 50 | 51 | # Unix-y system. Look in /etc as well. 52 | return dirval.split(os.pathsep) + ["/etc"] 53 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/compat.py: -------------------------------------------------------------------------------- 1 | """Stuff that differs in different Python versions and platform 2 | distributions.""" 3 | 4 | import logging 5 | import os 6 | import sys 7 | 8 | __all__ = ["get_path_uid", "stdlib_pkgs", "WINDOWS"] 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def has_tls() -> bool: 15 | try: 16 | import _ssl # noqa: F401 # ignore unused 17 | 18 | return True 19 | except ImportError: 20 | pass 21 | 22 | from urllib3.util import IS_PYOPENSSL 23 | 24 | return IS_PYOPENSSL 25 | 26 | 27 | def get_path_uid(path: str) -> int: 28 | """ 29 | Return path's uid. 30 | 31 | Does not follow symlinks: 32 | https://github.com/pypa/pip/pull/935#discussion_r5307003 33 | 34 | Placed this function in compat due to differences on AIX and 35 | Jython, that should eventually go away. 36 | 37 | :raises OSError: When path is a symlink or can't be read. 38 | """ 39 | if hasattr(os, "O_NOFOLLOW"): 40 | fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) 41 | file_uid = os.fstat(fd).st_uid 42 | os.close(fd) 43 | else: # AIX and Jython 44 | # WARNING: time of check vulnerability, but best we can do w/o NOFOLLOW 45 | if not os.path.islink(path): 46 | # older versions of Jython don't have `os.fstat` 47 | file_uid = os.stat(path).st_uid 48 | else: 49 | # raise OSError for parity with os.O_NOFOLLOW above 50 | raise OSError(f"{path} is a symlink; Will not return uid for symlinks") 51 | return file_uid 52 | 53 | 54 | # packages in the stdlib that may have installation metadata, but should not be 55 | # considered 'installed'. this theoretically could be determined based on 56 | # dist.location (py27:`sysconfig.get_paths()['stdlib']`, 57 | # py26:sysconfig.get_config_vars('LIBDEST')), but fear platform variation may 58 | # make this ineffective, so hard-coding 59 | stdlib_pkgs = {"python", "wsgiref", "argparse"} 60 | 61 | 62 | # windows detection, covers cpython and ironpython 63 | WINDOWS = sys.platform.startswith("win") or (sys.platform == "cli" and os.name == "nt") 64 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/deprecation.py: -------------------------------------------------------------------------------- 1 | """ 2 | A module that implements tooling to enable easy warnings about deprecations. 3 | """ 4 | 5 | import logging 6 | import warnings 7 | from typing import Any, Optional, TextIO, Type, Union 8 | 9 | from packaging.version import parse 10 | 11 | from pipask._vendor.pip import __version__ as current_version # NOTE: tests patch this name. 12 | 13 | DEPRECATION_MSG_PREFIX = "DEPRECATION: " 14 | 15 | 16 | class PipDeprecationWarning(Warning): 17 | pass 18 | 19 | 20 | _original_showwarning: Any = None 21 | 22 | 23 | # Warnings <-> Logging Integration 24 | def _showwarning( 25 | message: Union[Warning, str], 26 | category: Type[Warning], 27 | filename: str, 28 | lineno: int, 29 | file: Optional[TextIO] = None, 30 | line: Optional[str] = None, 31 | ) -> None: 32 | if file is not None: 33 | if _original_showwarning is not None: 34 | _original_showwarning(message, category, filename, lineno, file, line) 35 | elif issubclass(category, PipDeprecationWarning): 36 | # We use a specially named logger which will handle all of the 37 | # deprecation messages for pip. 38 | logger = logging.getLogger("pip._internal.deprecations") 39 | logger.warning(message) 40 | else: 41 | _original_showwarning(message, category, filename, lineno, file, line) 42 | 43 | 44 | def install_warning_logger() -> None: 45 | # Enable our Deprecation Warnings 46 | warnings.simplefilter("default", PipDeprecationWarning, append=True) 47 | 48 | global _original_showwarning 49 | 50 | if _original_showwarning is None: 51 | _original_showwarning = warnings.showwarning 52 | warnings.showwarning = _showwarning 53 | 54 | 55 | def deprecated( 56 | *, 57 | reason: str, 58 | replacement: Optional[str], 59 | gone_in: Optional[str], 60 | feature_flag: Optional[str] = None, 61 | issue: Optional[int] = None, 62 | ) -> None: 63 | """Helper to deprecate existing functionality. 64 | 65 | reason: 66 | Textual reason shown to the user about why this functionality has 67 | been deprecated. Should be a complete sentence. 68 | replacement: 69 | Textual suggestion shown to the user about what alternative 70 | functionality they can use. 71 | gone_in: 72 | The version of pip does this functionality should get removed in. 73 | Raises an error if pip's current version is greater than or equal to 74 | this. 75 | feature_flag: 76 | Command-line flag of the form --use-feature={feature_flag} for testing 77 | upcoming functionality. 78 | issue: 79 | Issue number on the tracker that would serve as a useful place for 80 | users to find related discussion and provide feedback. 81 | """ 82 | 83 | # Determine whether or not the feature is already gone in this version. 84 | is_gone = gone_in is not None and parse(current_version) >= parse(gone_in) 85 | 86 | message_parts = [ 87 | (reason, f"{DEPRECATION_MSG_PREFIX}{{}}"), 88 | ( 89 | gone_in, 90 | "pip {} will enforce this behaviour change." 91 | if not is_gone 92 | else "Since pip {}, this is no longer supported.", 93 | ), 94 | ( 95 | replacement, 96 | "A possible replacement is {}.", 97 | ), 98 | ( 99 | feature_flag, 100 | "You can use the flag --use-feature={} to test the upcoming behaviour." 101 | if not is_gone 102 | else None, 103 | ), 104 | ( 105 | issue, 106 | "Discussion can be found at https://github.com/pypa/pip/issues/{}", 107 | ), 108 | ] 109 | 110 | message = " ".join( 111 | format_str.format(value) 112 | for value, format_str in message_parts 113 | if format_str is not None and value is not None 114 | ) 115 | 116 | # Raise as an error if this behaviour is deprecated. 117 | if is_gone: 118 | raise PipDeprecationWarning(message) 119 | 120 | warnings.warn(message, category=PipDeprecationWarning, stacklevel=2) 121 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/direct_url_helpers.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pipask._vendor.pip._internal.models.direct_url import ArchiveInfo, DirectUrl, DirInfo, VcsInfo 4 | from pipask._vendor.pip._internal.models.link import Link 5 | from pipask._vendor.pip._internal.utils.urls import path_to_url 6 | from pipask._vendor.pip._internal.vcs import vcs 7 | 8 | 9 | def direct_url_as_pep440_direct_reference(direct_url: DirectUrl, name: str) -> str: 10 | """Convert a DirectUrl to a pip requirement string.""" 11 | direct_url.validate() # if invalid, this is a pip bug 12 | requirement = name + " @ " 13 | fragments = [] 14 | if isinstance(direct_url.info, VcsInfo): 15 | requirement += "{}+{}@{}".format( 16 | direct_url.info.vcs, direct_url.url, direct_url.info.commit_id 17 | ) 18 | elif isinstance(direct_url.info, ArchiveInfo): 19 | requirement += direct_url.url 20 | if direct_url.info.hash: 21 | fragments.append(direct_url.info.hash) 22 | else: 23 | assert isinstance(direct_url.info, DirInfo) 24 | requirement += direct_url.url 25 | if direct_url.subdirectory: 26 | fragments.append("subdirectory=" + direct_url.subdirectory) 27 | if fragments: 28 | requirement += "#" + "&".join(fragments) 29 | return requirement 30 | 31 | 32 | def direct_url_for_editable(source_dir: str) -> DirectUrl: 33 | return DirectUrl( 34 | url=path_to_url(source_dir), 35 | info=DirInfo(editable=True), 36 | ) 37 | 38 | 39 | def direct_url_from_link( 40 | link: Link, source_dir: Optional[str] = None, link_is_in_wheel_cache: bool = False 41 | ) -> DirectUrl: 42 | if link.is_vcs: 43 | vcs_backend = vcs.get_backend_for_scheme(link.scheme) 44 | assert vcs_backend 45 | url, requested_revision, _ = vcs_backend.get_url_rev_and_auth( 46 | link.url_without_fragment 47 | ) 48 | # For VCS links, we need to find out and add commit_id. 49 | if link_is_in_wheel_cache: 50 | # If the requested VCS link corresponds to a cached 51 | # wheel, it means the requested revision was an 52 | # immutable commit hash, otherwise it would not have 53 | # been cached. In that case we don't have a source_dir 54 | # with the VCS checkout. 55 | assert requested_revision 56 | commit_id = requested_revision 57 | else: 58 | # If the wheel was not in cache, it means we have 59 | # had to checkout from VCS to build and we have a source_dir 60 | # which we can inspect to find out the commit id. 61 | assert source_dir 62 | commit_id = vcs_backend.get_revision(source_dir) 63 | return DirectUrl( 64 | url=url, 65 | info=VcsInfo( 66 | vcs=vcs_backend.name, 67 | commit_id=commit_id, 68 | requested_revision=requested_revision, 69 | ), 70 | subdirectory=link.subdirectory_fragment, 71 | ) 72 | elif link.is_existing_dir(): 73 | return DirectUrl( 74 | url=link.url_without_fragment, 75 | info=DirInfo(), 76 | subdirectory=link.subdirectory_fragment, 77 | ) 78 | else: 79 | hash = None 80 | hash_name = link.hash_name 81 | if hash_name: 82 | hash = f"{hash_name}={link.hash}" 83 | return DirectUrl( 84 | url=link.url_without_fragment, 85 | info=ArchiveInfo(hash=hash), 86 | subdirectory=link.subdirectory_fragment, 87 | ) 88 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/egg_link.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | from typing import List, Optional 5 | 6 | from pipask._vendor.pip._internal.locations import site_packages, user_site 7 | from pipask._vendor.pip._internal.utils.virtualenv import ( 8 | running_under_virtualenv, 9 | virtualenv_no_global, 10 | ) 11 | 12 | __all__ = [ 13 | "egg_link_path_from_sys_path", 14 | "egg_link_path_from_location", 15 | ] 16 | 17 | 18 | def _egg_link_names(raw_name: str) -> List[str]: 19 | """ 20 | Convert a Name metadata value to a .egg-link name, by applying 21 | the same substitution as pkg_resources's safe_name function. 22 | Note: we cannot use canonicalize_name because it has a different logic. 23 | 24 | We also look for the raw name (without normalization) as setuptools 69 changed 25 | the way it names .egg-link files (https://github.com/pypa/setuptools/issues/4167). 26 | """ 27 | return [ 28 | re.sub("[^A-Za-z0-9.]+", "-", raw_name) + ".egg-link", 29 | f"{raw_name}.egg-link", 30 | ] 31 | 32 | 33 | def egg_link_path_from_sys_path(raw_name: str) -> Optional[str]: 34 | """ 35 | Look for a .egg-link file for project name, by walking sys.path. 36 | """ 37 | egg_link_names = _egg_link_names(raw_name) 38 | for path_item in sys.path: 39 | for egg_link_name in egg_link_names: 40 | egg_link = os.path.join(path_item, egg_link_name) 41 | if os.path.isfile(egg_link): 42 | return egg_link 43 | return None 44 | 45 | 46 | def egg_link_path_from_location(raw_name: str) -> Optional[str]: 47 | """ 48 | Return the path for the .egg-link file if it exists, otherwise, None. 49 | 50 | There's 3 scenarios: 51 | 1) not in a virtualenv 52 | try to find in site.USER_SITE, then site_packages 53 | 2) in a no-global virtualenv 54 | try to find in site_packages 55 | 3) in a yes-global virtualenv 56 | try to find in site_packages, then site.USER_SITE 57 | (don't look in global location) 58 | 59 | For #1 and #3, there could be odd cases, where there's an egg-link in 2 60 | locations. 61 | 62 | This method will just return the first one found. 63 | """ 64 | sites: List[str] = [] 65 | if running_under_virtualenv(): 66 | sites.append(site_packages) 67 | if not virtualenv_no_global() and user_site: 68 | sites.append(user_site) 69 | else: 70 | if user_site: 71 | sites.append(user_site) 72 | sites.append(site_packages) 73 | 74 | egg_link_names = _egg_link_names(raw_name) 75 | for site in sites: 76 | for egg_link_name in egg_link_names: 77 | egglink = os.path.join(site, egg_link_name) 78 | if os.path.isfile(egglink): 79 | return egglink 80 | return None 81 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/encoding.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import locale 3 | import re 4 | import sys 5 | from typing import List, Tuple 6 | 7 | BOMS: List[Tuple[bytes, str]] = [ 8 | (codecs.BOM_UTF8, "utf-8"), 9 | (codecs.BOM_UTF16, "utf-16"), 10 | (codecs.BOM_UTF16_BE, "utf-16-be"), 11 | (codecs.BOM_UTF16_LE, "utf-16-le"), 12 | (codecs.BOM_UTF32, "utf-32"), 13 | (codecs.BOM_UTF32_BE, "utf-32-be"), 14 | (codecs.BOM_UTF32_LE, "utf-32-le"), 15 | ] 16 | 17 | ENCODING_RE = re.compile(rb"coding[:=]\s*([-\w.]+)") 18 | 19 | 20 | def auto_decode(data: bytes) -> str: 21 | """Check a bytes string for a BOM to correctly detect the encoding 22 | 23 | Fallback to locale.getpreferredencoding(False) like open() on Python3""" 24 | for bom, encoding in BOMS: 25 | if data.startswith(bom): 26 | return data[len(bom) :].decode(encoding) 27 | # Lets check the first two lines as in PEP263 28 | for line in data.split(b"\n")[:2]: 29 | if line[0:1] == b"#" and ENCODING_RE.search(line): 30 | result = ENCODING_RE.search(line) 31 | assert result is not None 32 | encoding = result.groups()[0].decode("ascii") 33 | return data.decode(encoding) 34 | return data.decode( 35 | locale.getpreferredencoding(False) or sys.getdefaultencoding(), 36 | ) 37 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/filetypes.py: -------------------------------------------------------------------------------- 1 | """Filetype information. 2 | """ 3 | 4 | from typing import Tuple 5 | 6 | from pipask._vendor.pip._internal.utils.misc import splitext 7 | 8 | WHEEL_EXTENSION = ".whl" 9 | BZ2_EXTENSIONS: Tuple[str, ...] = (".tar.bz2", ".tbz") 10 | XZ_EXTENSIONS: Tuple[str, ...] = ( 11 | ".tar.xz", 12 | ".txz", 13 | ".tlz", 14 | ".tar.lz", 15 | ".tar.lzma", 16 | ) 17 | ZIP_EXTENSIONS: Tuple[str, ...] = (".zip", WHEEL_EXTENSION) 18 | TAR_EXTENSIONS: Tuple[str, ...] = (".tar.gz", ".tgz", ".tar") 19 | ARCHIVE_EXTENSIONS = ZIP_EXTENSIONS + BZ2_EXTENSIONS + TAR_EXTENSIONS + XZ_EXTENSIONS 20 | 21 | 22 | def is_archive_file(name: str) -> bool: 23 | """Return True if `name` is a considered as an archive file.""" 24 | ext = splitext(name)[1].lower() 25 | if ext in ARCHIVE_EXTENSIONS: 26 | return True 27 | return False 28 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/glibc.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import Optional, Tuple 4 | 5 | 6 | def glibc_version_string() -> Optional[str]: 7 | "Returns glibc version string, or None if not using glibc." 8 | return glibc_version_string_confstr() or glibc_version_string_ctypes() 9 | 10 | 11 | def glibc_version_string_confstr() -> Optional[str]: 12 | "Primary implementation of glibc_version_string using os.confstr." 13 | # os.confstr is quite a bit faster than ctypes.DLL. It's also less likely 14 | # to be broken or missing. This strategy is used in the standard library 15 | # platform module: 16 | # https://github.com/python/cpython/blob/fcf1d003bf4f0100c9d0921ff3d70e1127ca1b71/Lib/platform.py#L175-L183 17 | if sys.platform == "win32": 18 | return None 19 | try: 20 | gnu_libc_version = os.confstr("CS_GNU_LIBC_VERSION") 21 | if gnu_libc_version is None: 22 | return None 23 | # os.confstr("CS_GNU_LIBC_VERSION") returns a string like "glibc 2.17": 24 | _, version = gnu_libc_version.split() 25 | except (AttributeError, OSError, ValueError): 26 | # os.confstr() or CS_GNU_LIBC_VERSION not available (or a bad value)... 27 | return None 28 | return version 29 | 30 | 31 | def glibc_version_string_ctypes() -> Optional[str]: 32 | "Fallback implementation of glibc_version_string using ctypes." 33 | 34 | try: 35 | import ctypes 36 | except ImportError: 37 | return None 38 | 39 | # ctypes.CDLL(None) internally calls dlopen(NULL), and as the dlopen 40 | # manpage says, "If filename is NULL, then the returned handle is for the 41 | # main program". This way we can let the linker do the work to figure out 42 | # which libc our process is actually using. 43 | process_namespace = ctypes.CDLL(None) 44 | try: 45 | gnu_get_libc_version = process_namespace.gnu_get_libc_version 46 | except AttributeError: 47 | # Symbol doesn't exist -> therefore, we are not linked to 48 | # glibc. 49 | return None 50 | 51 | # Call gnu_get_libc_version, which returns a string like "2.5" 52 | gnu_get_libc_version.restype = ctypes.c_char_p 53 | version_str = gnu_get_libc_version() 54 | # py2 / py3 compatibility: 55 | if not isinstance(version_str, str): 56 | version_str = version_str.decode("ascii") 57 | 58 | return version_str 59 | 60 | 61 | # platform.libc_ver regularly returns completely nonsensical glibc 62 | # versions. E.g. on my computer, platform says: 63 | # 64 | # ~$ python2.7 -c 'import platform; print(platform.libc_ver())' 65 | # ('glibc', '2.7') 66 | # ~$ python3.5 -c 'import platform; print(platform.libc_ver())' 67 | # ('glibc', '2.9') 68 | # 69 | # But the truth is: 70 | # 71 | # ~$ ldd --version 72 | # ldd (Debian GLIBC 2.22-11) 2.22 73 | # 74 | # This is unfortunate, because it means that the linehaul data on libc 75 | # versions that was generated by pip 8.1.2 and earlier is useless and 76 | # misleading. Solution: instead of using platform, use our code that actually 77 | # works. 78 | def libc_ver() -> Tuple[str, str]: 79 | """Try to determine the glibc version 80 | 81 | Returns a tuple of strings (lib, version) which default to empty strings 82 | in case the lookup fails. 83 | """ 84 | glibc_version = glibc_version_string() 85 | if glibc_version is None: 86 | return ("", "") 87 | else: 88 | return ("glibc", glibc_version) 89 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/models.py: -------------------------------------------------------------------------------- 1 | """Utilities for defining models 2 | """ 3 | 4 | import operator 5 | from typing import Any, Callable, Type 6 | 7 | 8 | class KeyBasedCompareMixin: 9 | """Provides comparison capabilities that is based on a key""" 10 | 11 | __slots__ = ["_compare_key", "_defining_class"] 12 | 13 | def __init__(self, key: Any, defining_class: Type["KeyBasedCompareMixin"]) -> None: 14 | self._compare_key = key 15 | self._defining_class = defining_class 16 | 17 | def __hash__(self) -> int: 18 | return hash(self._compare_key) 19 | 20 | def __lt__(self, other: Any) -> bool: 21 | return self._compare(other, operator.__lt__) 22 | 23 | def __le__(self, other: Any) -> bool: 24 | return self._compare(other, operator.__le__) 25 | 26 | def __gt__(self, other: Any) -> bool: 27 | return self._compare(other, operator.__gt__) 28 | 29 | def __ge__(self, other: Any) -> bool: 30 | return self._compare(other, operator.__ge__) 31 | 32 | def __eq__(self, other: Any) -> bool: 33 | return self._compare(other, operator.__eq__) 34 | 35 | def _compare(self, other: Any, method: Callable[[Any, Any], bool]) -> bool: 36 | if not isinstance(other, self._defining_class): 37 | return NotImplemented 38 | 39 | return method(self._compare_key, other._compare_key) 40 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/packaging.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import logging 3 | import re 4 | from typing import NewType, Optional, Tuple, cast 5 | 6 | from packaging import specifiers, version 7 | from packaging.requirements import Requirement 8 | 9 | NormalizedExtra = NewType("NormalizedExtra", str) 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def check_requires_python( 15 | requires_python: Optional[str], version_info: Tuple[int, ...] 16 | ) -> bool: 17 | """ 18 | Check if the given Python version matches a "Requires-Python" specifier. 19 | 20 | :param version_info: A 3-tuple of ints representing a Python 21 | major-minor-micro version to check (e.g. `sys.version_info[:3]`). 22 | 23 | :return: `True` if the given Python version satisfies the requirement. 24 | Otherwise, return `False`. 25 | 26 | :raises InvalidSpecifier: If `requires_python` has an invalid format. 27 | """ 28 | if requires_python is None: 29 | # The package provides no information 30 | return True 31 | requires_python_specifier = specifiers.SpecifierSet(requires_python) 32 | 33 | python_version = version.parse(".".join(map(str, version_info))) 34 | return python_version in requires_python_specifier 35 | 36 | 37 | @functools.lru_cache(maxsize=512) 38 | def get_requirement(req_string: str) -> Requirement: 39 | """Construct a packaging.Requirement object with caching""" 40 | # Parsing requirement strings is expensive, and is also expected to happen 41 | # with a low diversity of different arguments (at least relative the number 42 | # constructed). This method adds a cache to requirement object creation to 43 | # minimize repeated parsing of the same string to construct equivalent 44 | # Requirement objects. 45 | return Requirement(req_string) 46 | 47 | 48 | def safe_extra(extra: str) -> NormalizedExtra: 49 | """Convert an arbitrary string to a standard 'extra' name 50 | 51 | Any runs of non-alphanumeric characters are replaced with a single '_', 52 | and the result is always lowercased. 53 | 54 | This function is duplicated from ``pkg_resources``. Note that this is not 55 | the same to either ``canonicalize_name`` or ``_egg_link_name``. 56 | """ 57 | return cast(NormalizedExtra, re.sub("[^A-Za-z0-9.-]+", "_", extra).lower()) 58 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/urls.py: -------------------------------------------------------------------------------- 1 | import os 2 | import string 3 | import urllib.parse 4 | import urllib.request 5 | from typing import Optional 6 | 7 | from .compat import WINDOWS 8 | 9 | 10 | def get_url_scheme(url: str) -> Optional[str]: 11 | if ":" not in url: 12 | return None 13 | return url.split(":", 1)[0].lower() 14 | 15 | 16 | def path_to_url(path: str) -> str: 17 | """ 18 | Convert a path to a file: URL. The path will be made absolute and have 19 | quoted path parts. 20 | """ 21 | path = os.path.normpath(os.path.abspath(path)) 22 | url = urllib.parse.urljoin("file:", urllib.request.pathname2url(path)) 23 | return url 24 | 25 | 26 | def url_to_path(url: str) -> str: 27 | """ 28 | Convert a file: URL to a path. 29 | """ 30 | assert url.startswith( 31 | "file:" 32 | ), f"You can only turn file: urls into filenames (not {url!r})" 33 | 34 | _, netloc, path, _, _ = urllib.parse.urlsplit(url) 35 | 36 | if not netloc or netloc == "localhost": 37 | # According to RFC 8089, same as empty authority. 38 | netloc = "" 39 | elif WINDOWS: 40 | # If we have a UNC path, prepend UNC share notation. 41 | netloc = "\\\\" + netloc 42 | else: 43 | raise ValueError( 44 | f"non-local file URIs are not supported on this platform: {url!r}" 45 | ) 46 | 47 | path = urllib.request.url2pathname(netloc + path) 48 | 49 | # On Windows, urlsplit parses the path as something like "/C:/Users/foo". 50 | # This creates issues for path-related functions like io.open(), so we try 51 | # to detect and strip the leading slash. 52 | if ( 53 | WINDOWS 54 | and not netloc # Not UNC. 55 | and len(path) >= 3 56 | and path[0] == "/" # Leading slash to strip. 57 | and path[1] in string.ascii_letters # Drive letter. 58 | and path[2:4] in (":", ":/") # Colon + end of string, or colon + absolute path. 59 | ): 60 | path = path[1:] 61 | 62 | return path 63 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/utils/virtualenv.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | import site 5 | import sys 6 | from typing import List, Optional 7 | 8 | from pipask.infra.sys_values import get_pip_sys_values 9 | 10 | logger = logging.getLogger(__name__) 11 | _INCLUDE_SYSTEM_SITE_PACKAGES_REGEX = re.compile( 12 | r"include-system-site-packages\s*=\s*(?Ptrue|false)" 13 | ) 14 | 15 | 16 | def _running_under_venv() -> bool: 17 | """Checks if sys.base_prefix and sys.prefix match. 18 | 19 | This handles PEP 405 compliant virtual environments. 20 | """ 21 | return get_pip_sys_values().prefix != get_pip_sys_values().base_prefix # MODIFIED for pipask 22 | 23 | 24 | def _running_under_legacy_virtualenv() -> bool: 25 | """Checks if sys.real_prefix is set. 26 | 27 | This handles virtual environments created with pypa's virtualenv. 28 | """ 29 | # pypa/virtualenv case 30 | return get_pip_sys_values().has_real_prefix # MODIFIED for pipask 31 | 32 | 33 | def running_under_virtualenv() -> bool: 34 | """True if we're running inside a virtual environment, False otherwise.""" 35 | return _running_under_venv() or _running_under_legacy_virtualenv() 36 | 37 | 38 | def _get_pyvenv_cfg_lines() -> Optional[List[str]]: 39 | """Reads {sys.prefix}/pyvenv.cfg and returns its contents as list of lines 40 | 41 | Returns None, if it could not read/access the file. 42 | """ 43 | pyvenv_cfg_file = os.path.join(get_pip_sys_values().prefix, "pyvenv.cfg") # MODIFIED for pipask 44 | try: 45 | # Although PEP 405 does not specify, the built-in venv module always 46 | # writes with UTF-8. (pypa/pip#8717) 47 | with open(pyvenv_cfg_file, encoding="utf-8") as f: 48 | return f.read().splitlines() # avoids trailing newlines 49 | except OSError: 50 | return None 51 | 52 | 53 | def _no_global_under_venv() -> bool: 54 | """Check `{sys.prefix}/pyvenv.cfg` for system site-packages inclusion 55 | 56 | PEP 405 specifies that when system site-packages are not supposed to be 57 | visible from a virtual environment, `pyvenv.cfg` must contain the following 58 | line: 59 | 60 | include-system-site-packages = false 61 | 62 | Additionally, log a warning if accessing the file fails. 63 | """ 64 | cfg_lines = _get_pyvenv_cfg_lines() 65 | if cfg_lines is None: 66 | # We're not in a "sane" venv, so assume there is no system 67 | # site-packages access (since that's PEP 405's default state). 68 | logger.warning( 69 | "Could not access 'pyvenv.cfg' despite a virtual environment " 70 | "being active. Assuming global site-packages is not accessible " 71 | "in this environment." 72 | ) 73 | return True 74 | 75 | for line in cfg_lines: 76 | match = _INCLUDE_SYSTEM_SITE_PACKAGES_REGEX.match(line) 77 | if match is not None and match.group("value") == "false": 78 | return True 79 | return False 80 | 81 | 82 | def _no_global_under_legacy_virtualenv() -> bool: 83 | """Check if "no-global-site-packages.txt" exists beside site.py 84 | 85 | This mirrors logic in pypa/virtualenv for determining whether system 86 | site-packages are visible in the virtual environment. 87 | """ 88 | site_mod_dir = os.path.dirname(os.path.abspath(get_pip_sys_values().site_file)) # MODIFIED for pipask 89 | no_global_site_packages_file = os.path.join( 90 | site_mod_dir, 91 | "no-global-site-packages.txt", 92 | ) 93 | return os.path.exists(no_global_site_packages_file) 94 | 95 | 96 | def virtualenv_no_global() -> bool: 97 | """Returns a boolean, whether running in venv with no system site-packages.""" 98 | # PEP 405 compliance needs to be checked first since virtualenv >=20 would 99 | # return True for both checks, but is only able to use the PEP 405 config. 100 | if _running_under_venv(): 101 | return _no_global_under_venv() 102 | 103 | if _running_under_legacy_virtualenv(): 104 | return _no_global_under_legacy_virtualenv() 105 | 106 | return False 107 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/vcs/__init__.py: -------------------------------------------------------------------------------- 1 | # Expose a limited set of classes and functions so callers outside of 2 | # the vcs package don't need to import deeper than `pip._internal.vcs`. 3 | # (The test directory may still need to import from a vcs sub-package.) 4 | # Import all vcs modules to register each VCS in the VcsSupport object. 5 | import pipask._vendor.pip._internal.vcs.bazaar 6 | import pipask._vendor.pip._internal.vcs.git 7 | import pipask._vendor.pip._internal.vcs.mercurial 8 | import pipask._vendor.pip._internal.vcs.subversion # noqa: F401 9 | from pipask._vendor.pip._internal.vcs.versioncontrol import ( # noqa: F401 10 | RemoteNotFoundError, 11 | RemoteNotValidError, 12 | is_url, 13 | make_vcs_requirement_url, 14 | vcs, 15 | ) 16 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_internal/vcs/bazaar.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Optional, Tuple 3 | 4 | from pipask._vendor.pip._internal.utils.misc import HiddenText, display_path 5 | from pipask._vendor.pip._internal.utils.subprocess import make_command 6 | from pipask._vendor.pip._internal.utils.urls import path_to_url 7 | from pipask._vendor.pip._internal.vcs.versioncontrol import ( 8 | AuthInfo, 9 | RemoteNotFoundError, 10 | RevOptions, 11 | VersionControl, 12 | vcs, 13 | ) 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class Bazaar(VersionControl): 19 | name = "bzr" 20 | dirname = ".bzr" 21 | repo_name = "branch" 22 | schemes = ( 23 | "bzr+http", 24 | "bzr+https", 25 | "bzr+ssh", 26 | "bzr+sftp", 27 | "bzr+ftp", 28 | "bzr+lp", 29 | "bzr+file", 30 | ) 31 | 32 | @staticmethod 33 | def get_base_rev_args(rev: str) -> List[str]: 34 | return ["-r", rev] 35 | 36 | def fetch_new( 37 | self, dest: str, url: HiddenText, rev_options: RevOptions, verbosity: int 38 | ) -> None: 39 | rev_display = rev_options.to_display() 40 | logger.info( 41 | "Checking out %s%s to %s", 42 | url, 43 | rev_display, 44 | display_path(dest), 45 | ) 46 | if verbosity <= 0: 47 | flag = "--quiet" 48 | elif verbosity == 1: 49 | flag = "" 50 | else: 51 | flag = f"-{'v'*verbosity}" 52 | cmd_args = make_command( 53 | "checkout", "--lightweight", flag, rev_options.to_args(), url, dest 54 | ) 55 | self.run_command(cmd_args) 56 | 57 | def switch(self, dest: str, url: HiddenText, rev_options: RevOptions) -> None: 58 | self.run_command(make_command("switch", url), cwd=dest) 59 | 60 | def update(self, dest: str, url: HiddenText, rev_options: RevOptions) -> None: 61 | output = self.run_command( 62 | make_command("info"), show_stdout=False, stdout_only=True, cwd=dest 63 | ) 64 | if output.startswith("Standalone "): 65 | # Older versions of pip used to create standalone branches. 66 | # Convert the standalone branch to a checkout by calling "bzr bind". 67 | cmd_args = make_command("bind", "-q", url) 68 | self.run_command(cmd_args, cwd=dest) 69 | 70 | cmd_args = make_command("update", "-q", rev_options.to_args()) 71 | self.run_command(cmd_args, cwd=dest) 72 | 73 | @classmethod 74 | def get_url_rev_and_auth(cls, url: str) -> Tuple[str, Optional[str], AuthInfo]: 75 | # hotfix the URL scheme after removing bzr+ from bzr+ssh:// re-add it 76 | url, rev, user_pass = super().get_url_rev_and_auth(url) 77 | if url.startswith("ssh://"): 78 | url = "bzr+" + url 79 | return url, rev, user_pass 80 | 81 | @classmethod 82 | def get_remote_url(cls, location: str) -> str: 83 | urls = cls.run_command( 84 | ["info"], show_stdout=False, stdout_only=True, cwd=location 85 | ) 86 | for line in urls.splitlines(): 87 | line = line.strip() 88 | for x in ("checkout of branch: ", "parent branch: "): 89 | if line.startswith(x): 90 | repo = line.split(x)[1] 91 | if cls._is_local_repository(repo): 92 | return path_to_url(repo) 93 | return repo 94 | raise RemoteNotFoundError 95 | 96 | @classmethod 97 | def get_revision(cls, location: str) -> str: 98 | revision = cls.run_command( 99 | ["revno"], 100 | show_stdout=False, 101 | stdout_only=True, 102 | cwd=location, 103 | ) 104 | return revision.splitlines()[-1] 105 | 106 | @classmethod 107 | def is_commit_id_equal(cls, dest: str, name: Optional[str]) -> bool: 108 | """Always assume the versions don't match""" 109 | return False 110 | 111 | 112 | vcs.register(Bazaar) 113 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_vendor/pkg_resources/LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy 2 | of this software and associated documentation files (the "Software"), to 3 | deal in the Software without restriction, including without limitation the 4 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 5 | sell copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in 9 | all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 16 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 17 | IN THE SOFTWARE. 18 | -------------------------------------------------------------------------------- /src/pipask/_vendor/pip/_vendor/typing_extensions.pyi: -------------------------------------------------------------------------------- 1 | from typing_extensions import * -------------------------------------------------------------------------------- /src/pipask/checks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/src/pipask/checks/__init__.py -------------------------------------------------------------------------------- /src/pipask/checks/base_checker.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from pipask.checks.types import CheckResult 4 | from pipask.infra.pypi import VerifiedPypiReleaseInfo 5 | 6 | 7 | class Checker(abc.ABC): 8 | @abc.abstractmethod 9 | async def check(self, verified_release_info: VerifiedPypiReleaseInfo) -> CheckResult: 10 | pass 11 | 12 | @property 13 | @abc.abstractmethod 14 | def description(self) -> str: 15 | pass 16 | -------------------------------------------------------------------------------- /src/pipask/checks/license.py: -------------------------------------------------------------------------------- 1 | from pipask.checks.base_checker import Checker 2 | from pipask.checks.types import CheckResult, CheckResultType 3 | from pipask.infra.pypi import VerifiedPypiReleaseInfo 4 | 5 | # See https://pypi.org/classifiers/ 6 | 7 | 8 | class LicenseChecker(Checker): 9 | @property 10 | def description(self) -> str: 11 | return "Checking package license" 12 | 13 | async def check(self, verified_release_info: VerifiedPypiReleaseInfo) -> CheckResult: 14 | info = verified_release_info.release_response.info 15 | license = next((c for c in info.classifiers if c.startswith("License :: ")), None) 16 | if license: 17 | license = license.split(" :: ")[-1] 18 | if not license: 19 | license = info.license 20 | if license: 21 | return CheckResult( 22 | result_type=CheckResultType.NEUTRAL, 23 | message=f"Package is licensed under {license}", 24 | ) 25 | 26 | return CheckResult( 27 | result_type=CheckResultType.WARNING, 28 | message="No license found in PyPI metadata - you may need to check manually", 29 | ) 30 | -------------------------------------------------------------------------------- /src/pipask/checks/package_age.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from pipask.checks.base_checker import Checker 4 | from pipask.checks.types import CheckResult, CheckResultType 5 | from pipask.infra.pypi import PypiClient, VerifiedPypiReleaseInfo 6 | 7 | _TOO_NEW_DAYS = 22 8 | _TOO_OLD_DAYS = 365 9 | 10 | 11 | class PackageAge(Checker): 12 | def __init__(self, pypi_client: PypiClient): 13 | self._pypi_client = pypi_client 14 | 15 | @property 16 | def description(self) -> str: 17 | return "Checking package age" 18 | 19 | async def check(self, verified_release_info: VerifiedPypiReleaseInfo) -> CheckResult: 20 | distributions = await self._pypi_client.get_distributions(verified_release_info.name) 21 | if distributions is None: 22 | return CheckResult( 23 | result_type=CheckResultType.FAILURE, 24 | message="No distributions information available", 25 | ) 26 | oldest_distribution = min(distributions.files, key=lambda x: x.upload_time) 27 | max_age_days = (datetime.datetime.now(datetime.timezone.utc) - oldest_distribution.upload_time).days 28 | if max_age_days < _TOO_NEW_DAYS: 29 | return CheckResult( 30 | result_type=CheckResultType.WARNING, 31 | message=f"A newly published package: created only {max_age_days} days ago", 32 | ) 33 | 34 | newest_release_file = max(verified_release_info.release_response.urls, key=lambda x: x.upload_time) 35 | release_age_days = (datetime.datetime.now(datetime.timezone.utc) - newest_release_file.upload_time).days 36 | if release_age_days > _TOO_OLD_DAYS: 37 | return CheckResult( 38 | result_type=CheckResultType.WARNING, 39 | message=f"The release is older than a year: {release_age_days} days old", 40 | ) 41 | return CheckResult( 42 | result_type=CheckResultType.SUCCESS, 43 | message=f"The release is {release_age_days} day{'' if release_age_days == 1 else 's'} old", 44 | ) 45 | -------------------------------------------------------------------------------- /src/pipask/checks/package_downloads.py: -------------------------------------------------------------------------------- 1 | from pipask.checks.base_checker import Checker 2 | from pipask.checks.types import CheckResult, CheckResultType 3 | from pipask.infra.pypi import VerifiedPypiReleaseInfo 4 | from pipask.infra.pypistats import PypiStatsClient 5 | 6 | _WARNING_THRESHOLD = 5000 7 | _FAILURE_THRESHOLD = 100 8 | 9 | 10 | class PackageDownloadsChecker(Checker): 11 | def __init__(self, pypi_stats_client: PypiStatsClient): 12 | self._pypi_stats_client = pypi_stats_client 13 | 14 | @property 15 | def description(self) -> str: 16 | return "Checking package download stats" 17 | 18 | async def check(self, verified_release_info: VerifiedPypiReleaseInfo) -> CheckResult: 19 | pypi_stats = await self._pypi_stats_client.get_download_stats(verified_release_info.name) 20 | if pypi_stats is None: 21 | return CheckResult( 22 | result_type=CheckResultType.FAILURE, 23 | message="No download statistics available", 24 | ) 25 | formatted_downloads = f"{pypi_stats.last_month:,}" 26 | if pypi_stats.last_month < _FAILURE_THRESHOLD: 27 | return CheckResult( 28 | result_type=CheckResultType.FAILURE, 29 | message=f"Only {formatted_downloads} downloads from PyPI in the last month", 30 | ) 31 | if pypi_stats.last_month < _WARNING_THRESHOLD: 32 | return CheckResult( 33 | result_type=CheckResultType.WARNING, 34 | message=f"Only {formatted_downloads} downloads from PyPI in the last month", 35 | ) 36 | return CheckResult( 37 | result_type=CheckResultType.SUCCESS, 38 | message=f"{formatted_downloads} downloads from PyPI in the last month", 39 | ) 40 | -------------------------------------------------------------------------------- /src/pipask/checks/release_metadata.py: -------------------------------------------------------------------------------- 1 | from pipask.checks.base_checker import Checker 2 | from pipask.checks.types import CheckResult, CheckResultType 3 | from pipask.infra.pypi import ReleaseResponse, VerifiedPypiReleaseInfo 4 | 5 | # See https://pypi.org/classifiers/ 6 | _WARNING_CLASSIFIERS = [ 7 | "Development Status :: 1 - Planning", 8 | "Development Status :: 2 - Pre-Alpha", 9 | "Development Status :: 3 - Alpha", 10 | "Development Status :: 4 - Beta", 11 | "Development Status :: 7 - Inactive", 12 | ] 13 | _SUCCESS_CLASSIFIERS = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "Development Status :: 6 - Mature", 16 | ] 17 | 18 | 19 | class ReleaseMetadataChecker(Checker): 20 | @property 21 | def description(self) -> str: 22 | return "Checking release metadata" 23 | 24 | async def check(self, verified_release_info: VerifiedPypiReleaseInfo) -> CheckResult: 25 | if verified_release_info.release_response.info.yanked: 26 | reason = ( 27 | f" (reason: {verified_release_info.release_response.info.yanked_reason})" 28 | if verified_release_info.release_response.info.yanked_reason 29 | else "" 30 | ) 31 | return CheckResult( 32 | result_type=CheckResultType.FAILURE, 33 | message=f"The release is yanked{reason}", 34 | ) 35 | if classifier := _first_matching_classifier(verified_release_info.release_response, _WARNING_CLASSIFIERS): 36 | return CheckResult( 37 | result_type=CheckResultType.WARNING, 38 | message=f"Package is classified as {classifier}", 39 | ) 40 | if classifier := _first_matching_classifier(verified_release_info.release_response, _SUCCESS_CLASSIFIERS): 41 | return CheckResult( 42 | result_type=CheckResultType.SUCCESS, 43 | message=f"Package is classified as {classifier}", 44 | ) 45 | return CheckResult( 46 | result_type=CheckResultType.NEUTRAL, 47 | message="No development status classifiers", 48 | ) 49 | 50 | 51 | def _first_matching_classifier(release_info: ReleaseResponse, classifiers: list[str]) -> str | None: 52 | for classifier in classifiers: 53 | if classifier in release_info.info.classifiers: 54 | return classifier 55 | return None 56 | -------------------------------------------------------------------------------- /src/pipask/checks/repo_popularity.py: -------------------------------------------------------------------------------- 1 | from pipask.checks.base_checker import Checker 2 | from pipask.checks.types import CheckResult, CheckResultType 3 | from pipask.infra.pypi import AttestationPublisher, PypiClient, VerifiedPypiReleaseInfo 4 | from pipask.infra.repo_client import RepoClient 5 | import logging 6 | 7 | from pipask.utils import format_link 8 | 9 | _WARNING_THRESHOLD = 1000 10 | _BOLD_WARNING_THRESHOLD = 100 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class RepoPopularityChecker(Checker): 16 | def __init__(self, repo_client: RepoClient, pypi_client: PypiClient): 17 | self._repo_client = repo_client 18 | self._pypi_client = pypi_client 19 | 20 | @property 21 | def description(self) -> str: 22 | return "Checking repository popularity" 23 | 24 | async def check(self, verified_release_info: VerifiedPypiReleaseInfo) -> CheckResult: 25 | attestations = await self._pypi_client.get_attestations(verified_release_info) 26 | project_urls = verified_release_info.release_response.info.project_urls 27 | if attestations is not None and len(attestations.attestation_bundles): 28 | # We have VERIFIED info about the source repository 29 | publisher = attestations.attestation_bundles[0].publisher 30 | repo_url = _get_repo_url(publisher) 31 | if repo_url is None: 32 | return CheckResult( 33 | result_type=CheckResultType.WARNING, 34 | message=f"Unrecognized repository type in attestation: {publisher.kind}", 35 | ) 36 | repo_info = await self._repo_client.get_repo_info(repo_url) 37 | if repo_info is None: 38 | return CheckResult( 39 | result_type=CheckResultType.FAILURE, 40 | message=f"Source repository not found: [link={repo_url}]{repo_url}[/link]", 41 | ) 42 | formatted_repository = format_link("Repository", repo_url, fallback=True) 43 | if repo_info.star_count > _WARNING_THRESHOLD: 44 | return CheckResult( 45 | result_type=CheckResultType.SUCCESS, 46 | message=f"{formatted_repository} has {repo_info.star_count} stars", 47 | ) 48 | elif repo_info.star_count > _BOLD_WARNING_THRESHOLD: 49 | return CheckResult( 50 | result_type=CheckResultType.WARNING, 51 | message=f"{formatted_repository} has less than 1000 stars: {repo_info.star_count} stars", 52 | ) 53 | else: 54 | return CheckResult( 55 | result_type=CheckResultType.WARNING, 56 | message=f"[bold]{formatted_repository} has less than 100 stars: {repo_info.star_count} stars", 57 | ) 58 | elif project_urls is not None and (repo_url := project_urls.recognized_repo_url()) is not None: 59 | # We only have an UNVERIFIED link to the repository 60 | formatted_repository = format_link("repository", repo_url, fallback=True) 61 | return CheckResult( 62 | result_type=CheckResultType.WARNING, 63 | message=f"Unverified link to source {formatted_repository} (true origin may be different)", 64 | ) 65 | else: 66 | # No recognized link to the source repository 67 | return CheckResult(result_type=CheckResultType.WARNING, message="No repository URL found") 68 | 69 | 70 | def _get_repo_url(publisher: AttestationPublisher) -> str | None: 71 | match publisher.kind: 72 | case "GitHub": 73 | return f"https://github.com/{publisher.repository}" 74 | case "GitLab": 75 | return f"https://gitlab.com/{publisher.repository}" 76 | case _: 77 | logger.debug("Unsupported publisher: %s", publisher.kind) 78 | return None 79 | -------------------------------------------------------------------------------- /src/pipask/checks/types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from typing import Optional 4 | 5 | 6 | class CheckResultType(str, Enum): 7 | SUCCESS = ("success", "green", "[green]✔[/green]") 8 | FAILURE = ("failure", "red", "[red]✖[/red]") 9 | WARNING = ("warning", "yellow", "[yellow bold]![/yellow bold]") 10 | NEUTRAL = ("neutral", "default", "✔") 11 | ERROR = ("error", "red", "[red]![/red]") 12 | 13 | rich_color: str 14 | rich_icon: str 15 | 16 | def __new__(cls, value: str, rich_color: str, rich_icon: str): 17 | obj = str.__new__(cls, [value]) 18 | obj._value_ = value 19 | obj.rich_color = rich_color 20 | obj.rich_icon = rich_icon 21 | return obj 22 | 23 | @staticmethod 24 | def get_worst(*results: Optional["CheckResultType"]) -> Optional["CheckResultType"]: 25 | if any(result is CheckResultType.FAILURE for result in results): 26 | return CheckResultType.FAILURE 27 | if any(result is CheckResultType.ERROR for result in results): 28 | return CheckResultType.ERROR 29 | if any(result is CheckResultType.WARNING for result in results): 30 | return CheckResultType.WARNING 31 | if any(result is CheckResultType.NEUTRAL for result in results): 32 | return CheckResultType.NEUTRAL 33 | if any(result is CheckResultType.SUCCESS for result in results): 34 | return CheckResultType.SUCCESS 35 | return None 36 | 37 | 38 | @dataclass 39 | class CheckResult: 40 | result_type: CheckResultType 41 | message: str 42 | 43 | 44 | @dataclass 45 | class PackageCheckResults: 46 | name: str 47 | version: str 48 | results: list[CheckResult] 49 | is_transitive_dependency: bool 50 | pypi_url: str | None = None 51 | -------------------------------------------------------------------------------- /src/pipask/checks/vulnerabilities.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections import defaultdict 3 | 4 | from pipask.checks.base_checker import Checker 5 | from pipask.checks.types import CheckResult, CheckResultType 6 | from pipask.infra.pypi import VerifiedPypiReleaseInfo 7 | from pipask.infra.vulnerability_details import VulnerabilityDetails, VulnerabilityDetailsService, VulnerabilitySeverity 8 | from pipask.utils import format_link 9 | 10 | MAX_DISPLAYED_VULNERABILITIES = 5 11 | 12 | 13 | class ReleaseVulnerabilityChecker(Checker): 14 | def __init__(self, vulnerability_details_service: VulnerabilityDetailsService): 15 | self._vulnerability_details_service = vulnerability_details_service 16 | 17 | @property 18 | def description(self) -> str: 19 | return "Checking known vulnerabilities" 20 | 21 | async def check(self, verified_release_info: VerifiedPypiReleaseInfo) -> CheckResult: 22 | release_response = verified_release_info.release_response 23 | relevant_vulnerabilities = [v for v in release_response.vulnerabilities if not v.withdrawn] 24 | if len(relevant_vulnerabilities) == 0: 25 | return CheckResult( 26 | result_type=CheckResultType.SUCCESS, 27 | message="No known vulnerabilities found", 28 | ) 29 | 30 | vulnerability_details = await asyncio.gather( 31 | *(self._vulnerability_details_service.get_details(v) for v in relevant_vulnerabilities) 32 | ) 33 | deduplicated_vulnerability_details = list({v.id: v for v in vulnerability_details if v.id is not None}.values()) 34 | worst_severity = VulnerabilitySeverity.get_worst(*(v.severity for v in deduplicated_vulnerability_details)) 35 | formatted_vulnerabilities = _format_vulnerabilities(deduplicated_vulnerability_details) 36 | return CheckResult( 37 | result_type=worst_severity.result_type if worst_severity is not None else CheckResultType.WARNING, 38 | message=f"Found the following vulnerabilities: {formatted_vulnerabilities}", 39 | ) 40 | 41 | 42 | def _format_vulnerabilities(vulnerabilities: list[VulnerabilityDetails]) -> str: 43 | severity_order = list(VulnerabilitySeverity) 44 | sorted_vulnerabilities = sorted( 45 | vulnerabilities, 46 | key=lambda v: (severity_order.index(v.severity) if v.severity is not None else len(severity_order)), 47 | ) 48 | sorted_vulnerabilities = sorted_vulnerabilities[:MAX_DISPLAYED_VULNERABILITIES] 49 | 50 | by_severity = defaultdict(list) 51 | for vuln in sorted_vulnerabilities: 52 | by_severity[vuln.severity].append(vuln) 53 | formatted = [] 54 | for severity in [*VulnerabilitySeverity, None]: 55 | if severity not in by_severity: 56 | continue 57 | formatted_ids = [ 58 | format_link(vuln.id, vuln.link, fallback=True) for vuln in by_severity[severity] if vuln.id is not None 59 | ] 60 | color = severity.result_type.rich_color if severity is not None else "default" 61 | formatted_severity = severity.value if severity is not None else "unknown severity" 62 | formatted.append(f"[{color}]{', '.join(formatted_ids)} ({formatted_severity})[/{color}]") 63 | result = ", ".join(formatted) 64 | if len(vulnerabilities) > MAX_DISPLAYED_VULNERABILITIES: 65 | result += f" and {len(vulnerabilities) - MAX_DISPLAYED_VULNERABILITIES} more" 66 | return result 67 | -------------------------------------------------------------------------------- /src/pipask/cli_args.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from optparse import Values 3 | 4 | 5 | @dataclass 6 | class PipCommandArgs: 7 | command_name: str 8 | command_args: list[str] 9 | raw_args: list[str] 10 | 11 | 12 | class InstallArgs: 13 | raw_args: list[str] 14 | options: Values 15 | install_args: list[str] 16 | 17 | # Typed values from `options`: 18 | help: bool 19 | version: bool 20 | dry_run: bool 21 | json_report_file: str | None 22 | quiet: int 23 | verbose: int 24 | upgrade: bool 25 | upgrade_strategy: str 26 | target_dir: str | None 27 | isolated: bool 28 | 29 | def __init__(self, raw_args: list[str], raw_options: Values, install_args: list[str]) -> None: 30 | self.raw_args = raw_args 31 | self.options = raw_options 32 | self.install_args = install_args 33 | 34 | self.help = getattr(raw_options, "help", False) 35 | self.version = getattr(raw_options, "version", False) 36 | self.dry_run = getattr(raw_options, "dry_run", False) 37 | self.json_report_file = getattr(raw_options, "json_report_file", None) 38 | self.quiet = getattr(raw_options, "quiet", 0) 39 | self.verbose = getattr(raw_options, "verbose", 0) 40 | self.upgrade = getattr(raw_options, "upgrade", False) 41 | self.upgrade_strategy = getattr(raw_options, "upgrade_strategy", "only-if-needed") 42 | self.target_dir = getattr(raw_options, "target_dir", None) 43 | self.isolated = "--isolated" in raw_args 44 | -------------------------------------------------------------------------------- /src/pipask/cli_helpers.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console, RenderableType 2 | from rich.progress import Progress, ProgressColumn, Task, TaskID, TextColumn, TimeElapsedColumn 3 | from rich.spinner import Spinner 4 | from rich.text import Text 5 | 6 | from pipask.checks.types import CheckResultType 7 | 8 | 9 | class CheckTask: 10 | def __init__(self, progress: Progress, task_id: TaskID): 11 | self._task_id = task_id 12 | self._progress = progress 13 | self._result: CheckResultType | None = None 14 | 15 | def update(self, partial_result: bool | CheckResultType): 16 | if partial_result is True: 17 | partial_result = CheckResultType.SUCCESS 18 | elif partial_result is False: 19 | partial_result = CheckResultType.FAILURE 20 | self._result = CheckResultType.get_worst(self._result, partial_result) 21 | self._progress.update(self._task_id, advance=1, result=self._result) 22 | 23 | def show(self): 24 | self._progress.update(self._task_id, visible=True) 25 | self._progress.start() 26 | 27 | def hide(self): 28 | self._progress.update(self._task_id, visible=False) 29 | self._progress.stop() 30 | 31 | def start(self): 32 | self._progress.start_task(self._task_id) 33 | 34 | 35 | class SimpleTaskProgress: 36 | def __init__(self, console: Console | None = None): 37 | self.progress = Progress( 38 | _SpinnerAndStatusColumn(), _StateAwareTextColumn(), TimeElapsedColumn(), console=console 39 | ) 40 | 41 | def __enter__(self): 42 | self.progress.__enter__() 43 | return self 44 | 45 | def __exit__(self, exc_type, exc_value, traceback): 46 | self.progress.__exit__(exc_type, exc_value, traceback) 47 | 48 | def add_task(self, description: str, start: bool = True, total: int = 1) -> CheckTask: 49 | return CheckTask(self.progress, self.progress.add_task(description, start=start, total=total)) 50 | 51 | 52 | class _SpinnerAndStatusColumn(ProgressColumn): 53 | def __init__(self): 54 | self.spinner = Spinner("dots", style="progress.spinner") 55 | super().__init__() 56 | 57 | def render(self, task: Task) -> RenderableType: 58 | if task.finished: 59 | if ( 60 | task.fields["result"] is True 61 | or task.fields["result"] is CheckResultType.SUCCESS 62 | or task.fields["result"] is CheckResultType.NEUTRAL 63 | ): 64 | return CheckResultType.SUCCESS.rich_icon 65 | elif task.fields["result"] is False or task.fields["result"] is CheckResultType.FAILURE: 66 | return CheckResultType.FAILURE.rich_icon 67 | elif task.fields["result"] is CheckResultType.ERROR: 68 | return CheckResultType.ERROR.rich_icon 69 | elif task.fields["result"] is CheckResultType.WARNING: 70 | return CheckResultType.WARNING.rich_icon 71 | else: 72 | return " " 73 | elif task.started: 74 | return self.spinner.render(task.get_time()) 75 | else: 76 | return " " 77 | 78 | 79 | class _StateAwareTextColumn(TextColumn): 80 | def __init__(self): 81 | super().__init__("{task.description}") 82 | 83 | def render(self, task: Task) -> Text: 84 | text_with_format = self.text_format.format(task=task) 85 | if not task.finished: 86 | text_with_format += "..." 87 | if not task.started: 88 | text_with_format = "[grey30]" + text_with_format + "[/grey30]" 89 | return Text.from_markup(text_with_format, style=self.style, justify=self.justify) 90 | -------------------------------------------------------------------------------- /src/pipask/code_execution_guard.py: -------------------------------------------------------------------------------- 1 | from rich.prompt import Confirm 2 | 3 | from pipask.cli_helpers import CheckTask 4 | from pipask.exception import PipAskCodeExecutionDeniedException 5 | from contextvars import ContextVar 6 | 7 | 8 | class PackageCodeExecutionGuard: 9 | _execution_allowed: ContextVar[bool | None] = ContextVar("execution_allowed", default=None) 10 | _progress_task: ContextVar[CheckTask | None] = ContextVar("progress_task", default=None) 11 | 12 | @classmethod 13 | def reset_confirmation_state(cls, progress_task: CheckTask | None = None): 14 | cls._execution_allowed.set(None) 15 | cls._progress_task.set(progress_task) 16 | 17 | @classmethod 18 | def check_execution_allowed(cls, package_name: str | None, package_url: str | None): 19 | """ 20 | This function should be called before any code path in the forked pip code 21 | that may execute 3rd party code from the packages to be installed. 22 | 23 | It may display a warning, ask for user consent, or raise an exception depending on configuration. 24 | 25 | :raises PipAskCodeExecutionDeniedException: if 3rd party code execution is not allowed 26 | """ 27 | 28 | package_detail = "" 29 | if package_name and package_url: 30 | package_detail = f"{package_name} from {package_url})" 31 | elif package_url or package_url: 32 | package_detail = package_name or package_url 33 | package_detail_message = f", including {package_detail}" if package_detail else "" 34 | 35 | if cls._execution_allowed.get() is True: 36 | return 37 | elif cls._execution_allowed.get() is False: 38 | raise PipAskCodeExecutionDeniedException( 39 | f"Building source distribution{' ' + package_detail if package_detail else ''} not allowed" 40 | ) 41 | 42 | if progress_task := cls._progress_task.get(): 43 | progress_task.hide() 44 | 45 | message = f"Unable to resolve dependencies without preparing a source distribution.\nIf you continue, 3rd party code may be executed before pipask can run checks on it{package_detail_message}.\nWould you like to continue?" 46 | if Confirm.ask(f"\n[yellow]{message}[/yellow]", choices=["y", "n"]): 47 | PackageCodeExecutionGuard._execution_allowed.set(True) 48 | if progress_task: 49 | progress_task.show() 50 | else: 51 | PackageCodeExecutionGuard._execution_allowed.set(False) 52 | raise PipAskCodeExecutionDeniedException( 53 | f"Building source distribution{' ' + package_detail if package_detail else ''} not allowed" 54 | ) 55 | -------------------------------------------------------------------------------- /src/pipask/exception.py: -------------------------------------------------------------------------------- 1 | class PipaskException(Exception): 2 | pass 3 | 4 | 5 | class HandoverToPipException(PipaskException): 6 | pass 7 | 8 | 9 | class PipAskCodeExecutionDeniedException(PipaskException): 10 | """Exception raised when we are not allowed execute 3rd party code in a package""" 11 | 12 | def __init__(self, message: str): 13 | super().__init__(message) 14 | -------------------------------------------------------------------------------- /src/pipask/infra/executables.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import subprocess 3 | from functools import cache 4 | import logging 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | _fallback_python_command = "python3" 9 | 10 | 11 | @cache # This is cleared between tests 12 | def get_pip_python_executable() -> str: 13 | # We can't use sys.executable because it may be a different python than the one we are using 14 | # pip debug is not guaranteed to be stable, but hopefully this won't change 15 | pip_executable = shutil.which("pip") or "pip" 16 | command = [pip_executable, "debug"] 17 | logger.debug("Running command: %s", " ".join(command)) 18 | pip_debug_output = subprocess.run(command, check=True, text=True, capture_output=True) 19 | 20 | executable_line = next(line for line in pip_debug_output.stdout.splitlines() if line.startswith("sys.executable:")) 21 | if not executable_line: 22 | # Could happen if pip debug output changes? 23 | logger.warning("Could not reliably determine python executable") 24 | return _fallback_python_command 25 | return executable_line[len("sys.executable:") :].strip() 26 | 27 | 28 | def get_pip_command() -> list[str]: 29 | python_executable = get_pip_python_executable() 30 | if python_executable == _fallback_python_command: 31 | pip_executable = shutil.which("pip") or "pip" 32 | return [pip_executable] 33 | return [python_executable, "-m", "pip"] 34 | -------------------------------------------------------------------------------- /src/pipask/infra/pip_types.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field, model_validator 2 | 3 | # See https://pip.pypa.io/en/stable/reference/installation-report/ 4 | 5 | 6 | class InstallationReportItemMetadata(BaseModel): 7 | name: str 8 | version: str 9 | license: str | None = None 10 | classifier: list[str] = Field(default_factory=list) 11 | 12 | 13 | class InstallationReportArchiveInfo(BaseModel): 14 | hash: str | None = None 15 | hashes: dict[str, str] | None = None 16 | 17 | @model_validator(mode="after") 18 | def fill_hashes_if_missing(self): 19 | if self.hash is not None and self.hashes is None: 20 | hash_name, hash_value = self.hash.split("=", 1) 21 | self.hashes = {hash_name: hash_value} 22 | return self 23 | 24 | 25 | class InstallationReportItemDownloadInfo(BaseModel): 26 | url: str 27 | archive_info: InstallationReportArchiveInfo | None = None 28 | 29 | 30 | class InstallationReportItem(BaseModel): 31 | metadata: InstallationReportItemMetadata 32 | download_info: InstallationReportItemDownloadInfo | None 33 | requested: bool 34 | is_direct: bool 35 | is_yanked: bool = False 36 | 37 | 38 | class PipInstallReport(BaseModel): 39 | version: str 40 | install: list[InstallationReportItem] 41 | -------------------------------------------------------------------------------- /src/pipask/infra/pypistats.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import httpx 4 | from packaging.utils import canonicalize_name 5 | from pydantic import BaseModel 6 | 7 | from pipask.utils import simple_get_request 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class DownloadStats(BaseModel): 13 | last_day: int 14 | last_week: int 15 | last_month: int 16 | 17 | 18 | class _DownloadStatsResponse(BaseModel): 19 | data: DownloadStats 20 | package: str 21 | type: str 22 | 23 | 24 | _BASE_URL = "https://pypistats.org/api" 25 | 26 | 27 | class PypiStatsClient: 28 | def __init__(self): 29 | self.client = httpx.AsyncClient() 30 | 31 | async def get_download_stats(self, package_name: str) -> DownloadStats | None: 32 | url = f"{_BASE_URL}/packages/{canonicalize_name(package_name)}/recent" 33 | parsed_response = await simple_get_request(url, self.client, _DownloadStatsResponse) 34 | return parsed_response.data if parsed_response is not None else None 35 | 36 | async def aclose(self) -> None: 37 | await self.client.aclose() 38 | -------------------------------------------------------------------------------- /src/pipask/infra/repo_client.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urllib.parse 3 | import logging 4 | from dataclasses import dataclass 5 | 6 | import httpx 7 | from pydantic import BaseModel 8 | 9 | from pipask.utils import simple_get_request 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | # Same options as in Google's https://docs.deps.dev/api/v3/#getproject, without discontinued bitbucket 15 | REPO_URL_REGEX = re.compile(r"^https://(github|gitlab)[.]com/([^/]+/[^/.]+)") 16 | 17 | 18 | class _GitHubRepoResponse(BaseModel): 19 | stargazers_count: int 20 | 21 | 22 | class _GitLabProjectResponse(BaseModel): 23 | star_count: int 24 | 25 | 26 | @dataclass 27 | class RepoInfo: 28 | star_count: int 29 | 30 | 31 | class RepoClient: 32 | def __init__(self): 33 | self.client = httpx.AsyncClient(follow_redirects=True) 34 | 35 | async def get_repo_info(self, repo_url: str) -> RepoInfo | None: 36 | match = REPO_URL_REGEX.match(repo_url) 37 | if not match: 38 | raise ValueError(f"Invalid repository URL: {repo_url}") 39 | service_name = match.group(1) 40 | repo_name = match.group(2) 41 | if service_name == "github": 42 | return await self._get_github_repo_info(repo_name) 43 | elif service_name == "gitlab": 44 | return await self._get_gitlab_repo_info(repo_name) 45 | else: 46 | raise ValueError(f"Unsupported service: {service_name}") 47 | 48 | async def _get_github_repo_info(self, repo_name: str) -> RepoInfo | None: 49 | url = f"https://api.github.com/repos/{repo_name}" 50 | parsed_response = await simple_get_request(url, self.client, _GitHubRepoResponse) 51 | return RepoInfo(star_count=parsed_response.stargazers_count) if parsed_response is not None else None 52 | 53 | async def _get_gitlab_repo_info(self, repo_name: str) -> RepoInfo | None: 54 | url = f"https://gitlab.com/api/v4/projects/{urllib.parse.quote(repo_name, safe='')}" 55 | parsed_response = await simple_get_request(url, self.client, _GitLabProjectResponse) 56 | return RepoInfo(star_count=parsed_response.star_count) if parsed_response is not None else None 57 | 58 | async def aclose(self) -> None: 59 | await self.client.aclose() 60 | -------------------------------------------------------------------------------- /src/pipask/infra/sys_values.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Tuple, cast 3 | import subprocess 4 | import json 5 | from functools import cache 6 | 7 | from pipask.infra.executables import get_pip_python_executable 8 | 9 | 10 | @dataclass 11 | class SysValues: 12 | exec_prefix: str 13 | prefix: str 14 | base_prefix: str 15 | has_real_prefix: bool 16 | implementation_name: str 17 | version_info: Tuple[int, int, int] 18 | path: str 19 | executable: str 20 | abiflags: str | None 21 | ldversion: str | None 22 | pip_pkg_dir: str 23 | site_file: str 24 | 25 | 26 | @cache # This is cleared between tests 27 | def get_pip_sys_values() -> SysValues: 28 | """ 29 | Returns various sys values as they are in the *target* environment. 30 | This is because pipask is typically installed in a different environment (e.g., pipx) 31 | than the installation target environment. 32 | """ 33 | script = """ 34 | import sys 35 | import json 36 | import os 37 | from pathlib import Path 38 | import pip 39 | import os.path 40 | import sysconfig 41 | import site 42 | 43 | values = { 44 | "exec_prefix": sys.exec_prefix, 45 | "prefix": sys.prefix, 46 | "implementation_name": sys.implementation.name, 47 | "version_info": list(sys.version_info[:3]), 48 | "path": sys.path, 49 | "executable": sys.executable, 50 | "abiflags": getattr(sys, "abiflags", None), 51 | "ldversion": sysconfig.get_config_var("LDVERSION"), 52 | "pip_pkg_dir": os.path.dirname(pip.__file__), 53 | "base_prefix": getattr(sys, "base_prefix", sys.prefix), 54 | "has_real_prefix": hasattr(sys, "real_prefix"), 55 | "site_file": site.__file__, 56 | } 57 | print(json.dumps(values)) 58 | """ 59 | 60 | result = subprocess.run([get_pip_python_executable(), "-c", script], capture_output=True, text=True, check=True) 61 | 62 | data = json.loads(result.stdout) 63 | return SysValues( 64 | exec_prefix=data["exec_prefix"], 65 | prefix=data["prefix"], 66 | implementation_name=data["implementation_name"], 67 | version_info=cast(Tuple[int, int, int], tuple(data["version_info"])), 68 | path=data["path"], 69 | executable=data["executable"], 70 | abiflags=data["abiflags"], 71 | ldversion=data["ldversion"], 72 | pip_pkg_dir=data["pip_pkg_dir"], 73 | base_prefix=data["base_prefix"], 74 | has_real_prefix=data["has_real_prefix"], 75 | site_file=data["site_file"], 76 | ) 77 | -------------------------------------------------------------------------------- /src/pipask/infra/vulnerability_details.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC, abstractmethod 3 | from dataclasses import dataclass 4 | from enum import Enum 5 | from typing import Optional 6 | 7 | import httpx 8 | from cvss import CVSS2, CVSS3, CVSS4 9 | from pydantic import BaseModel 10 | 11 | from pipask.checks.types import CheckResultType 12 | from pipask.infra.pypi import VulnerabilityPypi 13 | from pipask.utils import simple_get_request 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class VulnerabilitySeverity(str, Enum): 19 | # MUST be ordered from most severe to least severe 20 | CRITICAL = ("CRITICAL", CheckResultType.FAILURE) 21 | HIGH = ("HIGH", CheckResultType.FAILURE) 22 | MEDIUM = ("Medium", CheckResultType.WARNING) 23 | LOW = ("Low", CheckResultType.NEUTRAL) 24 | NONE = ("None", CheckResultType.NEUTRAL) 25 | 26 | result_type: CheckResultType 27 | 28 | def __new__(cls, value: str, result_type: CheckResultType): 29 | obj = str.__new__(cls, [value]) 30 | obj._value_ = value 31 | obj.result_type = result_type 32 | return obj 33 | 34 | @staticmethod 35 | def get_worst(*results: Optional["VulnerabilitySeverity"]) -> Optional["VulnerabilitySeverity"]: 36 | for severity in VulnerabilitySeverity: 37 | if any(result is severity for result in results): 38 | return severity 39 | return None 40 | 41 | @staticmethod 42 | def from_cvss(cvss: CVSS2 | CVSS3 | CVSS4) -> Optional["VulnerabilitySeverity"]: 43 | cvss_severities: tuple[str | None] = cvss.severities() 44 | base_severity = cvss_severities[0] 45 | if base_severity is None: 46 | return None 47 | for severity in VulnerabilitySeverity: 48 | if severity.value.lower() == base_severity.lower(): 49 | return severity 50 | return None 51 | 52 | 53 | @dataclass 54 | class VulnerabilityDetails: 55 | id: str | None 56 | severity: VulnerabilitySeverity | None 57 | link: str | None = None 58 | 59 | 60 | class VulnerabilityDetailsService(ABC): 61 | @abstractmethod 62 | async def get_details(self, vulnerability: VulnerabilityPypi) -> VulnerabilityDetails: 63 | pass 64 | 65 | 66 | class DummyVulnerabilityDetailsService(VulnerabilityDetailsService): 67 | async def get_details(self, vulnerability: VulnerabilityPypi) -> VulnerabilityDetails: 68 | return VulnerabilityDetails(id=vulnerability.id, severity=None, link=vulnerability.link) 69 | 70 | 71 | class _OsvSeverity(BaseModel): 72 | type: str 73 | score: str 74 | 75 | 76 | class _OsvVulnerabilityResponse(BaseModel): 77 | severity: list[_OsvSeverity] | None = None 78 | 79 | 80 | class OsvVulnerabilityDetailsService(VulnerabilityDetailsService): 81 | def __init__(self): 82 | self.client = httpx.AsyncClient(follow_redirects=True) 83 | 84 | async def get_details(self, vulnerability: VulnerabilityPypi) -> VulnerabilityDetails: 85 | # See https://google.github.io/osv.dev/get-v1-vulns/ for OSV API docs 86 | prefixes_with_severity = ["CVE-", "GHSA-"] 87 | all_ids = {vulnerability.id, *vulnerability.aliases} 88 | for prefix in prefixes_with_severity: 89 | id = next((alias for alias in all_ids if alias.startswith(prefix)), None) 90 | if id: 91 | url = f"https://api.osv.dev/v1/vulns/{id}" 92 | response = await simple_get_request(url, self.client, _OsvVulnerabilityResponse) 93 | if response and response.severity is not None: 94 | return VulnerabilityDetails( 95 | id=id, severity=_parse_severity(response.severity), link=f"https://osv.dev/vulnerability/{id}" 96 | ) 97 | return VulnerabilityDetails(id=vulnerability.id, severity=None, link=vulnerability.link) 98 | 99 | async def aclose(self) -> None: 100 | await self.client.aclose() 101 | 102 | 103 | def _parse_severity(severity: list[_OsvSeverity] | None) -> VulnerabilitySeverity | None: 104 | if severity is None: 105 | return None 106 | # Use the newest CVSS version possible; this actually makes difference in some cases (e.g., GHSA-f96h-pmfr-66vw) 107 | if (cvss_v4 := next((s for s in severity if s.type == "CVSS_V4"), None)) is not None: 108 | return VulnerabilitySeverity.from_cvss(CVSS4(cvss_v4.score)) 109 | if (cvss_v3 := next((s for s in severity if s.type == "CVSS_V3"), None)) is not None: 110 | return VulnerabilitySeverity.from_cvss(CVSS3(cvss_v3.score)) 111 | if (cvss_v2 := next((s for s in severity if s.type == "CVSS_V2"), None)) is not None: 112 | return VulnerabilitySeverity.from_cvss(CVSS2(cvss_v2.score)) 113 | return None # No severity identified 114 | -------------------------------------------------------------------------------- /src/pipask/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/src/pipask/py.typed -------------------------------------------------------------------------------- /src/pipask/report.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | 3 | from pipask.checks.types import CheckResultType, PackageCheckResults 4 | from pipask.utils import format_link 5 | 6 | 7 | def _get_worst_result(package_result: PackageCheckResults) -> CheckResultType: 8 | return ( 9 | CheckResultType.get_worst(*(result.result_type for result in package_result.results)) or CheckResultType.SUCCESS 10 | ) 11 | 12 | 13 | def _format_requirement_heading(package_result: PackageCheckResults) -> str: 14 | worst_result_color = _get_worst_result(package_result).rich_color 15 | formatted_requirement = ( 16 | f"{package_result.name}=={format_link(package_result.version, package_result.pypi_url)}" 17 | if package_result.pypi_url 18 | else f"{package_result.name}=={package_result.version}" 19 | ) 20 | bold = "" if package_result.is_transitive_dependency else "[bold]" 21 | return f" {bold}\\[[{worst_result_color}]{formatted_requirement}[/{worst_result_color}]]" 22 | 23 | 24 | def _format_check_result(result_type: CheckResultType, message: str) -> str: 25 | color = "default" if result_type is CheckResultType.SUCCESS else result_type.rich_color 26 | return f" {result_type.rich_icon} [{color}]{message}" 27 | 28 | 29 | def print_report(package_results: list[PackageCheckResults], console: Console) -> None: 30 | console.print("\nPackage check results:") 31 | requested_deps = [p for p in package_results if not p.is_transitive_dependency] 32 | for package_result in requested_deps: 33 | console.print(_format_requirement_heading(package_result)) 34 | for check_result in package_result.results: 35 | console.print(_format_check_result(check_result.result_type, check_result.message)) 36 | 37 | transitive_deps_with_warning_or_worse = [ 38 | p 39 | for p in package_results 40 | if p.is_transitive_dependency 41 | and len(p.results) 42 | and _get_worst_result(p) not in {CheckResultType.NEUTRAL, CheckResultType.SUCCESS} 43 | ] 44 | if len(transitive_deps_with_warning_or_worse): 45 | console.print("Vulnerable transitive dependencies:") 46 | for package_result in transitive_deps_with_warning_or_worse: 47 | console.print(_format_requirement_heading(package_result)) 48 | for check_result in package_result.results: 49 | console.print(_format_check_result(check_result.result_type, check_result.message)) 50 | -------------------------------------------------------------------------------- /src/pipask/utils.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import time 3 | import logging 4 | from typing import TypeVar 5 | from pydantic import BaseModel 6 | import httpx 7 | import os 8 | import sys 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class TimeLogger: 14 | def __init__(self, description: str, logger: logging.Logger = logger): 15 | self.description = description 16 | self.start_time = time.time() 17 | self._logger = logger 18 | 19 | def __enter__(self): 20 | self.start_time = time.time() 21 | return self 22 | 23 | def __exit__(self, exc_type, exc_val, exc_tb): 24 | self._logger.debug(f"{self.description} took {time.time() - self.start_time:.2f}s") 25 | 26 | async def __aenter__(self): 27 | self.start_time = time.time() 28 | return self 29 | 30 | async def __aexit__(self, exc_type, exc_val, exc_tb): 31 | self._logger.debug(f"{self.description} took {time.time() - self.start_time:.2f}s") 32 | 33 | 34 | ResponseT = TypeVar("ResponseT", bound=BaseModel) 35 | 36 | 37 | async def simple_get_request( 38 | url: str, client: httpx.AsyncClient, response_model: type[ResponseT], *, headers: dict[str, str] | None = None 39 | ) -> ResponseT | None: 40 | async with TimeLogger(f"GET {url}", logger): 41 | response = await client.get(url, headers=headers) 42 | if response.status_code == 404: 43 | return None 44 | response.raise_for_status() 45 | return response_model.model_validate(response.json()) 46 | 47 | 48 | def simple_get_request_sync( 49 | url: str, session: requests.Session, response_model: type[ResponseT], *, headers: dict[str, str] | None = None 50 | ) -> ResponseT | None: 51 | with TimeLogger(f"GET {url}", logger): 52 | response = session.get(url, headers=headers) 53 | if response.status_code == 404: 54 | return None 55 | response.raise_for_status() 56 | return response_model.model_validate(response.json()) 57 | 58 | 59 | def _terminal_does_not_support_hyperlinks(): 60 | """ 61 | Determine when we can be fairly certain that OSC 8 hyperlinks are NOT supported (can have false negatives). 62 | """ 63 | # Case 1: Not a terminal at all 64 | if not sys.stdout.isatty(): 65 | return True 66 | 67 | # Case 2: Known non-supporting terminal types 68 | term = os.environ.get("TERM", "").lower() 69 | known_non_supporting_terms = [ 70 | "dumb", # Dumb terminals don't support any escape sequences 71 | "vt100", # Original VT100 predates OSC 8 72 | "ansi", # Basic ANSI terminals don't support OSC 8 73 | "cygwin", # Traditional Cygwin terminal doesn't support hyperlinks 74 | "linux", # The raw Linux console doesn't support hyperlinks 75 | "screen", # Default screen without configuration doesn't support hyperlinks 76 | ] 77 | if any(term == non_supporting for non_supporting in known_non_supporting_terms): 78 | return True 79 | 80 | # Case 3: Non-supporting terminal environments 81 | term_program = os.environ.get("TERM_PROGRAM", "").lower() 82 | non_supporting_programs = [ 83 | "cmd.exe", # Windows Command Prompt doesn't support hyperlinks 84 | "apple_terminal", # Apple's Terminal.app doesn't support hyperlinks 85 | ] 86 | if any(program in term_program for program in non_supporting_programs): 87 | return True 88 | 89 | # Case 4: NO_COLOR environment variable 90 | # Some terminals respect this for disabling all formatting including hyperlinks 91 | if os.environ.get("NO_COLOR") is not None or os.environ.get("COLORTERM", "").lower() == "nocolor": 92 | return True 93 | 94 | # If none of the above cases match, we can't be certain 95 | # that OSC 8 is not supported, so return False 96 | return False 97 | 98 | 99 | _HYPERLINKS_NOT_SUPPORTED = _terminal_does_not_support_hyperlinks() 100 | 101 | 102 | def format_link(text: str, url: str | None, fallback: bool = False) -> str: 103 | if not url: 104 | return text 105 | if _HYPERLINKS_NOT_SUPPORTED: 106 | return f"{text} [{url}]" if fallback else text 107 | return f"[link={url}]{text}[/link]" 108 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/tests/__init__.py -------------------------------------------------------------------------------- /tests/checks/test_license.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pipask.checks.license import LicenseChecker 4 | from pipask.checks.types import CheckResultType 5 | from pipask.infra.pypi import ProjectInfo, ReleaseResponse, VerifiedPypiReleaseInfo 6 | 7 | PACKAGE_NAME = "package" 8 | PACKAGE_VERSION = "1.0.0" 9 | 10 | 11 | @pytest.mark.asyncio 12 | @pytest.mark.parametrize( 13 | "classifiers,metadata_license,expected_message", 14 | [ 15 | (["License :: OSI Approved :: MIT License"], "MIT", "Package is licensed under MIT License"), 16 | ( 17 | ["License :: OSI Approved :: Apache Software License"], 18 | "ASL", 19 | "Package is licensed under Apache Software License", 20 | ), 21 | (["License :: OSI Approved :: BSD License"], "BSD", "Package is licensed under BSD License"), 22 | (["License :: Unexpected"], None, "Package is licensed under Unexpected"), 23 | (["License :: OSI Approved :: BSD License"], None, "Package is licensed under BSD License"), 24 | ([], "MIT", "Package is licensed under MIT"), 25 | ], 26 | ) 27 | async def test_license_classifiers(classifiers: list[str], metadata_license: str, expected_message: str): 28 | checker = LicenseChecker() 29 | release_info = VerifiedPypiReleaseInfo( 30 | ReleaseResponse( 31 | info=ProjectInfo( 32 | name=PACKAGE_NAME, 33 | version=PACKAGE_VERSION, 34 | classifiers=classifiers, 35 | license=metadata_license, 36 | ) 37 | ), 38 | "file.whl", 39 | ) 40 | 41 | result = await checker.check(release_info) 42 | 43 | assert result.result_type == CheckResultType.NEUTRAL 44 | assert result.message == expected_message 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_no_license(): 49 | checker = LicenseChecker() 50 | release_info = VerifiedPypiReleaseInfo( 51 | ReleaseResponse( 52 | info=ProjectInfo( 53 | name=PACKAGE_NAME, 54 | version=PACKAGE_VERSION, 55 | classifiers=[], 56 | license=None, 57 | ) 58 | ), 59 | "file.whl", 60 | ) 61 | 62 | result = await checker.check(release_info) 63 | 64 | assert result.result_type == CheckResultType.WARNING 65 | assert result.message == "No license found in PyPI metadata - you may need to check manually" 66 | -------------------------------------------------------------------------------- /tests/checks/test_package_downloads.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock, MagicMock 2 | 3 | import pytest 4 | 5 | from pipask.checks.package_downloads import PackageDownloadsChecker 6 | from pipask.checks.types import CheckResultType 7 | from pipask.infra.pypi import ProjectInfo, ReleaseResponse, VerifiedPypiReleaseInfo 8 | from pipask.infra.pypistats import DownloadStats, PypiStatsClient 9 | 10 | PACKAGE_NAME = "package" 11 | PACKAGE_VERSION = "1.0.0" 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_package_downloads_no_stats(): 16 | pypi_stats_client = MagicMock(spec=PypiStatsClient) 17 | pypi_stats_client.get_download_stats = AsyncMock(return_value=None) 18 | checker = PackageDownloadsChecker(pypi_stats_client) 19 | release_info = VerifiedPypiReleaseInfo( 20 | ReleaseResponse(info=ProjectInfo(name=PACKAGE_NAME, version=PACKAGE_VERSION)), 21 | "file.whl", 22 | ) 23 | 24 | result = await checker.check(release_info) 25 | 26 | assert result.result_type == CheckResultType.FAILURE 27 | assert result.message == "No download statistics available" 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_high_download_count(): 32 | pypi_stats_client = MagicMock(spec=PypiStatsClient) 33 | pypi_stats_client.get_download_stats = AsyncMock( 34 | return_value=DownloadStats(last_month=15000, last_week=500, last_day=50) 35 | ) 36 | checker = PackageDownloadsChecker(pypi_stats_client) 37 | release_info = VerifiedPypiReleaseInfo( 38 | ReleaseResponse(info=ProjectInfo(name=PACKAGE_NAME, version=PACKAGE_VERSION)), 39 | "file.whl", 40 | ) 41 | 42 | result = await checker.check(release_info) 43 | 44 | assert result.result_type == CheckResultType.SUCCESS 45 | assert result.message == "15,000 downloads from PyPI in the last month" 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_low_download_count(): 50 | pypi_stats_client = MagicMock(spec=PypiStatsClient) 51 | pypi_stats_client.get_download_stats = AsyncMock( 52 | return_value=DownloadStats(last_month=50, last_week=10, last_day=0) 53 | ) 54 | checker = PackageDownloadsChecker(pypi_stats_client) 55 | release_info = VerifiedPypiReleaseInfo( 56 | ReleaseResponse(info=ProjectInfo(name=PACKAGE_NAME, version=PACKAGE_VERSION)), 57 | "file.whl", 58 | ) 59 | 60 | result = await checker.check(release_info) 61 | 62 | assert result.result_type == CheckResultType.FAILURE 63 | assert result.message == "Only 50 downloads from PyPI in the last month" 64 | -------------------------------------------------------------------------------- /tests/checks/test_release_metadata.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pipask.checks.release_metadata import ReleaseMetadataChecker 4 | from pipask.checks.types import CheckResultType 5 | from pipask.infra.pypi import ProjectInfo, ReleaseResponse, VerifiedPypiReleaseInfo 6 | 7 | PACKAGE_NAME = "package" 8 | PACKAGE_VERSION = "1.0.0" 9 | 10 | 11 | @pytest.mark.asyncio 12 | @pytest.mark.parametrize( 13 | "release_info,expected_type,expected_message", 14 | [ 15 | ( 16 | VerifiedPypiReleaseInfo( 17 | ReleaseResponse( 18 | info=ProjectInfo( 19 | name=PACKAGE_NAME, 20 | version=PACKAGE_VERSION, 21 | yanked=True, 22 | yanked_reason="Security vulnerability", 23 | ) 24 | ), 25 | "file.whl", 26 | ), 27 | CheckResultType.FAILURE, 28 | "The release is yanked (reason: Security vulnerability)", 29 | ), 30 | ( 31 | VerifiedPypiReleaseInfo( 32 | ReleaseResponse(info=ProjectInfo(name=PACKAGE_NAME, version=PACKAGE_VERSION, yanked=True)), 33 | "file.whl", 34 | ), 35 | CheckResultType.FAILURE, 36 | "The release is yanked", 37 | ), 38 | ], 39 | ) 40 | async def test_release_info_checks(release_info, expected_type, expected_message): 41 | checker = ReleaseMetadataChecker() 42 | 43 | result = await checker.check(release_info) 44 | 45 | assert result.result_type == expected_type 46 | assert result.message == expected_message 47 | 48 | 49 | @pytest.mark.asyncio 50 | @pytest.mark.parametrize( 51 | "classifier", 52 | [ 53 | "Development Status :: 1 - Planning", 54 | "Development Status :: 2 - Pre-Alpha", 55 | "Development Status :: 3 - Alpha", 56 | "Development Status :: 4 - Beta", 57 | "Development Status :: 7 - Inactive", 58 | ], 59 | ) 60 | async def test_warning_classifiers(classifier): 61 | checker = ReleaseMetadataChecker() 62 | release_info = VerifiedPypiReleaseInfo( 63 | ReleaseResponse( 64 | info=ProjectInfo( 65 | name=PACKAGE_NAME, 66 | version=PACKAGE_VERSION, 67 | yanked=False, 68 | classifiers=["License :: OSI Approved :: MIT License", classifier], 69 | ) 70 | ), 71 | "file.whl", 72 | ) 73 | 74 | result = await checker.check(release_info) 75 | 76 | assert result.result_type == CheckResultType.WARNING 77 | assert result.message == f"Package is classified as {classifier}" 78 | 79 | 80 | @pytest.mark.asyncio 81 | @pytest.mark.parametrize( 82 | "classifier", 83 | [ 84 | "Development Status :: 5 - Production/Stable", 85 | "Development Status :: 6 - Mature", 86 | ], 87 | ) 88 | async def test_success_classifiers(classifier): 89 | checker = ReleaseMetadataChecker() 90 | release_info = VerifiedPypiReleaseInfo( 91 | ReleaseResponse( 92 | info=ProjectInfo( 93 | name=PACKAGE_NAME, 94 | version=PACKAGE_VERSION, 95 | yanked=False, 96 | classifiers=[classifier] if classifier else [], 97 | ) 98 | ), 99 | "file.whl", 100 | ) 101 | 102 | result = await checker.check(release_info) 103 | 104 | assert result.result_type == CheckResultType.SUCCESS 105 | assert result.message == ( 106 | "Package is classified as " + classifier if classifier else "No development status classifiers" 107 | ) 108 | 109 | 110 | @pytest.mark.asyncio 111 | async def test_no_classifiers(): 112 | checker = ReleaseMetadataChecker() 113 | release_info = VerifiedPypiReleaseInfo( 114 | ReleaseResponse(info=ProjectInfo(name=PACKAGE_NAME, version=PACKAGE_VERSION)), 115 | "file.whl", 116 | ) 117 | 118 | result = await checker.check(release_info) 119 | 120 | assert result.result_type == CheckResultType.NEUTRAL 121 | assert result.message == "No development status classifiers" 122 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | import venv 4 | from contextlib import contextmanager 5 | from pathlib import Path 6 | from unittest.mock import patch 7 | 8 | import pytest 9 | from _pytest.tmpdir import TempPathFactory 10 | 11 | from pipask._vendor.pip._internal.locations import get_bin_prefix 12 | from pipask.infra.executables import get_pip_python_executable 13 | from pipask.infra.sys_values import get_pip_sys_values 14 | 15 | 16 | def _clear_venv_dependent_caches(): 17 | get_pip_python_executable.cache_clear() 18 | get_pip_sys_values.cache_clear() 19 | from pipask._vendor.pip._vendor import pkg_resources 20 | 21 | importlib.reload(pkg_resources) 22 | 23 | 24 | @pytest.fixture() 25 | def clear_venv_dependent_caches(): 26 | _clear_venv_dependent_caches() 27 | return _clear_venv_dependent_caches # Return in case the test needs to call it again 28 | 29 | 30 | def pytest_collection_modifyitems(config, items): 31 | run_integration = config.getoption("--integration") or config.getoption("-m") == "integration" 32 | if not run_integration: 33 | skip_integration_marker = pytest.mark.skip(reason="Need --integration option to run") 34 | for item in items: 35 | if "integration" in item.keywords: 36 | item.add_marker(skip_integration_marker) 37 | 38 | 39 | def pytest_addoption(parser): 40 | parser.addoption("--integration", action="store_true", default=False, help="run integration tests") 41 | 42 | 43 | def with_venv_python(tmp_path_factory: TempPathFactory): 44 | # Create virtual environment 45 | venv_path = tmp_path_factory.mktemp("venv") 46 | env_builder = venv.EnvBuilder(with_pip=True, system_site_packages=False) 47 | venv_ctx = env_builder.ensure_directories(venv_path) 48 | env_builder.create(venv_path) 49 | venv_python: str = venv_ctx.env_exe 50 | 51 | # "Activate" the virtual environment 52 | platform_scripts_dir = Path(get_bin_prefix()).name 53 | path_env_var = str(venv_path / platform_scripts_dir) + os.pathsep + os.environ["PATH"] 54 | with patch.dict(os.environ, {"PATH": path_env_var, "VIRTUAL_ENV": str(venv_path)}): 55 | os.environ.pop("PYTHONHOME", None) 56 | yield venv_python 57 | 58 | 59 | @pytest.fixture 60 | def temp_venv_python(tmp_path_factory): 61 | with contextmanager(with_venv_python)(tmp_path_factory) as venv_python: 62 | _clear_venv_dependent_caches() 63 | yield venv_python 64 | -------------------------------------------------------------------------------- /tests/infra/data/pyfluent_iterables-2.0.1-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/tests/infra/data/pyfluent_iterables-2.0.1-py3-none-any.whl -------------------------------------------------------------------------------- /tests/infra/data/pyfluent_iterables-2.0.1.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feynmanix/pipask/08adad73d08b0cb7b8578801153a9f81e243a3dd/tests/infra/data/pyfluent_iterables-2.0.1.tar.gz -------------------------------------------------------------------------------- /tests/infra/data/test-package/module.py: -------------------------------------------------------------------------------- 1 | def hello(): 2 | return "Hello, world!" 3 | -------------------------------------------------------------------------------- /tests/infra/data/test-package/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="test-package", 5 | version="0.1.0", 6 | description="Test package for integration tests", 7 | author="Test Author", 8 | author_email="test@example.com", 9 | py_modules=["test_module"], 10 | install_requires=["pyfluent-iterables>=2.0.0"], 11 | ) 12 | -------------------------------------------------------------------------------- /tests/infra/test_pypistats.py: -------------------------------------------------------------------------------- 1 | from contextlib import aclosing 2 | 3 | from pipask.infra.pypistats import PypiStatsClient 4 | import pytest 5 | 6 | 7 | @pytest.mark.integration 8 | async def test_pypi_stats_download_stats(): 9 | async with aclosing(PypiStatsClient()) as pypi_stats_client: 10 | pypi_stats = await pypi_stats_client.get_download_stats("fastapi") 11 | assert pypi_stats is not None 12 | assert pypi_stats.last_month > 1 13 | 14 | 15 | @pytest.mark.integration 16 | @pytest.mark.parametrize("package_name", ["Flask", "discord.py"]) # Actual package names 17 | async def test_pypi_stats_downloads_stats_for_repo_with_non_normalized_name(package_name: str): 18 | async with aclosing(PypiStatsClient()) as pypi_stats_client: 19 | pypi_stats = await pypi_stats_client.get_download_stats(package_name) 20 | assert pypi_stats is not None 21 | assert pypi_stats.last_month > 1 22 | -------------------------------------------------------------------------------- /tests/infra/test_repo_client.py: -------------------------------------------------------------------------------- 1 | from contextlib import aclosing 2 | 3 | from pipask.infra.repo_client import RepoClient 4 | import pytest 5 | 6 | 7 | @pytest.mark.integration 8 | @pytest.mark.parametrize( 9 | "repo_url", 10 | [ 11 | "https://github.com/mifeet/pyfluent-iterables", # This repo was moved -> client should follow 301 redirect 12 | "https://gitlab.com/ase/ase", 13 | ], 14 | ) 15 | async def test_repo_info(repo_url: str): 16 | async with aclosing(RepoClient()) as repo_client: 17 | repo_info = await repo_client.get_repo_info(repo_url) 18 | assert repo_info is not None 19 | assert repo_info.star_count > 1 20 | -------------------------------------------------------------------------------- /tests/infra/test_sys_values.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import sys 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | from pipask.infra.sys_values import get_pip_sys_values 9 | from tests.conftest import with_venv_python 10 | 11 | temp_venv_python = pytest.fixture()(with_venv_python) 12 | 13 | 14 | @pytest.mark.integration 15 | def test_returns_sys_values_respecting_venv( 16 | temp_venv_python: str, monkeypatch: pytest.MonkeyPatch, clear_venv_dependent_caches 17 | ) -> None: 18 | clear_venv_dependent_caches() 19 | assert temp_venv_python 20 | 21 | values = get_pip_sys_values() 22 | 23 | assert values.executable == temp_venv_python 24 | assert temp_venv_python.startswith(values.exec_prefix) 25 | assert temp_venv_python.startswith(values.prefix) 26 | assert values.implementation_name == sys.implementation.name 27 | assert values.version_info == sys.version_info[:3] 28 | assert any(p.startswith(os.fspath(Path(temp_venv_python).parent.parent)) for p in values.path) 29 | assert values.site_file 30 | assert values.base_prefix 31 | -------------------------------------------------------------------------------- /tests/infra/test_vulnerability_details.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pipask.infra.vulnerability_details import ( 3 | VulnerabilitySeverity, 4 | OsvVulnerabilityDetailsService, 5 | VulnerabilityDetails, 6 | ) 7 | from pipask.infra.pypi import VulnerabilityPypi 8 | from contextlib import aclosing 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "input_values,expected", 13 | [ 14 | # Single values 15 | ((VulnerabilitySeverity.CRITICAL,), VulnerabilitySeverity.CRITICAL), 16 | ((VulnerabilitySeverity.LOW,), VulnerabilitySeverity.LOW), 17 | # Multiple values 18 | ( 19 | (VulnerabilitySeverity.LOW, VulnerabilitySeverity.HIGH, VulnerabilitySeverity.MEDIUM), 20 | VulnerabilitySeverity.HIGH, 21 | ), 22 | # None values 23 | ((None,), None), 24 | ((None, None), None), 25 | # Mixed values 26 | ((None, VulnerabilitySeverity.MEDIUM, None, VulnerabilitySeverity.CRITICAL), VulnerabilitySeverity.CRITICAL), 27 | # Empty 28 | ((), None), 29 | ], 30 | ) 31 | def test_vulnerability_severity_get_worst(input_values, expected): 32 | assert VulnerabilitySeverity.get_worst(*input_values) == expected 33 | 34 | 35 | @pytest.mark.integration 36 | @pytest.mark.asyncio 37 | async def test_osv_vulnerability_details_with_cve(): 38 | async with aclosing(OsvVulnerabilityDetailsService()) as details_service: 39 | vuln = VulnerabilityPypi( 40 | id="PYSEC-2021-9", 41 | link="https://osv.dev/vulnerability/PYSEC-2021-9", 42 | aliases=["BIT-django-2021-3281", "CVE-2021-3281", "GHSA-fvgf-6h6h-3322"], 43 | ) 44 | 45 | details = await details_service.get_details(vuln) 46 | 47 | assert details == VulnerabilityDetails( 48 | id="CVE-2021-3281", 49 | severity=VulnerabilitySeverity.MEDIUM, 50 | link="https://osv.dev/vulnerability/CVE-2021-3281", 51 | ) 52 | 53 | 54 | @pytest.mark.integration 55 | @pytest.mark.asyncio 56 | async def test_osv_vulnerability_details_with_ghsa_only(): 57 | async with aclosing(OsvVulnerabilityDetailsService()) as details_service: 58 | vuln = VulnerabilityPypi( 59 | id="PYSEC-2021-9", 60 | link="https://osv.dev/vulnerability/PYSEC-2021-9", 61 | aliases=["BIT-django-2021-3281", "GHSA-fvgf-6h6h-3322"], 62 | ) 63 | 64 | details = await details_service.get_details(vuln) 65 | 66 | assert details == VulnerabilityDetails( 67 | id="GHSA-fvgf-6h6h-3322", 68 | severity=VulnerabilitySeverity.MEDIUM, 69 | link="https://osv.dev/vulnerability/GHSA-fvgf-6h6h-3322", 70 | ) 71 | 72 | 73 | @pytest.mark.asyncio 74 | async def test_osv_vulnerability_details_with_pysec_only(): 75 | async with aclosing(OsvVulnerabilityDetailsService()) as details_service: 76 | vuln = VulnerabilityPypi( 77 | id="PYSEC-2021-9", 78 | link="https://osv.dev/vulnerability/PYSEC-2021-9", 79 | aliases=[], 80 | ) 81 | 82 | details = await details_service.get_details(vuln) 83 | 84 | assert details == VulnerabilityDetails( 85 | id="PYSEC-2021-9", 86 | severity=None, 87 | link="https://osv.dev/vulnerability/PYSEC-2021-9", 88 | ) 89 | 90 | 91 | @pytest.mark.integration 92 | @pytest.mark.asyncio 93 | async def test_osv_vulnerability_details_uses_latest_cvss_version(): 94 | async with aclosing(OsvVulnerabilityDetailsService()) as details_service: 95 | vuln = VulnerabilityPypi( 96 | id="GHSA-f96h-pmfr-66vw", 97 | link="https://osv.dev/vulnerability/GHSA-f96h-pmfr-66vw", 98 | aliases=["CVE-2024-47874"], 99 | ) 100 | 101 | details = await details_service.get_details(vuln) 102 | 103 | assert details == VulnerabilityDetails( 104 | id="GHSA-f96h-pmfr-66vw", 105 | severity=VulnerabilitySeverity.HIGH, # This has None in V3 but High in V4 106 | link="https://osv.dev/vulnerability/GHSA-f96h-pmfr-66vw", 107 | ) 108 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | from pipask.main import main 7 | 8 | 9 | @pytest.mark.integration 10 | def test_installs_package_in_venv(temp_venv_python: str, clear_venv_dependent_caches): 11 | clear_venv_dependent_caches() 12 | args = ["install", "--no-input", "pyfluent-iterables"] 13 | 14 | # Act 15 | with patch("rich.prompt.Confirm.ask", return_value=True): 16 | main(args) 17 | 18 | # Verify the package is actually installed in the venv 19 | assert is_installed(temp_venv_python, "pyfluent_iterables") 20 | 21 | 22 | def is_installed(executable: str, package_name: str) -> bool: 23 | result = subprocess.run( 24 | [executable, "-c", f"import {package_name.replace('-', '_')}"], check=False, capture_output=True, text=True 25 | ) 26 | return result.returncode == 0 27 | -------------------------------------------------------------------------------- /trivy-config.yaml: -------------------------------------------------------------------------------- 1 | dependency-tree: true 2 | list-all-pkgs: false 3 | exit-code: 1 4 | severity: 5 | - HIGH 6 | - CRITICAL 7 | scan: 8 | skip-dirs: 9 | - .cache/trivy 10 | scanners: 11 | - vuln 12 | - secret 13 | - config 14 | vulnerability: 15 | ignore-unfixed: true 16 | --------------------------------------------------------------------------------