├── .coveragerc ├── .flake8 ├── .git-blame-ignore-revs ├── .github ├── ISSUE_TEMPLATE │ ├── 01_upload_failed.yml │ ├── 02_bug.yml │ ├── 03_feature.yml │ ├── 04_other.yml │ └── config.yml ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── main.yml │ └── release.yml ├── .gitignore ├── .isort.cfg ├── .readthedocs.yaml ├── AUTHORS ├── LICENSE ├── README.rst ├── changelog ├── .gitignore ├── 1217.bugfix.rst ├── 1224.bugfix.rst ├── 1229.bugfix.rst └── 1240.bugfix.rst ├── docs ├── changelog.rst ├── conf.py ├── contributing.rst ├── index.rst ├── internal │ ├── twine.auth.rst │ ├── twine.cli.rst │ ├── twine.commands.check.rst │ ├── twine.commands.register.rst │ ├── twine.commands.rst │ ├── twine.commands.upload.rst │ ├── twine.exceptions.rst │ ├── twine.package.rst │ ├── twine.repository.rst │ ├── twine.rst │ ├── twine.settings.rst │ ├── twine.utils.rst │ └── twine.wheel.rst └── requirements.txt ├── mypy.ini ├── pyproject.toml ├── pytest.ini ├── tests ├── __init__.py ├── conftest.py ├── fixtures │ ├── deprecated-pypirc │ ├── everything.metadata23 │ ├── everything.metadata24 │ ├── malformed.tar.gz │ ├── twine-1.5.0-py2.py3-none-any.whl │ ├── twine-1.5.0-py2.py3-none-any.whl.asc │ ├── twine-1.5.0.tar.gz │ ├── twine-1.6.5-py2.py3-none-any.whl │ └── twine-1.6.5.tar.gz ├── helpers.py ├── test_auth.py ├── test_check.py ├── test_cli.py ├── test_commands.py ├── test_integration.py ├── test_main.py ├── test_package.py ├── test_register.py ├── test_repository.py ├── test_sdist.py ├── test_settings.py ├── test_upload.py ├── test_utils.py └── test_wheel.py ├── tox.ini └── twine ├── __init__.py ├── __main__.py ├── auth.py ├── cli.py ├── commands ├── __init__.py ├── check.py ├── register.py └── upload.py ├── distribution.py ├── exceptions.py ├── package.py ├── py.typed ├── repository.py ├── sdist.py ├── settings.py ├── utils.py └── wheel.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | dynamic_context = test_function 4 | 5 | [report] 6 | # Regexes for lines to exclude from consideration 7 | exclude_lines = 8 | # Have to re-enable the standard pragma 9 | pragma: no cover 10 | 11 | # Don't complain if non-runnable code isn't run 12 | if __name__ == .__main__.: 13 | 14 | # exclude typing.TYPE_CHECKING 15 | if TYPE_CHECKING: 16 | 17 | [html] 18 | show_contexts = True 19 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Matching black's default 3 | max-line-length = 88 4 | 5 | extend-ignore = 6 | # Missing docstring in __init__ 7 | D107 8 | 9 | per-file-ignores = 10 | # TODO: Incrementally add missing docstrings 11 | # D100 Missing docstring in public module 12 | # D101 Missing docstring in public class 13 | # D102 Missing docstring in public method 14 | # D103 Missing docstring in public function 15 | # D104 Missing docstring in public package 16 | twine/*: D100,D101,D102,D103,D104 17 | tests/*: D100,D101,D102,D103,D104 18 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # When making commits that are strictly formatting/style changes, add the 2 | # commit hash here, so git blame can ignore the change. 3 | # Use as needed with: 4 | # git blame --ignore-revs-file .git-blame-ignore-revs 5 | # Or automatically with: 6 | # git config blame.ignoreRevsFile .git-blame-ignore-revs 7 | 8 | a12ad693137d82770e6118ea8d90955e2c753305 # Format twine and tests with black 9 | f468612c021eae225b07f1b654bdb620d1500bf1 # Sort imports with isort 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01_upload_failed.yml: -------------------------------------------------------------------------------- 1 | name: "Upload Error" 2 | description: "Failed to upload artifact(s)" 3 | labels: ["support"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: > 8 | ## Your Environment 9 | Thank you for taking the time to report an issue. 10 | 11 | To more efficiently resolve this issue, we'd like to know some basic 12 | information about your system and setup. 13 | 14 | - type: checkboxes 15 | attributes: 16 | label: "Is there an existing issue for this?" 17 | description: "Please search to see if there's an existing issue for what you're reporting" 18 | options: 19 | - label: "I have searched the existing issues (open and closed), and could not find an existing issue" 20 | required: true 21 | 22 | - type: textarea 23 | id: search-keywords 24 | attributes: 25 | label: "What keywords did you use to search existing issues?" 26 | description: "In the event that you could not find a duplicate, but it existed, this will help us better link issues in the future" 27 | placeholder: | 28 | authorization 29 | artifactory 30 | jfrog 31 | devpi 32 | 33 | - type: dropdown 34 | id: environment-os 35 | attributes: 36 | label: "What operating system(s) are you using?" 37 | multiple: true 38 | options: 39 | - "Windows 10" 40 | - "Windows 11" 41 | - "Linux" 42 | - "macOS" 43 | - "Other" 44 | validations: 45 | required: true 46 | 47 | - type: input 48 | id: environment-os-other 49 | attributes: 50 | label: "If you selected 'Other', describe your Operating System here" 51 | validations: 52 | required: false 53 | 54 | - type: textarea 55 | id: environment-py 56 | attributes: 57 | label: "What version of Python are you running?" 58 | description: "Please copy and paste the command and output used to retrieve this (this will be console rendered automatically)" 59 | placeholder: | 60 | $ python --version 61 | Python 3.11.4 62 | render: console 63 | validations: 64 | required: true 65 | 66 | - type: textarea 67 | id: environment-installer 68 | attributes: 69 | label: "How did you install twine? Did you use your operating system's package manager or pip or something else?" 70 | description: "Please copy and paste the command(s) you used to install twine" 71 | placeholder: | 72 | $ pip install twine 73 | $ dnf install twine 74 | $ apt install twine 75 | $ brew install twine 76 | render: console 77 | validations: 78 | required: true 79 | 80 | - type: textarea 81 | id: version 82 | attributes: 83 | label: "What version of twine do you have installed (include the complete output)" 84 | description: "Please copy and paste the complete output of `twine --version`" 85 | placeholder: | 86 | $ twine --version 87 | twine version 1.15.0 (pkginfo: 1.4.2, requests: 2.19.1, setuptools: 40.4.3, 88 | requests-toolbelt: 0.8.0, tqdm: 4.26.0) 89 | render: console 90 | validations: 91 | required: true 92 | 93 | - type: input 94 | id: package-repository 95 | attributes: 96 | label: "Which package repository are you using?" 97 | placeholder: | 98 | pypi.org 99 | test.pypi.org 100 | validations: 101 | required: true 102 | 103 | - type: textarea 104 | id: issue 105 | attributes: 106 | label: "Please describe the issue that you are experiencing" 107 | placeholder: "When I run twine upload it does ... but I expect it to do ..." 108 | validations: 109 | required: true 110 | 111 | - type: textarea 112 | id: reproduction-steps 113 | attributes: 114 | label: "Please list the steps required to reproduce this behaviour" 115 | placeholder: | 116 | 1. Install twine in a virtual environment 117 | 1. Build this package at github.com/... 118 | 1. Run `twine upload dist/*` 119 | validations: 120 | required: true 121 | 122 | - type: textarea 123 | id: pkg-info 124 | attributes: 125 | label: "Please include the PKG-INFO file contents from the artifact you're attempting to upload" 126 | description: "note: this will be email formatted automatically" 127 | render: email 128 | validations: 129 | required: true 130 | 131 | - type: textarea 132 | id: pypirc 133 | attributes: 134 | label: "A redacted version of your `.pypirc` file" 135 | description: "REMOVE ALL USERNAMES & PASSWORDS; note: this will be ini formatted automatically" 136 | render: ini 137 | validations: 138 | required: true 139 | 140 | - type: textarea 141 | id: other 142 | attributes: 143 | label: "Anything else you'd like to mention?" 144 | validations: 145 | required: false 146 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02_bug.yml: -------------------------------------------------------------------------------- 1 | name: "Bug Report" 2 | description: "Something went wrong with twine (other than an upload error)" 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: > 8 | ## Your Environment 9 | Thank you for taking the time to report an issue. 10 | 11 | To more efficiently resolve this issue, we'd like to know some basic 12 | information about your system and setup. 13 | 14 | - type: checkboxes 15 | attributes: 16 | label: "Is there an existing issue for this?" 17 | description: "Please search to see if there's an existing issue for what you're reporting" 18 | options: 19 | - label: "I have searched the existing issues (open and closed), and could not find an existing issue" 20 | required: true 21 | 22 | - type: textarea 23 | id: search-keywords 24 | attributes: 25 | label: "What keywords did you use to search existing issues?" 26 | description: "In the event that you could not find a duplicate, but it existed, this will help us better link issues in the future" 27 | placeholder: | 28 | authorization 29 | artifactory 30 | jfrog 31 | devpi 32 | 33 | - type: dropdown 34 | id: environment-os 35 | attributes: 36 | label: "What operating system are you using?" 37 | multiple: true 38 | options: 39 | - "Windows 10" 40 | - "Windows 11" 41 | - "Linux" 42 | - "macOS" 43 | - "Other" 44 | validations: 45 | required: true 46 | 47 | - type: input 48 | id: environment-os-other 49 | attributes: 50 | label: "If you selected 'Other', describe your Operating System here" 51 | placeholder: "example: Linux hostname 6.5.10-200.fc38.x86_64" 52 | validations: 53 | required: false 54 | 55 | - type: textarea 56 | id: environment-py 57 | attributes: 58 | label: "What version of Python are you running?" 59 | description: "Please copy and paste the command and output used to retrieve this (this will be console rendered automatically)" 60 | placeholder: | 61 | $ python --version 62 | Python 3.11.4 63 | render: console 64 | validations: 65 | required: true 66 | 67 | - type: textarea 68 | id: environment-installer 69 | attributes: 70 | label: "How did you install twine? Did you use your operating system's package manager or pip or something else?" 71 | description: "Please copy and paste the command(s) you used to install twine" 72 | placeholder: | 73 | $ pip install twine 74 | $ dnf install twine 75 | $ apt install twine 76 | $ brew install twine 77 | render: console 78 | validations: 79 | required: true 80 | 81 | - type: textarea 82 | id: version 83 | attributes: 84 | label: "What version of twine do you have installed (include the complete output)" 85 | description: "Please copy and paste the complete output of `twine --version`" 86 | placeholder: | 87 | $ twine --version 88 | twine version 1.15.0 (pkginfo: 1.4.2, requests: 2.19.1, setuptools: 40.4.3, 89 | requests-toolbelt: 0.8.0, tqdm: 4.26.0) 90 | render: console 91 | validations: 92 | required: true 93 | 94 | - type: input 95 | id: package-repository 96 | attributes: 97 | label: "Which package repository are you using?" 98 | placeholder: | 99 | pypi.org 100 | test.pypi.org 101 | validations: 102 | required: true 103 | 104 | - type: textarea 105 | id: issue 106 | attributes: 107 | label: "Please describe the issue that you are experiencing" 108 | placeholder: "When I run twine upload it does ... but I expect it to do ..." 109 | validations: 110 | required: true 111 | 112 | - type: textarea 113 | id: reproduction-steps 114 | attributes: 115 | label: "Please list the steps required to reproduce this behaviour" 116 | placeholder: | 117 | 1. Install twine in a virtual environment 118 | 1. Build this package at github.com/... 119 | 1. Run `twine upload dist/*` 120 | validations: 121 | required: true 122 | 123 | - type: textarea 124 | id: other 125 | attributes: 126 | label: "Anything else you'd like to mention?" 127 | validations: 128 | required: false 129 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/03_feature.yml: -------------------------------------------------------------------------------- 1 | name: "Feature Request" 2 | description: "Something is missing from Twine" 3 | labels: ["enhancement", "feature request"] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: "Is there an existing issue for this?" 8 | description: "Please search to see if there's an existing issue for what you're reporting" 9 | options: 10 | - label: "I have searched the existing issues (open and closed), and could not find an existing issue" 11 | required: true 12 | 13 | - type: textarea 14 | id: search-keywords 15 | attributes: 16 | label: "What keywords did you use to search existing issues?" 17 | description: "In the event that you could not find a duplicate, but it existed, this will help us better link issues in the future" 18 | placeholder: | 19 | authorization 20 | artifactory 21 | jfrog 22 | devpi 23 | 24 | - type: textarea 25 | id: problem 26 | attributes: 27 | label: "Please describe the problem you are attempting to solve with this request" 28 | description: "Is there missing behaviour or some other issue?" 29 | placeholder: "When I run twine upload it does ... but I wish it would ..." 30 | validations: 31 | required: true 32 | 33 | - type: textarea 34 | id: proposed-solution 35 | attributes: 36 | label: "How do you think we should solve this?" 37 | 38 | - type: textarea 39 | id: other 40 | attributes: 41 | label: "Anything else you'd like to mention?" 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/04_other.yml: -------------------------------------------------------------------------------- 1 | name: "Other" 2 | description: "This does not fit into the other categories" 3 | labels: ["enhancement", "feature request"] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: "Is there an existing issue for this?" 8 | description: "Please search to see if there's an existing issue for what you're reporting" 9 | options: 10 | - label: "I have searched the existing issues (open and closed), and could not find an existing issue" 11 | required: true 12 | 13 | - type: textarea 14 | id: search-keywords 15 | attributes: 16 | label: "What keywords did you use to search existing issues?" 17 | description: "In the event that you could not find a duplicate, but it existed, this will help us better link issues in the future" 18 | placeholder: | 19 | authorization 20 | artifactory 21 | jfrog 22 | devpi 23 | 24 | - type: textarea 25 | id: other-description 26 | attributes: 27 | label: "Please describe why your using this option" 28 | validations: 29 | required: true 30 | 31 | - type: textarea 32 | id: other 33 | attributes: 34 | label: "Anything else you'd like to mention?" 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Packaging issues or metadata issues 4 | url: https://github.com/pypa/packaging-problems/issues 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '43 23 * * 6' 22 | 23 | permissions: 24 | contents: read 25 | 26 | jobs: 27 | analyze: 28 | name: Analyze 29 | runs-on: ubuntu-latest 30 | permissions: 31 | actions: read 32 | contents: read 33 | security-events: write 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | language: [ 'python' ] 39 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 40 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 41 | 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v4.2.2 45 | with: 46 | persist-credentials: false 47 | 48 | # Initializes the CodeQL tools for scanning. 49 | - name: Initialize CodeQL 50 | uses: github/codeql-action/init@v3 51 | with: 52 | languages: ${{ matrix.language }} 53 | # If you wish to specify custom queries, you can do so here or in a config file. 54 | # By default, queries listed here will override any specified in a config file. 55 | # Prefix the list here with "+" to use these queries and those in the config file. 56 | 57 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 58 | # queries: security-extended,security-and-quality 59 | 60 | 61 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 62 | # If this step fails, then you should remove it and run the build manually (see below) 63 | - name: Autobuild 64 | uses: github/codeql-action/autobuild@v3 65 | 66 | # ℹ️ Command-line programs to run using the OS shell. 67 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 68 | 69 | # If the Autobuild fails above, remove it and uncomment the following three lines. 70 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 71 | 72 | # - run: | 73 | # echo "Run, Build Application using script" 74 | # ./location_of_script_within_repo/buildscript.sh 75 | 76 | - name: Perform CodeQL Analysis 77 | uses: github/codeql-action/analyze@v3 78 | with: 79 | category: "/language:${{matrix.language}}" 80 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | schedule: 9 | - cron: "0 0 * * *" # daily 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} 13 | cancel-in-progress: true 14 | 15 | env: 16 | FORCE_COLOR: "1" 17 | TOX_TESTENV_PASSENV: "FORCE_COLOR" 18 | MIN_PYTHON_VERSION: "3.9" 19 | DEFAULT_PYTHON_VERSION: "3.11" 20 | 21 | permissions: 22 | contents: read 23 | 24 | jobs: 25 | lint: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4.2.2 29 | with: 30 | persist-credentials: false 31 | - uses: actions/setup-python@v5.6.0 32 | with: 33 | python-version: ${{ env.DEFAULT_PYTHON_VERSION }} 34 | - name: Install dependencies 35 | run: python -m pip install tox 36 | - name: Run linting 37 | run: python -m tox -e lint 38 | 39 | test: 40 | strategy: 41 | matrix: 42 | python-version: 43 | - "3.9" 44 | - "3.10" 45 | - "3.11" 46 | - "3.12" 47 | - "3.13" 48 | platform: 49 | - ubuntu-latest 50 | - macos-latest 51 | - windows-latest 52 | tox-environment: 53 | - py 54 | include: 55 | # Test with the oldest supported ``packaging`` version. 56 | - platform: ubuntu-latest 57 | python-version: "3.9" 58 | tox-environment: py-packaging240 59 | runs-on: ${{ matrix.platform }} 60 | steps: 61 | - uses: actions/checkout@v4.2.2 62 | with: 63 | persist-credentials: false 64 | - uses: actions/setup-python@v5.6.0 65 | with: 66 | python-version: ${{ matrix.python-version }} 67 | - name: Install dependencies 68 | run: python -m pip install tox 69 | - name: Run type-checking 70 | run: python -m tox -e types 71 | - name: Run tests 72 | run: python -m tox -e ${{ matrix.tox-environment }} 73 | 74 | # Because the tests can be flaky, they shouldn't be required for merge, but 75 | # it's still helpful to run them on PRs. See: 76 | # https://github.com/pypa/twine/issues/684#issuecomment-703150619 77 | integration: 78 | # Only run on Ubuntu because most of the tests are skipped on Windows 79 | runs-on: ubuntu-latest 80 | steps: 81 | - uses: actions/checkout@v4.2.2 82 | with: 83 | persist-credentials: false 84 | - uses: actions/setup-python@v5.6.0 85 | with: 86 | python-version: ${{ env.MIN_PYTHON_VERSION }} 87 | - name: Install dependencies 88 | run: python -m pip install tox 89 | - name: Run tests 90 | run: python -m tox -e integration 91 | 92 | docs: 93 | runs-on: ubuntu-latest 94 | steps: 95 | - uses: actions/checkout@v4.2.2 96 | with: 97 | persist-credentials: false 98 | - uses: actions/setup-python@v5.6.0 99 | with: 100 | python-version: ${{ env.MIN_PYTHON_VERSION }} 101 | - name: Install dependencies 102 | run: python -m pip install tox 103 | - name: Build docs 104 | run: python -m tox -e docs 105 | 106 | # https://github.com/marketplace/actions/alls-green#why 107 | check: # This job does nothing and is only used for the branch protection 108 | if: always() 109 | 110 | needs: 111 | - lint 112 | - test 113 | - integration 114 | - docs 115 | 116 | runs-on: ubuntu-latest 117 | 118 | steps: 119 | - name: Decide whether the needed jobs succeeded or failed 120 | uses: re-actors/alls-green@release/v1 121 | with: 122 | allowed-failures: integration # can be flaky 123 | jobs: ${{ toJSON(needs) }} 124 | 125 | release: 126 | needs: 127 | - check 128 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 129 | runs-on: ubuntu-latest 130 | steps: 131 | - uses: actions/checkout@v4.2.2 132 | with: 133 | persist-credentials: false 134 | - uses: actions/setup-python@v5.6.0 135 | with: 136 | python-version: ${{ env.MIN_PYTHON_VERSION }} 137 | - name: Install dependencies 138 | run: python -m pip install tox 139 | - name: Release 140 | run: tox -e release 141 | env: 142 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 143 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | name: "Build dists" 14 | runs-on: "ubuntu-latest" 15 | environment: 16 | name: "publish" 17 | outputs: 18 | hashes: ${{ steps.hash.outputs.hashes }} 19 | 20 | steps: 21 | - name: "Checkout repository" 22 | uses: "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" 23 | with: 24 | persist-credentials: false 25 | 26 | - name: "Setup Python" 27 | uses: "actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065" 28 | with: 29 | python-version: "3.x" 30 | 31 | - name: "Install dependencies" 32 | run: python -m pip install build 33 | 34 | - name: "Build dists" 35 | run: | 36 | SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) \ 37 | python -m build 38 | 39 | - name: "Generate hashes" 40 | id: hash 41 | run: | 42 | cd dist && echo "hashes=$(sha256sum * | base64 -w0)" >> $GITHUB_OUTPUT 43 | 44 | - name: "Upload dists" 45 | uses: "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02" 46 | with: 47 | name: "dist" 48 | path: "dist/" 49 | if-no-files-found: error 50 | retention-days: 5 51 | 52 | provenance: 53 | needs: [build] 54 | permissions: 55 | actions: read 56 | contents: write 57 | id-token: write # Needed to access the workflow's OIDC identity. 58 | uses: "slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0" 59 | with: 60 | base64-subjects: "${{ needs.build.outputs.hashes }}" 61 | upload-assets: true 62 | compile-generator: true # Workaround for https://github.com/slsa-framework/slsa-github-generator/issues/1163 63 | 64 | publish: 65 | name: "Publish to PyPI" 66 | if: startsWith(github.ref, 'refs/tags/') 67 | needs: ["build", "provenance"] 68 | permissions: 69 | contents: write 70 | id-token: write 71 | runs-on: "ubuntu-latest" 72 | 73 | steps: 74 | - name: "Download dists" 75 | uses: "actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093" 76 | with: 77 | name: "dist" 78 | path: "dist/" 79 | 80 | - name: "Publish dists to PyPI" 81 | uses: "pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc" 82 | -------------------------------------------------------------------------------- /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | pip-wheel-metadata/ 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | .dmypy.json 112 | dmypy.json 113 | monkeytype.sqlite3 114 | mypy/ 115 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile=black 3 | force_single_line=True 4 | single_line_exclusions=typing 5 | default_section=THIRDPARTY 6 | known_third_party=build 7 | known_first_party=twine,helpers 8 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | sphinx: 8 | configuration: docs/conf.py 9 | fail_on_warning: true 10 | 11 | formats: 12 | - htmlzip 13 | - pdf 14 | - epub 15 | 16 | build: 17 | os: ubuntu-22.04 18 | tools: 19 | python: "3.11" 20 | 21 | python: 22 | # Install twine first, because RTD uses `--upgrade-strategy eager`, 23 | # which installs the latest version of docutils via readme_renderer. 24 | # However, Sphinx 4.2.0 requires docutils>=0.14,<0.18. 25 | install: 26 | - method: pip 27 | path: . 28 | - requirements: docs/requirements.txt 29 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # A list of people who have contributed to twine in order of their first 2 | # contribution. 3 | # 4 | # Uses the format of ``Name (url)`` with the ``(url)`` 5 | # being optional. 6 | 7 | Donald Stufft (https://caremad.io/) 8 | Jannis Leidel 9 | Ralf Schmitt 10 | Ian Cordasco 11 | Marc Abramowitz (http://marc-abramowitz.com/) 12 | Tom Myers 13 | Rodrigue Cloutier 14 | Tyrel Souza (https://tyrelsouza.com) 15 | Adam Talsma 16 | Jens Diemer (http://jensdiemer.de/) 17 | Andrew Watts 18 | Anna Martelli Ravenscroft 19 | Sumana Harihareswara 20 | Dustin Ingram (https://di.codes) 21 | Jesse Jarzynka (https://www.jessejoe.com/) 22 | László Kiss Kollár 23 | Frances Hocutt 24 | Tathagata Dasgupta 25 | Wasim Thabraze 26 | Varun Kamath 27 | Brian Rutledge 28 | Peter Stensmyr (http://www.peterstensmyr.com) 29 | Felipe Mulinari Rocha Campos 30 | Devesh Kumar Singh 31 | Yesha Maggi 32 | Cyril de Catheu (https://catheu.tech/) 33 | Thomas Miedema 34 | Hugo van Kemenade (https://github.com/hugovk) 35 | Jacob Woliver (jmw.sh) 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | https://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. |twine-version| image:: https://img.shields.io/pypi/v/twine.svg 2 | :target: https://pypi.org/project/twine 3 | 4 | .. |python-versions| image:: https://img.shields.io/pypi/pyversions/twine.svg 5 | :target: https://pypi.org/project/twine 6 | 7 | .. |docs-badge| image:: https://img.shields.io/readthedocs/twine 8 | :target: https://twine.readthedocs.io 9 | 10 | .. |build-badge| image:: https://img.shields.io/github/actions/workflow/status/pypa/twine/main.yml?branch=main 11 | :target: https://github.com/pypa/twine/actions 12 | 13 | |twine-version| |python-versions| |docs-badge| |build-badge| 14 | 15 | twine 16 | ===== 17 | 18 | Twine is a utility for `publishing`_ Python packages on `PyPI`_. 19 | 20 | It provides build system independent uploads of source and binary 21 | `distribution artifacts `_ for both new and existing 22 | `projects`_. 23 | 24 | See our `documentation`_ for a description of features, installation 25 | and usage instructions, and links to additional resources. 26 | 27 | Contributing 28 | ------------ 29 | 30 | See our `developer documentation`_ for how to get started, an 31 | architectural overview, and our future development plans. 32 | 33 | Code of Conduct 34 | --------------- 35 | 36 | Everyone interacting in the Twine project's codebases, issue 37 | trackers, chat rooms, and mailing lists is expected to follow the 38 | `PSF Code of Conduct`_. 39 | 40 | .. _`publishing`: https://packaging.python.org/tutorials/packaging-projects/ 41 | .. _`PyPI`: https://pypi.org 42 | .. _`distributions`: 43 | https://packaging.python.org/glossary/#term-Distribution-Package 44 | .. _`projects`: https://packaging.python.org/glossary/#term-Project 45 | .. _`documentation`: https://twine.readthedocs.io/ 46 | .. _`developer documentation`: 47 | https://twine.readthedocs.io/en/latest/contributing.html 48 | .. _`PSF Code of Conduct`: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md 49 | -------------------------------------------------------------------------------- /changelog/.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | -------------------------------------------------------------------------------- /changelog/1217.bugfix.rst: -------------------------------------------------------------------------------- 1 | Fix compatibility kludge for invalid License-File metadata entries emitted by 2 | build backends to work also with ``packaging`` version 24.0. 3 | -------------------------------------------------------------------------------- /changelog/1224.bugfix.rst: -------------------------------------------------------------------------------- 1 | Fix a couple of incorrectly rendered error messages. 2 | -------------------------------------------------------------------------------- /changelog/1229.bugfix.rst: -------------------------------------------------------------------------------- 1 | ``twine`` now enforces ``keyring >= 21.2.0``, which was previously 2 | implicitly required by API usage. 3 | -------------------------------------------------------------------------------- /changelog/1240.bugfix.rst: -------------------------------------------------------------------------------- 1 | ``twine`` now catches ``configparser.Error`` to prevent accidental 2 | leaks of secret tokens or passwords to the user's console. 3 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # twine documentation build configuration file, created by 2 | # sphinx-quickstart on Tue Aug 13 11:51:54 2013. 3 | # 4 | # This file is execfile()d with the current directory set to its containing 5 | # dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | # import sys 14 | # import os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # sys.path.insert(0, os.path.abspath('.')) 20 | # sys.path.insert(0, os.path.abspath(os.pardir)) 21 | 22 | import twine 23 | 24 | # -- General configuration ---------------------------------------------------- 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | needs_sphinx = "1.7.0" 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 31 | extensions = [ 32 | "sphinx.ext.autodoc", 33 | "sphinx.ext.doctest", 34 | "sphinx.ext.intersphinx", 35 | "sphinx.ext.coverage", 36 | "sphinx.ext.viewcode", 37 | "sphinxcontrib.programoutput", 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ["_templates"] 42 | 43 | # The suffix of source filenames. 44 | source_suffix = ".rst" 45 | 46 | # The encoding of source files. 47 | # source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = "index" 51 | 52 | # General information about the project. 53 | project = "twine" 54 | copyright = "2019, Donald Stufft and individual contributors" 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | # The short X.Y version. 61 | version = ".".join(twine.__version__.split(".")[:2]) 62 | # The full version, including alpha/beta/rc tags. 63 | release = twine.__version__ 64 | 65 | # The language for content autogenerated by Sphinx. Refer to documentation 66 | # for a list of supported languages. 67 | # language = None 68 | 69 | # There are two options for replacing |today|: either, you set today to some 70 | # non-false value, then it is used: 71 | # today = '' 72 | # Else, today_fmt is used as the format for a strftime call. 73 | # today_fmt = '%B %d, %Y' 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | exclude_patterns = ["_build"] 78 | 79 | # The reST default role (used for this markup: `text`) to use for all documents 80 | # default_role = None 81 | 82 | # If true, '()' will be appended to :func: etc. cross-reference text. 83 | # add_function_parentheses = True 84 | 85 | # If true, the current module name will be prepended to all description 86 | # unit titles (such as .. function::). 87 | # add_module_names = True 88 | 89 | # If true, sectionauthor and moduleauthor directives will be shown in the 90 | # output. They are ignored by default. 91 | # show_authors = False 92 | 93 | # The name of the Pygments (syntax highlighting) style to use. 94 | # pygments_style = "sphinx" 95 | 96 | # A list of ignored prefixes for module index sorting. 97 | # modindex_common_prefix = [] 98 | 99 | 100 | # -- Options for HTML output -------------------------------------------------- 101 | 102 | # The theme to use for HTML and HTML Help pages. See the documentation for 103 | # a list of builtin themes. 104 | html_theme = "furo" 105 | 106 | # Theme options are theme-specific and customize the look and feel of a theme 107 | # further. For a list of options available for each theme, see the 108 | # documentation. 109 | # html_theme_options = {} 110 | 111 | # Add any paths that contain custom themes here, relative to this directory. 112 | # html_theme_path = [] 113 | 114 | # The name for this set of Sphinx documents. If None, it defaults to 115 | # " v documentation". 116 | # html_title = None 117 | 118 | # A shorter title for the navigation bar. Default is the same as html_title. 119 | # html_short_title = None 120 | 121 | # The name of an image file (relative to this directory) to place at the top 122 | # of the sidebar. 123 | # html_logo = None 124 | 125 | # The name of an image file (within the static path) to use as favicon of the 126 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 127 | # pixels large. 128 | # html_favicon = None 129 | 130 | # Add any paths that contain custom static files (such as style sheets) here, 131 | # relative to this directory. They are copied after the builtin static files, 132 | # so a file named "default.css" will overwrite the builtin "default.css". 133 | # html_static_path = ["_static"] 134 | 135 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 136 | # using the given strftime format. 137 | # html_last_updated_fmt = '%b %d, %Y' 138 | 139 | # If true, SmartyPants will be used to convert quotes and dashes to 140 | # typographically correct entities. 141 | # html_use_smartypants = True 142 | 143 | # Custom sidebar templates, maps document names to template names. 144 | # html_sidebars = {} 145 | 146 | # Additional templates that should be rendered to pages, maps page names to 147 | # template names. 148 | # html_additional_pages = {} 149 | 150 | # If false, no module index is generated. 151 | # html_domain_indices = True 152 | 153 | # If false, no index is generated. 154 | # html_use_index = True 155 | 156 | # If true, the index is split into individual pages for each letter. 157 | # html_split_index = False 158 | 159 | # If true, links to the reST sources are added to the pages. 160 | # html_show_sourcelink = True 161 | 162 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 163 | # html_show_sphinx = True 164 | 165 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 166 | # html_show_copyright = True 167 | 168 | # If true, an OpenSearch description file will be output, and all pages will 169 | # contain a tag referring to it. The value of this option must be the 170 | # base URL from which the finished HTML is served. 171 | # html_use_opensearch = '' 172 | 173 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 174 | # html_file_suffix = None 175 | 176 | # Output file base name for HTML help builder. 177 | htmlhelp_basename = "twinedoc" 178 | 179 | 180 | # -- Options for LaTeX output ------------------------------------------------- 181 | 182 | latex_elements = { 183 | # The paper size ('letterpaper' or 'a4paper'). 184 | # 'papersize': 'letterpaper', 185 | # The font size ('10pt', '11pt' or '12pt'). 186 | # 'pointsize': '10pt', 187 | # Additional stuff for the LaTeX preamble. 188 | # 'preamble': '', 189 | } 190 | 191 | # Grouping the document tree into LaTeX files. List of tuples 192 | # (source start file, target name, title, author, documentclass [howto/manual]) 193 | latex_documents = [ 194 | ( 195 | "index", 196 | "twine.tex", 197 | "Twine Documentation", 198 | "Donald Stufft and individual contributors", 199 | "manual", 200 | ), 201 | ] 202 | 203 | # The name of an image file (relative to this directory) to place at the top of 204 | # the title page. 205 | # latex_logo = None 206 | 207 | # For "manual" documents, if this is true, then toplevel headings are parts, 208 | # not chapters. 209 | # latex_use_parts = False 210 | 211 | # If true, show page references after internal links. 212 | # latex_show_pagerefs = False 213 | 214 | # If true, show URL addresses after external links. 215 | # latex_show_urls = False 216 | 217 | # Documents to append as an appendix to all manuals. 218 | # latex_appendices = [] 219 | 220 | # If false, no module index is generated. 221 | # latex_domain_indices = True 222 | 223 | 224 | # -- Options for manual page output ------------------------------------------- 225 | 226 | # One entry per manual page. List of tuples 227 | # (source start file, name, description, authors, manual section). 228 | man_pages = [ 229 | ( 230 | "index", 231 | "twine", 232 | "twine Documentation", 233 | ["Donald Stufft", "Individual contributors"], 234 | 1, 235 | ), 236 | ] 237 | 238 | # If true, show URL addresses after external links. 239 | # man_show_urls = False 240 | 241 | 242 | # -- Options for Texinfo output ----------------------------------------------- 243 | 244 | # Grouping the document tree into Texinfo files. List of tuples 245 | # (source start file, target name, title, author, 246 | # dir menu entry, description, category) 247 | texinfo_documents = [ 248 | ( 249 | "index", 250 | "twine", 251 | "twine Documentation", 252 | "Donald Stufft and individual contributors", 253 | "twine", 254 | "One line description of project.", 255 | "Miscellaneous", 256 | ), 257 | ] 258 | 259 | # Documents to append as an appendix to all manuals. 260 | # texinfo_appendices = [] 261 | 262 | # If false, no module index is generated. 263 | # texinfo_domain_indices = True 264 | 265 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 266 | # texinfo_show_urls = 'footnote' 267 | 268 | # See https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-linkcheck_ignore 269 | linkcheck_ignore = [ 270 | r"https?://127\.0\.0\.1.*", 271 | # Avoid errors due to GitHub rate limit 272 | # https://github.com/sphinx-doc/sphinx/issues/7388 273 | r"https://github\.com/pypa/twine/issues/.*", 274 | # Avoid errors from channels interpreted as anchors 275 | r"https://web\.libera\.chat/#", 276 | # Avoid error from InvalidPyPIUploadURL docstring 277 | r"https://upload\.pypi\.org/legacy/?", 278 | # Avoid errors from 403/Login Redirects 279 | r"https://(test\.)?pypi\.org/manage/project/twine/collaboration/?", 280 | r"https://pypi\.org/manage/project/twine/collaboration/?", 281 | # PyPI uses
anchor links that are not understood by 282 | # linkcheck. Ignore these link targets in the check until they are 283 | # supported. Maybe use linkcheck_anchors_ignore_for_url configuration 284 | # option once Sphinx 7.1 or later is used, see 285 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-linkcheck_anchors_ignore_for_url 286 | r"https://pypi\.org/project/[^/]+/\#", 287 | ] 288 | 289 | intersphinx_mapping = { 290 | "python": ("https://docs.python.org/3", None), 291 | "requests": ("https://requests.readthedocs.io/en/latest/", None), 292 | "packaging": ("https://packaging.pypa.io/en/latest/", None), 293 | } 294 | 295 | # Be strict about the invalid references: 296 | nitpicky = True 297 | 298 | # -- Options for apidoc output ------------------------------------------------ 299 | 300 | autodoc_default_options = { 301 | "members": True, 302 | "private-members": True, 303 | "undoc-members": True, 304 | "member-order": "bysource", 305 | } 306 | 307 | autodoc_class_signature = "separated" 308 | autodoc_preserve_defaults = True 309 | # autodoc_typehints = "both" 310 | # autodoc_typehints_description_target = "documented" 311 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | We are happy you have decided to contribute to Twine. 5 | 6 | Please see `the GitHub repository`_ for code and more documentation, 7 | and the `official Python Packaging User Guide`_ for user documentation. 8 | To ask questions or get involved, you can join the `Python Packaging 9 | Discourse forum`_, ``#pypa`` or ``#pypa-dev`` on `IRC`_, or the 10 | `distutils-sig mailing list`_. 11 | 12 | Everyone interacting in the Twine project's codebases, issue 13 | trackers, chat rooms, and mailing lists is expected to follow the 14 | `PSF Code of Conduct`_. 15 | 16 | Getting started 17 | --------------- 18 | 19 | We use `tox`_ to run tests, check code style, and build the documentation. 20 | To install ``tox``, run: 21 | 22 | .. code-block:: bash 23 | 24 | python3 -m pip install tox 25 | 26 | Clone the twine repository from GitHub, then run: 27 | 28 | .. code-block:: bash 29 | 30 | cd /path/to/your/local/twine 31 | tox -e dev 32 | 33 | This creates a `virtual environment`_, so that twine and its 34 | dependencies do not interfere with other packages installed on your 35 | machine. In the virtual environment, ``twine`` is pointing at your 36 | local copy, so when you make changes, you can easily see their effect. 37 | 38 | The virtual environment also contains the tools for running tests 39 | and checking code style, so you can run them on single files directly or 40 | in your code editor. However, we still encourage using the ``tox`` commands 41 | below on the whole codebase. 42 | 43 | To use the virtual environment, run: 44 | 45 | .. code-block:: bash 46 | 47 | source venv/bin/activate 48 | 49 | Building the documentation 50 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 51 | 52 | Additions and edits to twine's documentation are welcome and 53 | appreciated. 54 | 55 | To preview the docs while you're making changes, run: 56 | 57 | .. code-block:: bash 58 | 59 | tox -e watch-docs 60 | 61 | Then open a web browser to ``_. 62 | 63 | When you're done making changes, lint and build the docs locally before making 64 | a pull request. In your active virtual environment, run: 65 | 66 | .. code-block:: bash 67 | 68 | tox -e docs 69 | 70 | The HTML of the docs will be written to :file:`docs/_build/html`. 71 | 72 | Code style 73 | ^^^^^^^^^^ 74 | 75 | To automatically reformat your changes with `isort`_ and `black`_, run: 76 | 77 | .. code-block:: bash 78 | 79 | tox -e format 80 | 81 | To detect any remaining code smells with `flake8`_, run: 82 | 83 | .. code-block:: bash 84 | 85 | tox -e lint 86 | 87 | To perform strict type-checking using `mypy`_, run: 88 | 89 | .. code-block:: bash 90 | 91 | tox -e types 92 | 93 | Any errors from ``lint`` or ``types`` need to be fixed manually. 94 | 95 | Additionally, we prefer that ``import`` statements be used for packages and 96 | modules only, rather than individual classes or functions. 97 | 98 | Testing 99 | ^^^^^^^ 100 | 101 | We use `pytest`_ for writing and running tests. 102 | 103 | To run the tests in your virtual environment, run: 104 | 105 | .. code-block:: bash 106 | 107 | tox -e py 108 | 109 | To pass options to ``pytest``, e.g. the name of a test, run: 110 | 111 | .. code-block:: bash 112 | 113 | tox -e py -- tests/test_upload.py::test_exception_for_http_status 114 | 115 | Twine is continuously tested against supported versions of Python using 116 | `GitHub Actions`_. To run the tests against a specific version, e.g. Python 117 | 3.8, you will need it installed on your machine. Then, run: 118 | 119 | .. code-block:: bash 120 | 121 | tox -e py38 122 | 123 | To run the "integration" tests of uploading to real package indexes, run: 124 | 125 | .. code-block:: bash 126 | 127 | tox -e integration 128 | 129 | To run the tests against all supported Python versions, check code style, 130 | and build the documentation, run: 131 | 132 | .. code-block:: bash 133 | 134 | tox 135 | 136 | 137 | Submitting changes 138 | ------------------ 139 | 140 | 1. Fork `the GitHub repository`_. 141 | 2. Make a branch off of ``main`` and commit your changes to it. 142 | 3. Run the tests, check code style, and build the docs as described above. 143 | 4. Optionally, add your name to the end of the :file:`AUTHORS` 144 | file using the format ``Name (url)``, where the 145 | ``(url)`` portion is optional. 146 | 5. Submit a pull request to the ``main`` branch on GitHub, referencing an 147 | open issue. 148 | 6. Add a changelog entry. 149 | 150 | Changelog entries 151 | ^^^^^^^^^^^^^^^^^ 152 | 153 | The ``docs/changelog.rst`` file is built by `towncrier`_ from files in the 154 | ``changelog/`` directory. To add an entry, create a file in that directory 155 | named ``{number}.{type}.rst``, where ``{number}`` is the pull request number, 156 | and ``{type}`` is ``feature``, ``bugfix``, ``doc``, ``removal``, or ``misc``. 157 | 158 | For example, if your PR number is 1234 and it's fixing a bug, then you 159 | would create ``changelog/1234.bugfix.rst``. PRs can span multiple categories by 160 | creating multiple files: if you added a feature and deprecated/removed an old 161 | feature in PR #5678, you would create ``changelog/5678.feature.rst`` and 162 | ``changelog/5678.removal.rst``. 163 | 164 | A changelog entry is meant for end users and should only contain details 165 | relevant to them. In order to maintain a consistent style, please keep the 166 | entry to the point, in sentence case, shorter than 80 characters, and in an 167 | imperative tone. An entry should complete the sentence "This change will ...". 168 | If one line is not enough, use a summary line in an imperative tone, followed 169 | by a description of the change in one or more paragraphs, each wrapped at 80 170 | characters and separated by blank lines. 171 | 172 | You don't need to reference the pull request or issue number in a changelog 173 | entry, since towncrier will add a link using the number in the file name, 174 | and the pull request should reference an issue number. Similarly, you don't 175 | need to add your name to the entry, since that will be associated with the pull 176 | request. 177 | 178 | Changelog entries are rendered using `reStructuredText`_, but they should only 179 | have minimal formatting (such as ````monospaced text````). 180 | 181 | .. _`towncrier`: https://pypi.org/project/towncrier/ 182 | .. _`reStructuredText`: https://www.writethedocs.org/guide/writing/reStructuredText/ 183 | 184 | 185 | Architectural overview 186 | ---------------------- 187 | 188 | Twine is a command-line tool for interacting with PyPI securely over 189 | HTTPS. Its three purposes are to be: 190 | 191 | 1. A user-facing tool for publishing on pypi.org 192 | 2. A user-facing tool for publishing on other Python package indexes 193 | (e.g., ``devpi`` instances) 194 | 3. A useful API for other programs (e.g., ``zest.releaser``) to call 195 | for publishing on any Python package index 196 | 197 | 198 | Currently, twine has two principal functions: uploading new packages 199 | and registering new `projects`_ (``register`` is no longer supported 200 | on PyPI, and is in Twine for use with other package indexes). 201 | 202 | Its command line arguments are parsed in :file:`twine/cli.py`. The 203 | code for registering new projects is in 204 | :file:`twine/commands/register.py`, and the code for uploading is in 205 | :file:`twine/commands/upload.py`. The file :file:`twine/package.py` 206 | contains a single class, ``PackageFile``, which hashes the project 207 | files and extracts their metadata. The file 208 | :file:`twine/repository.py` contains the ``Repository`` class, whose 209 | methods control the URL the package is uploaded to (which the user can 210 | specify either as a default, in the :file:`.pypirc` file, or pass on 211 | the command line), and the methods that upload the package securely to 212 | a URL. 213 | 214 | For more details, refer to the source documentation (currently a 215 | `work in progress `_): 216 | 217 | .. toctree:: 218 | 219 | internal/twine 220 | 221 | Where Twine gets configuration and credentials 222 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 223 | 224 | A user can set the repository URL, username, and/or password via 225 | command line, ``.pypirc`` files, environment variables, and 226 | ``keyring``. 227 | 228 | 229 | Adding a maintainer 230 | ------------------- 231 | 232 | A checklist for adding a new maintainer to the project. 233 | 234 | #. Add them as a Member in the GitHub repo settings. 235 | #. Get them Test PyPI and canon PyPI usernames and add them as a 236 | Maintainer on `our Test PyPI project 237 | `_ and 238 | `canon PyPI 239 | `_. 240 | 241 | 242 | Making a new release 243 | -------------------- 244 | 245 | A checklist for creating, testing, and distributing a new version. 246 | 247 | #. Choose a version number, and create a new branch 248 | 249 | .. code-block:: bash 250 | 251 | VERSION=3.4.2 252 | 253 | git switch -c release-$VERSION 254 | 255 | #. Update :file:`docs/changelog.rst` 256 | 257 | .. code-block:: bash 258 | 259 | tox -e changelog -- --version $VERSION 260 | 261 | git commit -am "Update changelog for $VERSION" 262 | 263 | #. Open a pull request for review 264 | 265 | #. Merge the pull request, and ensure the `GitHub Actions`_ build passes 266 | 267 | #. Create a new git tag for the version 268 | 269 | .. code-block:: bash 270 | 271 | git switch main 272 | 273 | git pull --ff-only upstream main 274 | 275 | git tag -m "Release v$VERSION" $VERSION 276 | 277 | #. Push to start the release, and watch it in `GitHub Actions`_ 278 | 279 | .. code-block:: bash 280 | 281 | git push upstream $VERSION 282 | 283 | #. View the new release on `PyPI`_ 284 | 285 | Future development 286 | ------------------ 287 | 288 | See our `open issues`_. 289 | 290 | In the future, ``pip`` and ``twine`` may 291 | merge into a single tool; see `ongoing discussion 292 | `_. 293 | 294 | .. _`official Python Packaging User Guide`: https://packaging.python.org/tutorials/packaging-projects/ 295 | .. _`the GitHub repository`: https://github.com/pypa/twine 296 | .. _`Python Packaging Discourse forum`: https://discuss.python.org/c/packaging/ 297 | .. _`IRC`: https://web.libera.chat/#pypa-dev,#pypa 298 | .. _`distutils-sig mailing list`: https://mail.python.org/mailman3/lists/distutils-sig.python.org/ 299 | .. _`PSF Code of Conduct`: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md 300 | .. _`virtual environment`: https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/ 301 | .. _`tox`: https://tox.readthedocs.io/ 302 | .. _`pytest`: https://docs.pytest.org/ 303 | .. _`GitHub Actions`: https://github.com/pypa/twine/actions 304 | .. _`isort`: https://timothycrosley.github.io/isort/ 305 | .. _`black`: https://black.readthedocs.io/ 306 | .. _`flake8`: https://flake8.pycqa.org/ 307 | .. _`mypy`: https://mypy.readthedocs.io/ 308 | .. _`projects`: https://packaging.python.org/glossary/#term-Project 309 | .. _`open issues`: https://github.com/pypa/twine/issues 310 | .. _`PyPI`: https://pypi.org/project/twine/ 311 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. twine documentation master file, originally created by 2 | sphinx-quickstart on Tue Aug 13 11:51:54 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. toctree:: 7 | :hidden: 8 | :maxdepth: 3 9 | 10 | changelog 11 | contributing 12 | Code of Conduct 13 | PyPI Project 14 | GitHub Repository 15 | Python Packaging Tutorial 16 | 17 | Twine 18 | ===== 19 | 20 | Twine is a utility for `publishing`_ Python packages to `PyPI`_ and other 21 | `repositories`_. It provides build system independent uploads of source and 22 | binary `distribution artifacts `_ for both new and existing 23 | `projects`_. 24 | 25 | Why Should I Use This? 26 | ---------------------- 27 | 28 | The goal of Twine is to improve PyPI interaction by improving 29 | security and testability. 30 | 31 | The biggest reason to use Twine is that it securely authenticates 32 | you to PyPI over HTTPS using a verified connection, regardless of 33 | the underlying Python version. Meanwhile, ``python setup.py upload`` 34 | will only work correctly and securely if your build system, Python 35 | version, and underlying operating system are configured properly. 36 | 37 | Secondly, Twine encourages you to build your distribution files. ``python 38 | setup.py upload`` only allows you to upload a package as a final step after 39 | building with ``distutils`` or ``setuptools``, within the same command 40 | invocation. This means that you cannot test the exact file you're going to 41 | upload to PyPI to ensure that it works before uploading it. 42 | 43 | Finally, Twine allows you to pre-sign your files and pass the 44 | ``.asc`` files into the command line invocation (``twine upload 45 | myproject-1.0.1.tar.gz myproject-1.0.1.tar.gz.asc``). This enables you 46 | to be assured that you're typing your ``gpg`` passphrase into ``gpg`` 47 | itself and not anything else, since *you* will be the one directly 48 | executing ``gpg --detach-sign -a ``. 49 | 50 | Features 51 | -------- 52 | 53 | - Verified HTTPS connections 54 | - Uploading doesn't require executing ``setup.py`` 55 | - Uploading files that have already been created, allowing testing of 56 | distributions before release 57 | - Supports uploading any packaging format (including `wheels`_) 58 | 59 | Installation 60 | ------------ 61 | 62 | .. code-block:: bash 63 | 64 | pip install twine 65 | 66 | Using Twine 67 | ----------- 68 | 69 | 1. Create some distributions in the normal way: 70 | 71 | .. code-block:: bash 72 | 73 | python -m build 74 | 75 | 2. Upload to `Test PyPI`_ and verify things look right: 76 | 77 | .. code-block:: bash 78 | 79 | twine upload -r testpypi dist/* 80 | 81 | Twine will prompt for your username and password. 82 | 83 | 3. Upload to `PyPI`_: 84 | 85 | .. code-block:: bash 86 | 87 | twine upload dist/* 88 | 89 | 4. Done! 90 | 91 | .. _entering-credentials: 92 | 93 | .. note:: 94 | 95 | Like many other command line tools, Twine does not show any characters when 96 | you enter your password. 97 | 98 | If you're using Windows and trying to paste your username, password, or 99 | token in the Command Prompt or PowerShell, ``Ctrl-V`` and ``Shift+Insert`` 100 | won't work. Instead, you can use "Edit > Paste" from the window menu, or 101 | enable "Use Ctrl+Shift+C/V as Copy/Paste" in "Properties". This is a 102 | `known issue `_ with Python's 103 | ``getpass`` module. 104 | 105 | More documentation on using Twine to upload packages to PyPI is in 106 | the `Python Packaging User Guide`_. 107 | 108 | Commands 109 | -------- 110 | 111 | ``twine upload`` 112 | ^^^^^^^^^^^^^^^^ 113 | 114 | Uploads one or more distributions to a repository. 115 | 116 | .. program-output:: twine upload -h 117 | 118 | ``twine check`` 119 | ^^^^^^^^^^^^^^^ 120 | 121 | Checks whether your distribution's long description will render correctly on 122 | PyPI. 123 | 124 | .. program-output:: twine check -h 125 | 126 | ``twine register`` 127 | ^^^^^^^^^^^^^^^^^^ 128 | 129 | Pre-register a name with a repository before uploading a distribution. 130 | 131 | .. warning:: 132 | 133 | Pre-registration is `not supported on PyPI`_, so the ``register`` command is 134 | only necessary if you are using a different repository that requires it. See 135 | `issue #1627 on Warehouse`_ (the software running on PyPI) for more details. 136 | 137 | .. program-output:: twine register -h 138 | 139 | Configuration 140 | ------------- 141 | 142 | Twine can read repository configuration from a ``.pypirc`` file, either in your 143 | home directory, or provided with the ``--config-file`` option. For details on 144 | writing and using ``.pypirc``, see the `specification `_ in the Python 145 | Packaging User Guide. 146 | 147 | Environment Variables 148 | ^^^^^^^^^^^^^^^^^^^^^ 149 | 150 | Twine also supports configuration via environment variables. Options passed on 151 | the command line will take precedence over options set via environment 152 | variables. Definition via environment variable is helpful in environments where 153 | it is not convenient to create a ``.pypirc`` file (for example, 154 | on a CI/build server). 155 | 156 | * ``TWINE_USERNAME`` - the username to use for authentication to the 157 | repository. 158 | * ``TWINE_PASSWORD`` - the password to use for authentication to the 159 | repository. 160 | * ``TWINE_REPOSITORY`` - the repository configuration, either defined as a 161 | section in ``.pypirc`` or provided as a full URL. 162 | * ``TWINE_REPOSITORY_URL`` - the repository URL to use. 163 | * ``TWINE_CERT`` - custom CA certificate to use for repositories with 164 | self-signed or untrusted certificates. 165 | * ``TWINE_NON_INTERACTIVE`` - Do not interactively prompt for username/password 166 | if the required credentials are missing. 167 | 168 | Proxy Support 169 | ^^^^^^^^^^^^^ 170 | 171 | Twine can be configured to use a proxy by setting environment variables. 172 | For example, to use a proxy for just the ``twine`` command, 173 | without ``export``-ing it for other tools: 174 | 175 | .. code-block:: bash 176 | 177 | HTTPS_PROXY=socks5://user:pass@host:port twine upload dist/* 178 | 179 | For more information, see the Requests documentation on 180 | :ref:`requests:proxies` and :ref:`requests:socks`, and 181 | `an in-depth article about proxy environment variables 182 | `_. 183 | 184 | Keyring Support 185 | --------------- 186 | 187 | Instead of typing in your password every time you upload a distribution, Twine 188 | allows storing a username and password securely using `keyring`_. 189 | Keyring is installed with Twine but for some systems (Linux mainly) may 190 | require `additional installation steps`_. 191 | 192 | Once Twine is installed, use the ``keyring`` program to set a username and 193 | password to use for each repository to which you may upload. 194 | 195 | For example, to set an API token for PyPI: 196 | 197 | .. code-block:: bash 198 | 199 | keyring set https://upload.pypi.org/legacy/ __token__ 200 | 201 | and paste your API key when prompted. 202 | 203 | For a different repository, replace the URL with the relevant repository 204 | URL. For example, for Test PyPI, use ``https://test.pypi.org/legacy/``. 205 | 206 | .. note:: 207 | 208 | If you are using Linux in a headless environment (such as on a 209 | server) you'll need to do some additional steps to ensure that Keyring can 210 | store secrets securely. See `Using Keyring on headless systems`_. 211 | 212 | Disabling Keyring 213 | ^^^^^^^^^^^^^^^^^ 214 | 215 | In most cases, simply not setting a password with ``keyring`` will allow Twine 216 | to fall back to prompting for a password. In some cases, the presence of 217 | Keyring will cause unexpected or undesirable prompts from the backing system. 218 | In these cases, it may be desirable to disable Keyring altogether. To disable 219 | Keyring, run: 220 | 221 | .. code-block:: bash 222 | 223 | keyring --disable 224 | 225 | See `Twine issue #338`_ for discussion and background. 226 | 227 | 228 | .. _`publishing`: https://packaging.python.org/tutorials/packaging-projects/ 229 | .. _`PyPI`: https://pypi.org 230 | .. _`Test PyPI`: https://packaging.python.org/guides/using-testpypi/ 231 | .. _`pypirc`: https://packaging.python.org/specifications/pypirc/ 232 | .. _`Python Packaging User Guide`: 233 | https://packaging.python.org/tutorials/packaging-projects/ 234 | .. _`keyring`: https://pypi.org/project/keyring/ 235 | .. _`Using Keyring on headless systems`: 236 | https://keyring.readthedocs.io/en/latest/#using-keyring-on-headless-linux-systems 237 | .. _`additional installation steps`: 238 | https://pypi.org/project/keyring/#installation-linux 239 | .. _`developer documentation`: 240 | https://twine.readthedocs.io/en/latest/contributing.html 241 | .. _`projects`: https://packaging.python.org/glossary/#term-Project 242 | .. _`distributions`: 243 | https://packaging.python.org/glossary/#term-Distribution-Package 244 | .. _`repositories`: 245 | https://packaging.python.org/glossary/#term-Package-Index 246 | .. _`PSF Code of Conduct`: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md 247 | .. _`Warehouse`: https://github.com/pypa/warehouse 248 | .. _`wheels`: https://packaging.python.org/glossary/#term-Wheel 249 | .. _`not supported on PyPI`: 250 | https://packaging.python.org/guides/migrating-to-pypi-org/#registering-package-names-metadata 251 | .. _`issue #1627 on Warehouse`: https://github.com/pypi/warehouse/issues/1627 252 | .. _`Twine issue #338`: https://github.com/pypa/twine/issues/338 253 | -------------------------------------------------------------------------------- /docs/internal/twine.auth.rst: -------------------------------------------------------------------------------- 1 | twine.auth module 2 | ================= 3 | 4 | .. automodule:: twine.auth 5 | -------------------------------------------------------------------------------- /docs/internal/twine.cli.rst: -------------------------------------------------------------------------------- 1 | twine.cli module 2 | ================ 3 | 4 | .. automodule:: twine.cli 5 | -------------------------------------------------------------------------------- /docs/internal/twine.commands.check.rst: -------------------------------------------------------------------------------- 1 | twine.commands.check module 2 | =========================== 3 | 4 | .. automodule:: twine.commands.check 5 | -------------------------------------------------------------------------------- /docs/internal/twine.commands.register.rst: -------------------------------------------------------------------------------- 1 | twine.commands.register module 2 | ============================== 3 | 4 | .. automodule:: twine.commands.register 5 | -------------------------------------------------------------------------------- /docs/internal/twine.commands.rst: -------------------------------------------------------------------------------- 1 | twine.commands package 2 | ====================== 3 | 4 | .. automodule:: twine.commands 5 | 6 | .. toctree:: 7 | :maxdepth: 4 8 | 9 | twine.commands.check 10 | twine.commands.register 11 | twine.commands.upload 12 | -------------------------------------------------------------------------------- /docs/internal/twine.commands.upload.rst: -------------------------------------------------------------------------------- 1 | twine.commands.upload module 2 | ============================ 3 | 4 | .. automodule:: twine.commands.upload 5 | -------------------------------------------------------------------------------- /docs/internal/twine.exceptions.rst: -------------------------------------------------------------------------------- 1 | twine.exceptions module 2 | ======================= 3 | 4 | .. automodule:: twine.exceptions 5 | -------------------------------------------------------------------------------- /docs/internal/twine.package.rst: -------------------------------------------------------------------------------- 1 | twine.package module 2 | ==================== 3 | 4 | .. automodule:: twine.package 5 | -------------------------------------------------------------------------------- /docs/internal/twine.repository.rst: -------------------------------------------------------------------------------- 1 | twine.repository module 2 | ======================= 3 | 4 | .. automodule:: twine.repository 5 | -------------------------------------------------------------------------------- /docs/internal/twine.rst: -------------------------------------------------------------------------------- 1 | twine package 2 | ============= 3 | 4 | .. automodule:: twine 5 | 6 | .. toctree:: 7 | :maxdepth: 4 8 | 9 | twine.commands 10 | twine.auth 11 | twine.cli 12 | twine.exceptions 13 | twine.package 14 | twine.repository 15 | twine.settings 16 | twine.utils 17 | twine.wheel 18 | -------------------------------------------------------------------------------- /docs/internal/twine.settings.rst: -------------------------------------------------------------------------------- 1 | twine.settings module 2 | ===================== 3 | 4 | .. automodule:: twine.settings 5 | -------------------------------------------------------------------------------- /docs/internal/twine.utils.rst: -------------------------------------------------------------------------------- 1 | twine.utils module 2 | ================== 3 | 4 | .. automodule:: twine.utils 5 | -------------------------------------------------------------------------------- /docs/internal/twine.wheel.rst: -------------------------------------------------------------------------------- 1 | twine.wheel module 2 | ================== 3 | 4 | .. automodule:: twine.wheel 5 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | doc8>=0.8.0 2 | furo>=2021.10.09 3 | readme-renderer>=17.4 4 | # Remove this upper bound when twine's minimum Python is 3.9+. 5 | # See: https://github.com/sphinx-doc/sphinx/issues/11767 6 | Sphinx>=6,<7.1 7 | sphinxcontrib-programoutput>=0.17 8 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | show_traceback = True 3 | 4 | ; --strict settings 5 | warn_redundant_casts = True 6 | warn_unused_configs = True 7 | warn_unused_ignores = True 8 | disallow_subclassing_any = True 9 | disallow_any_generics = True 10 | disallow_untyped_calls = True 11 | disallow_untyped_defs = True 12 | disallow_incomplete_defs = True 13 | check_untyped_defs = True 14 | disallow_untyped_decorators = True 15 | no_implicit_optional = True 16 | warn_return_any = True 17 | no_implicit_reexport = True 18 | strict_equality = True 19 | 20 | [mypy-requests_toolbelt,requests_toolbelt.*] 21 | ; https://github.com/requests/toolbelt/issues/279 22 | ignore_missing_imports = True 23 | 24 | [mypy-rfc3986] 25 | ignore_missing_imports = True 26 | 27 | [mypy-urllib3] 28 | ; https://github.com/urllib3/urllib3/issues/867 29 | ignore_missing_imports = True 30 | 31 | [mypy-tests.*] 32 | ignore_errors = True 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # pyproject.toml 2 | [build-system] 3 | requires = ["setuptools>=61.2", "setuptools_scm[toml]>=6.0"] 4 | build-backend = "setuptools.build_meta" 5 | 6 | [project] 7 | name = "twine" 8 | authors = [ 9 | { name = "Donald Stufft and individual contributors", email = "donald@stufft.io" }, 10 | ] 11 | description = "Collection of utilities for publishing packages on PyPI" 12 | classifiers = [ 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: Apache Software License", 15 | "Natural Language :: English", 16 | "Operating System :: MacOS :: MacOS X", 17 | "Operating System :: POSIX", 18 | "Operating System :: POSIX :: BSD", 19 | "Operating System :: POSIX :: Linux", 20 | "Operating System :: Microsoft :: Windows", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3 :: Only", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | "Programming Language :: Python :: Implementation :: CPython", 30 | ] 31 | requires-python = ">=3.9" 32 | dependencies = [ 33 | "readme-renderer >= 35.0", 34 | "requests >= 2.20", 35 | "requests-toolbelt >= 0.8.0, != 0.9.0", 36 | "urllib3 >= 1.26.0", 37 | "importlib-metadata >= 3.6; python_version < '3.10'", 38 | # workaround for missing binaries on these platforms, see #1158 39 | "keyring >= 21.2.0; platform_machine != 'ppc64le' and platform_machine != 's390x'", 40 | "rfc3986 >= 1.4.0", 41 | "rich >= 12.0.0", 42 | "packaging >= 24.0", 43 | "id", 44 | ] 45 | dynamic = ["version"] 46 | 47 | [project.readme] 48 | file = "README.rst" 49 | content-type = "text/x-rst" 50 | 51 | [project.urls] 52 | Homepage = "https://twine.readthedocs.io/" 53 | Source = "https://github.com/pypa/twine/" 54 | Documentation = "https://twine.readthedocs.io/en/latest/" 55 | "Packaging tutorial" = "https://packaging.python.org/tutorials/packaging-projects/" 56 | 57 | [project.entry-points."twine.registered_commands"] 58 | check = "twine.commands.check:main" 59 | upload = "twine.commands.upload:main" 60 | register = "twine.commands.register:main" 61 | 62 | [project.optional-dependencies] 63 | keyring = ["keyring >= 21.2.0"] 64 | 65 | [project.scripts] 66 | twine = "twine.__main__:main" 67 | 68 | [tool.setuptools] 69 | packages = ["twine", "twine.commands"] 70 | include-package-data = true 71 | license-files = ["LICENSE"] 72 | 73 | [tool.setuptools_scm] 74 | 75 | [tool.towncrier] 76 | package = "twine" 77 | filename = "docs/changelog.rst" 78 | directory = "changelog" 79 | issue_format = "`#{issue} `_" 80 | underlines = ["-", "^"] 81 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings= 3 | # workaround for https://github.com/mozilla/bleach/issues/425 4 | ignore:Using or importing the ABCs:DeprecationWarning:bleach 5 | # workaround for https://github.com/pypa/setuptools/issues/479 6 | ignore:the imp module is deprecated::setuptools 7 | 8 | addopts = 9 | --disable-socket 10 | --ignore-glob '*integration*.py' 11 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/twine/4b6c50bf858604df5cab98f15c2c691eb3bf9dfe/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import logging.config 3 | import textwrap 4 | 5 | import pytest 6 | import rich 7 | 8 | from twine import settings 9 | from twine import utils 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def configure_output(): 14 | """ 15 | Disable colored output and line wrapping before each test. 16 | 17 | Some tests (e.g. test_main.py) will end up calling (and making assertions based on) 18 | twine.cli.configure_output, which overrides this configuration. This fixture should 19 | prevent that leaking into subsequent tests. 20 | """ 21 | rich.reconfigure( 22 | no_color=True, 23 | color_system=None, 24 | emoji=False, 25 | highlight=False, 26 | width=500, 27 | ) 28 | 29 | logging.config.dictConfig( 30 | { 31 | "version": 1, 32 | "handlers": { 33 | "console": { 34 | "class": "logging.StreamHandler", 35 | } 36 | }, 37 | "loggers": { 38 | "twine": { 39 | "handlers": ["console"], 40 | }, 41 | }, 42 | } 43 | ) 44 | 45 | 46 | @pytest.fixture() 47 | def config_file(tmpdir, monkeypatch): 48 | path = tmpdir / ".pypirc" 49 | # Mimic common case of .pypirc in home directory 50 | monkeypatch.setattr(utils, "DEFAULT_CONFIG_FILE", path) 51 | return path 52 | 53 | 54 | @pytest.fixture 55 | def write_config_file(config_file): 56 | def _write(config): 57 | config_file.write(textwrap.dedent(config)) 58 | return config_file 59 | 60 | return _write 61 | 62 | 63 | @pytest.fixture() 64 | def make_settings(write_config_file): 65 | """Return a factory function for settings.Settings with defaults.""" 66 | default_config = """ 67 | [pypi] 68 | username:foo 69 | password:bar 70 | """ 71 | 72 | def _settings(config=default_config, **settings_kwargs): 73 | config_file = write_config_file(config) 74 | 75 | settings_kwargs.setdefault("sign_with", None) 76 | settings_kwargs.setdefault("config_file", config_file) 77 | 78 | return settings.Settings(**settings_kwargs) 79 | 80 | return _settings 81 | 82 | 83 | @pytest.fixture 84 | def entered_password(monkeypatch): 85 | monkeypatch.setattr(getpass, "getpass", lambda prompt: "entered pw") 86 | -------------------------------------------------------------------------------- /tests/fixtures/deprecated-pypirc: -------------------------------------------------------------------------------- 1 | [server-login] 2 | username:testusername 3 | password:testpassword 4 | 5 | [pypi] 6 | foo:bar 7 | -------------------------------------------------------------------------------- /tests/fixtures/everything.metadata23: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.3 2 | Name: BeagleVote 3 | Version: 1.0a2 4 | Platform: ObscureUnix 5 | Platform: RareDOS 6 | Supported-Platform: RedHat 7.2 7 | Supported-Platform: i386-win32-2791 8 | Summary: A module for collecting votes from beagles. 9 | Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM 10 | Keywords: dog,puppy,voting,election 11 | Home-page: http://www.example.com/~cschultz/bvote/ 12 | Download-URL: …/BeagleVote-0.45.tgz 13 | Author: C. Schultz, Universal Features Syndicate, 14 | Los Angeles, CA 15 | Author-email: "C. Schultz" 16 | Maintainer: C. Schultz, Universal Features Syndicate, 17 | Los Angeles, CA 18 | Maintainer-email: "C. Schultz" 19 | License: This software may only be obtained by sending the 20 | author a postcard, and then the user promises not 21 | to redistribute it. 22 | Classifier: Development Status :: 4 - Beta 23 | Classifier: Environment :: Console (Text Based) 24 | Provides-Extra: pdf 25 | Requires-Dist: reportlab; extra == 'pdf' 26 | Requires-Dist: pkginfo 27 | Requires-Dist: PasteDeploy 28 | Requires-Dist: zope.interface (>3.5.0) 29 | Requires-Dist: pywin32 >1.0; sys_platform == 'win32' 30 | Requires-Python: >=3 31 | Requires-External: C 32 | Requires-External: libpng (>=1.5) 33 | Requires-External: make; sys_platform != "win32" 34 | Project-URL: Bug Tracker, http://bitbucket.org/tarek/distribute/issues/ 35 | Project-URL: Documentation, https://example.com/BeagleVote 36 | Provides-Dist: OtherProject 37 | Provides-Dist: AnotherProject (3.4) 38 | Provides-Dist: virtual_package; python_version >= "3.4" 39 | Dynamic: Obsoletes-Dist 40 | 41 | This description intentionally left blank. 42 | -------------------------------------------------------------------------------- /tests/fixtures/everything.metadata24: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.4 2 | Name: BeagleVote 3 | Version: 1.0a2 4 | Platform: ObscureUnix 5 | Platform: RareDOS 6 | Supported-Platform: RedHat 7.2 7 | Supported-Platform: i386-win32-2791 8 | Summary: A module for collecting votes from beagles. 9 | Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM 10 | Keywords: dog,puppy,voting,election 11 | Home-page: http://www.example.com/~cschultz/bvote/ 12 | Download-URL: …/BeagleVote-0.45.tgz 13 | Author: C. Schultz, Universal Features Syndicate, 14 | Los Angeles, CA 15 | Author-email: "C. Schultz" 16 | Maintainer: C. Schultz, Universal Features Syndicate, 17 | Los Angeles, CA 18 | Maintainer-email: "C. Schultz" 19 | License: This software may only be obtained by sending the 20 | author a postcard, and then the user promises not 21 | to redistribute it. 22 | License-Expression: Apache-2.0 OR BSD-2-Clause 23 | License-File: LICENSE.APACHE 24 | License-File: LICENSE.BSD 25 | Classifier: Development Status :: 4 - Beta 26 | Classifier: Environment :: Console (Text Based) 27 | Provides-Extra: pdf 28 | Requires-Dist: reportlab; extra == 'pdf' 29 | Requires-Dist: pkginfo 30 | Requires-Dist: PasteDeploy 31 | Requires-Dist: zope.interface (>3.5.0) 32 | Requires-Dist: pywin32 >1.0; sys_platform == 'win32' 33 | Requires-Python: >=3 34 | Requires-External: C 35 | Requires-External: libpng (>=1.5) 36 | Requires-External: make; sys_platform != "win32" 37 | Project-URL: Bug Tracker, http://bitbucket.org/tarek/distribute/issues/ 38 | Project-URL: Documentation, https://example.com/BeagleVote 39 | Provides-Dist: OtherProject 40 | Provides-Dist: AnotherProject (3.4) 41 | Provides-Dist: virtual_package; python_version >= "3.4" 42 | Dynamic: Obsoletes-Dist 43 | 44 | This description intentionally left blank. 45 | -------------------------------------------------------------------------------- /tests/fixtures/malformed.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/twine/4b6c50bf858604df5cab98f15c2c691eb3bf9dfe/tests/fixtures/malformed.tar.gz -------------------------------------------------------------------------------- /tests/fixtures/twine-1.5.0-py2.py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/twine/4b6c50bf858604df5cab98f15c2c691eb3bf9dfe/tests/fixtures/twine-1.5.0-py2.py3-none-any.whl -------------------------------------------------------------------------------- /tests/fixtures/twine-1.5.0-py2.py3-none-any.whl.asc: -------------------------------------------------------------------------------- 1 | signature -------------------------------------------------------------------------------- /tests/fixtures/twine-1.5.0.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/twine/4b6c50bf858604df5cab98f15c2c691eb3bf9dfe/tests/fixtures/twine-1.5.0.tar.gz -------------------------------------------------------------------------------- /tests/fixtures/twine-1.6.5-py2.py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/twine/4b6c50bf858604df5cab98f15c2c691eb3bf9dfe/tests/fixtures/twine-1.6.5-py2.py3-none-any.whl -------------------------------------------------------------------------------- /tests/fixtures/twine-1.6.5.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/twine/4b6c50bf858604df5cab98f15c2c691eb3bf9dfe/tests/fixtures/twine-1.6.5.tar.gz -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Ian Cordasco 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | """Test functions useful across twine's tests.""" 15 | 16 | import io 17 | import os 18 | import pathlib 19 | import tarfile 20 | import textwrap 21 | import zipfile 22 | 23 | TESTS_DIR = pathlib.Path(__file__).parent 24 | FIXTURES_DIR = os.path.join(TESTS_DIR, "fixtures") 25 | SDIST_FIXTURE = os.path.join(FIXTURES_DIR, "twine-1.5.0.tar.gz") 26 | WHEEL_FIXTURE = os.path.join(FIXTURES_DIR, "twine-1.5.0-py2.py3-none-any.whl") 27 | NEW_SDIST_FIXTURE = os.path.join(FIXTURES_DIR, "twine-1.6.5.tar.gz") 28 | NEW_WHEEL_FIXTURE = os.path.join(FIXTURES_DIR, "twine-1.6.5-py2.py3-none-any.whl") 29 | 30 | 31 | def build_archive(path, name, archive_format, files): 32 | filepath = path / f"{name}.{archive_format}" 33 | 34 | if archive_format == "tar.gz": 35 | with tarfile.open(filepath, "x:gz") as archive: 36 | for mname, content in files.items(): 37 | if isinstance(content, tarfile.TarInfo): 38 | content.name = mname 39 | archive.addfile(content) 40 | else: 41 | data = textwrap.dedent(content).encode("utf8") 42 | member = tarfile.TarInfo(mname) 43 | member.size = len(data) 44 | archive.addfile(member, io.BytesIO(data)) 45 | return filepath 46 | 47 | if archive_format == "zip": 48 | with zipfile.ZipFile(filepath, mode="w") as archive: 49 | for mname, content in files.items(): 50 | archive.writestr(mname, textwrap.dedent(content)) 51 | return filepath 52 | 53 | raise ValueError(format) 54 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import logging 3 | import platform 4 | import re 5 | 6 | import pytest 7 | 8 | from twine import auth 9 | from twine import exceptions 10 | from twine import utils 11 | 12 | 13 | @pytest.fixture 14 | def config() -> utils.RepositoryConfig: 15 | return dict(repository="system") 16 | 17 | 18 | def test_get_username_keyring_defers_to_prompt(monkeypatch, entered_username, config): 19 | class MockKeyring: 20 | @staticmethod 21 | def get_credential(system, user): 22 | return None 23 | 24 | monkeypatch.setattr(auth, "keyring", MockKeyring) 25 | 26 | username = auth.Resolver(config, auth.CredentialInput()).username 27 | assert username == "entered user" 28 | 29 | 30 | def test_get_username_keyring_not_installed_defers_to_prompt( 31 | monkeypatch, entered_username, config 32 | ): 33 | monkeypatch.setattr(auth, "keyring", None) 34 | 35 | username = auth.Resolver(config, auth.CredentialInput()).username 36 | assert username == "entered user" 37 | 38 | 39 | def test_get_password_keyring_defers_to_prompt(monkeypatch, entered_password, config): 40 | class MockKeyring: 41 | @staticmethod 42 | def get_password(system, user): 43 | return None 44 | 45 | monkeypatch.setattr(auth, "keyring", MockKeyring) 46 | 47 | pw = auth.Resolver(config, auth.CredentialInput("user")).password 48 | assert pw == "entered pw" 49 | 50 | 51 | def test_get_password_keyring_not_installed_defers_to_prompt( 52 | monkeypatch, entered_password, config 53 | ): 54 | monkeypatch.setattr(auth, "keyring", None) 55 | 56 | pw = auth.Resolver(config, auth.CredentialInput("user")).password 57 | assert pw == "entered pw" 58 | 59 | 60 | def test_no_password_defers_to_prompt(monkeypatch, entered_password, config): 61 | config.update(password=None) 62 | pw = auth.Resolver(config, auth.CredentialInput("user")).password 63 | assert pw == "entered pw" 64 | 65 | 66 | def test_empty_password_bypasses_prompt(monkeypatch, entered_password, config): 67 | config.update(password="") 68 | pw = auth.Resolver(config, auth.CredentialInput("user")).password 69 | assert pw == "" 70 | 71 | 72 | def test_no_username_non_interactive_aborts(config): 73 | with pytest.raises(exceptions.NonInteractive): 74 | auth.Private(config, auth.CredentialInput()).username 75 | 76 | 77 | def test_no_password_non_interactive_aborts(config): 78 | with pytest.raises(exceptions.NonInteractive): 79 | auth.Private(config, auth.CredentialInput("user")).password 80 | 81 | 82 | def test_get_username_and_password_keyring_overrides_prompt( 83 | monkeypatch, config, caplog 84 | ): 85 | caplog.set_level(logging.INFO, "twine") 86 | 87 | class MockKeyring: 88 | @staticmethod 89 | def get_credential(system, user): 90 | return auth.CredentialInput( 91 | "real_user", f"real_user@{system} sekure pa55word" 92 | ) 93 | 94 | @staticmethod 95 | def get_password(system, user): 96 | cred = MockKeyring.get_credential(system, user) 97 | if user != cred.username: 98 | raise RuntimeError("unexpected username") 99 | return cred.password 100 | 101 | monkeypatch.setattr(auth, "keyring", MockKeyring) 102 | 103 | res = auth.Resolver(config, auth.CredentialInput()) 104 | 105 | assert res.username == "real_user" 106 | assert res.password == "real_user@system sekure pa55word" 107 | 108 | assert caplog.messages == [ 109 | "Querying keyring for username", 110 | "username set from keyring", 111 | "Querying keyring for password", 112 | "password set from keyring", 113 | ] 114 | 115 | 116 | @pytest.fixture 117 | def keyring_missing_get_credentials(monkeypatch): 118 | """Simulate keyring prior to 15.2 that does not have the 'get_credential' API.""" 119 | monkeypatch.delattr(auth.keyring, "get_credential") 120 | 121 | 122 | @pytest.fixture 123 | def entered_username(monkeypatch): 124 | monkeypatch.setattr(auth, "input", lambda prompt: "entered user", raising=False) 125 | 126 | 127 | def test_get_username_keyring_missing_get_credentials_prompts( 128 | entered_username, keyring_missing_get_credentials, config 129 | ): 130 | assert auth.Resolver(config, auth.CredentialInput()).username == "entered user" 131 | 132 | 133 | def test_get_username_keyring_missing_non_interactive_aborts( 134 | entered_username, keyring_missing_get_credentials, config 135 | ): 136 | with pytest.raises(exceptions.NonInteractive): 137 | auth.Private(config, auth.CredentialInput()).username 138 | 139 | 140 | def test_get_password_keyring_missing_non_interactive_aborts( 141 | entered_username, keyring_missing_get_credentials, config 142 | ): 143 | with pytest.raises(exceptions.NonInteractive): 144 | auth.Private(config, auth.CredentialInput("user")).password 145 | 146 | 147 | def test_get_username_keyring_runtime_error_logged( 148 | entered_username, monkeypatch, config, caplog 149 | ): 150 | class FailKeyring: 151 | """Simulate missing keyring backend raising RuntimeError on get_credential.""" 152 | 153 | @staticmethod 154 | def get_credential(system, username): 155 | raise RuntimeError("fail!") 156 | 157 | monkeypatch.setattr(auth, "keyring", FailKeyring) 158 | 159 | assert auth.Resolver(config, auth.CredentialInput()).username == "entered user" 160 | 161 | assert re.search( 162 | r"Error getting username from keyring.+Traceback.+RuntimeError: fail!", 163 | caplog.text, 164 | re.DOTALL, 165 | ) 166 | 167 | 168 | def test_get_password_keyring_runtime_error_logged( 169 | entered_username, entered_password, monkeypatch, config, caplog 170 | ): 171 | class FailKeyring: 172 | """Simulate missing keyring backend raising RuntimeError on get_password.""" 173 | 174 | @staticmethod 175 | def get_password(system, username): 176 | raise RuntimeError("fail!") 177 | 178 | monkeypatch.setattr(auth, "keyring", FailKeyring) 179 | 180 | assert auth.Resolver(config, auth.CredentialInput()).password == "entered pw" 181 | 182 | assert re.search( 183 | r"Error getting password from keyring.+Traceback.+RuntimeError: fail!", 184 | caplog.text, 185 | re.DOTALL, 186 | ) 187 | 188 | 189 | def _raise_home_key_error(): 190 | """Simulate environment from https://github.com/pypa/twine/issues/889.""" 191 | try: 192 | raise KeyError("HOME") 193 | except KeyError: 194 | raise KeyError("uid not found: 999") 195 | 196 | 197 | def test_get_username_keyring_key_error_logged( 198 | entered_username, monkeypatch, config, caplog 199 | ): 200 | class FailKeyring: 201 | @staticmethod 202 | def get_credential(system, username): 203 | _raise_home_key_error() 204 | 205 | monkeypatch.setattr(auth, "keyring", FailKeyring) 206 | 207 | assert auth.Resolver(config, auth.CredentialInput()).username == "entered user" 208 | 209 | assert re.search( 210 | r"Error getting username from keyring" 211 | r".+Traceback" 212 | r".+KeyError: 'HOME'" 213 | r".+KeyError: 'uid not found: 999'", 214 | caplog.text, 215 | re.DOTALL, 216 | ) 217 | 218 | 219 | def test_get_password_keyring_key_error_logged( 220 | entered_username, entered_password, monkeypatch, config, caplog 221 | ): 222 | class FailKeyring: 223 | @staticmethod 224 | def get_password(system, username): 225 | _raise_home_key_error() 226 | 227 | monkeypatch.setattr(auth, "keyring", FailKeyring) 228 | 229 | assert auth.Resolver(config, auth.CredentialInput()).password == "entered pw" 230 | 231 | assert re.search( 232 | r"Error getting password from keyring" 233 | r".+Traceback" 234 | r".+KeyError: 'HOME'" 235 | r".+KeyError: 'uid not found: 999'", 236 | caplog.text, 237 | re.DOTALL, 238 | ) 239 | 240 | 241 | def test_logs_cli_values(caplog, config): 242 | caplog.set_level(logging.INFO, "twine") 243 | 244 | res = auth.Resolver(config, auth.CredentialInput("username", "password")) 245 | 246 | assert res.username == "username" 247 | assert res.password == "password" 248 | 249 | assert caplog.messages == [ 250 | "username set by command options", 251 | "password set by command options", 252 | ] 253 | 254 | 255 | def test_logs_config_values(config, caplog): 256 | caplog.set_level(logging.INFO, "twine") 257 | 258 | config.update(username="username", password="password") 259 | res = auth.Resolver(config, auth.CredentialInput()) 260 | 261 | assert res.username == "username" 262 | assert res.password == "password" 263 | 264 | assert caplog.messages == [ 265 | "username set from config file", 266 | "password set from config file", 267 | ] 268 | 269 | 270 | @pytest.mark.parametrize( 271 | "password, warning", 272 | [ 273 | ("", "Your password is empty"), 274 | ("\x16", "Your password contains control characters"), 275 | ("entered\x16pw", "Your password contains control characters"), 276 | ], 277 | ) 278 | def test_warns_for_empty_password( 279 | password, 280 | warning, 281 | monkeypatch, 282 | entered_username, 283 | config, 284 | caplog, 285 | ): 286 | # Avoiding additional warning "No recommended backend was available" 287 | monkeypatch.setattr(auth.keyring, "get_password", lambda system, user: None) 288 | 289 | monkeypatch.setattr(getpass, "getpass", lambda prompt: password) 290 | 291 | assert auth.Resolver(config, auth.CredentialInput()).password == password 292 | 293 | assert caplog.messages[0].startswith(warning) 294 | 295 | 296 | @pytest.mark.skipif( 297 | platform.machine() in {"ppc64le", "s390x"}, 298 | reason="keyring module is optional on ppc64le and s390x", 299 | ) 300 | def test_keyring_module(): 301 | assert auth.keyring is not None 302 | -------------------------------------------------------------------------------- /tests/test_check.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Dustin Ingram 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import logging 15 | 16 | import pretend 17 | import pytest 18 | 19 | from tests import helpers 20 | from twine.commands import check 21 | 22 | 23 | class TestWarningStream: 24 | def setup_method(self): 25 | self.stream = check._WarningStream() 26 | 27 | def test_write_match(self): 28 | self.stream.write(":2: (WARNING/2) Title underline too short.") 29 | assert self.stream.getvalue() == "line 2: Warning: Title underline too short.\n" 30 | 31 | def test_write_nomatch(self): 32 | self.stream.write("this does not match") 33 | assert self.stream.getvalue() == "this does not match" 34 | 35 | def test_str_representation(self): 36 | self.stream.write(":2: (WARNING/2) Title underline too short.") 37 | assert str(self.stream) == "line 2: Warning: Title underline too short." 38 | 39 | 40 | def test_fails_no_distributions(caplog): 41 | assert not check.check([]) 42 | assert caplog.record_tuples == [ 43 | ( 44 | "twine.commands.check", 45 | logging.ERROR, 46 | "No files to check.", 47 | ), 48 | ] 49 | 50 | 51 | def build_sdist_with_metadata(path, metadata): 52 | name = "test" 53 | version = "1.2.3" 54 | sdist = helpers.build_archive( 55 | path, 56 | f"{name}-{version}", 57 | "tar.gz", 58 | { 59 | f"{name}-{version}/README": "README", 60 | f"{name}-{version}/PKG-INFO": metadata, 61 | }, 62 | ) 63 | return str(sdist) 64 | 65 | 66 | @pytest.mark.parametrize("strict", [False, True]) 67 | def test_warns_missing_description(strict, tmp_path, capsys, caplog): 68 | sdist = build_sdist_with_metadata( 69 | tmp_path, 70 | """\ 71 | Metadata-Version: 2.1 72 | Name: test 73 | Version: 1.2.3 74 | """, 75 | ) 76 | 77 | assert check.check([sdist], strict=strict) is strict 78 | 79 | assert capsys.readouterr().out == f"Checking {sdist}: " + ( 80 | "FAILED due to warnings\n" if strict else "PASSED with warnings\n" 81 | ) 82 | 83 | assert caplog.record_tuples == [ 84 | ( 85 | "twine.commands.check", 86 | logging.WARNING, 87 | "`long_description_content_type` missing. defaulting to `text/x-rst`.", 88 | ), 89 | ( 90 | "twine.commands.check", 91 | logging.WARNING, 92 | "`long_description` missing.", 93 | ), 94 | ] 95 | 96 | 97 | def test_fails_rst_syntax_error(tmp_path, capsys, caplog): 98 | sdist = build_sdist_with_metadata( 99 | tmp_path, 100 | """\ 101 | Metadata-Version: 2.1 102 | Name: test-package 103 | Version: 1.2.3 104 | Description-Content-Type: text/x-rst 105 | 106 | 107 | ============ 108 | 109 | """, 110 | ) 111 | 112 | assert check.check([sdist]) 113 | 114 | assert capsys.readouterr().out == f"Checking {sdist}: FAILED\n" 115 | 116 | assert caplog.record_tuples == [ 117 | ( 118 | "twine.commands.check", 119 | logging.ERROR, 120 | "`long_description` has syntax errors in markup " 121 | "and would not be rendered on PyPI.\n" 122 | "line 2: Error: Document or section may not begin with a transition.", 123 | ), 124 | ] 125 | 126 | 127 | def test_fails_rst_no_content(tmp_path, capsys, caplog): 128 | sdist = build_sdist_with_metadata( 129 | tmp_path, 130 | """\ 131 | Metadata-Version: 2.1 132 | Name: test-package 133 | Version: 1.2.3 134 | Description-Content-Type: text/x-rst 135 | 136 | test-package 137 | ============ 138 | """, 139 | ) 140 | 141 | assert check.check([sdist]) 142 | 143 | assert capsys.readouterr().out == f"Checking {sdist}: FAILED\n" 144 | 145 | assert caplog.record_tuples == [ 146 | ( 147 | "twine.commands.check", 148 | logging.ERROR, 149 | "`long_description` has syntax errors in markup " 150 | "and would not be rendered on PyPI.\n" 151 | "No content rendered from RST source.", 152 | ), 153 | ] 154 | 155 | 156 | def test_passes_rst_description(tmp_path, capsys, caplog): 157 | sdist = build_sdist_with_metadata( 158 | tmp_path, 159 | """\ 160 | Metadata-Version: 2.1 161 | Name: test-package 162 | Version: 1.2.3 163 | Description-Content-Type: text/x-rst 164 | 165 | test-package 166 | ============ 167 | 168 | A test package. 169 | """, 170 | ) 171 | 172 | assert not check.check([sdist]) 173 | 174 | assert capsys.readouterr().out == f"Checking {sdist}: PASSED\n" 175 | 176 | assert not caplog.record_tuples 177 | 178 | 179 | @pytest.mark.parametrize("content_type", ["text/markdown", "text/plain"]) 180 | def test_passes_markdown_description(content_type, tmp_path, capsys, caplog): 181 | sdist = build_sdist_with_metadata( 182 | tmp_path, 183 | f"""\ 184 | Metadata-Version: 2.1 185 | Name: test-package 186 | Version: 1.2.3 187 | Description-Content-Type: {content_type} 188 | 189 | # test-package 190 | 191 | A test package. 192 | """, 193 | ) 194 | 195 | assert not check.check([sdist]) 196 | 197 | assert capsys.readouterr().out == f"Checking {sdist}: PASSED\n" 198 | 199 | assert not caplog.record_tuples 200 | 201 | 202 | def test_main(monkeypatch): 203 | check_result = pretend.stub() 204 | check_stub = pretend.call_recorder(lambda a, strict=False: check_result) 205 | monkeypatch.setattr(check, "check", check_stub) 206 | 207 | assert check.main(["dist/*"]) == check_result 208 | assert check_stub.calls == [pretend.call(["dist/*"], strict=False)] 209 | 210 | 211 | def test_check_expands_glob(monkeypatch): 212 | """Regression test for #1187.""" 213 | warning_stream = pretend.stub() 214 | warning_stream_cls = pretend.call_recorder(lambda: warning_stream) 215 | monkeypatch.setattr(check, "_WarningStream", warning_stream_cls) 216 | 217 | check_file = pretend.call_recorder(lambda fn, stream: ([], True)) 218 | monkeypatch.setattr(check, "_check_file", check_file) 219 | 220 | assert not check.main([f"{helpers.FIXTURES_DIR}/*"]) 221 | 222 | # check_file is called more than once, indicating the glob has been expanded 223 | assert len(check_file.calls) > 1 224 | 225 | 226 | # TODO: Test print() color output 227 | 228 | # TODO: Test log formatting 229 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Donald Stufft 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import pretend 15 | import pytest 16 | 17 | from twine import cli 18 | from twine.commands import upload 19 | 20 | 21 | def test_dispatch_to_subcommand(monkeypatch): 22 | replaced_main = pretend.call_recorder(lambda args: None) 23 | monkeypatch.setattr(upload, "main", replaced_main) 24 | 25 | cli.dispatch(["upload", "path/to/file"]) 26 | 27 | assert replaced_main.calls == [pretend.call(["path/to/file"])] 28 | 29 | 30 | def test_catches_enoent(): 31 | with pytest.raises(SystemExit): 32 | cli.dispatch(["non-existent-command"]) 33 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from tests import helpers 6 | from twine import commands 7 | from twine import exceptions 8 | 9 | 10 | def test_ensure_wheel_files_uploaded_first(): 11 | files = commands._group_wheel_files_first( 12 | ["twine/foo.py", "twine/first.whl", "twine/bar.py", "twine/second.whl"] 13 | ) 14 | expected = [ 15 | "twine/first.whl", 16 | "twine/second.whl", 17 | "twine/foo.py", 18 | "twine/bar.py", 19 | ] 20 | assert expected == files 21 | 22 | 23 | def test_ensure_if_no_wheel_files(): 24 | files = commands._group_wheel_files_first(["twine/foo.py", "twine/bar.py"]) 25 | expected = ["twine/foo.py", "twine/bar.py"] 26 | assert expected == files 27 | 28 | 29 | def test_find_dists_expands_globs(): 30 | files = sorted(commands._find_dists(["twine/__*.py"])) 31 | expected = [ 32 | os.path.join("twine", "__init__.py"), 33 | os.path.join("twine", "__main__.py"), 34 | ] 35 | assert expected == files 36 | 37 | 38 | def test_find_dists_errors_on_invalid_globs(): 39 | with pytest.raises(exceptions.InvalidDistribution): 40 | commands._find_dists(["twine/*.rb"]) 41 | 42 | 43 | def test_find_dists_handles_real_files(): 44 | expected = [ 45 | "twine/__init__.py", 46 | "twine/__main__.py", 47 | "twine/cli.py", 48 | "twine/utils.py", 49 | "twine/wheel.py", 50 | ] 51 | files = commands._find_dists(expected) 52 | assert expected == files 53 | 54 | 55 | def test_split_inputs(): 56 | """Split inputs into dists, signatures, and attestations.""" 57 | inputs = [ 58 | helpers.WHEEL_FIXTURE, 59 | helpers.WHEEL_FIXTURE + ".asc", 60 | helpers.WHEEL_FIXTURE + ".build.attestation", 61 | helpers.WHEEL_FIXTURE + ".publish.attestation", 62 | helpers.SDIST_FIXTURE, 63 | helpers.SDIST_FIXTURE + ".asc", 64 | helpers.NEW_WHEEL_FIXTURE, 65 | helpers.NEW_WHEEL_FIXTURE + ".frob.attestation", 66 | helpers.NEW_SDIST_FIXTURE, 67 | ] 68 | 69 | inputs = commands._split_inputs(inputs) 70 | 71 | assert inputs.dists == [ 72 | helpers.WHEEL_FIXTURE, 73 | helpers.SDIST_FIXTURE, 74 | helpers.NEW_WHEEL_FIXTURE, 75 | helpers.NEW_SDIST_FIXTURE, 76 | ] 77 | 78 | expected_signatures = { 79 | os.path.basename(dist) + ".asc": dist + ".asc" 80 | for dist in [helpers.WHEEL_FIXTURE, helpers.SDIST_FIXTURE] 81 | } 82 | assert inputs.signatures == expected_signatures 83 | 84 | assert inputs.attestations_by_dist == { 85 | helpers.WHEEL_FIXTURE: [ 86 | helpers.WHEEL_FIXTURE + ".build.attestation", 87 | helpers.WHEEL_FIXTURE + ".publish.attestation", 88 | ], 89 | helpers.SDIST_FIXTURE: [], 90 | helpers.NEW_WHEEL_FIXTURE: [helpers.NEW_WHEEL_FIXTURE + ".frob.attestation"], 91 | helpers.NEW_SDIST_FIXTURE: [], 92 | } 93 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import datetime 3 | import functools 4 | import pathlib 5 | import platform 6 | import re 7 | import secrets 8 | import subprocess 9 | import sys 10 | from types import SimpleNamespace 11 | 12 | import pytest 13 | import requests 14 | 15 | from twine import __main__ as dunder_main 16 | from twine import cli 17 | 18 | pytestmark = [pytest.mark.enable_socket] 19 | 20 | skip_if_windows = pytest.mark.skipif( 21 | platform.system() == "Windows", 22 | reason="pytest-services fixtures don't support Windows", 23 | ) 24 | 25 | run = functools.partial(subprocess.run, check=True) 26 | 27 | 28 | @pytest.fixture(scope="session") 29 | def sampleproject_dist(tmp_path_factory: pytest.TempPathFactory): 30 | checkout = tmp_path_factory.mktemp("sampleproject", numbered=False) 31 | tag = datetime.datetime.now().strftime("%Y%m%d%H%M%S%f") 32 | 33 | run(["git", "clone", "https://github.com/pypa/sampleproject", str(checkout)]) 34 | 35 | pyproject = checkout / "pyproject.toml" 36 | pyproject.write_text( 37 | pyproject.read_text() 38 | .replace( 39 | 'name = "sampleproject"', 40 | 'name = "twine-sampleproject"', 41 | ) 42 | .replace( 43 | 'version = "3.0.0"', 44 | f'version = "3.0.0post{tag}"', 45 | ) 46 | ) 47 | 48 | run([sys.executable, "-m", "build", "--sdist"], cwd=checkout) 49 | 50 | [dist, *_] = (checkout / "dist").glob("*") 51 | # NOTE: newer versions of setuptools (invoked via build) adhere to PEP 625, 52 | # causing the dist name to be `twine_sampleproject` instead of 53 | # `twine-sampleproject`. Both are allowed here for now, but the hyphenated 54 | # version can be removed eventually. 55 | # See: https://github.com/pypa/setuptools/issues/3593 56 | assert dist.name in ( 57 | f"twine-sampleproject-3.0.0.post{tag}.tar.gz", 58 | f"twine_sampleproject-3.0.0.post{tag}.tar.gz", 59 | ) 60 | 61 | return dist 62 | 63 | 64 | sampleproject_token = ( 65 | "pypi-AgENdGVzdC5weXBpLm9yZwIkNDgzYTFhMjEtMzEwYi00NT" 66 | "kzLTkwMzYtYzc1Zjg4NmFiMjllAAJEeyJwZXJtaXNzaW9ucyI6IH" 67 | "sicHJvamVjdHMiOiBbInR3aW5lLXNhbXBsZXByb2plY3QiXX0sIC" 68 | "J2ZXJzaW9uIjogMX0AAAYg2kBZ1tN8lj8dlmL3ScoVvr_pvQE0t" 69 | "6PKqigoYJKvw3M" 70 | ) 71 | 72 | 73 | @pytest.mark.xfail(reason="service is unreliable (#684)") 74 | def test_pypi_upload(sampleproject_dist): 75 | command = [ 76 | "upload", 77 | "--repository-url", 78 | "https://test.pypi.org/legacy/", 79 | "--username", 80 | "__token__", 81 | "--password", 82 | sampleproject_token, 83 | str(sampleproject_dist), 84 | ] 85 | cli.dispatch(command) 86 | 87 | 88 | @pytest.mark.xfail(reason="service is unreliable (#684)") 89 | def test_pypi_error(sampleproject_dist, monkeypatch, capsys): 90 | command = [ 91 | "twine", 92 | "upload", 93 | "--repository-url", 94 | "https://test.pypi.org/legacy/", 95 | "--username", 96 | "foo", 97 | "--password", 98 | "bar", 99 | str(sampleproject_dist), 100 | ] 101 | monkeypatch.setattr(sys, "argv", command) 102 | 103 | message = ( 104 | r"HTTPError: 403 Forbidden from https://test\.pypi\.org/legacy/" 105 | + r".+authentication information" 106 | ) 107 | 108 | error = dunder_main.main() 109 | assert error 110 | 111 | captured = capsys.readouterr() 112 | 113 | assert re.search(message, captured.out, re.DOTALL) 114 | 115 | 116 | @pytest.fixture( 117 | params=[ 118 | "twine-1.5.0.tar.gz", 119 | "twine-1.5.0-py2.py3-none-any.whl", 120 | "twine-1.6.5.tar.gz", 121 | "twine-1.6.5-py2.py3-none-any.whl", 122 | ] 123 | ) 124 | def uploadable_dist(request): 125 | return pathlib.Path(__file__).parent / "fixtures" / request.param 126 | 127 | 128 | @pytest.fixture(scope="session") 129 | def devpi_server(request, port_getter, watcher_getter, tmp_path_factory): 130 | server_dir = tmp_path_factory.mktemp("devpi") 131 | username = "foober" 132 | password = secrets.token_urlsafe() 133 | port = port_getter() 134 | url = f"http://localhost:{port}/" 135 | repo = f"{url}/{username}/dev/" 136 | 137 | run(["devpi-init", "--serverdir", server_dir, "--root-passwd", password]) 138 | 139 | def ready(): 140 | with contextlib.suppress(Exception): 141 | return requests.get(url) 142 | 143 | watcher_getter( 144 | name="devpi-server", 145 | arguments=["--port", str(port), "--serverdir", server_dir], 146 | checker=ready, 147 | # Needed for the correct execution order of finalizers 148 | request=request, 149 | ) 150 | 151 | def devpi_run(cmd): 152 | return run(["devpi", "--clientdir", server_dir / "client", *cmd]) 153 | 154 | devpi_run(["use", url + "root/pypi/"]) 155 | devpi_run(["user", "--create", username, f"password={password}"]) 156 | devpi_run(["login", username, "--password", password]) 157 | devpi_run(["index", "-c", "dev"]) 158 | 159 | return SimpleNamespace(url=repo, username=username, password=password) 160 | 161 | 162 | @skip_if_windows 163 | def test_devpi_upload(devpi_server, uploadable_dist): 164 | command = [ 165 | "upload", 166 | "--repository-url", 167 | devpi_server.url, 168 | "--username", 169 | devpi_server.username, 170 | "--password", 171 | devpi_server.password, 172 | str(uploadable_dist), 173 | ] 174 | cli.dispatch(command) 175 | 176 | 177 | @pytest.fixture(scope="session") 178 | def pypiserver_instance(request, port_getter, watcher_getter, tmp_path_factory): 179 | port = port_getter() 180 | url = f"http://localhost:{port}/" 181 | 182 | def ready(): 183 | with contextlib.suppress(Exception): 184 | return requests.get(url) 185 | 186 | watcher_getter( 187 | name="pypi-server", 188 | arguments=[ 189 | "--port", 190 | str(port), 191 | # allow anonymous uploads 192 | "-P", 193 | ".", 194 | "-a", 195 | ".", 196 | tmp_path_factory.mktemp("packages"), 197 | ], 198 | checker=ready, 199 | # Needed for the correct execution order of finalizers 200 | request=request, 201 | ) 202 | 203 | return SimpleNamespace(url=url) 204 | 205 | 206 | @skip_if_windows 207 | def test_pypiserver_upload(pypiserver_instance, uploadable_dist): 208 | command = [ 209 | "upload", 210 | "--repository-url", 211 | pypiserver_instance.url, 212 | "--username", 213 | "any", 214 | "--password", 215 | "any", 216 | str(uploadable_dist), 217 | ] 218 | cli.dispatch(command) 219 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # https://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | 13 | import sys 14 | 15 | import pretend 16 | import requests 17 | 18 | from twine import __main__ as dunder_main 19 | from twine.commands import upload 20 | 21 | # Hard-coding control characters for red text; couldn't find a succinct alternative 22 | RED_ERROR = "\x1b[31mERROR \x1b[0m" 23 | PLAIN_ERROR = "ERROR " 24 | 25 | 26 | def _unwrap_lines(text): 27 | # Testing wrapped lines was ugly and inconsistent across environments 28 | return " ".join(line.strip() for line in text.splitlines()) 29 | 30 | 31 | def test_exception_handling(monkeypatch, capsys): 32 | monkeypatch.setattr(sys, "argv", ["twine", "upload", "missing.whl"]) 33 | 34 | error = dunder_main.main() 35 | assert error 36 | 37 | captured = capsys.readouterr() 38 | 39 | assert _unwrap_lines(captured.out) == ( 40 | f"{RED_ERROR} InvalidDistribution: Cannot find file (or expand pattern): " 41 | "'missing.whl'" 42 | ) 43 | 44 | 45 | def test_http_exception_handling(monkeypatch, capsys): 46 | monkeypatch.setattr(sys, "argv", ["twine", "upload", "test.whl"]) 47 | monkeypatch.setattr( 48 | upload, 49 | "upload", 50 | pretend.raiser( 51 | requests.HTTPError( 52 | response=pretend.stub( 53 | url="https://example.org", 54 | status_code=400, 55 | reason="Error reason", 56 | ) 57 | ) 58 | ), 59 | ) 60 | 61 | error = dunder_main.main() 62 | assert error 63 | 64 | captured = capsys.readouterr() 65 | 66 | assert _unwrap_lines(captured.out) == ( 67 | f"{RED_ERROR} HTTPError: 400 Bad Request from https://example.org " 68 | "Error reason" 69 | ) 70 | 71 | 72 | def test_no_color_exception(monkeypatch, capsys): 73 | monkeypatch.setattr(sys, "argv", ["twine", "--no-color", "upload", "missing.whl"]) 74 | 75 | error = dunder_main.main() 76 | assert error 77 | 78 | captured = capsys.readouterr() 79 | 80 | assert _unwrap_lines(captured.out) == ( 81 | f"{PLAIN_ERROR} InvalidDistribution: Cannot find file (or expand pattern): " 82 | "'missing.whl'" 83 | ) 84 | 85 | 86 | # TODO: Test verbose output formatting 87 | -------------------------------------------------------------------------------- /tests/test_register.py: -------------------------------------------------------------------------------- 1 | import pretend 2 | import pytest 3 | 4 | from twine import cli 5 | from twine import exceptions 6 | from twine.commands import register 7 | 8 | from . import helpers 9 | 10 | 11 | @pytest.fixture() 12 | def register_settings(make_settings): 13 | """Return a factory function for settings.Settings for register.""" 14 | return make_settings( 15 | """ 16 | [pypi] 17 | repository: https://test.pypi.org/legacy/ 18 | username:foo 19 | password:bar 20 | """ 21 | ) 22 | 23 | 24 | def test_successful_register(register_settings): 25 | """Return a successful result for a valid repository url and package.""" 26 | stub_response = pretend.stub( 27 | is_redirect=False, 28 | status_code=200, 29 | headers={"location": "https://test.pypi.org/legacy/"}, 30 | raise_for_status=lambda: None, 31 | ) 32 | 33 | stub_repository = pretend.stub( 34 | register=lambda package: stub_response, close=lambda: None 35 | ) 36 | 37 | register_settings.create_repository = lambda: stub_repository 38 | 39 | result = register.register(register_settings, helpers.WHEEL_FIXTURE) 40 | 41 | assert result is None 42 | 43 | 44 | def test_exception_for_redirect(register_settings): 45 | """Raise an exception when repository URL results in a redirect.""" 46 | repository_url = register_settings.repository_config["repository"] 47 | redirect_url = "https://malicious.website.org/danger/" 48 | 49 | stub_response = pretend.stub( 50 | is_redirect=True, 51 | status_code=301, 52 | headers={"location": redirect_url}, 53 | ) 54 | 55 | stub_repository = pretend.stub( 56 | register=lambda package: stub_response, close=lambda: None 57 | ) 58 | 59 | register_settings.create_repository = lambda: stub_repository 60 | 61 | with pytest.raises( 62 | exceptions.RedirectDetected, 63 | match=rf"{repository_url}.+{redirect_url}.+\nIf you trust these URLs", 64 | ): 65 | register.register(register_settings, helpers.WHEEL_FIXTURE) 66 | 67 | 68 | def test_non_existent_package(register_settings): 69 | """Raise an exception when package file doesn't exist.""" 70 | stub_repository = pretend.stub() 71 | 72 | register_settings.create_repository = lambda: stub_repository 73 | 74 | package = "/foo/bar/baz.whl" 75 | with pytest.raises( 76 | exceptions.PackageNotFound, 77 | match=f'"{package}" does not exist on the file system.', 78 | ): 79 | register.register(register_settings, package) 80 | 81 | 82 | @pytest.mark.parametrize("repo", ["pypi", "testpypi"]) 83 | def test_values_from_env_pypi(monkeypatch, repo): 84 | """Use env vars for settings when run from command line.""" 85 | 86 | def none_register(*args, **settings_kwargs): 87 | pass 88 | 89 | replaced_register = pretend.call_recorder(none_register) 90 | monkeypatch.setattr(register, "register", replaced_register) 91 | testenv = { 92 | "TWINE_REPOSITORY": repo, 93 | "TWINE_USERNAME": "pypiuser", 94 | "TWINE_PASSWORD": "pypipassword", 95 | "TWINE_CERT": "/foo/bar.crt", 96 | } 97 | for key, value in testenv.items(): 98 | monkeypatch.setenv(key, value) 99 | cli.dispatch(["register", helpers.WHEEL_FIXTURE]) 100 | register_settings = replaced_register.calls[0].args[0] 101 | assert "pypipassword" == register_settings.password 102 | assert "pypiuser" == register_settings.username 103 | assert "/foo/bar.crt" == register_settings.cacert 104 | 105 | 106 | def test_values_from_env_not_pypi(monkeypatch, write_config_file): 107 | """Use env vars for settings when run from command line.""" 108 | write_config_file( 109 | """ 110 | [distutils] 111 | index-servers = 112 | notpypi 113 | 114 | [notpypi] 115 | repository: https://upload.example.org/legacy/ 116 | username:someusername 117 | password:password 118 | """ 119 | ) 120 | 121 | def none_register(*args, **settings_kwargs): 122 | pass 123 | 124 | replaced_register = pretend.call_recorder(none_register) 125 | monkeypatch.setattr(register, "register", replaced_register) 126 | testenv = { 127 | "TWINE_REPOSITORY": "notpypi", 128 | "TWINE_USERNAME": "someusername", 129 | "TWINE_PASSWORD": "pypipassword", 130 | "TWINE_CERT": "/foo/bar.crt", 131 | } 132 | for key, value in testenv.items(): 133 | monkeypatch.setenv(key, value) 134 | cli.dispatch(["register", helpers.WHEEL_FIXTURE]) 135 | register_settings = replaced_register.calls[0].args[0] 136 | assert "pypipassword" == register_settings.password 137 | assert "someusername" == register_settings.username 138 | assert "/foo/bar.crt" == register_settings.cacert 139 | -------------------------------------------------------------------------------- /tests/test_sdist.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import tarfile 4 | 5 | import pytest 6 | 7 | from twine import exceptions 8 | from twine import sdist 9 | 10 | from .helpers import TESTS_DIR 11 | from .helpers import build_archive 12 | 13 | 14 | @pytest.fixture( 15 | params=[ 16 | "fixtures/twine-1.5.0.tar.gz", 17 | "fixtures/twine-1.6.5.tar.gz", 18 | ] 19 | ) 20 | def example_sdist(request): 21 | file_name = os.path.join(TESTS_DIR, request.param) 22 | return sdist.SDist(file_name) 23 | 24 | 25 | @pytest.fixture(params=["tar.gz", "zip"]) 26 | def archive_format(request): 27 | return request.param 28 | 29 | 30 | def test_read_example(example_sdist): 31 | """Parse metadata from a valid sdist file.""" 32 | metadata = example_sdist.read() 33 | assert b"Metadata-Version: 1.1" in metadata 34 | assert b"Name: twine" in metadata 35 | assert b"Version: 1." in metadata 36 | 37 | 38 | def test_read_non_existent(): 39 | """Raise an exception when sdist file doesn't exist.""" 40 | file_name = str(pathlib.Path("/foo/bar/baz.tar.gz").resolve()) 41 | with pytest.raises(exceptions.InvalidDistribution, match="No such file"): 42 | sdist.SDist(file_name).read() 43 | 44 | 45 | def test_formar_not_supported(): 46 | """Raise an exception when sdist is not a .tar.gz or a .zip.""" 47 | file_name = str(pathlib.Path("/foo/bar/baz.foo").resolve()) 48 | with pytest.raises(exceptions.InvalidDistribution, match="Unsupported sdist"): 49 | sdist.SDist(file_name).read() 50 | 51 | 52 | def test_read(archive_format, tmp_path): 53 | """Read PKG-INFO from a valid sdist.""" 54 | filepath = build_archive( 55 | tmp_path, 56 | "test-1.2.3", 57 | archive_format, 58 | { 59 | "test-1.2.3/README": "README", 60 | "test-1.2.3/PKG-INFO": """ 61 | Metadata-Version: 1.1 62 | Name: test 63 | Version: 1.2.3 64 | """, 65 | }, 66 | ) 67 | 68 | metadata = sdist.SDist(str(filepath)).read() 69 | assert b"Metadata-Version: 1.1" in metadata 70 | assert b"Name: test" in metadata 71 | assert b"Version: 1.2.3" in metadata 72 | 73 | 74 | def test_missing_pkg_info(archive_format, tmp_path): 75 | """Raise an exception when sdist does not contain PKG-INFO.""" 76 | filepath = build_archive( 77 | tmp_path, 78 | "test-1.2.3", 79 | archive_format, 80 | { 81 | "test-1.2.3/README": "README", 82 | }, 83 | ) 84 | 85 | with pytest.raises(exceptions.InvalidDistribution, match="No PKG-INFO in archive"): 86 | sdist.SDist(str(filepath)).read() 87 | 88 | 89 | def test_invalid_pkg_info(archive_format, tmp_path): 90 | """Raise an exception when PKG-INFO does not contain ``Metadata-Version``.""" 91 | filepath = build_archive( 92 | tmp_path, 93 | "test-1.2.3", 94 | archive_format, 95 | { 96 | "test-1.2.3/README": "README", 97 | "test-1.2.3/PKG-INFO": """ 98 | Name: test 99 | Version: 1.2.3. 100 | """, 101 | }, 102 | ) 103 | 104 | with pytest.raises(exceptions.InvalidDistribution, match="No PKG-INFO in archive"): 105 | sdist.SDist(str(filepath)).read() 106 | 107 | 108 | def test_pkg_info_directory(archive_format, tmp_path): 109 | """Raise an exception when PKG-INFO is a directory.""" 110 | filepath = build_archive( 111 | tmp_path, 112 | "test-1.2.3", 113 | archive_format, 114 | { 115 | "test-1.2.3/README": "README", 116 | "test-1.2.3/PKG-INFO/content": """ 117 | Metadata-Version: 1.1 118 | Name: test 119 | Version: 1.2.3. 120 | """, 121 | }, 122 | ) 123 | 124 | with pytest.raises(exceptions.InvalidDistribution, match="No PKG-INFO in archive"): 125 | sdist.SDist(str(filepath)).read() 126 | 127 | 128 | def test_pkg_info_not_regular_file(tmp_path): 129 | """Raise an exception when PKG-INFO is a directory.""" 130 | link = tarfile.TarInfo() 131 | link.type = tarfile.LNKTYPE 132 | link.linkname = "README" 133 | 134 | filepath = build_archive( 135 | tmp_path, 136 | "test-1.2.3", 137 | "tar.gz", 138 | { 139 | "test-1.2.3/README": "README", 140 | "test-1.2.3/PKG-INFO": link, 141 | }, 142 | ) 143 | 144 | with pytest.raises( 145 | exceptions.InvalidDistribution, 146 | match=r"^PKG-INFO is not a reg.*test-1.2.3.tar.gz$", 147 | ): 148 | sdist.SDist(str(filepath)).read() 149 | 150 | 151 | def test_multiple_top_level(archive_format, tmp_path): 152 | """Raise an exception when there are too many top-level members.""" 153 | filepath = build_archive( 154 | tmp_path, 155 | "test-1.2.3", 156 | archive_format, 157 | { 158 | "test-1.2.3/README": "README", 159 | "test-1.2.3/PKG-INFO": """ 160 | Metadata-Version: 1.1 161 | Name: test 162 | Version: 1.2.3. 163 | """, 164 | "test-2.0.0/README": "README", 165 | }, 166 | ) 167 | 168 | with pytest.raises( 169 | exceptions.InvalidDistribution, 170 | match=r"^Too many top-level.*test-1.2.3.(tar.gz|zip)$", 171 | ): 172 | sdist.SDist(str(filepath)).read() 173 | 174 | 175 | def test_py_version(example_sdist): 176 | assert example_sdist.py_version == "source" 177 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | """Tests for the Settings class and module.""" 2 | 3 | # Copyright 2018 Ian Stapleton Cordasco 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | import argparse 17 | import logging 18 | 19 | import pytest 20 | 21 | from twine import exceptions 22 | from twine import settings 23 | 24 | 25 | def test_settings_takes_no_positional_arguments(): 26 | """Raise an exception when Settings is initialized without keyword arguments.""" 27 | with pytest.raises(TypeError): 28 | settings.Settings("a", "b", "c") 29 | 30 | 31 | def test_settings_transforms_repository_config_pypi(write_config_file): 32 | """Set repository config and defaults when .pypirc is provided. 33 | 34 | Ignores the username setting due to PyPI being the index. 35 | """ 36 | config_file = write_config_file( 37 | """ 38 | [pypi] 39 | repository: https://upload.pypi.org/legacy/ 40 | username:this-is-ignored 41 | password:password 42 | """ 43 | ) 44 | 45 | s = settings.Settings(config_file=config_file) 46 | assert s.repository_config["repository"] == "https://upload.pypi.org/legacy/" 47 | assert s.sign is False 48 | assert s.sign_with == "gpg" 49 | assert s.identity is None 50 | assert s.username == "__token__" 51 | assert s.password == "password" 52 | assert s.cacert is None 53 | assert s.client_cert is None 54 | assert s.disable_progress_bar is False 55 | 56 | 57 | def test_settings_transforms_repository_config_non_pypi(write_config_file): 58 | """Set repository config and defaults when .pypirc is provided.""" 59 | config_file = write_config_file( 60 | """ 61 | [distutils] 62 | index-servers = 63 | notpypi 64 | 65 | [notpypi] 66 | repository: https://upload.example.org/legacy/ 67 | username:someusername 68 | password:password 69 | """ 70 | ) 71 | 72 | s = settings.Settings(config_file=config_file, repository_name="notpypi") 73 | assert s.repository_config["repository"] == "https://upload.example.org/legacy/" 74 | assert s.sign is False 75 | assert s.sign_with == "gpg" 76 | assert s.identity is None 77 | assert s.username == "someusername" 78 | assert s.password == "password" 79 | assert s.cacert is None 80 | assert s.client_cert is None 81 | assert s.disable_progress_bar is False 82 | 83 | 84 | @pytest.mark.parametrize( 85 | "verbose, log_level", [(True, logging.INFO), (False, logging.WARNING)] 86 | ) 87 | def test_setup_logging(verbose, log_level): 88 | """Set log level based on verbose field.""" 89 | settings.Settings(verbose=verbose) 90 | 91 | logger = logging.getLogger("twine") 92 | 93 | assert logger.level == log_level 94 | 95 | 96 | @pytest.mark.parametrize( 97 | "verbose", 98 | [True, False], 99 | ) 100 | def test_print_config_path_if_verbose(config_file, caplog, make_settings, verbose): 101 | """Print the location of the .pypirc config used by the user.""" 102 | make_settings(verbose=verbose) 103 | 104 | if verbose: 105 | assert caplog.messages == [f"Using configuration from {config_file}"] 106 | else: 107 | assert caplog.messages == [] 108 | 109 | 110 | def test_identity_requires_sign(): 111 | """Raise an exception when user provides identity but doesn't require signing.""" 112 | with pytest.raises(exceptions.InvalidSigningConfiguration): 113 | settings.Settings(sign=False, identity="fakeid") 114 | 115 | 116 | @pytest.mark.parametrize("client_cert", [None, ""]) 117 | def test_password_is_required_if_no_client_cert(client_cert, entered_password): 118 | """Set password when client_cert is not provided.""" 119 | settings_obj = settings.Settings(username="fakeuser", client_cert=client_cert) 120 | assert settings_obj.password == "entered pw" 121 | 122 | 123 | def test_client_cert_and_password_both_set_if_given(): 124 | """Set password and client_cert when both are provided.""" 125 | client_cert = "/random/path" 126 | settings_obj = settings.Settings( 127 | username="fakeuser", password="anything", client_cert=client_cert 128 | ) 129 | assert settings_obj.password == "anything" 130 | assert settings_obj.client_cert == client_cert 131 | 132 | 133 | def test_password_required_if_no_client_cert_and_non_interactive(): 134 | """Raise exception if no password or client_cert when non interactive.""" 135 | settings_obj = settings.Settings(username="fakeuser", non_interactive=True) 136 | with pytest.raises(exceptions.NonInteractive): 137 | settings_obj.password 138 | 139 | 140 | def test_no_password_prompt_if_client_cert_and_non_interactive(entered_password): 141 | """Don't prompt for password when client_cert is provided and non interactive.""" 142 | client_cert = "/random/path" 143 | settings_obj = settings.Settings( 144 | username="fakeuser", client_cert=client_cert, non_interactive=True 145 | ) 146 | assert not settings_obj.password 147 | 148 | 149 | class TestArgumentParsing: 150 | @staticmethod 151 | def parse_args(args): 152 | parser = argparse.ArgumentParser() 153 | settings.Settings.register_argparse_arguments(parser) 154 | return parser.parse_args(args) 155 | 156 | def test_non_interactive_flag(self): 157 | args = self.parse_args(["--non-interactive"]) 158 | assert args.non_interactive 159 | 160 | def test_non_interactive_environment(self, monkeypatch): 161 | monkeypatch.setenv("TWINE_NON_INTERACTIVE", "1") 162 | args = self.parse_args([]) 163 | assert args.non_interactive 164 | monkeypatch.setenv("TWINE_NON_INTERACTIVE", "0") 165 | args = self.parse_args([]) 166 | assert not args.non_interactive 167 | 168 | def test_attestations_flag(self): 169 | args = self.parse_args(["--attestations"]) 170 | assert args.attestations 171 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Donald Stufft 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os.path 16 | 17 | import pretend 18 | import pytest 19 | import requests 20 | 21 | from twine import exceptions 22 | from twine import utils 23 | 24 | 25 | def test_get_config(write_config_file): 26 | config_file = write_config_file( 27 | """ 28 | [distutils] 29 | index-servers = pypi 30 | 31 | [pypi] 32 | username = testuser 33 | password = testpassword 34 | """ 35 | ) 36 | 37 | assert utils.get_config(config_file) == { 38 | "pypi": { 39 | "repository": utils.DEFAULT_REPOSITORY, 40 | "username": "testuser", 41 | "password": "testpassword", 42 | }, 43 | } 44 | 45 | 46 | def test_get_config_no_distutils(write_config_file): 47 | """Upload by default to PyPI if an index server is not set in .pypirc.""" 48 | config_file = write_config_file( 49 | """ 50 | [pypi] 51 | username = testuser 52 | password = testpassword 53 | """ 54 | ) 55 | 56 | assert utils.get_config(config_file) == { 57 | "pypi": { 58 | "repository": utils.DEFAULT_REPOSITORY, 59 | "username": "testuser", 60 | "password": "testpassword", 61 | }, 62 | "testpypi": { 63 | "repository": utils.TEST_REPOSITORY, 64 | "username": None, 65 | "password": None, 66 | }, 67 | } 68 | 69 | 70 | def test_get_config_no_section(write_config_file): 71 | config_file = write_config_file( 72 | """ 73 | [distutils] 74 | index-servers = pypi foo 75 | 76 | [pypi] 77 | username = testuser 78 | password = testpassword 79 | """ 80 | ) 81 | 82 | assert utils.get_config(config_file) == { 83 | "pypi": { 84 | "repository": utils.DEFAULT_REPOSITORY, 85 | "username": "testuser", 86 | "password": "testpassword", 87 | }, 88 | } 89 | 90 | 91 | def test_get_config_override_pypi_url(write_config_file): 92 | config_file = write_config_file( 93 | """ 94 | [pypi] 95 | repository = http://pypiproxy 96 | """ 97 | ) 98 | 99 | assert utils.get_config(config_file)["pypi"]["repository"] == "http://pypiproxy" 100 | 101 | 102 | def test_get_config_missing(config_file): 103 | assert utils.get_config(config_file) == { 104 | "pypi": { 105 | "repository": utils.DEFAULT_REPOSITORY, 106 | "username": None, 107 | "password": None, 108 | }, 109 | "testpypi": { 110 | "repository": utils.TEST_REPOSITORY, 111 | "username": None, 112 | "password": None, 113 | }, 114 | } 115 | 116 | 117 | def test_empty_userpass(write_config_file): 118 | """Suppress prompts if empty username and password are provided in .pypirc.""" 119 | config_file = write_config_file( 120 | """ 121 | [pypi] 122 | username= 123 | password= 124 | """ 125 | ) 126 | 127 | config = utils.get_config(config_file) 128 | pypi = config["pypi"] 129 | 130 | assert pypi["username"] == pypi["password"] == "" 131 | 132 | 133 | def test_get_repository_config_missing(config_file): 134 | repository_url = "https://notexisting.python.org/pypi" 135 | exp = { 136 | "repository": repository_url, 137 | "username": None, 138 | "password": None, 139 | } 140 | assert utils.get_repository_from_config(config_file, "foo", repository_url) == exp 141 | assert utils.get_repository_from_config(config_file, "pypi", repository_url) == exp 142 | 143 | exp = { 144 | "repository": utils.DEFAULT_REPOSITORY, 145 | "username": None, 146 | "password": None, 147 | } 148 | assert utils.get_repository_from_config(config_file, "pypi") == exp 149 | 150 | 151 | @pytest.mark.parametrize( 152 | "repository_url, expected_config", 153 | [ 154 | ( 155 | "https://user:pass@notexisting.python.org/pypi", 156 | { 157 | "repository": "https://notexisting.python.org/pypi", 158 | "username": "user", 159 | "password": "pass", 160 | }, 161 | ), 162 | ( 163 | "https://auser:pass@pypi.proxy.local.repo.net:8443", 164 | { 165 | "repository": "https://pypi.proxy.local.repo.net:8443", 166 | "username": "auser", 167 | "password": "pass", 168 | }, 169 | ), 170 | ], 171 | ) 172 | def test_get_repository_config_url_with_auth( 173 | config_file, repository_url, expected_config 174 | ): 175 | assert ( 176 | utils.get_repository_from_config(config_file, "foo", repository_url) 177 | == expected_config 178 | ) 179 | assert ( 180 | utils.get_repository_from_config(config_file, "pypi", repository_url) 181 | == expected_config 182 | ) 183 | 184 | 185 | @pytest.mark.parametrize( 186 | "input_url, expected_url", 187 | [ 188 | ("https://upload.pypi.org/legacy/", "https://upload.pypi.org/legacy/"), 189 | ( 190 | "https://user:pass@upload.pypi.org/legacy/", 191 | "https://********@upload.pypi.org/legacy/", 192 | ), 193 | ], 194 | ) 195 | def test_sanitize_url(input_url: str, expected_url: str) -> None: 196 | assert utils.sanitize_url(input_url) == expected_url 197 | 198 | 199 | @pytest.mark.parametrize( 200 | "repo_url, message", 201 | [ 202 | ( 203 | "ftp://test.pypi.org", 204 | r"scheme was required to be one of \['http', 'https'\]", 205 | ), 206 | ("https:/", "host was required but missing."), 207 | ("//test.pypi.org", "scheme was required but missing."), 208 | ("foo.bar", "host, scheme were required but missing."), 209 | ], 210 | ) 211 | def test_get_repository_config_with_invalid_url(config_file, repo_url, message): 212 | """Raise an exception for a URL with an invalid/missing scheme and/or host.""" 213 | with pytest.raises( 214 | exceptions.UnreachableRepositoryURLDetected, 215 | match=message, 216 | ): 217 | utils.get_repository_from_config(config_file, "pypi", repo_url) 218 | 219 | 220 | def test_get_repository_config_missing_repository(write_config_file): 221 | """Raise an exception when a custom repository isn't defined in .pypirc.""" 222 | config_file = write_config_file("") 223 | with pytest.raises( 224 | exceptions.InvalidConfiguration, 225 | match="Missing 'missing-repository'", 226 | ): 227 | utils.get_repository_from_config(config_file, "missing-repository") 228 | 229 | 230 | @pytest.mark.parametrize( 231 | "invalid_config", 232 | [ 233 | # No surrounding [server] section 234 | """ 235 | username = testuser 236 | password = testpassword 237 | """, 238 | # Valid section but bare API token 239 | """ 240 | [pypi] 241 | pypi-lolololol 242 | """, 243 | # No section, bare API token 244 | """ 245 | pypi-lolololol 246 | """, 247 | ], 248 | ) 249 | def test_get_repository_config_invalid_syntax(write_config_file, invalid_config): 250 | """Raise an exception when the .pypirc has invalid syntax.""" 251 | config_file = write_config_file(invalid_config) 252 | 253 | with pytest.raises( 254 | exceptions.InvalidConfiguration, 255 | match="Malformed configuration", 256 | ): 257 | utils.get_repository_from_config(config_file, "pypi") 258 | 259 | 260 | @pytest.mark.parametrize("repository", ["pypi", "missing-repository"]) 261 | def test_get_repository_config_missing_file(repository): 262 | """Raise an exception when a custom config file doesn't exist.""" 263 | with pytest.raises( 264 | exceptions.InvalidConfiguration, 265 | match=r"No such file.*missing-file", 266 | ): 267 | utils.get_repository_from_config("missing-file", repository) 268 | 269 | 270 | def test_get_config_deprecated_pypirc(): 271 | tests_dir = os.path.dirname(os.path.abspath(__file__)) 272 | deprecated_pypirc_path = os.path.join(tests_dir, "fixtures", "deprecated-pypirc") 273 | 274 | assert utils.get_config(deprecated_pypirc_path) == { 275 | "pypi": { 276 | "repository": utils.DEFAULT_REPOSITORY, 277 | "username": "testusername", 278 | "password": "testpassword", 279 | }, 280 | "testpypi": { 281 | "repository": utils.TEST_REPOSITORY, 282 | "username": "testusername", 283 | "password": "testpassword", 284 | }, 285 | } 286 | 287 | 288 | @pytest.mark.parametrize( 289 | ("cli_value", "config", "key", "strategy", "expected"), 290 | ( 291 | ("cli", {}, "key", lambda: "fallback", "cli"), 292 | (None, {"key": "value"}, "key", lambda: "fallback", "value"), 293 | (None, {}, "key", lambda: "fallback", "fallback"), 294 | ), 295 | ) 296 | def test_get_userpass_value(cli_value, config, key, strategy, expected): 297 | ret = utils.get_userpass_value(cli_value, config, key, strategy) 298 | assert ret == expected 299 | 300 | 301 | @pytest.mark.parametrize( 302 | ("env_name", "default", "environ", "expected"), 303 | [ 304 | ("MY_PASSWORD", None, {}, None), 305 | ("MY_PASSWORD", None, {"MY_PASSWORD": "foo"}, "foo"), 306 | ("URL", "https://example.org", {}, "https://example.org"), 307 | ("URL", "https://example.org", {"URL": "https://pypi.org"}, "https://pypi.org"), 308 | ], 309 | ) 310 | def test_default_to_environment_action( 311 | monkeypatch, env_name, default, environ, expected 312 | ): 313 | option_strings = ("-x", "--example") 314 | dest = "example" 315 | for key, value in environ.items(): 316 | monkeypatch.setenv(key, value) 317 | action = utils.EnvironmentDefault( 318 | env=env_name, 319 | default=default, 320 | option_strings=option_strings, 321 | dest=dest, 322 | ) 323 | assert action.env == env_name 324 | assert action.default == expected 325 | 326 | 327 | @pytest.mark.parametrize( 328 | "repo_url", ["https://pypi.python.org", "https://testpypi.python.org"] 329 | ) 330 | def test_check_status_code_for_deprecated_pypi_url(repo_url): 331 | response = pretend.stub(status_code=410, url=repo_url) 332 | 333 | # value of Verbose doesn't matter for this check 334 | with pytest.raises(exceptions.UploadToDeprecatedPyPIDetected): 335 | utils.check_status_code(response, False) 336 | 337 | 338 | @pytest.mark.parametrize( 339 | "repo_url", 340 | ["https://pypi.python.org", "https://testpypi.python.org"], 341 | ) 342 | @pytest.mark.parametrize( 343 | "verbose", 344 | [True, False], 345 | ) 346 | def test_check_status_code_for_missing_status_code( 347 | caplog, repo_url, verbose, make_settings, config_file 348 | ): 349 | """Print HTTP errors based on verbosity level.""" 350 | response = pretend.stub( 351 | status_code=403, 352 | url=repo_url, 353 | raise_for_status=pretend.raiser(requests.HTTPError), 354 | text="Forbidden", 355 | ) 356 | 357 | make_settings(verbose=verbose) 358 | 359 | with pytest.raises(requests.HTTPError): 360 | utils.check_status_code(response, verbose) 361 | 362 | message = ( 363 | "Error during upload. Retry with the --verbose option for more details." 364 | ) 365 | assert caplog.messages.count(message) == 0 if verbose else 1 366 | 367 | 368 | @pytest.mark.parametrize( 369 | ("size_in_bytes, formatted_size"), 370 | [(3704, "3.6 KB"), (1153433, "1.1 MB"), (21412841, "20.4 MB")], 371 | ) 372 | def test_get_file_size(size_in_bytes, formatted_size, monkeypatch): 373 | """Get the size of file as a string with units.""" 374 | monkeypatch.setattr(os.path, "getsize", lambda _: size_in_bytes) 375 | 376 | file_size = utils.get_file_size(size_in_bytes) 377 | 378 | assert file_size == formatted_size 379 | -------------------------------------------------------------------------------- /tests/test_wheel.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Ian Cordasco 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import pathlib 15 | import re 16 | import zipfile 17 | 18 | import pretend 19 | import pytest 20 | 21 | from twine import exceptions 22 | from twine import wheel 23 | 24 | 25 | @pytest.fixture() 26 | def example_wheel(request): 27 | parent = pathlib.Path(__file__).parent 28 | file_name = str(parent / "fixtures" / "twine-1.5.0-py2.py3-none-any.whl") 29 | return wheel.Wheel(file_name) 30 | 31 | 32 | def test_version_parsing(example_wheel): 33 | assert example_wheel.py_version == "py2.py3" 34 | 35 | 36 | def test_version_parsing_missing_pyver(monkeypatch, example_wheel): 37 | wheel.wheel_file_re = pretend.stub(match=lambda a: None) 38 | assert example_wheel.py_version == "any" 39 | 40 | 41 | def test_find_metadata_files(): 42 | names = [ 43 | "package/lib/__init__.py", 44 | "package/lib/version.py", 45 | "package/METADATA.txt", 46 | "package/METADATA.json", 47 | "package/METADATA", 48 | ] 49 | expected = [ 50 | ["package", "METADATA"], 51 | ["package", "METADATA.json"], 52 | ["package", "METADATA.txt"], 53 | ] 54 | candidates = wheel.Wheel.find_candidate_metadata_files(names) 55 | assert expected == candidates 56 | 57 | 58 | def test_read_valid(example_wheel): 59 | """Parse metadata from a valid wheel file.""" 60 | metadata = example_wheel.read().decode().splitlines() 61 | assert "Name: twine" in metadata 62 | assert "Version: 1.5.0" in metadata 63 | 64 | 65 | def test_read_non_existent_wheel_file_name(): 66 | """Raise an exception when wheel file doesn't exist.""" 67 | file_name = str(pathlib.Path("/foo/bar/baz.whl").resolve()) 68 | with pytest.raises( 69 | exceptions.InvalidDistribution, match=re.escape(f"No such file: {file_name}") 70 | ): 71 | wheel.Wheel(file_name).read() 72 | 73 | 74 | def test_read_invalid_wheel_extension(): 75 | """Raise an exception when file is missing .whl extension.""" 76 | file_name = str(pathlib.Path(__file__).parent / "fixtures" / "twine-1.5.0.tar.gz") 77 | with pytest.raises( 78 | exceptions.InvalidDistribution, 79 | match=re.escape(f"Not a known archive format for file: {file_name}"), 80 | ): 81 | wheel.Wheel(file_name).read() 82 | 83 | 84 | def test_read_wheel_empty_metadata(tmpdir): 85 | """Raise an exception when a wheel file is missing METADATA.""" 86 | whl_file = tmpdir.mkdir("wheel").join("not-a-wheel.whl") 87 | with zipfile.ZipFile(whl_file, "w") as zip_file: 88 | zip_file.writestr("METADATA", "") 89 | 90 | with pytest.raises( 91 | exceptions.InvalidDistribution, 92 | match=re.escape( 93 | f"No METADATA in archive or METADATA missing 'Metadata-Version': {whl_file}" 94 | ), 95 | ): 96 | wheel.Wheel(whl_file).read() 97 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 3.9 3 | envlist = lint,types,py{39,310,311,312,313}{,-packaging240},integration,docs 4 | isolated_build = True 5 | 6 | [testenv] 7 | deps = 8 | pretend 9 | pytest 10 | pytest-socket 11 | coverage 12 | packaging240: packaging==24.0 13 | passenv = 14 | PYTEST_ADDOPTS 15 | setenv = 16 | HOME=/tmp/nonexistent 17 | commands = 18 | python -m coverage run -m pytest {posargs} 19 | python -m coverage html 20 | python -m coverage report --skip-covered --show-missing --fail-under 97 21 | 22 | [testenv:integration] 23 | deps = 24 | {[testenv]deps} 25 | pytest-rerunfailures 26 | pytest-services 27 | devpi-server 28 | devpi 29 | pypiserver 30 | passenv = 31 | PYTEST_ADDOPTS 32 | commands = 33 | pytest -r aR tests/test_integration.py {posargs} 34 | 35 | [testenv:docs] 36 | deps = 37 | -rdocs/requirements.txt 38 | allowlist_externals = 39 | sh 40 | commands = 41 | sphinx-build -W --keep-going -b html -d {envtmpdir}/doctrees docs docs/_build/html 42 | sphinx-build -W --keep-going -b doctest -d {envtmpdir}/doctrees docs docs/_build/html 43 | doc8 docs README.rst --ignore-path docs/_build/html 44 | sphinx-build -W --keep-going -b linkcheck -d {envtmpdir}/doctrees docs docs/_build/linkcheck 45 | sh -c "python -m twine check --strict $TOX_PACKAGE" 46 | 47 | [testenv:watch-docs] 48 | deps = 49 | -rdocs/requirements.txt 50 | sphinx-autobuild 51 | commands = 52 | sphinx-autobuild -b html -d {envtmpdir}/doctrees \ 53 | --watch twine \ 54 | {posargs:--host 127.0.0.1} \ 55 | docs docs/_build/html 56 | 57 | [testenv:format] 58 | skip_install = True 59 | deps = 60 | isort 61 | black 62 | commands = 63 | isort twine/ tests/ 64 | black twine/ tests/ 65 | 66 | [testenv:lint] 67 | skip_install = True 68 | deps = 69 | {[testenv:format]deps} 70 | flake8 71 | flake8-docstrings 72 | commands = 73 | isort --check-only --diff twine/ tests/ 74 | black --check --diff twine/ tests/ 75 | flake8 twine/ tests/ 76 | 77 | [testenv:types] 78 | deps = 79 | mypy 80 | # required for report generation. 5.2.1 is forbidden due to an observed 81 | # broken wheel on CPython 3.8: 82 | # https://bugs.launchpad.net/lxml/+bug/2064158 83 | lxml >= 5.2.0, != 5.2.1 84 | # required for more thorough type declarations 85 | keyring >= 22.3 86 | # consider replacing with `mypy --install-types` when 87 | # https://github.com/python/mypy/issues/10600 is resolved 88 | types-requests 89 | commands = 90 | pip list 91 | mypy --html-report mypy --txt-report mypy {posargs:twine} 92 | python -c 'with open("mypy/index.txt") as f: print(f.read())' 93 | 94 | [testenv:changelog] 95 | basepython = python3 96 | deps = 97 | towncrier 98 | commands = 99 | towncrier build {posargs} 100 | 101 | 102 | # Usage: 103 | # tox -e create-changelog-item -- [additional arguments] {filename}.{bugfix,feature,doc,removal,misc} 104 | [testenv:create-changelog-item] 105 | basepython = python3 106 | skip_install = True 107 | deps = towncrier 108 | commands = 109 | towncrier create --config pyproject.toml {posargs} 110 | 111 | [testenv:release] 112 | # specify Python 3 to use platform's default Python 3 113 | basepython = python3 114 | deps = 115 | build 116 | passenv = 117 | TWINE_PASSWORD 118 | TWINE_REPOSITORY 119 | setenv = 120 | TWINE_USERNAME = {env:TWINE_USERNAME:__token__} 121 | commands = 122 | python -c "import shutil; shutil.rmtree('dist', ignore_errors=True)" 123 | python -m build 124 | python -m twine upload dist/* 125 | 126 | [testenv:dev] 127 | envdir = {posargs:venv} 128 | recreate = True 129 | deps = 130 | {[testenv]deps} 131 | {[testenv:integration]deps} 132 | {[testenv:format]deps} 133 | {[testenv:lint]deps} 134 | {[testenv:types]deps} 135 | download = True 136 | usedevelop = True 137 | commands = 138 | python -c 'import sys; print(sys.executable)' 139 | python --version 140 | -------------------------------------------------------------------------------- /twine/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level module for Twine. 2 | 3 | The contents of this package are not a public API. For more details, see 4 | https://github.com/pypa/twine/issues/194 and https://github.com/pypa/twine/issues/665. 5 | """ 6 | 7 | # Copyright 2018 Donald Stufft and individual contributors 8 | # 9 | # Licensed under the Apache License, Version 2.0 (the "License"); 10 | # you may not use this file except in compliance with the License. 11 | # You may obtain a copy of the License at 12 | # 13 | # https://www.apache.org/licenses/LICENSE-2.0 14 | # 15 | # Unless required by applicable law or agreed to in writing, software 16 | # distributed under the License is distributed on an "AS IS" BASIS, 17 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | # See the License for the specific language governing permissions and 19 | # limitations under the License. 20 | __all__ = ( 21 | "__title__", 22 | "__summary__", 23 | "__uri__", 24 | "__version__", 25 | "__author__", 26 | "__email__", 27 | "__license__", 28 | "__copyright__", 29 | ) 30 | 31 | __copyright__ = "Copyright 2019 Donald Stufft and individual contributors" 32 | 33 | import email.utils 34 | import sys 35 | 36 | if sys.version_info >= (3, 10): 37 | import importlib.metadata as importlib_metadata 38 | else: 39 | import importlib_metadata 40 | 41 | metadata = importlib_metadata.metadata("twine") 42 | 43 | 44 | __title__ = metadata["name"] 45 | __summary__ = metadata["summary"] 46 | __uri__ = next( 47 | entry.split(", ")[1] 48 | for entry in metadata.get_all("Project-URL", ()) 49 | if entry.startswith("Homepage") 50 | ) 51 | __version__ = metadata["version"] 52 | __author__, __email__ = email.utils.parseaddr(metadata["author-email"]) 53 | __license__ = None 54 | -------------------------------------------------------------------------------- /twine/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright 2013 Donald Stufft 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | import http 16 | import logging 17 | import sys 18 | from typing import Any, cast 19 | 20 | import requests 21 | 22 | from twine import cli 23 | from twine import exceptions 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | def main() -> Any: 29 | # Ensure that all errors are logged, even before argparse 30 | cli.configure_output() 31 | 32 | try: 33 | error = cli.dispatch(sys.argv[1:]) 34 | except requests.HTTPError as exc: 35 | # Assuming this response will never be None 36 | response = cast(requests.Response, exc.response) 37 | 38 | error = True 39 | status_code = response.status_code 40 | status_phrase = http.HTTPStatus(status_code).phrase 41 | logger.error( 42 | f"{exc.__class__.__name__}: {status_code} {status_phrase} " 43 | f"from {response.url}\n" 44 | f"{response.reason}" 45 | ) 46 | except exceptions.TwineException as exc: 47 | error = True 48 | logger.error(f"{exc.__class__.__name__}: {exc.args[0]}") 49 | 50 | return error 51 | 52 | 53 | if __name__ == "__main__": 54 | sys.exit(main()) 55 | -------------------------------------------------------------------------------- /twine/auth.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import getpass 3 | import json 4 | import logging 5 | from typing import TYPE_CHECKING, Callable, Optional, Type, cast 6 | from urllib.parse import urlparse 7 | 8 | from id import AmbientCredentialError # type: ignore 9 | from id import detect_credential 10 | 11 | # keyring has an indirect dependency on PyCA cryptography, which has no 12 | # pre-built wheels for ppc64le and s390x, see #1158. 13 | if TYPE_CHECKING: 14 | import keyring 15 | from keyring.errors import NoKeyringError 16 | else: 17 | try: 18 | import keyring 19 | from keyring.errors import NoKeyringError 20 | except ModuleNotFoundError: # pragma: no cover 21 | keyring = None 22 | NoKeyringError = None 23 | 24 | from twine import exceptions 25 | from twine import utils 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | class CredentialInput: 31 | def __init__( 32 | self, username: Optional[str] = None, password: Optional[str] = None 33 | ) -> None: 34 | self.username = username 35 | self.password = password 36 | 37 | 38 | class Resolver: 39 | def __init__( 40 | self, 41 | config: utils.RepositoryConfig, 42 | input: CredentialInput, 43 | ) -> None: 44 | self.config = config 45 | self.input = input 46 | 47 | @classmethod 48 | def choose(cls, interactive: bool) -> Type["Resolver"]: 49 | return cls if interactive else Private 50 | 51 | @property 52 | @functools.lru_cache() 53 | def username(self) -> Optional[str]: 54 | if self.is_pypi() and not self.input.username: 55 | # Default username. 56 | self.input.username = "__token__" 57 | 58 | return utils.get_userpass_value( 59 | self.input.username, 60 | self.config, 61 | key="username", 62 | prompt_strategy=self.username_from_keyring_or_prompt, 63 | ) 64 | 65 | @property 66 | @functools.lru_cache() 67 | def password(self) -> Optional[str]: 68 | return utils.get_userpass_value( 69 | self.input.password, 70 | self.config, 71 | key="password", 72 | prompt_strategy=self.password_from_keyring_or_trusted_publishing_or_prompt, 73 | ) 74 | 75 | def make_trusted_publishing_token(self) -> Optional[str]: 76 | # Trusted publishing (OpenID Connect): get one token from the CI 77 | # system, and exchange that for a PyPI token. 78 | repository_domain = cast(str, urlparse(self.system).netloc) 79 | session = utils.make_requests_session() 80 | 81 | # Indices are expected to support `https://{domain}/_/oidc/audience`, 82 | # which tells OIDC exchange clients which audience to use. 83 | audience_url = f"https://{repository_domain}/_/oidc/audience" 84 | resp = session.get(audience_url, timeout=5) 85 | resp.raise_for_status() 86 | audience = cast(str, resp.json()["audience"]) 87 | 88 | try: 89 | oidc_token = detect_credential(audience) 90 | except AmbientCredentialError as e: 91 | # If we get here, we're on a supported CI platform for trusted 92 | # publishing, and we have not been given any token, so we can error. 93 | raise exceptions.TrustedPublishingFailure( 94 | "Unable to retrieve an OIDC token from the CI platform for " 95 | f"trusted publishing {e}" 96 | ) 97 | 98 | if oidc_token is None: 99 | logger.debug("This environment is not supported for trusted publishing") 100 | return None # Fall back to prompting for a token (if possible) 101 | 102 | logger.debug("Got OIDC token for audience %s", audience) 103 | 104 | token_exchange_url = f"https://{repository_domain}/_/oidc/mint-token" 105 | 106 | mint_token_resp = session.post( 107 | token_exchange_url, 108 | json={"token": oidc_token}, 109 | timeout=5, # S113 wants a timeout 110 | ) 111 | try: 112 | mint_token_payload = mint_token_resp.json() 113 | except json.JSONDecodeError: 114 | raise exceptions.TrustedPublishingFailure( 115 | "The token-minting request returned invalid JSON" 116 | ) 117 | 118 | if not mint_token_resp.ok: 119 | reasons = "\n".join( 120 | f'* `{error["code"]}`: {error["description"]}' 121 | for error in mint_token_payload["errors"] 122 | ) 123 | raise exceptions.TrustedPublishingFailure( 124 | "The token request failed; the index server gave the following " 125 | f"reasons:\n\n{reasons}" 126 | ) 127 | 128 | logger.debug("Minted upload token for trusted publishing") 129 | return cast(str, mint_token_payload["token"]) 130 | 131 | @property 132 | def system(self) -> Optional[str]: 133 | return self.config["repository"] 134 | 135 | def get_username_from_keyring(self) -> Optional[str]: 136 | if keyring is None: 137 | logger.info("keyring module is not available") 138 | return None 139 | try: 140 | system = cast(str, self.system) 141 | logger.info("Querying keyring for username") 142 | creds = keyring.get_credential(system, None) 143 | if creds: 144 | return creds.username 145 | except AttributeError: 146 | # To support keyring prior to 15.2 147 | pass 148 | except Exception as exc: 149 | logger.warning("Error getting username from keyring", exc_info=exc) 150 | return None 151 | 152 | def get_password_from_keyring(self) -> Optional[str]: 153 | if keyring is None: 154 | logger.info("keyring module is not available") 155 | return None 156 | try: 157 | system = cast(str, self.system) 158 | username = cast(str, self.username) 159 | logger.info("Querying keyring for password") 160 | return cast(str, keyring.get_password(system, username)) 161 | except NoKeyringError: 162 | logger.info("No keyring backend found") 163 | except Exception as exc: 164 | logger.warning("Error getting password from keyring", exc_info=exc) 165 | return None 166 | 167 | def username_from_keyring_or_prompt(self) -> str: 168 | username = self.get_username_from_keyring() 169 | if username: 170 | logger.info("username set from keyring") 171 | return username 172 | 173 | return self.prompt("username", input) 174 | 175 | def password_from_keyring_or_trusted_publishing_or_prompt(self) -> str: 176 | password = self.get_password_from_keyring() 177 | if password: 178 | logger.info("password set from keyring") 179 | return password 180 | 181 | if self.is_pypi() and self.username == "__token__": 182 | logger.debug( 183 | "Trying to use trusted publishing (no token was explicitly provided)" 184 | ) 185 | if (token := self.make_trusted_publishing_token()) is not None: 186 | return token 187 | 188 | # Prompt for API token when required. 189 | what = "API token" if self.is_pypi() else "password" 190 | 191 | return self.prompt(what, getpass.getpass) 192 | 193 | def prompt(self, what: str, how: Callable[..., str]) -> str: 194 | return how(f"Enter your {what}: ") 195 | 196 | def is_pypi(self) -> bool: 197 | """As of 2024-01-01, PyPI requires API tokens for uploads.""" 198 | return cast(str, self.config["repository"]).startswith( 199 | ( 200 | utils.DEFAULT_REPOSITORY, 201 | utils.TEST_REPOSITORY, 202 | ) 203 | ) 204 | 205 | 206 | class Private(Resolver): 207 | def prompt(self, what: str, how: Optional[Callable[..., str]] = None) -> str: 208 | raise exceptions.NonInteractive(f"Credential not found for {what}.") 209 | -------------------------------------------------------------------------------- /twine/cli.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Donald Stufft 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import argparse 15 | import logging.config 16 | import sys 17 | from typing import Any, List, Tuple 18 | 19 | if sys.version_info >= (3, 10): 20 | import importlib.metadata as importlib_metadata 21 | else: 22 | import importlib_metadata 23 | 24 | import rich 25 | import rich.highlighter 26 | import rich.logging 27 | import rich.theme 28 | 29 | import twine 30 | 31 | args = argparse.Namespace() 32 | 33 | 34 | def configure_output() -> None: 35 | # Configure the global Console, available via rich.get_console(). 36 | # https://rich.readthedocs.io/en/latest/reference/init.html 37 | # https://rich.readthedocs.io/en/latest/console.html 38 | rich.reconfigure( 39 | # Setting force_terminal makes testing easier by ensuring color codes. This 40 | # could be based on FORCE_COLORS or PY_COLORS in os.environ, since Rich 41 | # doesn't support that (https://github.com/Textualize/rich/issues/343). 42 | force_terminal=True, 43 | no_color=getattr(args, "no_color", False), 44 | highlight=False, 45 | theme=rich.theme.Theme( 46 | { 47 | "logging.level.debug": "green", 48 | "logging.level.info": "blue", 49 | "logging.level.warning": "yellow", 50 | "logging.level.error": "red", 51 | "logging.level.critical": "reverse red", 52 | } 53 | ), 54 | ) 55 | 56 | # Using dictConfig to override existing loggers, which prevents failures in 57 | # test_main.py due to capsys not being cleared. 58 | logging.config.dictConfig( 59 | { 60 | "disable_existing_loggers": False, 61 | "version": 1, 62 | "handlers": { 63 | "console": { 64 | "class": "rich.logging.RichHandler", 65 | "show_time": False, 66 | "show_path": False, 67 | "highlighter": rich.highlighter.NullHighlighter(), 68 | } 69 | }, 70 | "root": { 71 | "handlers": ["console"], 72 | }, 73 | } 74 | ) 75 | 76 | 77 | def list_dependencies_and_versions() -> List[Tuple[str, str]]: 78 | deps = [ 79 | "keyring", # optional for non-desktop use 80 | "packaging", 81 | "requests", 82 | "requests-toolbelt", 83 | "urllib3", 84 | "id", 85 | ] 86 | if sys.version_info < (3, 10): 87 | deps.append("importlib-metadata") 88 | 89 | result: List[Tuple[str, str]] = [] 90 | for dep in deps: 91 | try: 92 | version = importlib_metadata.version(dep) 93 | except importlib_metadata.PackageNotFoundError: 94 | version = "NOT INSTALLED" 95 | result.append((dep, version)) 96 | 97 | return result 98 | 99 | 100 | def dep_versions() -> str: 101 | return ", ".join( 102 | "{}: {}".format(*dependency) for dependency in list_dependencies_and_versions() 103 | ) 104 | 105 | 106 | def dispatch(argv: List[str]) -> Any: 107 | registered_commands = importlib_metadata.entry_points( 108 | group="twine.registered_commands" 109 | ) 110 | 111 | parser = argparse.ArgumentParser(prog="twine") 112 | parser.add_argument( 113 | "--version", 114 | action="version", 115 | version=f"%(prog)s version {twine.__version__} ({dep_versions()})", 116 | ) 117 | parser.add_argument( 118 | "--no-color", 119 | default=False, 120 | required=False, 121 | action="store_true", 122 | help="disable colored output", 123 | ) 124 | parser.add_argument( 125 | "command", 126 | choices=registered_commands.names, 127 | ) 128 | parser.add_argument( 129 | "args", 130 | help=argparse.SUPPRESS, 131 | nargs=argparse.REMAINDER, 132 | ) 133 | parser.parse_args(argv, namespace=args) 134 | 135 | configure_output() 136 | 137 | main = registered_commands[args.command].load() 138 | 139 | return main(args.args) 140 | -------------------------------------------------------------------------------- /twine/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """Module containing the logic for the ``twine`` sub-commands. 2 | 3 | The contents of this package are not a public API. For more details, see 4 | https://github.com/pypa/twine/issues/194 and https://github.com/pypa/twine/issues/665. 5 | """ 6 | 7 | # Copyright 2013 Donald Stufft 8 | # 9 | # Licensed under the Apache License, Version 2.0 (the "License"); 10 | # you may not use this file except in compliance with the License. 11 | # You may obtain a copy of the License at 12 | # 13 | # https://www.apache.org/licenses/LICENSE-2.0 14 | # 15 | # Unless required by applicable law or agreed to in writing, software 16 | # distributed under the License is distributed on an "AS IS" BASIS, 17 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | # See the License for the specific language governing permissions and 19 | # limitations under the License. 20 | import fnmatch 21 | import glob 22 | import os.path 23 | from typing import Dict, List, NamedTuple 24 | 25 | from twine import exceptions 26 | 27 | __all__: List[str] = [] 28 | 29 | 30 | def _group_wheel_files_first(files: List[str]) -> List[str]: 31 | if not any(fname for fname in files if fname.endswith(".whl")): 32 | # Return early if there's no wheel files 33 | return files 34 | 35 | files.sort(key=lambda x: -1 if x.endswith(".whl") else 0) 36 | 37 | return files 38 | 39 | 40 | def _find_dists(dists: List[str]) -> List[str]: 41 | uploads = [] 42 | for filename in dists: 43 | if os.path.exists(filename): 44 | uploads.append(filename) 45 | continue 46 | # The filename didn't exist so it may be a glob 47 | files = glob.glob(filename) 48 | # If nothing matches, files is [] 49 | if not files: 50 | raise exceptions.InvalidDistribution( 51 | "Cannot find file (or expand pattern): '%s'" % filename 52 | ) 53 | # Otherwise, files will be filenames that exist 54 | uploads.extend(files) 55 | return _group_wheel_files_first(uploads) 56 | 57 | 58 | class Inputs(NamedTuple): 59 | """Represents structured user inputs.""" 60 | 61 | dists: List[str] 62 | signatures: Dict[str, str] 63 | attestations_by_dist: Dict[str, List[str]] 64 | 65 | 66 | def _split_inputs( 67 | inputs: List[str], 68 | ) -> Inputs: 69 | """ 70 | Split the unstructured list of input files provided by the user into groups. 71 | 72 | Three groups are returned: upload files (i.e. dists), signatures, and attestations. 73 | 74 | Upload files are returned as a linear list, signatures are returned as a 75 | dict of ``basename -> path``, and attestations are returned as a dict of 76 | ``dist-path -> [attestation-path]``. 77 | """ 78 | signatures = {os.path.basename(i): i for i in fnmatch.filter(inputs, "*.asc")} 79 | attestations = fnmatch.filter(inputs, "*.*.attestation") 80 | dists = [ 81 | dist 82 | for dist in inputs 83 | if dist not in (set(signatures.values()) | set(attestations)) 84 | ] 85 | 86 | attestations_by_dist = {} 87 | for dist in dists: 88 | dist_basename = os.path.basename(dist) 89 | attestations_by_dist[dist] = [ 90 | a for a in attestations if os.path.basename(a).startswith(dist_basename) 91 | ] 92 | 93 | return Inputs(dists, signatures, attestations_by_dist) 94 | -------------------------------------------------------------------------------- /twine/commands/check.py: -------------------------------------------------------------------------------- 1 | """Module containing the logic for ``twine check``.""" 2 | 3 | # Copyright 2018 Dustin Ingram 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | import argparse 17 | import email.message 18 | import io 19 | import logging 20 | import re 21 | from typing import Dict, List, Tuple 22 | 23 | import readme_renderer.rst 24 | from rich import print 25 | 26 | from twine import commands 27 | from twine import package as package_file 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | 32 | _RENDERERS = { 33 | None: readme_renderer.rst, # Default if description_content_type is None 34 | "text/plain": None, # Rendering cannot fail 35 | "text/x-rst": readme_renderer.rst, 36 | "text/markdown": None, # Rendering cannot fail 37 | } 38 | 39 | 40 | # Regular expression used to capture and reformat docutils warnings into 41 | # something that a human can understand. This is loosely borrowed from 42 | # Sphinx: https://github.com/sphinx-doc/sphinx/blob 43 | # /c35eb6fade7a3b4a6de4183d1dd4196f04a5edaf/sphinx/util/docutils.py#L199 44 | _REPORT_RE = re.compile( 45 | r"^:(?P(?:\d+)?): " 46 | r"\((?PDEBUG|INFO|WARNING|ERROR|SEVERE)/(\d+)?\) " 47 | r"(?P.*)", 48 | re.DOTALL | re.MULTILINE, 49 | ) 50 | 51 | 52 | class _WarningStream(io.StringIO): 53 | def write(self, text: str) -> int: 54 | matched = _REPORT_RE.search(text) 55 | if matched: 56 | line = matched.group("line") 57 | level_text = matched.group("level").capitalize() 58 | message = matched.group("message").rstrip("\r\n") 59 | text = f"line {line}: {level_text}: {message}\n" 60 | 61 | return super().write(text) 62 | 63 | def __str__(self) -> str: 64 | return self.getvalue().strip() 65 | 66 | 67 | def _parse_content_type(value: str) -> Tuple[str, Dict[str, str]]: 68 | """Implement logic of deprecated cgi.parse_header(). 69 | 70 | From https://docs.python.org/3.11/library/cgi.html#cgi.parse_header. 71 | """ 72 | msg = email.message.EmailMessage() 73 | msg["content-type"] = value 74 | return msg.get_content_type(), msg["content-type"].params 75 | 76 | 77 | def _check_file( 78 | filename: str, render_warning_stream: _WarningStream 79 | ) -> Tuple[List[str], bool]: 80 | """Check given distribution.""" 81 | warnings = [] 82 | is_ok = True 83 | 84 | package = package_file.PackageFile.from_filename(filename, comment=None) 85 | 86 | metadata = package.metadata_dictionary() 87 | description = metadata.get("description") 88 | description_content_type = metadata.get("description_content_type") 89 | 90 | if description_content_type is None: 91 | warnings.append( 92 | "`long_description_content_type` missing. defaulting to `text/x-rst`." 93 | ) 94 | description_content_type = "text/x-rst" 95 | 96 | content_type, params = _parse_content_type(description_content_type) 97 | renderer = _RENDERERS.get(content_type, _RENDERERS[None]) 98 | 99 | if not description or description.rstrip() == "UNKNOWN": 100 | warnings.append("`long_description` missing.") 101 | elif renderer: 102 | rendering_result = renderer.render( 103 | description, stream=render_warning_stream, **params 104 | ) 105 | if rendering_result is None: 106 | is_ok = False 107 | 108 | return warnings, is_ok 109 | 110 | 111 | def check( 112 | dists: List[str], 113 | strict: bool = False, 114 | ) -> bool: 115 | """Check that a distribution will render correctly on PyPI and display the results. 116 | 117 | This is currently only validates ``long_description``, but more checks could be 118 | added. 119 | 120 | :param dists: 121 | The distribution files to check. 122 | :param output_stream: 123 | The destination of the resulting output. 124 | :param strict: 125 | If ``True``, treat warnings as errors. 126 | 127 | :return: 128 | ``True`` if there are rendering errors, otherwise ``False``. 129 | """ 130 | dists = commands._find_dists(dists) 131 | uploads, _, _ = commands._split_inputs(dists) 132 | if not uploads: # Return early, if there are no files to check. 133 | logger.error("No files to check.") 134 | return False 135 | 136 | failure = False 137 | 138 | for filename in uploads: 139 | print(f"Checking {filename}: ", end="") 140 | render_warning_stream = _WarningStream() 141 | warnings, is_ok = _check_file(filename, render_warning_stream) 142 | 143 | # Print the status and/or error 144 | if not is_ok: 145 | failure = True 146 | print("[red]FAILED[/red]") 147 | logger.error( 148 | "`long_description` has syntax errors in markup" 149 | " and would not be rendered on PyPI." 150 | f"\n{render_warning_stream}" 151 | ) 152 | elif warnings: 153 | if strict: 154 | failure = True 155 | print("[red]FAILED due to warnings[/red]") 156 | else: 157 | print("[yellow]PASSED with warnings[/yellow]") 158 | else: 159 | print("[green]PASSED[/green]") 160 | 161 | # Print warnings after the status and/or error 162 | for message in warnings: 163 | logger.warning(message) 164 | 165 | return failure 166 | 167 | 168 | def main(args: List[str]) -> bool: 169 | """Execute the ``check`` command. 170 | 171 | :param args: 172 | The command-line arguments. 173 | 174 | :return: 175 | The exit status of the ``check`` command. 176 | """ 177 | parser = argparse.ArgumentParser(prog="twine check") 178 | parser.add_argument( 179 | "dists", 180 | nargs="+", 181 | metavar="dist", 182 | help="The distribution files to check, usually dist/*", 183 | ) 184 | parser.add_argument( 185 | "--strict", 186 | action="store_true", 187 | default=False, 188 | required=False, 189 | help="Fail on warnings", 190 | ) 191 | 192 | parsed_args = parser.parse_args(args) 193 | 194 | # Call the check function with the arguments from the command line 195 | return check(parsed_args.dists, strict=parsed_args.strict) 196 | -------------------------------------------------------------------------------- /twine/commands/register.py: -------------------------------------------------------------------------------- 1 | """Module containing the logic for ``twine register``.""" 2 | 3 | # Copyright 2015 Ian Cordasco 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | import argparse 17 | import os.path 18 | from typing import List, cast 19 | 20 | from rich import print 21 | 22 | from twine import exceptions 23 | from twine import package as package_file 24 | from twine import settings 25 | 26 | 27 | def register(register_settings: settings.Settings, package: str) -> None: 28 | """Pre-register a package name with a repository before uploading a distribution. 29 | 30 | Pre-registration is not supported on PyPI, so the ``register`` command is only 31 | necessary if you are using a different repository that requires it. 32 | 33 | :param register_settings: 34 | The configured options relating to repository registration. 35 | :param package: 36 | The path of the distribution to use for package metadata. 37 | 38 | :raises twine.exceptions.TwineException: 39 | The registration failed due to a configuration error. 40 | :raises requests.HTTPError: 41 | The repository responded with an error. 42 | """ 43 | repository_url = cast(str, register_settings.repository_config["repository"]) 44 | print(f"Registering package to {repository_url}") 45 | repository = register_settings.create_repository() 46 | 47 | if not os.path.exists(package): 48 | raise exceptions.PackageNotFound( 49 | f'"{package}" does not exist on the file system.' 50 | ) 51 | 52 | resp = repository.register( 53 | package_file.PackageFile.from_filename(package, register_settings.comment) 54 | ) 55 | repository.close() 56 | 57 | if resp.is_redirect: 58 | raise exceptions.RedirectDetected.from_args( 59 | repository_url, 60 | resp.headers["location"], 61 | ) 62 | 63 | resp.raise_for_status() 64 | 65 | 66 | def main(args: List[str]) -> None: 67 | """Execute the ``register`` command. 68 | 69 | :param args: 70 | The command-line arguments. 71 | """ 72 | parser = argparse.ArgumentParser( 73 | prog="twine register", 74 | description="register operation is not required with PyPI.org", 75 | ) 76 | settings.Settings.register_argparse_arguments(parser) 77 | parser.add_argument( 78 | "package", 79 | metavar="package", 80 | help="File from which we read the package metadata.", 81 | ) 82 | 83 | parsed_args = parser.parse_args(args) 84 | register_settings = settings.Settings.from_argparse(parsed_args) 85 | 86 | # Call the register function with the args from the command line 87 | register(register_settings, parsed_args.package) 88 | -------------------------------------------------------------------------------- /twine/commands/upload.py: -------------------------------------------------------------------------------- 1 | """Module containing the logic for ``twine upload``.""" 2 | 3 | # Copyright 2013 Donald Stufft 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | import argparse 17 | import logging 18 | from typing import Dict, List, cast 19 | 20 | import requests 21 | from rich import print 22 | 23 | from twine import commands 24 | from twine import exceptions 25 | from twine import package as package_file 26 | from twine import settings 27 | from twine import utils 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | 32 | def skip_upload( 33 | response: requests.Response, skip_existing: bool, package: package_file.PackageFile 34 | ) -> bool: 35 | """Determine if a failed upload is an error or can be safely ignored. 36 | 37 | :param response: 38 | The response from attempting to upload ``package`` to a repository. 39 | :param skip_existing: 40 | If ``True``, use the status and content of ``response`` to determine if the 41 | package already exists on the repository. If so, then a failed upload is safe 42 | to ignore. 43 | :param package: 44 | The package that was being uploaded. 45 | 46 | :return: 47 | ``True`` if a failed upload can be safely ignored, otherwise ``False``. 48 | """ 49 | if not skip_existing: 50 | return False 51 | 52 | status = response.status_code 53 | reason = getattr(response, "reason", "").lower() 54 | text = getattr(response, "text", "").lower() 55 | 56 | # NOTE(sigmavirus24): PyPI presently returns a 400 status code with the 57 | # error message in the reason attribute. Other implementations return a 58 | # 403 or 409 status code. 59 | return ( 60 | # pypiserver (https://pypi.org/project/pypiserver) 61 | status == 409 62 | # PyPI / TestPyPI / GCP Artifact Registry 63 | or (status == 400 and any("already exist" in x for x in [reason, text])) 64 | # Nexus Repository OSS (https://www.sonatype.com/nexus-repository-oss) 65 | or (status == 400 and any("updating asset" in x for x in [reason, text])) 66 | # Artifactory (https://jfrog.com/artifactory/) 67 | or (status == 403 and "overwrite artifact" in text) 68 | # Gitlab Enterprise Edition (https://about.gitlab.com) 69 | or (status == 400 and "already been taken" in text) 70 | ) 71 | 72 | 73 | def _make_package( 74 | filename: str, 75 | signatures: Dict[str, str], 76 | attestations: List[str], 77 | upload_settings: settings.Settings, 78 | ) -> package_file.PackageFile: 79 | """Create and sign a package, based off of filename, signatures, and settings. 80 | 81 | Additionally, any supplied attestations are attached to the package when 82 | the settings indicate to do so. 83 | """ 84 | package = package_file.PackageFile.from_filename(filename, upload_settings.comment) 85 | 86 | signed_name = package.signed_basefilename 87 | if signed_name in signatures: 88 | package.add_gpg_signature(signatures[signed_name], signed_name) 89 | elif upload_settings.sign: 90 | package.sign(upload_settings.sign_with, upload_settings.identity) 91 | 92 | # Attestations are only attached if explicitly requested with `--attestations`. 93 | if upload_settings.attestations: 94 | # Passing `--attestations` without any actual attestations present 95 | # indicates user confusion, so we fail rather than silently allowing it. 96 | if not attestations: 97 | raise exceptions.InvalidDistribution( 98 | "Upload with attestations requested, but " 99 | f"{filename} has no associated attestations" 100 | ) 101 | package.add_attestations(attestations) 102 | 103 | file_size = utils.get_file_size(package.filename) 104 | logger.info(f"{package.filename} ({file_size})") 105 | if package.gpg_signature: 106 | logger.info(f"Signed with {package.signed_filename}") 107 | 108 | return package 109 | 110 | 111 | def upload(upload_settings: settings.Settings, dists: List[str]) -> None: 112 | """Upload one or more distributions to a repository, and display the progress. 113 | 114 | If a package already exists on the repository, most repositories will return an 115 | error response. However, if ``upload_settings.skip_existing`` is ``True``, a message 116 | will be displayed and any remaining distributions will be uploaded. 117 | 118 | For known repositories (like PyPI), the web URLs of successfully uploaded packages 119 | will be displayed. 120 | 121 | :param upload_settings: 122 | The configured options related to uploading to a repository. 123 | :param dists: 124 | The distribution files to upload to the repository. This can also include 125 | ``.asc`` and ``.attestation`` files, which will be added to their respective 126 | file uploads. 127 | 128 | :raises twine.exceptions.TwineException: 129 | The upload failed due to a configuration error. 130 | :raises requests.HTTPError: 131 | The repository responded with an error. 132 | """ 133 | upload_settings.check_repository_url() 134 | repository_url = cast(str, upload_settings.repository_config["repository"]) 135 | 136 | # Attestations are only supported on PyPI and TestPyPI at the moment. 137 | # We warn instead of failing to allow twine to be used in local testing 138 | # setups (where the PyPI deployment doesn't have a well-known domain). 139 | if upload_settings.attestations and not repository_url.startswith( 140 | (utils.DEFAULT_REPOSITORY, utils.TEST_REPOSITORY) 141 | ): 142 | logger.warning( 143 | "Only PyPI and TestPyPI support attestations; " 144 | "if you experience failures, remove the --attestations flag and " 145 | "re-try this command" 146 | ) 147 | 148 | dists = commands._find_dists(dists) 149 | # Determine if the user has passed in pre-signed distributions or any attestations. 150 | uploads, signatures, attestations_by_dist = commands._split_inputs(dists) 151 | 152 | print(f"Uploading distributions to {utils.sanitize_url(repository_url)}") 153 | 154 | packages_to_upload = [ 155 | _make_package( 156 | filename, signatures, attestations_by_dist[filename], upload_settings 157 | ) 158 | for filename in uploads 159 | ] 160 | 161 | if any(p.gpg_signature for p in packages_to_upload): 162 | if repository_url.startswith((utils.DEFAULT_REPOSITORY, utils.TEST_REPOSITORY)): 163 | # Warn the user if they're trying to upload a PGP signature to PyPI 164 | # or TestPyPI, which will (as of May 2023) ignore it. 165 | # This warning is currently limited to just those indices, since other 166 | # indices may still support PGP signatures. 167 | logger.warning( 168 | "One or more packages has an associated PGP signature; " 169 | "these will be silently ignored by the index" 170 | ) 171 | else: 172 | # On other indices, warn the user that twine is considering 173 | # removing PGP support outright. 174 | logger.warning( 175 | "One or more packages has an associated PGP signature; " 176 | "a future version of twine may silently ignore these. " 177 | "See https://github.com/pypa/twine/issues/1009 for more " 178 | "information" 179 | ) 180 | 181 | repository = upload_settings.create_repository() 182 | uploaded_packages = [] 183 | 184 | if signatures and not packages_to_upload: 185 | raise exceptions.InvalidDistribution( 186 | "Cannot upload signed files by themselves, must upload with a " 187 | "corresponding distribution file." 188 | ) 189 | 190 | for package in packages_to_upload: 191 | skip_message = ( 192 | f"Skipping {package.basefilename} because it appears to already exist" 193 | ) 194 | 195 | # Note: The skip_existing check *needs* to be first, because otherwise 196 | # we're going to generate extra HTTP requests against a hardcoded 197 | # URL for no reason. 198 | if upload_settings.skip_existing and repository.package_is_uploaded(package): 199 | logger.warning(skip_message) 200 | continue 201 | 202 | resp = repository.upload(package) 203 | logger.info(f"Response from {resp.url}:\n{resp.status_code} {resp.reason}") 204 | if resp.text: 205 | logger.info(resp.text) 206 | 207 | # Bug 92. If we get a redirect we should abort because something seems 208 | # funky. The behaviour is not well defined and redirects being issued 209 | # by PyPI should never happen in reality. This should catch malicious 210 | # redirects as well. 211 | if resp.is_redirect: 212 | raise exceptions.RedirectDetected.from_args( 213 | utils.sanitize_url(repository_url), 214 | utils.sanitize_url(resp.headers["location"]), 215 | ) 216 | 217 | if skip_upload(resp, upload_settings.skip_existing, package): 218 | logger.warning(skip_message) 219 | continue 220 | 221 | utils.check_status_code(resp, upload_settings.verbose) 222 | 223 | uploaded_packages.append(package) 224 | 225 | release_urls = repository.release_urls(uploaded_packages) 226 | if release_urls: 227 | print("\n[green]View at:") 228 | for url in release_urls: 229 | print(url) 230 | 231 | # Bug 28. Try to silence a ResourceWarning by clearing the connection 232 | # pool. 233 | repository.close() 234 | 235 | 236 | def main(args: List[str]) -> None: 237 | """Execute the ``upload`` command. 238 | 239 | :param args: 240 | The command-line arguments. 241 | """ 242 | parser = argparse.ArgumentParser(prog="twine upload") 243 | settings.Settings.register_argparse_arguments(parser) 244 | parser.add_argument( 245 | "dists", 246 | nargs="+", 247 | metavar="dist", 248 | help="The distribution files to upload to the repository " 249 | "(package index). Usually dist/* . May additionally contain " 250 | "a .asc file to include an existing signature with the " 251 | "file upload.", 252 | ) 253 | 254 | parsed_args = parser.parse_args(args) 255 | upload_settings = settings.Settings.from_argparse(parsed_args) 256 | 257 | # Call the upload function with the arguments from the command line 258 | return upload(upload_settings, parsed_args.dists) 259 | -------------------------------------------------------------------------------- /twine/distribution.py: -------------------------------------------------------------------------------- 1 | class Distribution: 2 | 3 | def read(self) -> bytes: 4 | raise NotImplementedError 5 | 6 | @property 7 | def py_version(self) -> str: 8 | return "any" 9 | -------------------------------------------------------------------------------- /twine/exceptions.py: -------------------------------------------------------------------------------- 1 | """Module containing exceptions raised by twine.""" 2 | 3 | # Copyright 2015 Ian Stapleton Cordasco 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | class TwineException(Exception): 19 | """Base class for all exceptions raised by twine.""" 20 | 21 | pass 22 | 23 | 24 | class RedirectDetected(TwineException): 25 | """A redirect was detected that the user needs to resolve. 26 | 27 | In some cases, requests refuses to issue a new POST request after a 28 | redirect. In order to prevent a confusing user experience, we raise this 29 | exception to allow users to know the index they're uploading to is 30 | redirecting them. 31 | """ 32 | 33 | @classmethod 34 | def from_args(cls, repository_url: str, redirect_url: str) -> "RedirectDetected": 35 | if redirect_url == f"{repository_url}/": 36 | return cls( 37 | f"{repository_url} attempted to redirect to {redirect_url}.\n" 38 | f"Your repository URL is missing a trailing slash. " 39 | "Please add it and try again.", 40 | ) 41 | 42 | return cls( 43 | f"{repository_url} attempted to redirect to {redirect_url}.\n" 44 | f"If you trust these URLs, set {redirect_url} as your repository URL " 45 | "and try again.", 46 | ) 47 | 48 | 49 | class PackageNotFound(TwineException): 50 | """A package file was provided that could not be found on the file system. 51 | 52 | This is only used when attempting to register a package_file. 53 | """ 54 | 55 | pass 56 | 57 | 58 | class UploadToDeprecatedPyPIDetected(TwineException): 59 | """An upload attempt was detected to deprecated PyPI domains. 60 | 61 | The sites pypi.python.org and testpypi.python.org are deprecated. 62 | """ 63 | 64 | @classmethod 65 | def from_args( 66 | cls, target_url: str, default_url: str, test_url: str 67 | ) -> "UploadToDeprecatedPyPIDetected": 68 | """Return an UploadToDeprecatedPyPIDetected instance.""" 69 | return cls( 70 | "You're trying to upload to the legacy PyPI site '{}'. " 71 | "Uploading to those sites is deprecated. \n " 72 | "The new sites are pypi.org and test.pypi.org. Try using " 73 | "{} (or {}) to upload your packages instead. " 74 | "These are the default URLs for Twine now. \n More at " 75 | "https://packaging.python.org/guides/migrating-to-pypi-org/" 76 | " .".format(target_url, default_url, test_url) 77 | ) 78 | 79 | 80 | class UnreachableRepositoryURLDetected(TwineException): 81 | """An upload attempt was detected to a URL without a protocol prefix. 82 | 83 | All repository URLs must have a protocol (e.g., ``https://``). 84 | """ 85 | 86 | pass 87 | 88 | 89 | class InvalidSigningConfiguration(TwineException): 90 | """Both the sign and identity parameters must be present.""" 91 | 92 | pass 93 | 94 | 95 | class InvalidSigningExecutable(TwineException): 96 | """Signing executable must be installed on system.""" 97 | 98 | pass 99 | 100 | 101 | class InvalidConfiguration(TwineException): 102 | """Raised when configuration is invalid.""" 103 | 104 | pass 105 | 106 | 107 | class InvalidDistribution(TwineException): 108 | """Raised when a distribution is invalid.""" 109 | 110 | pass 111 | 112 | 113 | class NonInteractive(TwineException): 114 | """Raised in non-interactive mode when credentials could not be found.""" 115 | 116 | pass 117 | 118 | 119 | class TrustedPublishingFailure(TwineException): 120 | """Raised if we expected to use trusted publishing but couldn't.""" 121 | 122 | pass 123 | 124 | 125 | class InvalidPyPIUploadURL(TwineException): 126 | """Repository configuration tries to use PyPI with an incorrect URL. 127 | 128 | For example, https://pypi.org instead of https://upload.pypi.org/legacy. 129 | """ 130 | 131 | pass 132 | -------------------------------------------------------------------------------- /twine/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pypa/twine/4b6c50bf858604df5cab98f15c2c691eb3bf9dfe/twine/py.typed -------------------------------------------------------------------------------- /twine/repository.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Ian Cordasco 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import logging 15 | from typing import Any, Dict, List, Optional, Set, Tuple 16 | 17 | import requests 18 | import requests_toolbelt 19 | import rich.progress 20 | from rich import print 21 | 22 | from twine import package as package_file 23 | from twine.utils import make_requests_session 24 | 25 | LEGACY_PYPI = "https://pypi.python.org/" 26 | LEGACY_TEST_PYPI = "https://testpypi.python.org/" 27 | WAREHOUSE = "https://upload.pypi.org/" 28 | OLD_WAREHOUSE = "https://upload.pypi.io/" 29 | TEST_WAREHOUSE = "https://test.pypi.org/" 30 | WAREHOUSE_WEB = "https://pypi.org/" 31 | 32 | logger = logging.getLogger(__name__) 33 | 34 | 35 | class Repository: 36 | def __init__( 37 | self, 38 | repository_url: str, 39 | username: Optional[str], 40 | password: Optional[str], 41 | disable_progress_bar: bool = False, 42 | ) -> None: 43 | self.url = repository_url 44 | 45 | self.session = make_requests_session() 46 | # requests.Session.auth should be Union[None, Tuple[str, str], ...] 47 | # But username or password could be None 48 | # See TODO for utils.RepositoryConfig 49 | self.session.auth = ( 50 | (username or "", password or "") if username or password else None 51 | ) 52 | logger.info(f"username: {username if username else ''}") 53 | logger.info(f"password: <{'hidden' if password else 'empty'}>") 54 | 55 | # Working around https://github.com/python/typing/issues/182 56 | self._releases_json_data: Dict[str, Dict[str, Any]] = {} 57 | self.disable_progress_bar = disable_progress_bar 58 | 59 | def close(self) -> None: 60 | self.session.close() 61 | 62 | @staticmethod 63 | def _convert_metadata_to_list_of_tuples( 64 | data: package_file.PackageMetadata, 65 | ) -> List[Tuple[str, Any]]: 66 | # This does what ``warehouse.forklift.parse_form_metadata()`` does, in reverse. 67 | data_to_send: List[Tuple[str, Any]] = [] 68 | for key, value in data.items(): 69 | if key == "gpg_signature": 70 | assert isinstance(value, tuple) 71 | data_to_send.append((key, value)) 72 | elif key == "project_urls": 73 | assert isinstance(value, dict) 74 | for name, url in value.items(): 75 | data_to_send.append((key, f"{name}, {url}")) 76 | elif key == "keywords": 77 | assert isinstance(value, list) 78 | data_to_send.append((key, ", ".join(value))) 79 | elif isinstance(value, (list, tuple)): 80 | data_to_send.extend((key, item) for item in value) 81 | else: 82 | assert isinstance(value, str) 83 | data_to_send.append((key, value)) 84 | return data_to_send 85 | 86 | def set_certificate_authority(self, cacert: Optional[str]) -> None: 87 | if cacert: 88 | self.session.verify = cacert 89 | 90 | def set_client_certificate(self, clientcert: Optional[str]) -> None: 91 | if clientcert: 92 | self.session.cert = clientcert 93 | 94 | def register(self, package: package_file.PackageFile) -> requests.Response: 95 | print(f"Registering {package.basefilename}") 96 | 97 | metadata = package.metadata_dictionary() 98 | data_to_send = self._convert_metadata_to_list_of_tuples(metadata) 99 | data_to_send.append((":action", "submit")) 100 | data_to_send.append(("protocol_version", "1")) 101 | encoder = requests_toolbelt.MultipartEncoder(data_to_send) 102 | resp = self.session.post( 103 | self.url, 104 | data=encoder, 105 | allow_redirects=False, 106 | headers={"Content-Type": encoder.content_type}, 107 | ) 108 | # Bug 28. Try to silence a ResourceWarning by releasing the socket. 109 | resp.close() 110 | return resp 111 | 112 | def _upload(self, package: package_file.PackageFile) -> requests.Response: 113 | print(f"Uploading {package.basefilename}") 114 | 115 | metadata = package.metadata_dictionary() 116 | data_to_send = self._convert_metadata_to_list_of_tuples(metadata) 117 | data_to_send.append((":action", "file_upload")) 118 | data_to_send.append(("protocol_version", "1")) 119 | with open(package.filename, "rb") as fp: 120 | data_to_send.append( 121 | ( 122 | "content", 123 | (package.basefilename, fp, "application/octet-stream"), 124 | ) 125 | ) 126 | encoder = requests_toolbelt.MultipartEncoder(data_to_send) 127 | 128 | with rich.progress.Progress( 129 | "[progress.percentage]{task.percentage:>3.0f}%", 130 | rich.progress.BarColumn(), 131 | rich.progress.DownloadColumn(), 132 | "•", 133 | rich.progress.TimeRemainingColumn( 134 | compact=True, 135 | elapsed_when_finished=True, 136 | ), 137 | "•", 138 | rich.progress.TransferSpeedColumn(), 139 | disable=self.disable_progress_bar, 140 | ) as progress: 141 | task_id = progress.add_task("", total=encoder.len) 142 | 143 | monitor = requests_toolbelt.MultipartEncoderMonitor( 144 | encoder, 145 | lambda monitor: progress.update( 146 | task_id, 147 | completed=monitor.bytes_read, 148 | ), 149 | ) 150 | 151 | resp = self.session.post( 152 | self.url, 153 | data=monitor, 154 | allow_redirects=False, 155 | headers={"Content-Type": monitor.content_type}, 156 | ) 157 | 158 | return resp 159 | 160 | def upload( 161 | self, package: package_file.PackageFile, max_redirects: int = 5 162 | ) -> requests.Response: 163 | number_of_redirects = 0 164 | while number_of_redirects < max_redirects: 165 | resp = self._upload(package) 166 | 167 | if resp.status_code == requests.codes.OK: 168 | return resp 169 | if 500 <= resp.status_code < 600: 170 | number_of_redirects += 1 171 | logger.warning( 172 | f'Received "{resp.status_code}: {resp.reason}"' 173 | "\nPackage upload appears to have failed." 174 | f" Retry {number_of_redirects} of {max_redirects}." 175 | ) 176 | else: 177 | return resp 178 | 179 | return resp 180 | 181 | def package_is_uploaded( 182 | self, package: package_file.PackageFile, bypass_cache: bool = False 183 | ) -> bool: 184 | # NOTE(sigmavirus24): Not all indices are PyPI and pypi.io doesn't 185 | # have a similar interface for finding the package versions. 186 | if not self.url.startswith((LEGACY_PYPI, WAREHOUSE, OLD_WAREHOUSE)): 187 | return False 188 | 189 | safe_name = package.safe_name 190 | releases = None 191 | 192 | if not bypass_cache: 193 | releases = self._releases_json_data.get(safe_name) 194 | 195 | if releases is None: 196 | url = f"{LEGACY_PYPI}pypi/{safe_name}/json" 197 | headers = {"Accept": "application/json"} 198 | response = self.session.get(url, headers=headers) 199 | if response.status_code == 200: 200 | releases = response.json()["releases"] 201 | else: 202 | releases = {} 203 | self._releases_json_data[safe_name] = releases 204 | 205 | packages = releases.get(package.version, []) 206 | 207 | for uploaded_package in packages: 208 | if uploaded_package["filename"] == package.basefilename: 209 | return True 210 | 211 | return False 212 | 213 | def release_urls(self, packages: List[package_file.PackageFile]) -> Set[str]: 214 | if self.url.startswith(WAREHOUSE): 215 | url = WAREHOUSE_WEB 216 | elif self.url.startswith(TEST_WAREHOUSE): 217 | url = TEST_WAREHOUSE 218 | else: 219 | return set() 220 | 221 | return { 222 | f"{url}project/{package.safe_name}/{package.version}/" 223 | for package in packages 224 | } 225 | 226 | def verify_package_integrity(self, package: package_file.PackageFile) -> None: 227 | # TODO(sigmavirus24): Add a way for users to download the package and 228 | # check its hash against what it has locally. 229 | pass 230 | -------------------------------------------------------------------------------- /twine/sdist.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tarfile 3 | import zipfile 4 | from contextlib import suppress 5 | 6 | from twine import distribution 7 | from twine import exceptions 8 | 9 | 10 | class SDist(distribution.Distribution): 11 | def __new__(cls, filename: str) -> "SDist": 12 | if cls is not SDist: 13 | return object.__new__(cls) 14 | 15 | FORMATS = { 16 | ".tar.gz": TarGzSDist, 17 | ".zip": ZipSDist, 18 | } 19 | 20 | for extension, impl in FORMATS.items(): 21 | if filename.endswith(extension): 22 | return impl(filename) 23 | raise exceptions.InvalidDistribution(f"Unsupported sdist format: {filename}") 24 | 25 | def __init__(self, filename: str) -> None: 26 | if not os.path.exists(filename): 27 | raise exceptions.InvalidDistribution(f"No such file: {filename}") 28 | self.filename = filename 29 | 30 | @property 31 | def py_version(self) -> str: 32 | return "source" 33 | 34 | 35 | class TarGzSDist(SDist): 36 | 37 | def read(self) -> bytes: 38 | with tarfile.open(self.filename, "r:gz") as sdist: 39 | # The sdist must contain a single top-level direcotry... 40 | root = os.path.commonpath(sdist.getnames()) 41 | if root in {".", "/", ""}: 42 | raise exceptions.InvalidDistribution( 43 | f"Too many top-level members in sdist archive: {self.filename}" 44 | ) 45 | # ...containing the package metadata in a ``PKG-INFO`` file. 46 | with suppress(KeyError): 47 | member = sdist.getmember(root.rstrip("/") + "/PKG-INFO") 48 | if not member.isfile(): 49 | raise exceptions.InvalidDistribution( 50 | f"PKG-INFO is not a regular file: {self.filename}" 51 | ) 52 | fd = sdist.extractfile(member) 53 | assert fd is not None, "for mypy" 54 | data = fd.read() 55 | if b"Metadata-Version" in data: 56 | return data 57 | 58 | raise exceptions.InvalidDistribution( 59 | "No PKG-INFO in archive or " 60 | f"PKG-INFO missing 'Metadata-Version': {self.filename}" 61 | ) 62 | 63 | 64 | class ZipSDist(SDist): 65 | 66 | def read(self) -> bytes: 67 | with zipfile.ZipFile(self.filename) as sdist: 68 | # The sdist must contain a single top-level direcotry... 69 | root = os.path.commonpath(sdist.namelist()) 70 | if root in {".", "/", ""}: 71 | raise exceptions.InvalidDistribution( 72 | f"Too many top-level members in sdist archive: {self.filename}" 73 | ) 74 | # ...containing the package metadata in a ``PKG-INFO`` file. 75 | with suppress(KeyError): 76 | data = sdist.read(root.rstrip("/") + "/PKG-INFO") 77 | if b"Metadata-Version" in data: 78 | return data 79 | 80 | raise exceptions.InvalidDistribution( 81 | "No PKG-INFO in archive or " 82 | f"PKG-INFO missing 'Metadata-Version': {self.filename}" 83 | ) 84 | -------------------------------------------------------------------------------- /twine/wheel.py: -------------------------------------------------------------------------------- 1 | # Copyright 2013 Donald Stufft 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import os 15 | import re 16 | import zipfile 17 | from typing import List 18 | 19 | from twine import distribution 20 | from twine import exceptions 21 | 22 | wheel_file_re = re.compile( 23 | r"""^(?P(?P.+?)(-(?P\d.+?))?) 24 | ((-(?P\d.*?))?-(?P.+?)-(?P.+?)-(?P.+?) 25 | \.whl|\.dist-info)$""", 26 | re.VERBOSE, 27 | ) 28 | 29 | 30 | class Wheel(distribution.Distribution): 31 | def __init__(self, filename: str) -> None: 32 | if not os.path.exists(filename): 33 | raise exceptions.InvalidDistribution(f"No such file: {filename}") 34 | self.filename = filename 35 | 36 | @property 37 | def py_version(self) -> str: 38 | wheel_info = wheel_file_re.match(os.path.basename(self.filename)) 39 | if wheel_info is None: 40 | return "any" 41 | else: 42 | return wheel_info.group("pyver") 43 | 44 | @staticmethod 45 | def find_candidate_metadata_files(names: List[str]) -> List[List[str]]: 46 | """Filter files that may be METADATA files.""" 47 | tuples = [x.split("/") for x in names if "METADATA" in x] 48 | return [x[1] for x in sorted((len(x), x) for x in tuples)] 49 | 50 | def read(self) -> bytes: 51 | fqn = os.path.abspath(os.path.normpath(self.filename)) 52 | if not os.path.exists(fqn): 53 | raise exceptions.InvalidDistribution("No such file: %s" % fqn) 54 | 55 | if fqn.endswith(".whl"): 56 | archive = zipfile.ZipFile(fqn) 57 | names = archive.namelist() 58 | 59 | def read_file(name: str) -> bytes: 60 | return archive.read(name) 61 | 62 | else: 63 | raise exceptions.InvalidDistribution( 64 | "Not a known archive format for file: %s" % fqn 65 | ) 66 | 67 | searched_files: List[str] = [] 68 | try: 69 | for path in self.find_candidate_metadata_files(names): 70 | candidate = "/".join(path) 71 | data = read_file(candidate) 72 | if b"Metadata-Version" in data: 73 | return data 74 | searched_files.append(candidate) 75 | finally: 76 | archive.close() 77 | 78 | raise exceptions.InvalidDistribution( 79 | "No METADATA in archive or METADATA missing 'Metadata-Version': " 80 | "%s (searched %s)" % (fqn, ",".join(searched_files)) 81 | ) 82 | --------------------------------------------------------------------------------