diff --git a/.github/DISCUSSION_TEMPLATE/questions.yml b/.github/DISCUSSION_TEMPLATE/questions.yml deleted file mode 100644 index 92d4d7a143..0000000000 --- a/.github/DISCUSSION_TEMPLATE/questions.yml +++ /dev/null @@ -1,145 +0,0 @@ -labels: [question] -body: - - type: markdown - attributes: - value: | - Thanks for your interest in Typer! 🚀 - - Please follow these instructions, fill every question, and do every step. 🙏 - - I'm asking this because answering questions and solving problems in GitHub is what consumes most of the time. - - I end up not being able to add new features, fix bugs, review pull requests, etc. as fast as I wish because I have to spend too much time handling questions. - - All that, on top of all the incredible help provided by a bunch of community members that give a lot of their time to come here and help others. - - If more Typer users came to help others like them just a little bit more, it would be much less effort for them (and you and me 😅). - - By asking questions in a structured way (following this) it will be much easier to help you. - - And there's a high chance that you will find the solution along the way and you won't even have to submit it and wait for an answer. 😎 - - As there are too many questions, I'll have to discard and close the incomplete ones. That will allow me (and others) to focus on helping people like you that follow the whole process and help us help you. 🤓 - - type: checkboxes - id: checks - attributes: - label: First Check - description: Please confirm and check all the following options. - options: - - label: I added a very descriptive title here. - required: true - - label: I used the GitHub search to find a similar question and didn't find it. - required: true - - label: I searched the Typer documentation, with the integrated search. - required: true - - label: I already searched in Google "How to X in Typer" and didn't find any information. - required: true - - label: I already read and followed all the tutorials in the docs and didn't find an answer. - required: true - - label: I already checked if it is not related to Typer but to [Click](https://github.com/pallets/click). - required: true - - type: checkboxes - id: help - attributes: - label: Commit to Help - description: | - After submitting this, I commit to one of: - - * Read open issues with questions until I find 2 issues where I can help someone and add a comment to help there. - * I already hit the "watch" button in this repository to receive notifications and I commit to help at least 2 people that ask questions in the future. - * Review one Pull Request by downloading the code and following all the [review process](https://typer.tiangolo.com/help-typer/#review-pull-requests). - - options: - - label: I commit to help with one of those options 👆 - required: true - - type: textarea - id: example - attributes: - label: Example Code - description: | - Please add a self-contained, [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) with your use case. - - If I (or someone) can copy it, run it, and see it right away, there's a much higher chance I (or someone) will be able to help you. - - placeholder: | - import typer - - app = typer.Typer() - - @app.command() - def main(name: str): - typer.echo(f"Hello {name}") - - - if __name__ == "__main__": - app() - render: python - validations: - required: true - - type: textarea - id: description - attributes: - label: Description - description: | - What is the problem, question, or error? - - Write a short description telling me what you are doing, what you expect to happen, and what is currently happening. - placeholder: | - * Create a small Typer script. - * Open a Terminal with Ninja-Turtle-Shell. - * Trigger autocompletion hitting TAB. - * I don't see any completion in the terminal using Ninja-Turtle-Shell. - * I expected to see autocompletion there. - validations: - required: true - - type: dropdown - id: os - attributes: - label: Operating System - description: What operating system are you on? - multiple: true - options: - - Linux - - Windows - - macOS - - Other - validations: - required: true - - type: textarea - id: os-details - attributes: - label: Operating System Details - description: You can add more details about your operating system here, in particular if you chose "Other". - - type: input - id: typer-version - attributes: - label: Typer Version - description: | - What Typer version are you using? - - You can find the Typer version with: - - ```bash - python -c "import typer; print(typer.__version__)" - ``` - validations: - required: true - - type: input - id: python-version - attributes: - label: Python Version - description: | - What Python version are you using? - - You can find the Python version with: - - ```bash - python --version - ``` - validations: - required: true - - type: textarea - id: context - attributes: - label: Additional Context - description: Add any additional context information or screenshots you think are useful. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 0ffc101a3f..0000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: [tiangolo] diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 8e589cd6ab..0000000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,13 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: Security Contact - about: Please report security vulnerabilities to security@tiangolo.com - - name: Question or Problem - about: Ask a question or ask about a problem in GitHub Discussions. - url: https://github.com/fastapi/typer/discussions/categories/questions - - name: Feature Request - about: To suggest an idea or ask about a feature, please start with a question saying what you would like to achieve. There might be a way to do it already. - url: https://github.com/fastapi/typer/discussions/categories/questions - - name: Show and tell - about: Show what you built with Typer or to be used with Typer. - url: https://github.com/fastapi/typer/discussions/categories/show-and-tell diff --git a/.github/ISSUE_TEMPLATE/privileged.yml b/.github/ISSUE_TEMPLATE/privileged.yml deleted file mode 100644 index 8037408426..0000000000 --- a/.github/ISSUE_TEMPLATE/privileged.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Privileged -description: You are @tiangolo or he asked you directly to create an issue here. If not, check the other options. 👇 -body: - - type: markdown - attributes: - value: | - Thanks for your interest in Typer! 🚀 - - If you are not @tiangolo or he didn't ask you directly to create an issue here, please start the conversation in a [Question in GitHub Discussions](https://github.com/fastapi/typer/discussions/categories/questions) instead. - - type: checkboxes - id: privileged - attributes: - label: Privileged issue - description: Confirm that you are allowed to create an issue here. - options: - - label: I'm @tiangolo or he asked me directly to create an issue here. - required: true - - type: textarea - id: content - attributes: - label: Issue Content - description: Add the content of the issue here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 959ec970fb..95c9f07cb9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,13 +4,47 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" + interval: "weekly" + cooldown: + default-days: 7 commit-message: prefix: ⬆ + labels: + - "internal" + - "dependencies" + - "github_actions" + groups: + github-actions: + patterns: + - "*" # Python - package-ecosystem: "uv" directory: "/" schedule: - interval: "daily" + interval: "weekly" + cooldown: + default-days: 7 commit-message: prefix: ⬆ + groups: + python-packages: + dependency-type: "development" + patterns: + - "*" + # pre-commit + - package-ecosystem: "pre-commit" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + commit-message: + prefix: ⬆ + labels: + - "internal" + - "dependencies" + - "pre-commit" + groups: + pre-commit: + patterns: + - "*" diff --git a/.github/workflows/add-to-project.yml b/.github/workflows/add-to-project.yml index 0308d7a07f..35d089860c 100644 --- a/.github/workflows/add-to-project.yml +++ b/.github/workflows/add-to-project.yml @@ -1,18 +1,21 @@ name: Add to Project on: - pull_request_target: + pull_request_target: # zizmor: ignore[dangerous-triggers] issues: types: - opened - reopened +permissions: {} + jobs: add-to-project: name: Add to project runs-on: ubuntu-latest + timeout-minutes: 5 steps: - - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 + - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2.0.0 with: project-url: https://github.com/orgs/fastapi/projects/2 - github-token: ${{ secrets.PROJECTS_TOKEN }} + github-token: ${{ secrets.PROJECTS_TOKEN }} # zizmor: ignore[secrets-outside-env] diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index dfaf511962..ee1d41ecd8 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -8,18 +8,23 @@ on: - opened - synchronize +permissions: {} + jobs: changes: runs-on: ubuntu-latest # Required permissions permissions: pull-requests: read + timeout-minutes: 5 # Set job outputs to values from filter step outputs: docs: ${{ steps.filter.outputs.docs }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # For pull requests it's not necessary to checkout the code but for the main branch it is + with: + persist-credentials: false - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: filter with: @@ -41,12 +46,15 @@ jobs: - changes if: ${{ needs.changes.outputs.docs == 'true' }} runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Dump GitHub context env: GITHUB_CONTEXT: ${{ toJson(github) }} run: echo "$GITHUB_CONTEXT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -54,6 +62,9 @@ jobs: - name: Setup uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: + # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. + # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 + version: "0.11.4" enable-cache: true cache-dependency-glob: | pyproject.toml @@ -78,6 +89,7 @@ jobs: needs: - build-docs runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 diff --git a/.github/workflows/conflict.yml b/.github/workflows/conflict.yml index 3ac6f65e2f..b824f8ae3a 100644 --- a/.github/workflows/conflict.yml +++ b/.github/workflows/conflict.yml @@ -1,15 +1,18 @@ name: "Conflict detector" on: push: - pull_request_target: + pull_request_target: # zizmor: ignore[dangerous-triggers] types: [synchronize] +permissions: {} + jobs: main: permissions: contents: read pull-requests: write runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Check if PRs have merge conflicts uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3 diff --git a/.github/workflows/create-draft-release.yml b/.github/workflows/create-draft-release.yml new file mode 100644 index 0000000000..7fef8566e4 --- /dev/null +++ b/.github/workflows/create-draft-release.yml @@ -0,0 +1,56 @@ +name: Create Draft Release + +on: + pull_request: + types: + - closed + +permissions: {} + +jobs: + create-draft-release: + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + env: + PREPARE_RELEASE_VERSION_FILE: typer/__init__.py + PREPARE_RELEASE_RELEASE_NOTES_FILE: docs/release-notes.md + steps: + - name: Dump GitHub context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: echo "$GITHUB_CONTEXT" + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: true + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version-file: ".python-version" + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. + # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 + version: "0.11.4" + - name: Extract release details + id: release-details + run: | + set -euo pipefail + version="$(uv run python scripts/prepare_release.py current-version)" + uv run python scripts/prepare_release.py release-notes > draft-release-notes.md + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Create draft release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.release-details.outputs.version }} + run: | + set -euo pipefail + gh release create "$VERSION" \ + --draft \ + --title "$VERSION" \ + --notes-file draft-release-notes.md \ + --target "$(git rev-parse HEAD)" diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 6382e15210..ea38a8dd86 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -1,26 +1,30 @@ name: Deploy Docs on: - workflow_run: + workflow_run: # zizmor: ignore[dangerous-triggers] workflows: - Build Docs types: - completed -permissions: - deployments: write - issues: write - pull-requests: write - statuses: write +permissions: {} jobs: deploy-docs: runs-on: ubuntu-latest + permissions: + deployments: write + issues: write + pull-requests: write + statuses: write + timeout-minutes: 5 steps: - name: Dump GitHub context env: GITHUB_CONTEXT: ${{ toJson(github) }} run: echo "$GITHUB_CONTEXT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -28,10 +32,10 @@ jobs: - name: Setup uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - enable-cache: true - cache-dependency-glob: | - pyproject.toml - uv.lock + # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. + # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 + version: "0.11.4" + enable-cache: false - name: Install GitHub Actions dependencies run: uv sync --locked --no-dev --group github-actions - name: Deploy Docs Status Pending @@ -61,8 +65,8 @@ jobs: BRANCH: ${{ ( github.event.workflow_run.head_repository.full_name == github.repository && github.event.workflow_run.head_branch == 'master' && 'main' ) || ( github.event.workflow_run.head_sha ) }} uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0 with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} # zizmor: ignore[secrets-outside-env] + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # zizmor: ignore[secrets-outside-env] command: pages deploy ./site --project-name=${{ env.PROJECT_NAME }} --branch=${{ env.BRANCH }} - name: Deploy Docs Status Error if: failure() diff --git a/.github/workflows/guard-dependencies.yml b/.github/workflows/guard-dependencies.yml new file mode 100644 index 0000000000..c3f97c3752 --- /dev/null +++ b/.github/workflows/guard-dependencies.yml @@ -0,0 +1,52 @@ +name: Guard Dependencies + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] -- This workflow only reads context.payload metadata, never checks out PR code + branches: [master] + paths: + - pyproject.toml + - uv.lock + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + check-author: + runs-on: ubuntu-latest + steps: + - name: Check if author is org member or allowed bot + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const pr = context.payload.pull_request; + const author = pr.user.login; + const assoc = pr.author_association; + + const botAllowlist = new Set(['dependabot[bot]']); + const orgAuthorAssociations = new Set(['MEMBER', 'OWNER']); + + const allowed = + botAllowlist.has(author) || + (assoc != null && orgAuthorAssociations.has(assoc)); + + if (!allowed) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: `This PR modifies dependency files (\`pyproject.toml\` or \`uv.lock\`), which is restricted to members of the **${context.repo.owner}** organization on GitHub.\n\nIf you need a dependency change, please [open a discussion](https://github.com/${context.repo.owner}/${context.repo.repo}/discussions/new) describing what you need and why.\n\nClosing this PR automatically.` + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + state: 'closed' + }); + + core.setFailed('Dependency changes are restricted to organization members.'); + } else { + console.log(`Author ${author} (author_association=${assoc}) is allowed to make dependency changes.`); + } diff --git a/.github/workflows/issue-manager.yml b/.github/workflows/issue-manager.yml index 0a53693b7d..31ee1df60b 100644 --- a/.github/workflows/issue-manager.yml +++ b/.github/workflows/issue-manager.yml @@ -9,19 +9,21 @@ on: issues: types: - labeled - pull_request_target: + pull_request_target: # zizmor: ignore[dangerous-triggers] types: - labeled workflow_dispatch: -permissions: - issues: write - pull-requests: write +permissions: {} jobs: issue-manager: if: github.repository_owner == 'fastapi' runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + timeout-minutes: 5 steps: - name: Dump GitHub context env: diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 6ba567399b..803160ef58 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,6 +1,6 @@ name: Labels on: - pull_request_target: + pull_request_target: # zizmor: ignore[dangerous-triggers] types: - opened - synchronize @@ -9,14 +9,17 @@ on: - labeled - unlabeled +permissions: {} + jobs: labeler: permissions: contents: read pull-requests: write runs-on: ubuntu-latest + timeout-minutes: 5 steps: - - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 if: ${{ github.event.action != 'labeled' && github.event.action != 'unlabeled' }} - run: echo "Done adding labels" # Run this after labeler applied labels @@ -26,8 +29,9 @@ jobs: permissions: pull-requests: read runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65 with: - one_of: breaking,security,feature,bug,refactor,upgrade,docs,lang-all,internal + one_of: breaking,security,feature,bug,refactor,upgrade,docs,lang-all,internal,release repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/latest-changes.yml b/.github/workflows/latest-changes.yml index 4e97b48746..551552394e 100644 --- a/.github/workflows/latest-changes.yml +++ b/.github/workflows/latest-changes.yml @@ -1,7 +1,7 @@ name: Latest Changes on: - pull_request_target: + pull_request_target: # zizmor: ignore[dangerous-triggers] branches: - master types: @@ -16,9 +16,13 @@ on: required: false default: 'false' +permissions: {} + jobs: latest-changes: runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true steps: - name: Dump GitHub context env: @@ -27,14 +31,15 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # To allow latest-changes to commit to the main branch - token: ${{ secrets.TYPER_LATEST_CHANGES }} + token: ${{ secrets.TYPER_LATEST_CHANGES }} # zizmor: ignore[secrets-outside-env] + persist-credentials: true # required by tiangolo/latest-changes # Allow debugging with tmate - name: Setup tmate session uses: mxschmitt/action-tmate@c0afd6f790e3a5564914980036ebf83216678101 # v3.23 if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }} with: limit-access-to-actor: true - - uses: tiangolo/latest-changes@c9d329cb147f0ddf4fb631214e3f838ff17ccbbd # 0.4.1 + - uses: tiangolo/latest-changes@eb3f6e7ff0073896ecb561e774a121de9418fa06 # 0.5.0 with: token: ${{ secrets.GITHUB_TOKEN }} latest_changes_file: docs/release-notes.md diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index e5e9c5740e..1699652462 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -6,6 +6,8 @@ on: - opened - synchronize +permissions: {} + env: # Forks and Dependabot don't have access to secrets HAS_SECRETS: ${{ secrets.PRE_COMMIT != '' }} @@ -13,6 +15,7 @@ env: jobs: pre-commit: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Dump GitHub context env: @@ -28,7 +31,8 @@ jobs: # And it needs the full history to be able to compute diffs fetch-depth: 0 # A token other than the default GITHUB_TOKEN is needed to be able to trigger CI - token: ${{ secrets.PRE_COMMIT }} + token: ${{ secrets.PRE_COMMIT }} # zizmor: ignore[secrets-outside-env] + persist-credentials: true # Required for `git push` command # pre-commit lite ci needs the default checkout configs to work - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 name: Checkout PR for fork @@ -37,6 +41,7 @@ jobs: # To be able to commit it needs the head branch of the PR, the remote one ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 + persist-credentials: false - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -44,6 +49,9 @@ jobs: - name: Setup uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: + # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. + # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 + version: "0.11.4" cache-dependency-glob: | pyproject.toml uv.lock @@ -51,7 +59,7 @@ jobs: run: uv sync --locked - name: Run prek - pre-commit id: precommit - run: uvx prek run --from-ref origin/${GITHUB_BASE_REF} --to-ref HEAD --show-diff-on-failure + run: uv run prek run --from-ref origin/${GITHUB_BASE_REF} --to-ref HEAD --show-diff-on-failure continue-on-error: true - name: Commit and push changes if: env.HAS_SECRETS == 'true' @@ -79,6 +87,7 @@ jobs: needs: - pre-commit runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Dump GitHub context env: diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000000..4dc23b7cff --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,80 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + bump: + description: Release bump + required: true + type: choice + options: + - patch + - minor + - major + date: + description: Release date in YYYY-MM-DD format. Defaults to today. + required: false + type: string + +permissions: {} + +jobs: + prepare-release: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + issues: write + pull-requests: write + env: + PREPARE_RELEASE_VERSION_FILE: typer/__init__.py + PREPARE_RELEASE_RELEASE_NOTES_FILE: docs/release-notes.md + steps: + - name: Dump GitHub context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: echo "$GITHUB_CONTEXT" + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ secrets.TYPER_LATEST_CHANGES }} # zizmor: ignore[secrets-outside-env] + persist-credentials: true + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version-file: ".python-version" + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. + # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 + version: "0.11.4" + - name: Prepare release + env: + PREPARE_RELEASE_BUMP: ${{ inputs.bump }} + PREPARE_RELEASE_DATE: ${{ inputs.date }} + run: uv run python scripts/prepare_release.py prepare + - name: Get release version + id: release-version + run: | + version="$(uv run python scripts/prepare_release.py current-version)" + echo "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Create release pull request + env: + GH_TOKEN: ${{ secrets.TYPER_LATEST_CHANGES }} + VERSION: ${{ steps.release-version.outputs.version }} + run: | + set -euo pipefail + branch="release-${VERSION}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git add $PREPARE_RELEASE_VERSION_FILE $PREPARE_RELEASE_RELEASE_NOTES_FILE + git commit -m "🔖 Release version ${VERSION}" + git push --set-upstream origin "$branch" + gh pr create \ + --base master \ + --head "$branch" \ + --title "🔖 Release version ${VERSION}" \ + --body "Prepare release ${VERSION}." \ + --label release diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 87dc782d99..cb773d7d15 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,7 +3,9 @@ name: Publish on: release: types: - - created + - published + +permissions: {} jobs: publish: @@ -11,18 +13,26 @@ jobs: permissions: id-token: write contents: read + timeout-minutes: 5 steps: - name: Dump GitHub context env: GITHUB_CONTEXT: ${{ toJson(github) }} run: echo "$GITHUB_CONTEXT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version-file: ".python-version" - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. + # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 + version: "0.11.4" + enable-cache: false - name: Build distribution run: uv build - name: Publish diff --git a/.github/workflows/smokeshow.yml b/.github/workflows/smokeshow.yml index 9518f6f68c..e33c290469 100644 --- a/.github/workflows/smokeshow.yml +++ b/.github/workflows/smokeshow.yml @@ -1,30 +1,37 @@ name: Smokeshow on: - workflow_run: + workflow_run: # zizmor: ignore[dangerous-triggers] workflows: - Test types: - completed -permissions: - statuses: write +permissions: {} jobs: smokeshow: runs-on: ubuntu-latest + permissions: + statuses: write + timeout-minutes: 5 steps: - name: Dump GitHub context env: GITHUB_CONTEXT: ${{ toJson(github) }} run: echo "$GITHUB_CONTEXT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version-file: ".python-version" - name: Setup uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: + # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. + # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 + version: "0.11.4" cache-dependency-glob: | pyproject.toml uv.lock @@ -42,4 +49,4 @@ jobs: SMOKESHOW_GITHUB_CONTEXT: coverage SMOKESHOW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - SMOKESHOW_AUTH_KEY: ${{ secrets.SMOKESHOW_AUTH_KEY }} + SMOKESHOW_AUTH_KEY: ${{ secrets.SMOKESHOW_AUTH_KEY }} # zizmor: ignore[secrets-outside-env] diff --git a/.github/workflows/test-cpython-nightly.yml b/.github/workflows/test-cpython-nightly.yml index 6d9e93eea6..8300731dea 100644 --- a/.github/workflows/test-cpython-nightly.yml +++ b/.github/workflows/test-cpython-nightly.yml @@ -5,6 +5,8 @@ on: - cron: "0 0 * * *" workflow_dispatch: +permissions: {} + env: UV_NO_SYNC: true @@ -12,11 +14,15 @@ jobs: test-latest-python: runs-on: ubuntu-latest continue-on-error: true + timeout-minutes: 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Checkout CPython main uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + persist-credentials: false repository: python/cpython ref: main path: ./.cpython @@ -29,6 +35,9 @@ jobs: - name: Setup uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: + # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. + # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 + version: "0.11.4" enable-cache: true cache-dependency-glob: | pyproject.toml diff --git a/.github/workflows/test-redistribute.yml b/.github/workflows/test-redistribute.yml index 89ef4bcfa0..a0c8b0c33e 100644 --- a/.github/workflows/test-redistribute.yml +++ b/.github/workflows/test-redistribute.yml @@ -9,15 +9,20 @@ on: - opened - synchronize +permissions: {} + jobs: test-redistribute: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Dump GitHub context env: GITHUB_CONTEXT: ${{ toJson(github) }} run: echo "$GITHUB_CONTEXT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef7b0aaf26..70ee9b6332 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,8 @@ on: # cron every week on monday - cron: "0 0 * * 1" +permissions: {} + env: UV_NO_SYNC: true @@ -38,6 +40,7 @@ jobs: uv-resolution: highest fail-fast: false runs-on: ${{ matrix.os }} + timeout-minutes: 15 env: UV_PYTHON: ${{ matrix.python-version }} UV_RESOLUTION: ${{ matrix.uv-resolution }} @@ -47,6 +50,8 @@ jobs: GITHUB_CONTEXT: ${{ toJson(github) }} run: echo "$GITHUB_CONTEXT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -54,6 +59,9 @@ jobs: - name: Setup uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: + # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. + # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 + version: "0.11.4" enable-cache: true cache-dependency-glob: | pyproject.toml @@ -77,18 +85,24 @@ jobs: coverage-combine: needs: [test] runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Dump GitHub context env: GITHUB_CONTEXT: ${{ toJson(github) }} run: echo "$GITHUB_CONTEXT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version-file: ".python-version" - name: Setup uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: + # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. + # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 + version: "0.11.4" enable-cache: true cache-dependency-glob: | pyproject.toml @@ -118,6 +132,7 @@ jobs: needs: - coverage-combine runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Dump GitHub context env: diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000000..b9b112928d --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,24 @@ +name: Zizmor + +on: + push: + branches: + - master + workflow_dispatch: + +permissions: {} + +jobs: + zizmor: + name: Run zizmor + runs-on: ubuntu-latest + permissions: + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Run zizmor + uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4c5b9d722d..7d7e7c6660 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,6 +12,12 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace +- repo: https://github.com/crate-ci/typos + rev: bbaefadf97b0ec5fdc942684b647f1a6ab250274 # v1.46.0 + hooks: + - id: typos + args: [--force-exclude] + - repo: local hooks: - id: local-ruff-check @@ -55,3 +61,11 @@ repos: entry: uv run ./scripts/docs.py generate-readme files: ^docs/index\.md|scripts/docs\.py$ pass_filenames: false + + - id: zizmor + name: zizmor + language: python + entry: uv run zizmor . + files: ^\.github\/workflows\/ + require_serial: true + pass_filenames: false diff --git a/CITATION.cff b/CITATION.cff index 5fe4aa2d34..b4902c6ab1 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -18,5 +18,6 @@ abstract: >- Typer, build great CLIs. Easy to code. Based on Python type hints. keywords: - typer - - click + - cli + - python license: MIT diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 466bbbac26..0000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1 +0,0 @@ -Please read the [Development - Contributing](https://typer.tiangolo.com/contributing/) guidelines in the documentation site. diff --git a/README.md b/README.md index 87b5f03118..43a46c5aa0 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,24 @@ Typer, build great CLIs. Easy to code. Based on Python type hints.

- + Test - + Publish - + Coverage - + Package version

--- -**Documentation**: https://typer.tiangolo.com +**Documentation**: [https://typer.tiangolo.com](https://typer.tiangolo.com) -**Source Code**: https://github.com/fastapi/typer +**Source Code**: [https://github.com/fastapi/typer](https://github.com/fastapi/typer) --- @@ -42,11 +42,11 @@ The key features are: ## FastAPI of CLIs -**Typer** is FastAPI's little sibling, it's the FastAPI of CLIs. +**Typer** is [FastAPI](https://fastapi.tiangolo.com)'s little sibling, it's the FastAPI of CLIs. ## Installation -Create and activate a virtual environment and then install **Typer**: +Create and activate a [virtual environment](https://typer.tiangolo.com/virtual-environments/) and then install **Typer**:
@@ -352,11 +352,20 @@ For a more complete example including more features, see the Click: a popular tool for building CLIs in Python. Typer is based on it. -* rich: to show nicely formatted errors automatically. -* shellingham: to automatically detect the current shell when installing completion. +* [`rich`](https://rich.readthedocs.io/en/stable/index.html): to show nicely formatted errors automatically. +* [`shellingham`](https://github.com/sarugaku/shellingham): to automatically detect the current shell when installing completion. +* [`annotated-doc`](https://github.com/fastapi/annotated-doc): to generate documentation from Python type annotations. +* [`colorama`](https://github.com/tartley/colorama) (only on Windows): for producing colored terminal text on Windows. + +### Click code + +Typer used to depend on [Click](https://click.palletsprojects.com/) as well, a popular tool for building CLIs in Python. + +Since version 0.26.0, Typer has vendored Click (included Click's source code internally, instead of installing it as a third party package) and has unified the code interactions between Typer and the embedded Click source code for easier maintainability in the future. + +Note that some Click functionality will not be available anymore in the future, as we continue to improve and extend Typer's codebase. ### `typer-slim` diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index fd3a6b14f0..0000000000 --- a/SECURITY.md +++ /dev/null @@ -1,29 +0,0 @@ -# Security Policy - -Security is very important for Typer and its community. 🔒 - -Learn more about it below. 👇 - -## Versions - -The latest versions of Typer are supported. - -You are encouraged to [write tests](https://typer.tiangolo.com/tutorial/testing/) for your application and update your Typer version frequently after ensuring that your tests are passing. This way you will benefit from the latest features, bug fixes, and **security fixes**. - -## Reporting a Vulnerability - -If you think you found a vulnerability, and even if you are not sure about it, please report it right away by sending an email to: security@tiangolo.com. Please try to be as explicit as possible, describing all the steps and example code to reproduce the security issue. - -I (the author, [@tiangolo](https://twitter.com/tiangolo)) will review it thoroughly and get back to you. - -## Public Discussions - -Please restrain from publicly discussing a potential security vulnerability. 🙊 - -It's better to discuss privately and try to find a solution first, to limit the potential impact as much as possible. - ---- - -Thanks for your help! - -The Typer community and I thank you for that. 🙇 diff --git a/data/members.yml b/data/members.yml deleted file mode 100644 index d5e314dccb..0000000000 --- a/data/members.yml +++ /dev/null @@ -1,4 +0,0 @@ -members: -- login: tiangolo -- login: svlandeg -- login: patrick91 diff --git a/docs/alternatives.md b/docs/alternatives.md index 1f1b7904de..03912e80ef 100644 --- a/docs/alternatives.md +++ b/docs/alternatives.md @@ -10,41 +10,41 @@ There have been many tools created before that have helped inspire its creation. ## Previous tools -### `argparse` +### [`argparse`](https://docs.python.org/3/library/argparse.html) `argparse` is the Python standard library's module to write CLIs. It provides a better alternative than reading the *CLI Parameters* as a `list` of `str` and parsing everything by hand. -/// check | Inspired **Typer** to +/// tip | Inspired **Typer** to Provide a better development experience than just reading *CLI Parameters* by hand. /// -### Hug +### [Hug](https://hugapi.github.io/hug/) Hug is a library to create APIs and CLIs, it uses parameters in functions to declare the required data. It inspired a lot of the ideas in **FastAPI** and **Typer**. -/// check | Inspired **Typer** to +/// tip | Inspired **Typer** to Use function parameters to declare *CLI arguments* and *CLI options* as it simplifies a lot the development experience. /// -### Plac +### [Plac](https://plac.readthedocs.io/en/latest/) Plac is another library to create CLIs using parameters in functions, similar to Hug. -/// check | Inspired **Typer** to +/// tip | Inspired **Typer** to Provide a simple way to use a function as a command line app, without having to create a complete app, with `typer.run(some_function)`. /// -### Pydantic +### [Pydantic](https://pydantic-docs.helpmanual.io/) Pydantic is a library to handle data validation using standard modern Python type annotations. @@ -52,13 +52,13 @@ It powers **FastAPI** underneath. It is not used by **Typer**, but it inspired a lot of the design (through **FastAPI**). -/// check | Inspired **Typer** to +/// tip | Inspired **Typer** to Use standard Python type annotations to declare types instead of library-specific types or classes and use them for data validation and documentation. /// -### Click +### [Click](https://click.palletsprojects.com) Click is one of the most widely used libraries to create CLIs in Python. @@ -70,17 +70,17 @@ It uses decorators on top of functions to modify the actual value of those funct It was built with some great ideas and design using the features available in the language at the time (Python 2.x). -/// check | **Typer** uses it for +/// tip | **Typer** builds on top of Click functionality -Everything. 🚀 +It has vendored Click version 8.3.1 and adds a layer on top of it. -**Typer** mainly adds a layer on top of Click, making the code simpler and easier to use, with autocompletion everywhere, etc, but providing all the powerful features of Click underneath. +Typer aims to make the code simpler and easier to use, with autocompletion everywhere, etc, while still providing many of the powerful features of Click underneath. -As someone pointed out: "Nice to see it is built on Click but adds the type stuff. Me gusta!" +As someone pointed out: ["Nice to see it is built on Click but adds the type stuff. Me gusta!"](https://twitter.com/fishnets88/status/1210126833745838080) /// -### `click-completion` +### [`click-completion`](https://github.com/click-contrib/click-completion) `click-completion` is a plug-in for Click. It was created to extend completion support for shells when Click only had support for Bash completion. @@ -88,15 +88,15 @@ Previous versions of **Typer** had deep integrations with `click-completion` and And now **Typer** improved it to have new features, tests, some bug fixes (for issues in plain `click-completion` and Click), and better support for shells, including modern versions of PowerShell (e.g. the default versions that come with Windows 10). -/// check | Inspired **Typer** to +/// tip | Inspired **Typer** to Provide auto completion for all the shells. /// -### FastAPI +### [FastAPI](https://fastapi.tiangolo.com/) -I created **FastAPI** to provide an easy way to build APIs with autocompletion for everything in the code (and some other features). +I created **FastAPI** to provide an easy way to build APIs with autocompletion for everything in the code (and some other [features](https://fastapi.tiangolo.com/features/)). **Typer** is the "FastAPI of CLIs". diff --git a/docs/contributing.md b/docs/contributing.md index f7cbb5badd..980f8b6c09 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,70 +1,10 @@ # Development - Contributing -First, you might want to see the basic ways to [help Typer and get help](help-typer.md){.internal-link target=_blank}. +First, you might want to see the basic ways to [help Typer and get help](help-typer.md). ## Developing -If you already cloned the typer repository and you want to deep dive in the code, here are some guidelines to set up your environment. - -### Install Requirements Using `uv` - -Create a virtual environment and install the required packages in one command: - -
- -```console -$ uv sync - ----> 100% -``` - -
- -It will install all the dependencies and your local Typer in your local environment. - -### Using your Local Typer - -If you create a Python file that imports and uses Typer, and run it with the Python from your local environment, it will use your cloned local Typer source code. - -And if you update that local Typer source code when you run that Python file again, it will use the fresh version of Typer you just edited. - -That way, you don't have to "install" your local version to be able to test every change. - -/// note | "Technical Details" - -This only happens when you install using this included `requirements.txt` instead of running `pip install typer` directly. - -That is because inside the `requirements.txt` file, the local version of Typer is marked to be installed in "editable" mode, with the `-e` option. - -/// - -### Format - -There is a script that you can run that will format and clean all your code: - -
- -```console -$ bash scripts/format.sh -``` - -
- -It will also auto-sort all your imports. - -## Tests - -There is a script that you can run locally to test all the code and generate coverage reports in HTML: - -
- -```console -$ bash scripts/test-cov-html.sh -``` - -
- -This command generates a directory `./htmlcov/`, if you open the file `./htmlcov/index.html` in your browser, you can explore interactively the regions of code that are covered by the tests, and notice if there is any region missing. +To contribute code to the project, please follow the guidelines in [tiangolo.com - Contributing](https://tiangolo.com/open-source/contributing/). ## Completion @@ -119,7 +59,7 @@ Then install `typer` completion: $ typer --install-completion ``` -/// info +/// note In `pwsh` you will probably get a warning of: @@ -206,132 +146,3 @@ zstyle ':completion:*' menu select If you exit from the container, you can start a new one, you will probably have to install the packages again and install completion again. Using this process, you can test all the shells, with their completions, being able to start from scratch quickly in a fresh container, and verifying that everything works as expected. - -## Docs - -First, make sure you set up your environment as described above, that will install all the requirements. - -### Docs live - -During local development, there is a script that builds the site and checks for any changes, live-reloading: - -
- -```console -$ python ./scripts/docs.py live - -[INFO] Serving on http://127.0.0.1:8008 -[INFO] Start watching changes -[INFO] Start detecting changes -``` - -
- -It will serve the documentation on `http://127.0.0.1:8008`. - -That way, you can edit the documentation/source files and see the changes live. - -/// tip - -Alternatively, you can perform the same steps that script does manually. - -Go into the docs directory at `docs/`: - -```console -$ cd docs/ -``` - -Then run `mkdocs` in that directory: - -```console -$ mkdocs serve --dev-addr 8008 -``` - -/// - -#### Typer CLI (optional) - -The instructions here show you how to use the script at `./scripts/docs.py` with the `python` program directly. - -But you can also use Typer CLI, and you will get autocompletion in your terminal for the commands after installing completion. - -If you install Typer CLI, you can install completion with: - -
- -```console -$ typer --install-completion - -zsh completion installed in /home/user/.bashrc. -Completion will take effect once you restart the terminal. -``` - -
- -### Docs Structure - -The documentation uses MkDocs. - -And there are extra tools/scripts in place in `./scripts/docs.py`. - -/// tip - -You don't need to see the code in `./scripts/docs.py`, you just use it in the command line. - -/// - -All the documentation is in Markdown format in the directory `./docs`. - -Many of the tutorials have blocks of code. - -In most of the cases, these blocks of code are actual complete applications that can be run as is. - -In fact, those blocks of code are not written inside the Markdown, they are Python files in the `./docs_src/` directory. - -And those Python files are included/injected in the documentation when generating the site. - -### Docs for Tests - -Most of the tests actually run against the example source files in the documentation. - -This helps to make sure that: - -* The documentation is up-to-date. -* The documentation examples can be run as is. -* Most of the features are covered by the documentation, ensured by test coverage. - -## Automated Code and AI - -You are encouraged to use all the tools you want to do your work and contribute as efficiently as possible, this includes AI (LLM) tools, etc. Nevertheless, contributions should have meaningful human intervention, judgement, context, etc. - -If the **human effort** put in a PR, e.g. writing LLM prompts, is **less** than the **effort we would need to put** to **review it**, please **don't** submit the PR. - -Think of it this way: we can already write LLM prompts or run automated tools ourselves, and that would be faster than reviewing external PRs. - -### Closing Automated and AI PRs - -If we see PRs that seem AI generated or automated in similar ways, we'll flag them and close them. - -The same applies to comments and descriptions, please don't copy paste the content generated by an LLM. - -### Human Effort Denial of Service - -Using automated tools and AI to submit PRs or comments that we have to carefully review and handle would be the equivalent of a Denial-of-service attack on our human effort. - -It would be very little effort from the person submitting the PR (an LLM prompt) that generates a large amount of effort on our side (carefully reviewing code). - -Please don't do that. - -We'll need to block accounts that spam us with repeated automated PRs or comments. - -### Use Tools Wisely - -As Uncle Ben said: - -
-With great power tools comes great responsibility. -
- -Avoid inadvertently doing harm. - -You have amazing tools at hand, use them wisely to help effectively. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index d1dab46313..f351b4aab7 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -67,7 +67,7 @@ print(f"Hello {name} from Python") /// tip -The second argument to `os.getenv()` is the default value to return. +The second argument to [`os.getenv()`](https://docs.python.org/3.8/library/os.html#os.getenv) is the default value to return. If not provided, it's `None` by default, here we provide `"World"` as the default value to use. @@ -155,7 +155,7 @@ Hello World from Python /// tip -You can read more about it at The Twelve-Factor App: Config. +You can read more about it at [The Twelve-Factor App: Config](https://12factor.net/config). /// @@ -165,7 +165,7 @@ These environment variables can only handle **text strings**, as they are extern That means that **any value** read in Python from an environment variable **will be a `str`**, and any conversion to a different type or any validation has to be done in code. -You will learn more about using environment variables for your CLI applications later in the section about [CLI Arguments with Environment Variables](./tutorial/arguments/envvar.md){.internal-link target=_blank}. +You will learn more about using environment variables for your CLI applications later in the section about [CLI Arguments with Environment Variables](./tutorial/arguments/envvar.md). ## `PATH` Environment Variable @@ -283,7 +283,7 @@ $ C:\opt\custompython\bin\python //// -This information will be useful when learning about [Virtual Environments](virtual-environments.md){.internal-link target=_blank}. +This information will be useful when learning about [Virtual Environments](virtual-environments.md). It will also be useful when you **create your own CLI programs** as, for them to be available for your users, they will need to be somewhere in the `PATH` environment variable. @@ -291,7 +291,7 @@ It will also be useful when you **create your own CLI programs** as, for them to With this you should have a basic understanding of what **environment variables** are and how to use them in Python. -You can also read more about them in the Wikipedia for Environment Variable. +You can also read more about them in the [Wikipedia for Environment Variable](https://en.wikipedia.org/wiki/Environment_variable). In many cases it's not very obvious how environment variables would be useful and applicable right away. But they keep showing up in many different scenarios when you are developing, so it's good to know about them. diff --git a/docs/features.md b/docs/features.md index e8e7f46195..25490ec610 100644 --- a/docs/features.md +++ b/docs/features.md @@ -2,9 +2,9 @@ ## Design based on **FastAPI** - + -**Typer** is FastAPI's little sibling. +**Typer** is [FastAPI](https://fastapi.tiangolo.com)'s little sibling. It follows the same design and ideas. If you know **FastAPI**, you already know **Typer**... more or less. @@ -12,9 +12,9 @@ It follows the same design and ideas. If you know **FastAPI**, you already know It's all based on standard **Python type** declarations. No new syntax to learn. Just standard modern Python. -If you need a 2 minute refresher of how to use Python types (even if you don't use FastAPI or Typer), check the FastAPI tutorial section: Python types intro. +If you need a 2 minute refresher of how to use Python types (even if you don't use FastAPI or Typer), check the FastAPI tutorial section: [Python types intro](https://fastapi.tiangolo.com/python-types/). -You will also see a 20 seconds refresher on the section [Tutorial - User Guide: First Steps](tutorial/first-steps.md){.internal-link target=_blank}. +You will also see a 20 seconds refresher on the section [Tutorial - User Guide: First Steps](tutorial/first-steps.md). ## Editor support @@ -24,11 +24,11 @@ You will rarely need to come back to the docs. Here's how your editor might help you: -* in Visual Studio Code: +* in [Visual Studio Code](https://code.visualstudio.com/): ![editor support](img/vscode-completion.png) -* in PyCharm: +* in [PyCharm](https://www.jetbrains.com/pycharm/): ![editor support](img/pycharm-completion.png) @@ -63,14 +63,6 @@ Auto completion works when you create a package (installable with `pip`). Or whe /// -/// tip - -**Typer**'s completion is implemented internally, it uses ideas and components from Click and ideas from `click-completion`, but it doesn't use `click-completion` and re-implements some of the relevant parts of Click. - -Then it extends those ideas with features and bug fixes. For example, **Typer** programs also support modern versions of PowerShell (e.g. in Windows 10) among all the other shells. - -/// - ## Tested * 100% test coverage. diff --git a/docs/help-typer.md b/docs/help-typer.md index 0f2aa6e58d..beace325e4 100644 --- a/docs/help-typer.md +++ b/docs/help-typer.md @@ -1,18 +1,12 @@ -# Help Typer - Get Help +# Help -Are you liking **Typer**? +Would you like to help Typer or get help about Typer? -Would you like to help Typer, other users, and the author? - -Or would you like to get help with **Typer**? - -There are very simple ways to help (several involve just one or two clicks). - -And there are several ways to get help too. +There are very simple ways to help and get help. ## Subscribe to the newsletter -You can subscribe to the (infrequent) [**FastAPI and friends** newsletter](https://fastapi.tiangolo.com/newsletter/){.internal-link target=_blank} to stay updated about: +You can subscribe to the (infrequent) [**FastAPI and friends** newsletter](https://fastapi.tiangolo.com/newsletter/) to stay updated about: * News about FastAPI and friends, including Typer 🚀 * Guides 📝 @@ -20,230 +14,72 @@ You can subscribe to the (infrequent) [**FastAPI and friends** newsletter](https * Breaking changes 🚨 * Tips and tricks ✅ +## Follow FastAPI online + +You can follow **FastAPI** online in several places: + +* [@fastapi on **X / Twitter**](https://x.com/fastapi) +* [@fastapi.tiangolo.com on **Bluesky**](https://bsky.app/profile/fastapi.tiangolo.com) +* [FastAPI on **LinkedIn**](https://www.linkedin.com/company/fastapi/) + ## Star **Typer** in GitHub -You can "star" Typer in GitHub (clicking the star button at the top right): https://github.com/fastapi/typer. +You can "star" Typer in GitHub (clicking the star button at the top right): [https://github.com/fastapi/typer](https://github.com/fastapi/typer). ⭐️ By adding a star, other users will be able to find it more easily and see that it has been already useful for others. ## Watch the GitHub repository for releases -You can "watch" Typer in GitHub (clicking the "watch" button at the top right): https://github.com/fastapi/typer. +You can "watch" Typer in GitHub (clicking the "watch" button at the top right): [https://github.com/fastapi/typer](https://github.com/fastapi/typer). 👀 There you can select "Releases only". By doing it, you will receive notifications (in your email) whenever there's a new release (a new version) of **Typer** with bug fixes and new features. -## Connect with the author +## Follow the author -You can connect with me (Sebastián Ramírez / `tiangolo`), the author. +You can follow [me (Sebastián Ramírez / `tiangolo`)](https://tiangolo.com), the author in a few places, to hear when I have news to share about FastAPI and friends: -You can: - -* Follow me on **GitHub**. - * See other Open Source projects I have created that could help you. - * Follow me to see when I create a new Open Source project. -* Follow me on **Twitter**. - * Tell me how you use Typer (I love to hear that). - * Hear when I make announcements or release new tools. -* Connect with me on **Linkedin**. - * Hear when I make announcements or release new tools (although I use Twitter more often 🤷‍♂). -* Read what I write (or follow me) on **Dev.to** or **Medium**. - * Read other ideas, articles, and read about tools I have created. - * Follow me to read when I publish something new. - -## Tweet about **Typer** - -Tweet about **Typer** and let me and others know why you like it. - -I love to hear about how **Typer** is being used, what have you liked in it, in which project/company you are using it, etc. +* [@tiangolo on **GitHub**](https://github.com/tiangolo). +* [@tiangolo on **X (Twitter)**](https://x.com/tiangolo) +* [@tiangolo.com on **Bluesky**](https://bsky.app/profile/tiangolo.com) +* [@tiangolo on **LinkedIn**](https://www.linkedin.com/in/tiangolo/). ## Help others with questions in GitHub -You can try and help others with their questions in: - -* GitHub Discussions -* GitHub Issues +You can try and help others with their questions in [GitHub Discussions](https://github.com/fastapi/typer/discussions/categories/questions?discussions_q=category%3AQuestions+is%3Aunanswered). In many cases you might already know the answer for those questions. 🤓 -Just remember, the most important point is: try to be kind. People come with their frustrations and in many cases don't ask in the best way, but try as best as you can to be kind. 🤗 - -The idea is for the **Typer** community to be kind and welcoming. At the same time, don't accept bullying or disrespectful behavior towards others. We have to take care of each other. - ---- - -Here's how to help others with questions (in discussions or issues): - -### Understand the question - -* Check if you can understand what is the **purpose** and use case of the person asking. - -* Then check if the question (the vast majority are questions) is **clear**. - -* In many cases the question asked is about an imaginary solution from the user, but there might be a **better** one. If you can understand the problem and use case better, you might be able to suggest a better **alternative solution**. - -* If you can't understand the question, ask for more **details**. - -### Reproduce the problem - -For most of the cases and most of the questions there's something related to the person's **original code**. +Just remember, the most important point is: try to be kind. 🤗 -In many cases they will only copy a fragment of the code, but that's not enough to **reproduce the problem**. +### How to Help -* You can ask them to provide a minimal, reproducible, example, that you can **copy-paste** and run locally to see the same error or behavior they are seeing, or to understand their use case better. - -* If you are feeling too generous, you can try to **create an example** like that yourself, just based on the description of the problem. Just have in mind that this might take a lot of time and it might be better to ask them to clarify the problem first. - -### Suggest solutions - -* After being able to understand the question, you can give them a possible **answer**. - -* In many cases, it's better to understand their **underlying problem or use case**, because there might be a better way to solve it than what they are trying to do. - -### Ask to close - -If they reply, there's a high chance you would have solved their problem, congrats, **you're a hero**! 🦸 - -* Now, if that solved their problem, you can ask them to: - - * In GitHub Discussions: mark the comment as the **answer**. - * In GitHub Issues: **close** the issue**. - -## Watch the GitHub repository - -You can "watch" Typer in GitHub (clicking the "watch" button at the top right): https://github.com/fastapi/typer. - -If you select "Watching" instead of "Releases only" you will receive notifications when someone creates a new issue or question. You can also specify that you only want to be notified about new issues, or discussions, or PRs, etc. - -Then you can try and help them solve those questions. +Follow the [guide on how to help](https://tiangolo.com/open-source/help/#help-others-with-questions-in-github) here. ## Ask Questions -You can create a new question in the GitHub repository, for example to: +You can [create a new question](https://github.com/fastapi/typer/discussions/new?category=questions) in the GitHub repository, for example to: * Ask a **question** or ask about a **problem**. * Suggest a new **feature**. -**Note**: if you do it, then I'm going to ask you to also help others. 😉 - -## Review Pull Requests - -You can help me review pull requests from others. - -Again, please try your best to be kind. 🤗 - ---- - -Here's what to have in mind and how to review a pull request: - -### Understand the problem - -* First, make sure you **understand the problem** that the pull request is trying to solve. It might have a longer discussion in a GitHub Discussion or issue. - -* There's also a good chance that the pull request is not actually needed because the problem can be solved in a **different way**. Then you can suggest or ask about that. +## Join the Chat -### Don't worry about style - -* Don't worry too much about things like commit message styles, I will squash and merge customizing the commit manually. - -* Also don't worry about style rules, there are already automated tools checking that. - -And if there's any other style or consistency need, I'll ask directly for that, or I'll add commits on top with the needed changes. - -### Check the code - -* Check and read the code, see if it makes sense, **run it locally** and see if it actually solves the problem. - -* Then **comment** saying that you did that, that's how I will know you really checked it. - -/// info - -Unfortunately, I can't simply trust PRs that just have several approvals. - -Several times it has happened that there are PRs with 3, 5 or more approvals, probably because the description is appealing, but when I check the PRs, they are actually broken, have a bug, or don't solve the problem they claim to solve. 😅 - -So, it's really important that you actually read and run the code, and let me know in the comments that you did. 🤓 - -/// - -* If the PR can be simplified in a way, you can ask for that, but there's no need to be too picky, there might be a lot of subjective points of view (and I will have my own as well 🙈), so it's better if you can focus on the fundamental things. - -### Tests - -* Help me check that the PR has **tests**. - -* Check that the tests **fail** before the PR. 🚨 - -* Then check that the tests **pass** after the PR. ✅ - -* Many PRs don't have tests, you can **remind** them to add tests, or you can even **suggest** some tests yourself. That's one of the things that consume most time and you can help a lot with that. - -* Then also comment what you tried, that way I'll know that you checked it. 🤓 - -## Create a Pull Request - -You can [contribute](contributing.md){.internal-link target=_blank} to the source code with Pull Requests, for example: - -* To fix a typo you found on the documentation. -* To propose new documentation sections. -* To fix an existing issue/bug. - * Make sure to add tests. -* To add a new feature. - * Make sure to add tests. - * Make sure to add documentation if it's relevant. - -## Help Maintain Typer - -Help me maintain **Typer**! 🤓 - -There's a lot of work to do, and for most of it, **YOU** can do it. - -The main tasks that you can do right now are: - -* [Help others with questions in GitHub](#help-others-with-questions-in-github){.internal-link target=_blank} (see the section above). -* [Review Pull Requests](#review-pull-requests){.internal-link target=_blank} (see the section above). - -Those two tasks are what **consume time the most**. That's the main work of maintaining Typer. - -If you can help me with that, **you are helping me maintain Typer** and making sure it keeps **advancing faster and better**. 🚀 - -## Join the chat - -Join the 👥 FastAPI and Friends Discord chat server 👥 and hang out with others in the community. There's a `#typer` channel. +Join the 👥 [FastAPI and Friends Discord chat server](https://discord.gg/VQjSZaeJmf) 👥 and hang out with others in the community. There's a `#typer` channel. /// tip -For questions, ask them in GitHub Discussions, there's a much better chance you will receive help there. +For questions, ask them in [GitHub Discussions](https://github.com/fastapi/typer/discussions/new?category=questions), there's a much better chance you will receive help there. Use the chat only for other general conversations. /// -### Don't use the chat for questions - -Have in mind that as chats allow more "free conversation", it's easy to ask questions that are too general and more difficult to answer, so, you might not receive answers. - -In GitHub, the template will guide you to write the right question so that you can more easily get a good answer, or even solve the problem yourself even before asking. And in GitHub I can make sure I always answer everything, even if it takes some time. I can't personally do that with the chat. 😅 - -Conversations in the chat are also not as easily searchable as in GitHub, so questions and answers might get lost in the conversation. - -On the other side, there are thousands of users in the chat, so there's a high chance you'll find someone to talk to there, almost all the time. 😄 - -## Sponsor the author - -You can also financially support the author (me) through GitHub sponsors. - -There you could buy me a coffee ☕️ to say thanks. 😄 - -## Sponsor the tools that power Typer - -As you have seen in the documentation, Typer is built on top of Click. - -You can also sponsor: +### Don't use the Chat for Questions -* Pallets Project (Click maintainers) via the PSF or via Tidelift +Keep in mind that as chats allow more "free conversation", it's easy to ask questions that are too general and more difficult to answer, so, you might not receive answers. ---- +In GitHub, the template will guide you to write the right question so that you can more easily get a good answer, or even solve the problem yourself even before asking. -Thanks! 🚀 +Conversations in the chat systems are also not as easily searchable as in GitHub, they get lost. diff --git a/docs/index.md b/docs/index.md index 0e71eab196..fec3eb402e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,5 @@ +# + @@ -12,24 +14,24 @@ Typer, build great CLIs. Easy to code. Based on Python type hints.

- + Test - + Publish - + Coverage - + Package version

--- -**Documentation**: https://typer.tiangolo.com +**Documentation**: [https://typer.tiangolo.com](https://typer.tiangolo.com) -**Source Code**: https://github.com/fastapi/typer +**Source Code**: [https://github.com/fastapi/typer](https://github.com/fastapi/typer) --- @@ -48,11 +50,11 @@ The key features are: ## FastAPI of CLIs -**Typer** is FastAPI's little sibling, it's the FastAPI of CLIs. +**Typer** is [FastAPI](https://fastapi.tiangolo.com)'s little sibling, it's the FastAPI of CLIs. ## Installation -Create and activate a virtual environment and then install **Typer**: +Create and activate a [virtual environment](https://typer.tiangolo.com/virtual-environments/) and then install **Typer**:
@@ -358,11 +360,20 @@ For a more complete example including more features, see the Click: a popular tool for building CLIs in Python. Typer is based on it. -* rich: to show nicely formatted errors automatically. -* shellingham: to automatically detect the current shell when installing completion. +Note that some Click functionality will not be available anymore in the future, as we continue to improve and extend Typer's codebase. ### `typer-slim` diff --git a/docs/management-tasks.md b/docs/management-tasks.md deleted file mode 100644 index cede9f3088..0000000000 --- a/docs/management-tasks.md +++ /dev/null @@ -1,117 +0,0 @@ -# Repository Management Tasks - -These are the tasks that can be performed to manage the Typer repository by [team members](./management.md#team){.internal-link target=_blank}. - -/// tip - -This section is useful only to a handful of people, team members with permissions to manage the repository. You can probably skip it. 😉 - -/// - -...so, you are a [team member of Typer](./management.md#team){.internal-link target=_blank}? Wow, you are so cool! 😎 - -You can help with everything on [Help Typer - Get Help](./help-typer.md){.internal-link target=_blank} the same ways as external contributors. But additionally, there are some tasks that only you (as part of the team) can perform. - -Here are the general instructions for the tasks you can perform. - -Thanks a lot for your help. 🙇 - -## Be Nice - -First of all, be nice. 😊 - -You probably are super nice if you were added to the team, but it's worth mentioning it. 🤓 - -### When Things are Difficult - -When things are great, everything is easier, so that doesn't need much instructions. But when things are difficult, here are some guidelines. - -Try to find the good side. In general, if people are not being unfriendly, try to thank their effort and interest, even if you disagree with the main subject (discussion, PR), just thank them for being interested in the project, or for having dedicated some time to try to do something. - -It's difficult to convey emotion in text, use emojis to help. 😅 - -In discussions and PRs, in many cases, people bring their frustration and show it without filter, in many cases exaggerating, complaining, being entitled, etc. That's really not nice, and when it happens, it lowers our priority to solve their problems. But still, try to breath, and be gentle with your answers. - -Try to avoid using bitter sarcasm or potentially passive-aggressive comments. If something is wrong, it's better to be direct (try to be gentle) than sarcastic. - -Try to be as specific and objective as possible, avoid generalizations. - -For conversations that are more difficult, for example to reject a PR, you can ask me (@tiangolo) to handle it directly. - -## Edit PR Titles - -* Edit the PR title to start with an emoji from gitmoji. - * Use the emoji character, not the GitHub code. So, use `🐛` instead of `:bug:`. This is so that it shows up correctly outside of GitHub, for example in the release notes. -* Start the title with a verb. For example `Add`, `Refactor`, `Fix`, etc. This way the title will say the action that the PR does. Like `Add support for teleporting`, instead of `Teleporting wasn't working, so this PR fixes it`. -* Edit the text of the PR title to start in "imperative", like giving an order. So, instead of `Adding support for teleporting` use `Add support for teleporting`. -* Try to make the title descriptive about what it achieves. If it's a feature, try to describe it, for example `Add support for teleporting` instead of `Create TeleportAdapter class`. -* Do not finish the title with a period (`.`). - -Once the PR is merged, a GitHub Action (latest-changes) will use the PR title to update the latest changes automatically. - -So, having a nice PR title will not only look nice in GitHub, but also in the release notes. 📝 - -## Add Labels to PRs - -The same GitHub Action latest-changes uses one label in the PR to decide the section in the release notes to put this PR in. - -Make sure you use a supported label from the latest-changes list of labels: - -* `breaking`: Breaking Changes - * Existing code will break if they update the version without changing their code. This rarely happens, so this label is not frequently used. -* `security`: Security Fixes - * This is for security fixes, like vulnerabilities. It would almost never be used. -* `feature`: Features - * New features, adding support for things that didn't exist before. -* `bug`: Fixes - * Something that was supported didn't work, and this fixes it. There are many PRs that claim to be bug fixes because the user is doing something in an unexpected way that is not supported, but they considered it what should be supported by default. Many of these are actually features or refactors. But in some cases there's an actual bug. -* `refactor`: Refactors - * This is normally for changes to the internal code that don't change the behavior. Normally it improves maintainability, or enables future features, etc. -* `upgrade`: Upgrades - * This is for upgrades to direct dependencies from the project, or extra optional dependencies, normally in `pyproject.toml`. So, things that would affect final users, they would end up receiving the upgrade in their code base once they update. But this is not for upgrades to internal dependencies used for development, testing, docs, etc. Those internal dependencies or GitHub Action versions should be marked as `internal`, not `upgrade`. -* `docs`: Docs - * Changes in docs. This includes updating the docs, fixing typos. But it doesn't include changes to translations. - * You can normally quickly detect it by going to the "Files changed" tab in the PR and checking if the updated file(s) starts with `docs/en/docs`. The original version of the docs is always in English, so in `docs/en/docs`. -* `internal`: Internal - * Use this for changes that only affect how the repo is managed. For example upgrades to internal dependencies, changes in GitHub Actions or scripts, etc. - -/// tip - -Some tools like Dependabot, will add some labels, like `dependencies`, but have in mind that this label is not used by the `latest-changes` GitHub Action, so it won't be used in the release notes. Please make sure one of the labels above is added. - -/// - -## Review PRs - -* If a PR doesn't explain what it does or why, if it seems like it could be useful, ask for more information. Otherwise, feel free to close it. - -* If a PR seems to be spam, meaningless, only to change statistics (to appear as "contributor") or similar, you can simply mark it as `invalid`, and it will be automatically closed. - -* If a PR seems to be AI generated, and seems like reviewing it would take more time from you than the time it took to write the prompt, mark it as `maybe-ai`, and it will be automatically closed. - -* A PR should have a specific use case that it is solving. - -* If the PR is for a feature, it should have docs. - * Unless it's a feature we want to discourage, like support for a corner case that we don't want users to use. -* The docs should include a source example file, not write Python directly in Markdown. -* If the source example(s) file can have different syntax for different Python versions, there should be different versions of the file, and they should be shown in tabs in the docs. -* There should be tests testing the source example. -* Before the PR is applied, the new tests should fail. -* After applying the PR, the new tests should pass. -* Coverage should stay at 100%. -* If you see the PR makes sense, or we discussed it and considered it should be accepted, you can add commits on top of the PR to tweak it, to add docs, tests, format, refactor, remove extra files, etc. -* Feel free to comment in the PR to ask for more information, to suggest changes, etc. -* Once you think the PR is ready, move it in the internal GitHub project for me to review it. - -## Dependabot PRs - -Dependabot will create PRs to update dependencies for several things, and those PRs all look similar, but some are way more delicate than others. - -* If the PR is for a direct dependency, so, Dependabot is modifying `pyproject.toml` in the main dependencies, **don't merge it**. 😱 Let me check it first. There's a good chance that some additional tweaks or updates are needed. -* If the PR updates one of the internal dependencies, for example the group `dev` in `pyproject.toml`, or GitHub Action versions, if the tests are passing, the release notes (shown in a summary in the PR) don't show any obvious potential breaking change, you can merge it. 😎 - -## Mark GitHub Discussions Answers - -When a question in GitHub Discussions has been answered, mark the answer by clicking "Mark as answer". - -You can filter discussions by `Questions` that are `Unanswered`. diff --git a/docs/management.md b/docs/management.md index 5c1f66e90c..35a698ada8 100644 --- a/docs/management.md +++ b/docs/management.md @@ -4,42 +4,16 @@ Here's a short description of how the Typer repository is managed and maintained ## Owner -I, @tiangolo, am the creator and owner of the Typer repository. 🤓 +I, [@tiangolo](https://github.com/tiangolo), am the creator and owner of the Typer repository. 🤓 -I normally give the final review to each PR before merging them. I make the final decisions on the project, I'm the BDFL. 😅 +I normally give the final review to each PR before merging them. I make the final decisions on the project, I'm the [BDFL](https://en.wikipedia.org/wiki/Benevolent_dictator_for_life). 😅 ## Team There's a team of people that help manage and maintain the project. 😎 -They have different levels of permissions and [specific instructions](./management-tasks.md){.internal-link target=_blank}. +Learn more about it in [tiangolo.com - GitHub FastAPI](https://tiangolo.com/github-fastapi/). -Some of the tasks they can perform include: +## External Help -* Adding labels to PRs. -* Editing PR titles. -* Adding commits on top of PRs to tweak them. -* Mark answers in GitHub Discussions questions, etc. -* Merge some specific types of PRs. - -Joining the team is by invitation only, and I could update or remove permissions, instructions, or membership. - -### Team Members - -This is the current list of team members. 😎 - -
-{% for user in members["members"] %} - - -{% endfor %} - -
- -Additional to them, there's a large community of people helping each other and getting involved in the projects in different ways. - -## External Contributions - -External contributions are very welcome and appreciated, including answering questions, submitting PRs, etc. 🙇‍♂️ - -There are many ways to [help maintain Typer](./help-typer.md){.internal-link target=_blank}. +External help is very much appreciated. There are many ways to [help](./help-typer.md). ☕️ diff --git a/docs/release-notes.md b/docs/release-notes.md index 0ffa78ad59..2642532cb5 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -2,6 +2,107 @@ ## Latest Changes +## 0.26.5 (2026-06-01) + +### Fixes + +* 🐛 Ensure that hidden commands are not shown when Rich markup is disabled. PR [#1812](https://github.com/fastapi/typer/pull/1812) by [@svlandeg](https://github.com/svlandeg). + +### Internal + +* 🔥 Remove old stub packages. PR [#1810](https://github.com/fastapi/typer/pull/1810) by [@tiangolo](https://github.com/tiangolo). + +## 0.26.4 (2026-05-30) + +### Features + +* 📝 Update AI Library Skill to avoid verbose code for CLI Options. PR [#1808](https://github.com/fastapi/typer/pull/1808) by [@tiangolo](https://github.com/tiangolo). + +### Internal + +* 👷 Add CI to create draft release after merging a `release` PR. PR [#1807](https://github.com/fastapi/typer/pull/1807) by [@tiangolo](https://github.com/tiangolo). +* 👷 Update labeler to accept label `release`. PR [#1806](https://github.com/fastapi/typer/pull/1806) by [@tiangolo](https://github.com/tiangolo). +* 👷 Update GitHub Action permissions for prepare-release. PR [#1804](https://github.com/fastapi/typer/pull/1804) by [@tiangolo](https://github.com/tiangolo). +* 👷 Add GitHub Actions prepare release workflow. PR [#1802](https://github.com/fastapi/typer/pull/1802) by [@tiangolo](https://github.com/tiangolo). +* 👷 Update publish action, do not use uv cache. PR [#1803](https://github.com/fastapi/typer/pull/1803) by [@tiangolo](https://github.com/tiangolo). +* ⬆ Bump the python-packages group across 1 directory with 5 updates. PR [#1793](https://github.com/fastapi/typer/pull/1793) by [@dependabot[bot]](https://github.com/apps/dependabot). + +## 0.26.3 (2026-05-28) + +### Refactors + +* ♻️ Unify the testing functionality. PR [#1792](https://github.com/fastapi/typer/pull/1792) by [@svlandeg](https://github.com/svlandeg). + +### Internal + +* 👷 Update version of latest-changes GitHub action. PR [#1798](https://github.com/fastapi/typer/pull/1798) by [@tiangolo](https://github.com/tiangolo). + +## 0.26.2 (2026-05-27) + +### Fixes + +* 🐛 Ensure that an envvar set for a `typer.Option` list is split on whitespace. PR [#1791](https://github.com/fastapi/typer/pull/1791) by [@svlandeg](https://github.com/svlandeg). + +## 0.26.1 (2026-05-26) + +### Fixes + +* 🐛 Ensure that an envvar set for `typer.Option` works as expected. PR [#1788](https://github.com/fastapi/typer/pull/1788) by [@svlandeg](https://github.com/svlandeg). + +### Internal + +* ⬆ Bump python-dotenv from 1.2.1 to 1.2.2. PR [#1714](https://github.com/fastapi/typer/pull/1714) by [@dependabot[bot]](https://github.com/apps/dependabot). + +## 0.26.0 (2026-05-26) + +### Breaking Changes + +* ➖ Vendor Click and streamline Typer's functionality and code base. PR [#1774](https://github.com/fastapi/typer/pull/1774) by [@svlandeg](https://github.com/svlandeg). + * Typer no longer depends on Click as a third party dependency, it vendors (includes the source code of) Click. + * This simplifies the work done by both Click and Typer teams. + * It allows Typer to evolve independently, and enables several new planned features. + * It will solve several dependency conflict situations for projects that use some packages that depend on Click and some that depend on Typer. + * This also means that Click-specific functionality is no longer supported, like extracting the Click app and adding Click-specific plug-ins, or customizing the field types with Click-specific types. + * You can read more about it in the docs for [Vendored Click](https://typer.tiangolo.com/tutorial/click/). + +### Docs + +* 📝 Update and simplify docs about help and management. PR [#1778](https://github.com/fastapi/typer/pull/1778) by [@tiangolo](https://github.com/tiangolo). +* 📝 Update contributing docs, reference central docs. PR [#1777](https://github.com/fastapi/typer/pull/1777) by [@tiangolo](https://github.com/tiangolo). +* 📝 Update security policy. PR [#1775](https://github.com/fastapi/typer/pull/1775) by [@tiangolo](https://github.com/tiangolo). +* 📝 Update link syntax to minimal Markdown. PR [#1623](https://github.com/fastapi/typer/pull/1623) by [@tiangolo](https://github.com/tiangolo). +* 📝 Update and simplify usage of admonitions. PR [#1755](https://github.com/fastapi/typer/pull/1755) by [@tiangolo](https://github.com/tiangolo). + +### Internal + +* 📌 Pin max version of Click to `>= 8.2.1, < 8.4` temporarily to prevent incompatibilities. PR [#1753](https://github.com/fastapi/typer/pull/1753) by [@tiangolo](https://github.com/tiangolo). + * Superseded by vendoring Click. +* 👷 Configure Dependabot to group updates and update weekly. PR [#1768](https://github.com/fastapi/typer/pull/1768) by [@YuriiMotov](https://github.com/YuriiMotov). +* 🔥 Remove config files now in central GitHub repo. PR [#1780](https://github.com/fastapi/typer/pull/1780) by [@tiangolo](https://github.com/tiangolo). +* ⬆ Bump idna from 3.11 to 3.15. PR [#1771](https://github.com/fastapi/typer/pull/1771) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ Bump pymdown-extensions from 10.21.2 to 10.21.3. PR [#1772](https://github.com/fastapi/typer/pull/1772) by [@dependabot[bot]](https://github.com/apps/dependabot). +* 📝 Fix categorization of PR 1475 in release notes. PR [#1769](https://github.com/fastapi/typer/pull/1769) by [@svlandeg](https://github.com/svlandeg). +* ✅ Add Fish shell completion tests for colon options. PR [#1475](https://github.com/fastapi/typer/pull/1475) by [@Mohamed-Elwasila](https://github.com/Mohamed-Elwasila). +* ✅ Extend completion unit tests for zsh, powershell and pwsh. PR [#1767](https://github.com/fastapi/typer/pull/1767) by [@ADiTyaRaj8969](https://github.com/ADiTyaRaj8969). +* ⬆ Bump actions/github-script from 7.1.0 to 9.0.0. PR [#1746](https://github.com/fastapi/typer/pull/1746) by [@dependabot[bot]](https://github.com/apps/dependabot). +* 🔧 Fix GitHub link in docs. PR [#1754](https://github.com/fastapi/typer/pull/1754) by [@tiangolo](https://github.com/tiangolo). +* ⬆️ Migrate to Zensical. PR [#1470](https://github.com/fastapi/typer/pull/1470) by [@tiangolo](https://github.com/tiangolo). +* ⬆ Bump urllib3 from 2.6.3 to 2.7.0. PR [#1741](https://github.com/fastapi/typer/pull/1741) by [@dependabot[bot]](https://github.com/apps/dependabot). +* 🔧 Remove unnecessary Ruff rule. PR [#1752](https://github.com/fastapi/typer/pull/1752) by [@tiangolo](https://github.com/tiangolo). +* 🔒️ Add zizmor and fix audit findings. PR [#1705](https://github.com/fastapi/typer/pull/1705) by [@YuriiMotov](https://github.com/YuriiMotov). +* 🔒️ Only allow team members to modify dependencies. PR [#1744](https://github.com/fastapi/typer/pull/1744) by [@svlandeg](https://github.com/svlandeg). +* ⬆ Bump mypy from 2.0.0 to 2.1.0. PR [#1742](https://github.com/fastapi/typer/pull/1742) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ Bump pydantic-settings from 2.14.0 to 2.14.1. PR [#1739](https://github.com/fastapi/typer/pull/1739) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ Bump ty from 0.0.34 to 0.0.35. PR [#1740](https://github.com/fastapi/typer/pull/1740) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ Bump actions/add-to-project from 1.0.2 to 2.0.0. PR [#1731](https://github.com/fastapi/typer/pull/1731) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ Bump actions/labeler from 6.0.1 to 6.1.0. PR [#1734](https://github.com/fastapi/typer/pull/1734) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ Bump pydantic from 2.13.3 to 2.13.4. PR [#1736](https://github.com/fastapi/typer/pull/1736) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ Bump prek from 0.3.11 to 0.3.13. PR [#1735](https://github.com/fastapi/typer/pull/1735) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ Bump mypy from 1.20.2 to 2.0.0. PR [#1737](https://github.com/fastapi/typer/pull/1737) by [@dependabot[bot]](https://github.com/apps/dependabot). +* 👷 Add pre-commit for typos. PR [#1730](https://github.com/fastapi/typer/pull/1730) by [@tiangolo](https://github.com/tiangolo). +* ⬆ Bump ty from 0.0.33 to 0.0.34. PR [#1729](https://github.com/fastapi/typer/pull/1729) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ Bump ty from 0.0.32 to 0.0.33. PR [#1724](https://github.com/fastapi/typer/pull/1724) by [@dependabot[bot]](https://github.com/apps/dependabot). + ## 0.25.1 (2026-04-30) ### Features diff --git a/docs/tutorial/app-dir.md b/docs/tutorial/app-dir.md index d85ec31f52..e79fcc9587 100644 --- a/docs/tutorial/app-dir.md +++ b/docs/tutorial/app-dir.md @@ -32,7 +32,7 @@ If the first element is a `Path` object the next ones (after the `/`) can be `st And it will create a new `Path` object from that. -If you want a quick guide on using `Path()` you can check this post on Real Python or this post by Trey Hunner. +If you want a quick guide on using `Path()` you can check [this post on Real Python](https://realpython.com/python-pathlib/) or [this post by Trey Hunner](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/). In the code above, we are also explicitly declaring `config_path` as having type `Path` to help the editor provide completion and type checks: diff --git a/docs/tutorial/arguments/envvar.md b/docs/tutorial/arguments/envvar.md index 1638b5e720..53d04c9862 100644 --- a/docs/tutorial/arguments/envvar.md +++ b/docs/tutorial/arguments/envvar.md @@ -4,7 +4,7 @@ You can also configure a *CLI argument* to read a value from an environment vari /// tip -You can learn more about environment variables in the [Environment Variables](../../environment-variables.md){.internal-link target=_blank} page. +You can learn more about environment variables in the [Environment Variables](../../environment-variables.md) page. /// diff --git a/docs/tutorial/arguments/help.md b/docs/tutorial/arguments/help.md index bea2c85ced..9c390ef874 100644 --- a/docs/tutorial/arguments/help.md +++ b/docs/tutorial/arguments/help.md @@ -185,7 +185,7 @@ Options: You might want to show the help information for *CLI arguments* in different panels when using the `--help` option. -If you have installed Rich as described in the docs for [Printing and Colors](../printing.md){.internal-link target=_blank}, you can set the `rich_help_panel` parameter to the name of the panel where you want this *CLI argument* to be shown: +If you have installed Rich as described in the docs for [Printing and Colors](../printing.md), you can set the `rich_help_panel` parameter to the name of the panel where you want this *CLI argument* to be shown: {* docs_src/arguments/help/tutorial007_an_py310.py hl[12,16] *} @@ -220,7 +220,7 @@ In this example we have a custom *CLI arguments* panel named "`Secondary Argumen ## Help with style using Rich -In a future section you will see how to use custom markup in the `help` for *CLI arguments* when reading about [Commands - Command Help](../commands/help.md#rich-markdown-and-markup){.internal-link target=_blank}. +In a future section you will see how to use custom markup in the `help` for *CLI arguments* when reading about [Commands - Command Help](../commands/help.md#rich-markdown-and-markup). If you are in a hurry you can jump there, but otherwise, it would be better to continue reading here and following the tutorial in order. @@ -250,7 +250,7 @@ Options:
-/// info +/// note Have in mind that the *CLI argument* will still show up in the first line with `Usage`. diff --git a/docs/tutorial/arguments/optional.md b/docs/tutorial/arguments/optional.md index 8bc1e736ca..10b03af7b0 100644 --- a/docs/tutorial/arguments/optional.md +++ b/docs/tutorial/arguments/optional.md @@ -33,7 +33,7 @@ __init__.py test_tutorial ## An alternative *CLI argument* declaration -In the [First Steps](../first-steps.md#add-a-cli-argument){.internal-link target=_blank} you saw how to add a *CLI argument*: +In the [First Steps](../first-steps.md#add-a-cli-argument) you saw how to add a *CLI argument*: {* docs_src/first_steps/tutorial002_py310.py hl[4] *} @@ -45,7 +45,7 @@ Or, using an explicit `Typer()` instance creation: {* docs_src/arguments/optional/tutorial001_an_py310.py hl[9] *} -/// info +/// note Typer added support for `Annotated` (and started recommending it) in version 0.9.0. @@ -209,9 +209,9 @@ Not passing any value to the `default` argument is the same as marking it as req name: str = typer.Argument(default=...) ``` -/// info +/// note -If you hadn't seen that `...` before: it is a special single value, it is part of Python and is called "Ellipsis". +If you hadn't seen that `...` before: it is a special single value, it is [part of Python and is called "Ellipsis"](https://docs.python.org/3/library/constants.html#Ellipsis). /// diff --git a/docs/tutorial/click.md b/docs/tutorial/click.md new file mode 100644 index 0000000000..2f21f0dada --- /dev/null +++ b/docs/tutorial/click.md @@ -0,0 +1,69 @@ +# Vendored Click + +/// note + +This is historical information, if you are just learning Typer from scratch, you don't need to read it. ☕️ + +/// + +Typer used to depend on [Click](https://click.palletsprojects.com/), a popular tool for building CLIs in Python, as an external dependency. + +Since version 0.26.0, Typer has vendored Click (included Click's source code internally, instead of installing it as a third party package) and has unified the code interactions between Typer and the embedded Click source code for easier maintainability in the future. + +Note that some Click functionality will not be available anymore in the future, as we continue to improve and extend Typer's codebase. + +## Breaking Changes + +Typer used to support extracting the internal Click app from a Typer app to use and modify it with any Click functionality. For example, to add Click-specific plug-ins. + +The same way, it supported adding Click-specific types to override the default Typer ones. + +Using Click directly was an edge case feature that was not commonly used, and it is no longer supported. If your app depended specifically on this, you will need to either migrate it to use plain Typer, or migrate it to use Click directly instead of Typer. + +## Codebase Compatibility Improvements + +Because Typer used to depend on Click, any new features or changes in newer Click versions could break compatibility in Typer. + +The Click team has always been very helpful and supportive with Typer. But still, this dependency interaction would cause extra effort and burden for both the Typer team and the Click team. + +Now that Typer continues evolving, starting from a fixed copy of Click's source code, any changes in Click's codebase will not affect Typer. + +The Typer team will not need to make sure there are workarounds for changes in new versions of Click, and the Click team will not need to consider additional edge cases caused by Typer. + +## Compatibility Improvements for Your Apps + +The fact that Typer depended on Click caused an additional ongoing issue that could happen from time to time to user projects. + +Many packages come with a CLI, some of them could use Click, some could use Typer. + +Some of these packages could require a recent version of Click that Typer still didn't support, but as all these package dependencies would belong to the same project, there would be conflicts. + +In these cases, the package that depended on the newer version of Click would require installing it, but the other package that used Typer would break. Or if there were version pins, some combinations of packages would not be installable together. + +Across different versions of Click through time, there were many changes needed in Typer to make it all compatible with multiple versions of Click at the same time. + +Now that Typer and Click are decoupled, a package could depend on a newer version of Click, while another package that uses Typer would continue working as normally, as that Typer version would bundle anything necessary from Click's source code to work. + +## Future Improvements + +Both the Click team and the Typer team have future improvements planned, not having to coordinate with each other for compatibility will simplify the work of both teams. + +In some cases feature ideas could overlap, and could have caused incompatibilities. Now this won't be a problem as each team can focus on each project independently. + +## Typer Changes + +After vendoring Click, Typer will reduce, simplify and refactor parts of the vendored Click code that are not necessary for Typer, or that could be done in a different way to facilitate future improvements in Typer. + +Then Typer will gradually introduce new features and improvements that have been planned for a while but were too difficult to implement before this. + +## User Focus + +These decisions are all carefully planned and based on real world use cases extracted from the official Typer developer survey, including the tradeoff between the potential breaking changes for some use cases and the planned future features and improvements that will be enabled. + +## Thank You Click + +Click has been the foundation building block of Typer and most CLI's in Python (through Typer or directly with Click). + +We wouldn't be here without Click, and we are very grateful for all the work that the Click team has done. + +Thank you Click! 🙇 diff --git a/docs/tutorial/commands/context.md b/docs/tutorial/commands/context.md index 1fd6edfea6..190a2b08c2 100644 --- a/docs/tutorial/commands/context.md +++ b/docs/tutorial/commands/context.md @@ -4,7 +4,7 @@ When you create a **Typer** application it always has a special, hidden object u But you can access the context by declaring a function parameter of type `typer.Context`. -You might have read it in [CLI Option Callback and Context](../options/callback-and-context.md){.internal-link target=_blank}. +You might have read it in [CLI Option Callback and Context](../options/callback-and-context.md). The same way, in commands or in the main `Typer` callback you can access the context by declaring a function parameter of type `typer.Context`. diff --git a/docs/tutorial/commands/help.md b/docs/tutorial/commands/help.md index c3865f8116..359339bb78 100644 --- a/docs/tutorial/commands/help.md +++ b/docs/tutorial/commands/help.md @@ -204,7 +204,7 @@ If there are multiple close matches, Typer will suggest them all. This feature u Typer installs **Rich** to allow for more formatting in the docstrings and the `help` parameter for *CLI arguments* and *CLI options*. You will see more about it below. 👇 -/// info +/// note You can disable rich text formatting by setting `rich_markup_mode` to `None` for your specific app. Alternatively, you can disable it globally using an environmental variable `TYPER_USE_RICH` set to `False` or `0`. @@ -213,11 +213,11 @@ Alternatively, you can disable it globally using an environmental variable `TYPE ### Rich Markup -If you set `rich_markup_mode="rich"` when creating the `typer.Typer()` app (which is the default), you will be able to use Rich Console Markup in the docstring, and even in the help for the *CLI arguments* and options: +If you set `rich_markup_mode="rich"` when creating the `typer.Typer()` app (which is the default), you will be able to use [Rich Console Markup](https://rich.readthedocs.io/en/stable/markup.html) in the docstring, and even in the help for the *CLI arguments* and options: {* docs_src/commands/help/tutorial004_an_py310.py hl[5,11,15:17,22,25,28] *} -With that, you can use Rich Console Markup to format the text in the docstring for the command `create`, make the word "`create`" bold and green, and even use an emoji. +With that, you can use [Rich Console Markup](https://rich.readthedocs.io/en/stable/markup.html) to format the text in the docstring for the command `create`, make the word "`create`" bold and green, and even use an [emoji](https://rich.readthedocs.io/en/stable/markup.html#emoji). You can also use markup in the help for the `username` CLI Argument. @@ -280,7 +280,7 @@ If you set `rich_markup_mode="markdown"` when creating the `typer.Typer()` app, {* docs_src/commands/help/tutorial005_an_py310.py hl[5,10,13:21,26,28:29] *} -With that, you can use Markdown to format the text in the docstring for the command `create`, make the word "`create`" bold, show a list of items, and even use an emoji. +With that, you can use Markdown to format the text in the docstring for the command `create`, make the word "`create`" bold, show a list of items, and even use an [emoji](https://rich.readthedocs.io/en/stable/markup.html#emoji). And the same as before, the help text overwritten for the command `delete` can also use Markdown. @@ -338,7 +338,7 @@ $ python main.py delete --help
-/// info +/// note Notice that in Markdown you cannot define colors. For colors you might prefer to use Rich markup. @@ -348,7 +348,7 @@ Notice that in Markdown you cannot define colors. For colors you might prefer to If you have many commands or CLI parameters, you might want to show their documentation in different panels when using the `--help` option. -If you installed Rich as described in [Printing and Colors](../printing.md){.internal-link target=_blank}, you can configure the panel to use for each command or CLI parameter. +If you installed [Rich](https://rich.readthedocs.io/) as described in [Printing and Colors](../printing.md), you can configure the panel to use for each command or CLI parameter. ### Help Panels for Commands diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md index bad806c88a..ab1a7a3381 100644 --- a/docs/tutorial/index.md +++ b/docs/tutorial/index.md @@ -8,9 +8,9 @@ You could consider this a **book**, a **course**, the **official** and recommend ## Python Types -If you need a refresher about how to use Python type hints, check the first part of FastAPI's Python types intro. +If you need a refresher about how to use Python type hints, check the first part of [FastAPI's Python types intro](https://fastapi.tiangolo.com/python-types/). -You can also check the mypy cheat sheet. +You can also check the [mypy cheat sheet](https://mypy.readthedocs.io/en/latest/cheat_sheet_py3.html). In short (very short), you can declare a function with parameters like: diff --git a/docs/tutorial/install.md b/docs/tutorial/install.md index bb2db1d8ff..3658e84283 100644 --- a/docs/tutorial/install.md +++ b/docs/tutorial/install.md @@ -2,14 +2,14 @@ The first step is to install **Typer**. -First, make sure you create your [virtual environment](../virtual-environments.md){.internal-link target=_blank}, activate it, and then install it, for example with: +First, make sure you create your [virtual environment](../virtual-environments.md), activate it, and then install it, for example with:
```console $ pip install typer ---> 100% -Successfully installed typer click shellingham rich +Successfully installed typer shellingham rich ```
diff --git a/docs/tutorial/multiple-values/arguments-with-multiple-values.md b/docs/tutorial/multiple-values/arguments-with-multiple-values.md index 3c89d218c9..2446974667 100644 --- a/docs/tutorial/multiple-values/arguments-with-multiple-values.md +++ b/docs/tutorial/multiple-values/arguments-with-multiple-values.md @@ -27,7 +27,7 @@ We also declared a final *CLI argument* `celebration`, and it's correctly used e /// -/// info +/// note A `list` can only be used in the last command (if there are subcommands), as this will take anything to the right and assume it's part of the expected *CLI arguments*. diff --git a/docs/tutorial/options-autocompletion.md b/docs/tutorial/options-autocompletion.md index 9b0e296924..4077251088 100644 --- a/docs/tutorial/options-autocompletion.md +++ b/docs/tutorial/options-autocompletion.md @@ -48,7 +48,7 @@ Hello Camila Right now we get completion for the *CLI option* names, but not for the values. -We can provide completion for the values creating an `autocompletion` function, similar to the `callback` functions from [CLI Option Callback and Context](./options/callback-and-context.md){.internal-link target=_blank}: +We can provide completion for the values creating an `autocompletion` function, similar to the `callback` functions from [CLI Option Callback and Context](./options/callback-and-context.md): {* docs_src/options_autocompletion/tutorial002_an_py310.py hl[6:7,16] *} @@ -128,7 +128,7 @@ In the end, the return will be a `list` (or other iterable) of `tuples` of 2 `st /// -/// info +/// note The help text will be visible in Zsh, Fish, and PowerShell. @@ -155,7 +155,7 @@ Sebastian -- The type hints guy. Instead of creating and returning a list with values (`str` or `tuple`), we can use `yield` with each value that we want in the completion. -That way our function will be a generator that **Typer** can iterate: +That way our function will be a [generator](https://docs.python.org/3.8/glossary.html#index-19) that **Typer** can iterate: {* docs_src/options_autocompletion/tutorial005_an_py310.py hl[12:15] *} @@ -169,9 +169,9 @@ In the end, that's just to save us a couple of lines of code. /// -/// info +/// note -The function can use `yield`, so it doesn't have to return strictly a `list`, it just has to be iterable. +The function can use `yield`, so it doesn't have to return strictly a `list`, it just has to be [iterable](https://docs.python.org/3.8/glossary.html#term-iterable). But each of the elements for completion has to be a `str` or a `tuple` (when containing a help text). @@ -284,7 +284,7 @@ Because completion is based on the output printed by your program (handled inter /// tip -If you need a refresher about what is "standard output" and "standard error" check the section in [Printing and Colors: "Standard Output" and "Standard Error"](./printing.md#standard-output-and-standard-error){.internal-link target=_blank}. +If you need a refresher about what is "standard output" and "standard error" check the section in [Printing and Colors: "Standard Output" and "Standard Error"](./printing.md#standard-output-and-standard-error). /// @@ -296,7 +296,7 @@ Using `stderr=True` tells **Rich** that the output should be shown in "standard {* docs_src/options_autocompletion/tutorial008_an_py310.py hl[12,15:16] *} -/// info +/// note If you have disabled Rich, you can also use `print(lastname, file=sys.stderr)` or `typer.echo("some text", err=True)` instead. diff --git a/docs/tutorial/options/help.md b/docs/tutorial/options/help.md index 1887b2f289..c2a115796f 100644 --- a/docs/tutorial/options/help.md +++ b/docs/tutorial/options/help.md @@ -93,7 +93,7 @@ Here we have a custom *CLI options* panel named "`Customization and Utils`". ## Help with style using Rich -In a future section you will see how to use custom markup in the `help` for *CLI options* when reading about [Commands - Command Help](../commands/help.md#rich-markdown-and-markup){.internal-link target=_blank}. +In a future section you will see how to use custom markup in the `help` for *CLI options* when reading about [Commands - Command Help](../commands/help.md#rich-markdown-and-markup). If you are in a hurry you can jump there, but otherwise, it would be better to continue reading here and following the tutorial in order. diff --git a/docs/tutorial/options/name.md b/docs/tutorial/options/name.md index 82ca489f67..621735f7e0 100644 --- a/docs/tutorial/options/name.md +++ b/docs/tutorial/options/name.md @@ -30,9 +30,9 @@ You can pass the *CLI option* name that you want to have in the following positi {* docs_src/options/name/tutorial001_an_py310.py hl[9] *} -/// info +/// note -"Positional" means that it's not a function argument with a keyword name. +"[Positional](https://docs.python.org/3.8/glossary.html#term-argument)" means that it's not a function argument with a keyword name. For example `show_default=True` is a keyword argument. "`show_default`" is the keyword. diff --git a/docs/tutorial/options/required.md b/docs/tutorial/options/required.md index d2dde83641..a702bbaa80 100644 --- a/docs/tutorial/options/required.md +++ b/docs/tutorial/options/required.md @@ -23,9 +23,9 @@ Or you can explicitly pass `...` to `typer.Option(default=...)`: {* docs_src/options/required/tutorial002_py310.py hl[7] *} -/// info +/// note -If you hadn't seen that `...` before: it is a special single value, it is part of Python and is called "Ellipsis". +If you hadn't seen that `...` before: it is a special single value, it is [part of Python and is called "Ellipsis"](https://docs.python.org/3/library/constants.html#Ellipsis). /// diff --git a/docs/tutorial/options/version.md b/docs/tutorial/options/version.md index 7b584ce2aa..609c3978fc 100644 --- a/docs/tutorial/options/version.md +++ b/docs/tutorial/options/version.md @@ -84,9 +84,9 @@ We don't have to check for `ctx.resilient_parsing` in the `name_callback()` for /// -/// info +/// note -If you need a refresher about what is "standard output" and "standard error" check the section in [Printing and Colors: "Standard Output" and "Standard Error"](../printing.md#standard-output-and-standard-error){.internal-link target=_blank}. +If you need a refresher about what is "standard output" and "standard error" check the section in [Printing and Colors: "Standard Output" and "Standard Error"](../printing.md#standard-output-and-standard-error). /// diff --git a/docs/tutorial/package.md b/docs/tutorial/package.md index f98160e50b..ce87570fd6 100644 --- a/docs/tutorial/package.md +++ b/docs/tutorial/package.md @@ -20,7 +20,7 @@ If you already have a favorite way of creating Python packages, feel free to ski ## Prerequisites -For this guide we'll use uv. +For this guide we'll use [uv](https://docs.astral.sh/uv/). uv's docs are great, so go ahead, check them and install it. @@ -60,11 +60,10 @@ $ uv add typer Using CPython 3.14.0 interpreter at: /location/of/python/ Creating virtual environment at: .venv -Resolved 10 packages in 21ms +Resolved 9 packages in 21ms Built rick-portal-gun @ file:/home/rick-portal-gun Prepared 1 package in 19ms -Installed 10 packages in 34ms - + click==8.3.1 +Installed 9 packages in 34ms + colorama==0.4.6 + markdown-it-py==4.0.0 + mdurl==0.1.2 @@ -321,7 +320,7 @@ If you installed it in the global system (e.g. with `sudo`) you could install a /// tip -Bonus points if you use uvx to install it while keeping an isolated environment for your Python CLI programs 🚀 +Bonus points if you use [uvx](https://docs.astral.sh/uv/) to install it while keeping an isolated environment for your Python CLI programs 🚀 /// @@ -486,7 +485,7 @@ But you can still support `python -m` for the cases where it's useful. ## Publish to PyPI (optional) -You can publish that new package to PyPI to make it public, so others can install it easily. +You can publish that new package to [PyPI](https://pypi.org/) to make it public, so others can install it easily. So, go ahead and create an account there (it's free). @@ -494,9 +493,9 @@ So, go ahead and create an account there (it's free). To do it, you first need to configure a PyPI auth token. -Login to PyPI. +Login to [PyPI](https://pypi.org/). -And then go to https://pypi.org/manage/account/token/ to create a new token. +And then go to [https://pypi.org/manage/account/token/](https://pypi.org/manage/account/token/) to create a new token. Let's say your new API token is: @@ -531,7 +530,7 @@ Uploading rick_portal_gun-0.1.0.tar.gz (841.0B) -Now you can go to PyPI and check your projects at https://pypi.org/manage/projects/. +Now you can go to PyPI and check your projects at [https://pypi.org/manage/projects/](https://pypi.org/manage/projects/). You should now see your new "rick-portal-gun" package. @@ -568,7 +567,6 @@ Collecting rick-portal-gun Downloading rick_portal_gun-0.1.0-py3-none-any.whl.metadata (435 bytes) Requirement already satisfied: typer<0.13.0,>=0.12.3 in ./.local/lib/python3.10/site-packages (from rick-portal-gun==0.1.0) (0.12.3) Requirement already satisfied: typing-extensions>=3.7.4.3 in ./.local/lib/python3.10/site-packages (from typer<0.13.0,>=0.12.3->rick-portal-gun==0.1.0) (4.11.0) -Requirement already satisfied: click>=8.0.0 in ./.local/lib/python3.10/site-packages (from typer<0.13.0,>=0.12.3->rick-portal-gun==0.1.0) (8.1.7) Requirement already satisfied: shellingham>=1.3.0 in ./.local/lib/python3.10/site-packages (from typer<0.13.0,>=0.12.3->rick-portal-gun==0.1.0) (1.5.4) Requirement already satisfied: rich>=10.11.0 in ./.local/lib/python3.10/site-packages (from typer<0.13.0,>=0.12.3->rick-portal-gun==0.1.0) (13.7.1) Requirement already satisfied: pygments<3.0.0,>=2.13.0 in ./.local/lib/python3.10/site-packages (from rich>=10.11.0->typer<0.13.0,>=0.12.3->rick-portal-gun==0.1.0) (2.17.2) @@ -659,13 +657,13 @@ And now you can go to PyPI, to the project page, and reload it, and it will now This is a very simple guide. You could add many more steps. -For example, you should use Git, the version control system, to save your code. +For example, you should use [Git](https://git-scm.com/), the version control system, to save your code. -You could use uv to manage your installed CLI Python programs in isolated environments. +You could use [uv](https://docs.astral.sh/uv/) to manage your installed CLI Python programs in isolated environments. -Maybe use automatic formatting with Ruff. +Maybe use automatic formatting with [Ruff](https://docs.astral.sh/ruff/). -You'll probably want to publish your code as open source to GitHub. +You'll probably want to publish your code as open source to [GitHub](https://github.com/). And then you could integrate a CI tool to run your tests and deploy your package automatically. diff --git a/docs/tutorial/parameter-types/datetime.md b/docs/tutorial/parameter-types/datetime.md index f2571126d2..4b685a3cf1 100644 --- a/docs/tutorial/parameter-types/datetime.md +++ b/docs/tutorial/parameter-types/datetime.md @@ -1,6 +1,6 @@ # DateTime -You can specify a *CLI parameter* as a Python `datetime`. +You can specify a *CLI parameter* as a Python [`datetime`](https://docs.python.org/3/library/datetime.html). Your function will receive a standard Python `datetime` object, and again, your editor will give you completion, etc. @@ -47,7 +47,7 @@ Error: Invalid value for 'BIRTH:[%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]': You can also customize the formats received for the `datetime` with the `formats` parameter. -`formats` receives a list of strings with the date formats that would be passed to datetime.strptime(). +`formats` receives a list of strings with the date formats that would be passed to [datetime.strptime()](https://docs.python.org/3/library/datetime.html#datetime.datetime.strptime). For example, let's imagine that you want to accept an ISO formatted datetime, but for some strange reason, you also want to accept a format with: diff --git a/docs/tutorial/parameter-types/enum.md b/docs/tutorial/parameter-types/enum.md index 278908fc72..185ad420c3 100644 --- a/docs/tutorial/parameter-types/enum.md +++ b/docs/tutorial/parameter-types/enum.md @@ -1,6 +1,6 @@ # Enum - Choices -To define a *CLI parameter* that can take a value from a predefined set of values you can use a standard Python `enum.Enum`: +To define a *CLI parameter* that can take a value from a predefined set of values you can use a standard Python [`enum.Enum`](https://docs.python.org/3/library/enum.html): {* docs_src/parameter_types/enum/tutorial001_py310.py hl[1,6:9,16:17] *} diff --git a/docs/tutorial/parameter-types/file.md b/docs/tutorial/parameter-types/file.md index f551b1db22..76a92749ad 100644 --- a/docs/tutorial/parameter-types/file.md +++ b/docs/tutorial/parameter-types/file.md @@ -10,7 +10,7 @@ You can read and write data with `Path` the same way. /// -The difference is that these types will give you a Python file-like object instead of a Python Path. +The difference is that these types will give you a Python [file-like object](https://docs.python.org/3/glossary.html#term-file-object) instead of a Python [Path](https://docs.python.org/3/library/pathlib.html#basic-use). A "file-like object" is the same type of object returned by `open()` as in: @@ -102,7 +102,7 @@ Some config written by the app -/// info | Technical Details +/// note | Technical Details `typer.FileTextWrite` is a just a convenience class. @@ -168,7 +168,7 @@ $ ls ./binary.dat You can use several configuration parameters for these types (classes) in `typer.Option()` and `typer.Argument()`: -* `mode`: controls the "mode" to open the file with. +* `mode`: controls the "[mode](https://docs.python.org/3/library/functions.html#open)" to open the file with. * It's automatically set for you by using the classes above. * Read more about it below. * `encoding`: to force a specific encoding, e.g. `"utf-8"`. @@ -178,7 +178,7 @@ You can use several configuration parameters for these types (classes) in `typer ## Advanced `mode` -By default, **Typer** will configure the `mode` for you: +By default, **Typer** will configure the [`mode`](https://docs.python.org/3/library/functions.html#open) for you: * `typer.FileText`: `mode="r"`, to read text. * `typer.FileTextWrite`: `mode="w"`, to write text. diff --git a/docs/tutorial/parameter-types/path.md b/docs/tutorial/parameter-types/path.md index 869b38db07..e2d8177210 100644 --- a/docs/tutorial/parameter-types/path.md +++ b/docs/tutorial/parameter-types/path.md @@ -1,6 +1,6 @@ # Path -You can declare a *CLI parameter* to be a standard Python `pathlib.Path`. +You can declare a *CLI parameter* to be a standard Python [`pathlib.Path`](https://docs.python.org/3/library/pathlib.html#basic-use). This is what you would do for directory paths, file paths, etc: diff --git a/docs/tutorial/parameter-types/uuid.md b/docs/tutorial/parameter-types/uuid.md index cd93b1a1a2..8de1ef1bf4 100644 --- a/docs/tutorial/parameter-types/uuid.md +++ b/docs/tutorial/parameter-types/uuid.md @@ -1,8 +1,8 @@ # UUID -/// info +/// note -A UUID is a "Universally Unique Identifier". +A UUID is a ["Universally Unique Identifier"](https://en.wikipedia.org/wiki/Universally_unique_identifier). It's a standard format for identifiers, like passport numbers, but for anything, not just people in countries. @@ -24,7 +24,7 @@ You can declare a *CLI parameter* as a UUID: {* docs_src/parameter_types/uuid/tutorial001_py310.py hl[1,9:11] *} -Your Python code will receive a standard Python `UUID` object with all its attributes and methods, and as you are annotating your function parameter with that type, you will have type checks, autocompletion in your editor, etc. +Your Python code will receive a standard Python [`UUID`](https://docs.python.org/3.8/library/uuid.html) object with all its attributes and methods, and as you are annotating your function parameter with that type, you will have type checks, autocompletion in your editor, etc. Check it: diff --git a/docs/tutorial/printing.md b/docs/tutorial/printing.md index da998b04c3..62c83205d0 100644 --- a/docs/tutorial/printing.md +++ b/docs/tutorial/printing.md @@ -18,7 +18,7 @@ Hello World ## Use Rich -You can also display beautiful and more complex information using Rich. It comes by default when you install `typer`. +You can also display beautiful and more complex information using [Rich](https://rich.readthedocs.io/). It comes by default when you install `typer`. ### Use Rich `print` @@ -50,7 +50,7 @@ Here's the data ### Rich Markup -Rich also supports a custom markup syntax to set colors and styles, for example: +Rich also supports a [custom markup syntax](https://rich.readthedocs.io/en/stable/markup.html) to set colors and styles, for example: {* docs_src/printing/tutorial002_py310.py hl[9] *} @@ -66,7 +66,7 @@ $ python main.py In this example you can see how to use font styles, colors, and even emojis. -To learn more check out the Rich docs. +To learn more check out the [Rich docs](https://rich.readthedocs.io/en/stable/markup.html). ### Rich Tables @@ -99,9 +99,9 @@ $ python main.py Rich has many other features, as an example, you can check the docs for: -* Prompt -* Markdown -* Panel +* [Prompt](https://rich.readthedocs.io/en/stable/prompt.html) +* [Markdown](https://rich.readthedocs.io/en/stable/markdown.html) +* [Panel](https://rich.readthedocs.io/en/stable/panel.html) * ...and more. ### Typer and Rich @@ -130,7 +130,7 @@ And there's another "**virtual file**" called "**standard error**" that is norma But we can also "print" to "standard error". And both are shown on the terminal to the users. -/// info +/// note If you use PowerShell it's quite possible that what you print to "standard error" won't be shown in the terminal. @@ -182,7 +182,7 @@ But understanding that will come handy in the future, for example for autocomple /// warning -In most of the cases, for displaying advanced information, it is recommended to use Rich. +In most of the cases, for displaying advanced information, it is recommended to use [Rich](https://rich.readthedocs.io/). You can probably skip the rest of this section. 🎉😎 @@ -196,7 +196,7 @@ And for the cases where you want to display data more beautifully, or more advan ### Why `typer.echo` -`typer.echo()` (which is actually just `click.echo()`) applies some checks to try and convert binary data to strings, and other similar things. +`typer.echo()` applies some checks to try and convert binary data to strings, and other similar things. But in most of the cases you wouldn't need it, as in modern Python strings (`str`) already support and use Unicode, and you would rarely deal with pure `bytes` that you want to print on the screen. @@ -216,7 +216,7 @@ So, a colored text is still just a `str`. /// tip -Again, you are much better off using Rich for this. 😎 +Again, you are much better off using [Rich](https://rich.readthedocs.io/) for this. 😎 /// @@ -256,7 +256,7 @@ You can pass these function arguments to `typer.style()`: /// tip -In case you didn't see above, you are much better off using Rich for this. 😎 +In case you didn't see above, you are much better off using [Rich](https://rich.readthedocs.io/) for this. 😎 /// diff --git a/docs/tutorial/progressbar.md b/docs/tutorial/progressbar.md index 29c118f6bd..a907e42625 100644 --- a/docs/tutorial/progressbar.md +++ b/docs/tutorial/progressbar.md @@ -4,7 +4,7 @@ If you are executing an operation that can take some time, you can inform it to ## Progress Bar -You can use Rich's Progress Display to show a progress bar, for example: +You can use [Rich's Progress Display](https://rich.readthedocs.io/en/stable/progress.html) to show a progress bar, for example: {* docs_src/progressbar/tutorial001_py310.py hl[4,12] *} @@ -63,7 +63,7 @@ $ python main.py -You can learn more about it in the Rich docs for Progress Display. +You can learn more about it in the [Rich docs for Progress Display](https://rich.readthedocs.io/en/stable/progress.html). ## Typer `progressbar` @@ -81,7 +81,7 @@ But if you can't use Rich and have it disabled, Typer comes with a simple utilit /// tip -Remember, you are much better off using Rich for this. 😎 +Remember, you are much better off using [Rich](https://rich.readthedocs.io/) for this. 😎 /// @@ -123,7 +123,7 @@ Notice that there are 2 levels of code blocks. One for the `with` statement and /// -/// info +/// note This is mostly useful for operations that take some time. @@ -149,7 +149,7 @@ Processed 100 things. /// tip -Remember, you are much better off using Rich for this. 😎 +Remember, you are much better off using [Rich](https://rich.readthedocs.io/) for this. 😎 /// @@ -175,7 +175,7 @@ Processed 100 user IDs. #### About the function with `yield` -If you hadn't seen something like that `yield` above, that's a "generator". +If you hadn't seen something like that `yield` above, that's a "[generator](https://docs.python.org/3/glossary.html#term-generator)". You can iterate over that function with a `for` and at each iteration it will give you the value at `yield`. @@ -199,7 +199,7 @@ would print each of the "user IDs" (here it's just the numbers from `0` to `99`) /// tip -Remember, you are much better off using Rich for this. 😎 +Remember, you are much better off using [Rich](https://rich.readthedocs.io/) for this. 😎 /// diff --git a/docs/tutorial/prompt.md b/docs/tutorial/prompt.md index 34871796d3..522f6ba693 100644 --- a/docs/tutorial/prompt.md +++ b/docs/tutorial/prompt.md @@ -1,6 +1,6 @@ # Ask with Prompt -When you need to ask the user for info interactively you should normally use [*CLI Option*s with Prompt](options/prompt.md){.internal-link target=_blank}, because they allow using the CLI program in a non-interactive way (for example, a Bash script could use it). +When you need to ask the user for info interactively you should normally use [*CLI Option*s with Prompt](options/prompt.md), because they allow using the CLI program in a non-interactive way (for example, a Bash script could use it). But if you absolutely need to ask for interactive information without using a *CLI option*, you can use `typer.prompt()`: @@ -22,7 +22,7 @@ Hello Camila ## Confirm -There's also an alternative to ask for confirmation. Again, if possible, you should use a [*CLI Option* with a confirmation prompt](options/prompt.md){.internal-link target=_blank}: +There's also an alternative to ask for confirmation. Again, if possible, you should use a [*CLI Option* with a confirmation prompt](options/prompt.md): {* docs_src/prompt/tutorial002_py310.py hl[8] *} diff --git a/docs/tutorial/subcommands/callback-override.md b/docs/tutorial/subcommands/callback-override.md index 45602a2cb8..8ab05df993 100644 --- a/docs/tutorial/subcommands/callback-override.md +++ b/docs/tutorial/subcommands/callback-override.md @@ -49,7 +49,7 @@ Creating user: Camila If a callback was added when creating the `typer.Typer()` app, it's possible to override it with a new one using `@app.callback()`. -This is the same information you saw on the section about [Commands - Typer Callback](../commands/callback.md){.internal-link target=_blank}, and it applies the same for sub-Typer apps: +This is the same information you saw on the section about [Commands - Typer Callback](../commands/callback.md), and it applies the same for sub-Typer apps: {* docs_src/subcommands/callback_override/tutorial003_py310.py hl[6,7,10,14,15,16] *} diff --git a/docs/tutorial/subcommands/index.md b/docs/tutorial/subcommands/index.md index b20e21b578..5494247fc1 100644 --- a/docs/tutorial/subcommands/index.md +++ b/docs/tutorial/subcommands/index.md @@ -1,6 +1,6 @@ # SubCommands - Command Groups -You read before how to create a program with [Commands](../commands/index.md){.internal-link target=_blank}. +You read before how to create a program with [Commands](../commands/index.md). Now we'll see how to create a *CLI program* with commands that have their own subcommands. Also known as command groups. diff --git a/docs/tutorial/subcommands/name-and-help.md b/docs/tutorial/subcommands/name-and-help.md index c6b3d0b52c..16829ebb9c 100644 --- a/docs/tutorial/subcommands/name-and-help.md +++ b/docs/tutorial/subcommands/name-and-help.md @@ -305,7 +305,7 @@ You can set it when creating a new `typer.Typer()`: {* docs_src/subcommands/name_help/tutorial006_py310.py hl[12] *} -/// info +/// note The rest of the callbacks and overrides are there only to show you that they don't affect the name and help text when you set it explicitly. diff --git a/docs/tutorial/testing.md b/docs/tutorial/testing.md index cbabfd5d04..26219e0340 100644 --- a/docs/tutorial/testing.md +++ b/docs/tutorial/testing.md @@ -1,6 +1,6 @@ # Testing -Testing **Typer** applications is very easy with pytest. +Testing **Typer** applications is very easy with [pytest](https://docs.pytest.org/en/latest/). Let's say you have an application `app/main.py` with: @@ -75,9 +75,9 @@ You could also check the output sent to "standard error" (`stderr`) or "standard /// -/// info +/// note -If you need a refresher about what is "standard output" and "standard error" check the section in [Printing and Colors: "Standard Output" and "Standard Error"](printing.md#standard-output-and-standard-error){.internal-link target=_blank}. +If you need a refresher about what is "standard output" and "standard error" check the section in [Printing and Colors: "Standard Output" and "Standard Error"](printing.md#standard-output-and-standard-error). /// @@ -129,9 +129,9 @@ You can test the input typed in the terminal using `input="camila@example.com\n" This is because what you type in the terminal goes to "**standard input**" and is handled by the operating system as if it was a "virtual file". -/// info +/// note -If you need a refresher about what is "standard output", "standard error", and "standard input" check the section in [Printing and Colors: "Standard Output" and "Standard Error"](printing.md#standard-output-and-standard-error){.internal-link target=_blank}. +If you need a refresher about what is "standard output", "standard error", and "standard input" check the section in [Printing and Colors: "Standard Output" and "Standard Error"](printing.md#standard-output-and-standard-error). /// diff --git a/docs/tutorial/typer-app.md b/docs/tutorial/typer-app.md index 4d94776ff2..29ff2f6812 100644 --- a/docs/tutorial/typer-app.md +++ b/docs/tutorial/typer-app.md @@ -20,7 +20,7 @@ When you use `typer.run()`, **Typer** is doing more or less the same as above, i * Create a new "`command`" with your function. * Call the same "application" as if it was a function with "`app()`". -/// info | `@decorator` Info +/// note | `@decorator` Info That `@something` syntax in Python is called a "decorator". @@ -29,7 +29,7 @@ You put it on top of a function. Like a pretty decorative hat (I guess that's wh A "decorator" takes the function below and does something with it. In our case, this decorator tells **Typer** that the function below is a "`command`". -You will learn more about commands later in the section [commands](./commands/index.md){.internal-link target=_blank}. +You will learn more about commands later in the section [commands](./commands/index.md). /// @@ -113,6 +113,6 @@ Having a standalone program like that allows setting up shell/tab completion. The first step to be able to create an installable package like that is to use an explicit `typer.Typer()` app. -Later you can learn all the process to create a standalone CLI application and [Build a Package](./package.md){.internal-link target=_blank}. +Later you can learn all the process to create a standalone CLI application and [Build a Package](./package.md). But for now, it's just good to know that you are on that path. 😎 diff --git a/docs/virtual-environments.md b/docs/virtual-environments.md index d4db3a77c3..4c7c9b291f 100644 --- a/docs/virtual-environments.md +++ b/docs/virtual-environments.md @@ -2,7 +2,7 @@ When you work in Python projects you probably should use a **virtual environment** (or a similar mechanism) to isolate the packages you install for each project. -/// info +/// note If you already know about virtual environments, how to create them and use them, you might want to skip this section. 🤓 @@ -18,11 +18,11 @@ A **virtual environment** is a directory with some files in it. /// -/// info +/// note This page will teach you how to use **virtual environments** and how they work. -If you are ready to adopt a **tool that manages everything** for you (including installing Python), try uv. +If you are ready to adopt a **tool that manages everything** for you (including installing Python), try [uv](https://github.com/astral-sh/uv). /// @@ -86,7 +86,7 @@ $ python -m venv .venv //// tab | `uv` -If you have `uv` installed, you can use it to create a virtual environment. +If you have [`uv`](https://github.com/astral-sh/uv) installed, you can use it to create a virtual environment.
@@ -150,7 +150,7 @@ $ .venv\Scripts\Activate.ps1 //// tab | Windows Bash -Or if you use Bash for Windows (e.g. Git Bash): +Or if you use Bash for Windows (e.g. [Git Bash](https://gitforwindows.org/)):
@@ -216,7 +216,7 @@ If it shows the `python` binary at `.venv\Scripts\python`, inside of your projec /// tip -If you use `uv` you would use it to install things instead of `pip`, so you don't need to upgrade `pip`. 😎 +If you use [`uv`](https://github.com/astral-sh/uv) you would use it to install things instead of `pip`, so you don't need to upgrade `pip`. 😎 /// @@ -248,7 +248,7 @@ If you are using **Git** (you should), add a `.gitignore` file to exclude everyt /// tip -If you used `uv` to create the virtual environment, it already did this for you, you can skip this step. 😎 +If you used [`uv`](https://github.com/astral-sh/uv) to create the virtual environment, it already did this for you, you can skip this step. 😎 /// @@ -320,7 +320,7 @@ $ pip install typer //// tab | `uv` -If you have `uv`: +If you have [`uv`](https://github.com/astral-sh/uv):
@@ -352,7 +352,7 @@ $ pip install -r requirements.txt //// tab | `uv` -If you have `uv`: +If you have [`uv`](https://github.com/astral-sh/uv):
@@ -396,8 +396,8 @@ You would probably use an editor, make sure you configure it to use the same vir For example: -* VS Code -* PyCharm +* [VS Code](https://code.visualstudio.com/docs/python/environments#_select-and-activate-an-environment) +* [PyCharm](https://www.jetbrains.com/help/pycharm/creating-virtual-environment.html) /// tip @@ -435,7 +435,7 @@ Continue reading. 👇🤓 ## Why Virtual Environments -To work with Typer you need to install Python. +To work with Typer you need to install [Python](https://www.python.org/). After that, you would need to **install** Typer and any other **packages** you want to use. @@ -544,7 +544,7 @@ $ pip install typer
-That will download a compressed file with the Typer code, normally from PyPI. +That will download a compressed file with the Typer code, normally from [PyPI](https://pypi.org/project/typer/). It will also **download** files for other packages that Typer depends on. @@ -607,7 +607,7 @@ $ .venv\Scripts\Activate.ps1 //// tab | Windows Bash -Or if you use Bash for Windows (e.g. Git Bash): +Or if you use Bash for Windows (e.g. [Git Bash](https://gitforwindows.org/)):
@@ -619,13 +619,13 @@ $ source .venv/Scripts/activate //// -That command will create or modify some [environment variables](environment-variables.md){.internal-link target=_blank} that will be available for the next commands. +That command will create or modify some [environment variables](environment-variables.md) that will be available for the next commands. One of those variables is the `PATH` variable. /// tip -You can learn more about the `PATH` environment variable in the [Environment Variables](environment-variables.md#path-environment-variable){.internal-link target=_blank} section. +You can learn more about the `PATH` environment variable in the [Environment Variables](environment-variables.md#path-environment-variable) section. /// @@ -826,7 +826,7 @@ This is a simple guide to get you started and teach you how everything works **u There are many **alternatives** to managing virtual environments, package dependencies (requirements), projects. -Once you are ready and want to use a tool to **manage the entire project**, packages dependencies, virtual environments, etc. I would suggest you try uv. +Once you are ready and want to use a tool to **manage the entire project**, packages dependencies, virtual environments, etc. I would suggest you try [uv](https://github.com/astral-sh/uv). `uv` can do a lot of things, it can: diff --git a/mkdocs.yml b/mkdocs.yml index 6e09fb21c4..75b9c43262 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,6 +4,7 @@ site_description: Typer, build great CLIs. Easy to code. Based on Python type hi site_url: https://typer.tiangolo.com/ theme: + variant: classic name: material custom_dir: docs/overrides palette: @@ -49,24 +50,13 @@ theme: - toc.follow icon: - repo: fontawesome/brands/github-alt + repo: octicons/mark-github-24 logo: img/icon.svg favicon: img/favicon.png language: en repo_name: fastapi/typer repo_url: https://github.com/fastapi/typer plugins: - # Material for MkDocs - search: - social: - typeset: - # Other plugins - macros: - include_yaml: - - members: data/members.yml - redirects: - redirect_maps: - typer-cli.md: tutorial/typer-command.md mkdocstrings: handlers: python: @@ -90,7 +80,7 @@ plugins: nav: - Typer: index.md - features.md - - Tutorial - User Guide: + - "": - tutorial/index.md - environment-variables.md - virtual-environments.md @@ -99,14 +89,14 @@ nav: - tutorial/typer-app.md - tutorial/printing.md - tutorial/terminating.md - - CLI Arguments: + - "": - tutorial/arguments/index.md - tutorial/arguments/optional.md - tutorial/arguments/default.md - tutorial/arguments/help.md - tutorial/arguments/envvar.md - tutorial/arguments/other-uses.md - - CLI Options: + - "": - tutorial/options/index.md - tutorial/options/help.md - tutorial/options/required.md @@ -115,7 +105,7 @@ nav: - tutorial/options/name.md - tutorial/options/callback-and-context.md - tutorial/options/version.md - - Commands: + - "": - tutorial/commands/index.md - tutorial/commands/arguments.md - tutorial/commands/options.md @@ -125,7 +115,7 @@ nav: - tutorial/commands/one-or-multiple.md - tutorial/commands/context.md - tutorial/options-autocompletion.md - - CLI Parameter Types: + - "": - tutorial/parameter-types/index.md - tutorial/parameter-types/number.md - tutorial/parameter-types/bool.md @@ -135,14 +125,14 @@ nav: - tutorial/parameter-types/path.md - tutorial/parameter-types/file.md - tutorial/parameter-types/custom-types.md - - SubCommands - Command Groups: + - "": - tutorial/subcommands/index.md - tutorial/subcommands/add-typer.md - tutorial/subcommands/single-file.md - tutorial/subcommands/nested-subcommands.md - tutorial/subcommands/callback-override.md - tutorial/subcommands/name-and-help.md - - Multiple Values: + - "": - tutorial/multiple-values/index.md - tutorial/multiple-values/multiple-options.md - tutorial/multiple-values/options-with-multiple-values.md @@ -156,19 +146,19 @@ nav: - tutorial/exceptions.md - tutorial/one-file-per-command.md - tutorial/typer-command.md - - Reference (Code API): + - tutorial/click.md + - "": - reference/index.md - reference/typer.md - reference/run_launch.md - reference/parameters.md - reference/file_objects.md - reference/context.md - - Resources: + - "": - resources/index.md - help-typer.md - contributing.md - - management-tasks.md - - About: + - "": - about/index.md - alternatives.md - management.md @@ -180,6 +170,7 @@ markdown_extensions: targets: include: - "*" + zensical.extensions.macros: # Python Markdown abbr: attr_list: @@ -208,18 +199,6 @@ markdown_extensions: # pymdownx blocks pymdownx.blocks.admonition: - types: - - note - - attention - - caution - - danger - - error - - tip - - hint - - warning - # Custom types - - info - - check pymdownx.blocks.details: pymdownx.blocks.tab: alternate_style: True @@ -230,16 +209,14 @@ markdown_extensions: extra: social: - - icon: fontawesome/brands/github-alt + - icon: octicons/mark-github-24 link: https://github.com/fastapi/typer - - icon: fontawesome/brands/twitter - link: https://twitter.com/tiangolo + - icon: fontawesome/brands/x-twitter + link: https://x.com/tiangolo + - icon: fontawesome/brands/bluesky + link: https://bsky.app/profile/tiangolo.com - icon: fontawesome/brands/linkedin link: https://www.linkedin.com/in/tiangolo - - icon: fontawesome/brands/dev - link: https://dev.to/tiangolo - - icon: fontawesome/brands/medium - link: https://medium.com/@tiangolo - icon: fontawesome/solid/globe link: https://tiangolo.com @@ -251,5 +228,5 @@ extra_javascript: - js/termynal.js - js/custom.js -hooks: - - scripts/mkdocs_hooks.py +validation: + unresolved_references: false diff --git a/pyproject.toml b/pyproject.toml index 89cbde9893..ad636bb93d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,10 +33,10 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "click >= 8.2.1", "shellingham >=1.3.0", "rich >=13.8.0", "annotated-doc >=0.0.2", + "colorama; platform_system == 'Windows'", ] readme = "README.md" @@ -55,6 +55,7 @@ dev = [ { include-group = "tests" }, { include-group = "docs" }, "prek >=0.3.2", + "zizmor >=1.23.1", ] docs = [ "cairosvg >=2.8.2", @@ -62,12 +63,10 @@ docs = [ "griffe-warnings-deprecated >=1.1.0", "markdown-include-variants >=0.0.8", "mdx-include >=1.4.1", - "mkdocs-macros-plugin >=1.5.0", - "mkdocs-material >=9.7.1", - "mkdocs-redirects >=1.2.1", - "mkdocstrings[python] >=0.30.1", + "mkdocstrings[python] >=1.0.3", "pillow >=11.3.0", "pyyaml >=5.3.1", + "zensical>=0.0.42", ] github-actions = [ "httpx >=0.27.0", @@ -163,7 +162,6 @@ ignore = [ "E501", # line too long, handled by black "B008", # do not perform function calls in argument defaults "C901", # too complex - "W191", # indentation contains tabs "TID252", # relative imports okay ] @@ -191,7 +189,7 @@ ignore = [ "docs_src/*" = ["TID"] [tool.ruff.lint.isort] -known-third-party = ["typer", "click"] +known-third-party = ["typer"] # For docs_src/subcommands/tutorial003/ known-first-party = ["reigns", "towns", "lands", "items", "users"] @@ -212,3 +210,21 @@ Use 'typer._completion_shared._get_shell_name' instead of using \ [tool.ty.terminal] error-on-warning = true + +[tool.typos.files] +extend-exclude = [ + "coverage/", + "dist/", + "docs/img/", + "docs/release-notes.md", + "htmlcov/", + "site/", + "site_build/", + "uv.lock", +] + +[tool.typos.default.extend-identifiers] +alls = "alls" + +[tool.typos.default.extend-words] +Ines = "Ines" diff --git a/scripts/docs.py b/scripts/docs.py index 4aafaa98c4..802dab53af 100644 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -82,7 +82,7 @@ def live(dirty: bool = False) -> None: en. """ # Enable line numbers during local development to make it easier to highlight - args = ["mkdocs", "serve", "--dev-addr", "127.0.0.1:8008"] + args = ["zensical", "serve", "--dev-addr", "127.0.0.1:8008"] if dirty: args.append("--dirty") subprocess.run(args, env={**os.environ, "LINENUMS": "true"}, check=True) @@ -94,7 +94,7 @@ def build() -> None: Build the docs. """ print("Building docs") - subprocess.run(["mkdocs", "build"], check=True) + subprocess.run(["zensical", "build"], check=True) typer.secho("Successfully built docs", color=typer.colors.GREEN) @@ -103,7 +103,7 @@ def serve() -> None: """ A quick server to preview a built site. - For development, prefer the command live (or just mkdocs serve). + For development, prefer the command live (or just zensical serve). This is here only to preview the documentation site. diff --git a/scripts/mkdocs_hooks.py b/scripts/mkdocs_hooks.py deleted file mode 100644 index 783ff23302..0000000000 --- a/scripts/mkdocs_hooks.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import Any - -from mkdocs.config.defaults import MkDocsConfig -from mkdocs.structure.files import Files -from mkdocs.structure.nav import Link, Navigation, Section -from mkdocs.structure.pages import Page - - -def generate_renamed_section_items( - items: list[Page | Section | Link], *, config: MkDocsConfig -) -> list[Page | Section | Link]: - new_items: list[Page | Section | Link] = [] - for item in items: - if isinstance(item, Section): - new_title = item.title - new_children = generate_renamed_section_items(item.children, config=config) - first_child = new_children[0] - if isinstance(first_child, Page): - if first_child.file.src_path.endswith("index.md"): - # Read the source so that the title is parsed and available - first_child.read_source(config=config) - new_title = first_child.title or new_title - # Creating a new section makes it render it collapsed by default - # no idea why, so, let's just modify the existing one - # new_section = Section(title=new_title, children=new_children) - item.title = new_title - item.children = new_children - new_items.append(item) - else: - new_items.append(item) - return new_items - - -def on_nav( - nav: Navigation, *, config: MkDocsConfig, files: Files, **kwargs: Any -) -> Navigation: - new_items = generate_renamed_section_items(nav.items, config=config) - return Navigation(items=new_items, pages=nav.pages) diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py new file mode 100644 index 0000000000..73ea40f2ff --- /dev/null +++ b/scripts/prepare_release.py @@ -0,0 +1,209 @@ +"""Prepare a release by updating the package version and release notes.""" + +import re +from datetime import date +from pathlib import Path +from typing import Annotated, Literal + +import typer + +VERSION_PATTERN = re.compile(r'(?m)^__version__ = "(\d+\.\d+\.\d+)"$') +VERSION_HEADING_PATTERN = re.compile(r"(?m)^## (\d+\.\d+\.\d+)(?: \([^)]+\))?$") +RELEASE_NOTES_HEADER = "# Release Notes\n\n" +LATEST_CHANGES_HEADER = "## Latest Changes" +BumpType = Literal["major", "minor", "patch"] + +app = typer.Typer() + + +def parse_version(version: str) -> tuple[int, int, int]: + match = re.fullmatch(r"\d+\.\d+\.\d+", version) + if not match: + raise ValueError(f"Invalid version: {version!r}. Expected format: X.Y.Z") + major, minor, patch = version.split(".") + return int(major), int(minor), int(patch) + + +def get_current_version(content: str, version_file: Path) -> str: + matches = list(VERSION_PATTERN.finditer(content)) + if len(matches) != 1: + raise RuntimeError( + f"Expected exactly one __version__ assignment in {version_file}, " + f"found {len(matches)}" + ) + return matches[0].group(1) + + +def bump_version(version: str, bump: BumpType) -> str: + major, minor, patch = parse_version(version) + if bump == "major": + return f"{major + 1}.0.0" + if bump == "minor": + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + + +def update_version_file(content: str, version: str, version_file: Path) -> str: + current_version = get_current_version(content, version_file) + if parse_version(version) <= parse_version(current_version): + raise RuntimeError( + f"New version {version} must be greater than current version {current_version}" + ) + return VERSION_PATTERN.sub(f'__version__ = "{version}"', content, count=1) + + +def update_release_notes( + content: str, version: str, release_date: date, release_notes_file: Path +) -> str: + if not content.startswith(RELEASE_NOTES_HEADER): + raise RuntimeError( + f"{release_notes_file} must start with {RELEASE_NOTES_HEADER!r}" + ) + if re.search(rf"^## {re.escape(version)}(?: \([^)]+\))?$", content, re.M): + raise RuntimeError(f"Release notes already contain a section for {version}") + + latest_header = f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n" + if not content.startswith(latest_header): + raise RuntimeError(f"{release_notes_file} must start with {latest_header!r}") + + release_header = f"## {version} ({release_date.isoformat()})" + return content.replace( + latest_header, + f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n\n{release_header}\n", + 1, + ) + + +def get_release_notes_body(content: str, version: str, release_notes_file: Path) -> str: + version_heading = re.compile(rf"(?m)^## {re.escape(version)}(?: \([^)]+\))?$") + match = version_heading.search(content) + if not match: + raise RuntimeError( + f"Could not find release notes section for {version} in {release_notes_file}" + ) + + next_match = VERSION_HEADING_PATTERN.search(content, match.end()) + end = next_match.start() if next_match else len(content) + body = content[match.end() : end].strip() + if not body: + raise RuntimeError( + f"Release notes section for {version} in {release_notes_file} is empty" + ) + return f"{body}\n" + + +@app.command() +def prepare( + bump: Annotated[ + BumpType, + typer.Argument( + envvar="PREPARE_RELEASE_BUMP", + help="The release bump to make: major, minor, or patch.", + ), + ], + version_file: Annotated[ + Path, + typer.Option( + envvar="PREPARE_RELEASE_VERSION_FILE", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + writable=True, + help="Path to the Python file containing the __version__ assignment.", + ), + ], + release_notes_file: Annotated[ + Path, + typer.Option( + envvar="PREPARE_RELEASE_RELEASE_NOTES_FILE", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + writable=True, + help="Path to the release notes Markdown file.", + ), + ], + release_date: Annotated[ + str, + typer.Option( + "--date", + envvar="PREPARE_RELEASE_DATE", + help="Release date in YYYY-MM-DD format. Defaults to today.", + ), + ] = date.today().isoformat(), +) -> None: + parsed_release_date = date.fromisoformat(release_date or date.today().isoformat()) + + version_file_content = version_file.read_text() + release_notes_content = release_notes_file.read_text() + version = bump_version( + get_current_version(version_file_content, version_file), bump + ) + + version_file.write_text( + update_version_file(version_file_content, version, version_file) + ) + release_notes_file.write_text( + update_release_notes( + release_notes_content, version, parsed_release_date, release_notes_file + ) + ) + + typer.echo(f"Prepared release {version} ({parsed_release_date.isoformat()})") + + +@app.command() +def current_version( + version_file: Annotated[ + Path, + typer.Option( + envvar="PREPARE_RELEASE_VERSION_FILE", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="Path to the Python file containing the __version__ assignment.", + ), + ], +) -> None: + typer.echo(get_current_version(version_file.read_text(), version_file)) + + +@app.command() +def release_notes( + version_file: Annotated[ + Path, + typer.Option( + envvar="PREPARE_RELEASE_VERSION_FILE", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="Path to the Python file containing the __version__ assignment.", + ), + ], + release_notes_file: Annotated[ + Path, + typer.Option( + envvar="PREPARE_RELEASE_RELEASE_NOTES_FILE", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="Path to the release notes Markdown file.", + ), + ], +) -> None: + version = get_current_version(version_file.read_text(), version_file) + typer.echo( + get_release_notes_body( + release_notes_file.read_text(), version, release_notes_file + ), + nl=False, + ) + + +if __name__ == "__main__": + app() diff --git a/tests/assets/completion_argument.py b/tests/assets/completion_argument.py index f91e2b7cfb..e2754c4357 100644 --- a/tests/assets/completion_argument.py +++ b/tests/assets/completion_argument.py @@ -1,10 +1,10 @@ -import click import typer +from typer import _click app = typer.Typer() -def shell_complete(ctx: click.Context, param: click.Parameter, incomplete: str): +def shell_complete(ctx: _click.Context, param: _click.Parameter, incomplete: str): typer.echo(f"ctx: {ctx.info_name}", err=True) typer.echo(f"arg is: {param.name}", err=True) typer.echo(f"incomplete is: {incomplete}", err=True) diff --git a/tests/assets/hidden_commands.py b/tests/assets/hidden_commands.py new file mode 100644 index 0000000000..a51809b803 --- /dev/null +++ b/tests/assets/hidden_commands.py @@ -0,0 +1,30 @@ +import typer + +app = typer.Typer() + + +@app.command() +def visible(): + """Visible command.""" + + +@app.command(hidden=True) +def hidden_decorated(): + """Hidden via @app.command(hidden=True).""" + + +def hidden_var(): + """Hidden via app.command(name, hidden=True)(fn).""" + + +app.command("hidden-var", hidden=True)(hidden_var) + +hidden_subgroup = typer.Typer(hidden=True) + + +@hidden_subgroup.command() +def sub(): + """Hidden subgroup command.""" + + +app.add_typer(hidden_subgroup, name="hidden-subgroup") diff --git a/tests/assets/corner_cases.py b/tests/assets/hidden_option.py similarity index 100% rename from tests/assets/corner_cases.py rename to tests/assets/hidden_option.py diff --git a/tests/atomic_write_example.py b/tests/atomic_write_example.py new file mode 100644 index 0000000000..a190cc154f --- /dev/null +++ b/tests/atomic_write_example.py @@ -0,0 +1,63 @@ +import time + +import typer + +app = typer.Typer() + + +@app.command() +def write_atomic( + config: typer.FileText = typer.Option(..., mode="w", atomic=True), + pause: float = typer.Option(0.3), +) -> None: + config.write("atomic-content-1\n") + config.flush() + typer.echo("halfway") + time.sleep(pause) + config.write("atomic-content-2\n") + config.flush() + typer.echo("written atomically") + + +@app.command() +def write_atomic_binary( + config: typer.FileBinaryWrite = typer.Option(..., atomic=True, lazy=False), +) -> None: + config.write(b"\x00\x01binary-atomic\n") + typer.echo("written binary atomically") + + +@app.command() +def api_atomic( + config: typer.FileText = typer.Option(..., mode="w", atomic=True, lazy=False), +) -> None: + typer.echo(f"name={config.name}") + typer.echo(f"repr={repr(config)}") + with config as entered: + typer.echo(f"entered={entered is config}") + entered.write("atomic-api-done\n") + + +@app.command() +def invalid_atomic_append( + config: typer.FileText = typer.Option(..., mode="a", atomic=True, lazy=False), +) -> None: + typer.echo(config.name) # pragma: no cover + + +@app.command() +def invalid_atomic_exclusive( + config: typer.FileText = typer.Option(..., mode="x", atomic=True, lazy=False), +) -> None: + typer.echo(config.name) # pragma: no cover + + +@app.command() +def invalid_atomic_read( + config: typer.FileText = typer.Option(..., mode="r", atomic=True, lazy=False), +) -> None: + typer.echo(config.name) # pragma: no cover + + +if __name__ == "__main__": + app() diff --git a/tests/test_annotated.py b/tests/test_annotated.py index c487eae9d0..af3cc0680b 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import Annotated +import pytest import typer from typer.testing import CliRunner @@ -95,3 +96,14 @@ def custom_parser( result = runner.invoke(app, "/some/quirky/path/implementation") assert result.exit_code == 0 + + +def test_annotated_option_invalid(): + app = typer.Typer() + + @app.command() + def cmd(value: Annotated[str, typer.Option(..., "foo-bar")]): + print(value) # pragma: no cover + + with pytest.raises(ValueError, match="Invalid start character for option"): + runner.invoke(app, ["--help"], catch_exceptions=False) diff --git a/tests/test_atomic_file.py b/tests/test_atomic_file.py new file mode 100644 index 0000000000..010f899034 --- /dev/null +++ b/tests/test_atomic_file.py @@ -0,0 +1,122 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + +from . import atomic_write_example as mod + + +def test_atomic_write(tmp_path: Path) -> None: + original_content = "existing-content\n" + output_file = tmp_path / "atomic-write-target.txt" + output_file.write_text(original_content, encoding="utf-8") + + process = subprocess.Popen( + [ + sys.executable, + "-m", + "coverage", + "run", + mod.__file__, + "write-atomic", + f"--config={output_file}", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + assert process.stdout is not None + + # Halfway of writing the file, check that the original content is still there + halfway_line = process.stdout.readline().strip() + assert halfway_line == "halfway" + assert output_file.read_text(encoding="utf-8") == original_content + + # Only at the end, the full new content is visible + stdout, stderr = process.communicate(timeout=5) + assert process.returncode == 0, stderr + assert "written atomically" in stdout + assert ( + output_file.read_text(encoding="utf-8") + == "atomic-content-1\natomic-content-2\n" + ) + + +def test_atomic_binary_write(tmp_path: Path) -> None: + output_file = tmp_path / "atomic-binary.bin" + + result = subprocess.run( + [ + sys.executable, + "-m", + "coverage", + "run", + mod.__file__, + "write-atomic-binary", + f"--config={output_file}", + ], + capture_output=True, + encoding="utf-8", + ) + + assert result.returncode == 0, result.stderr + assert "written binary atomically" in result.stdout + assert output_file.read_bytes() == b"\x00\x01binary-atomic\n" + + +def test_atomic_api(tmp_path: Path) -> None: + output_file = tmp_path / "atomic-api.txt" + + result = subprocess.run( + [ + sys.executable, + "-m", + "coverage", + "run", + mod.__file__, + "api-atomic", + f"--config={output_file}", + ], + capture_output=True, + encoding="utf-8", + ) + + assert result.returncode == 0, result.stderr + assert f"name={output_file}" in result.stdout + assert "repr=<_io.TextIOWrapper" in result.stdout + assert "entered=True" in result.stdout + assert output_file.read_text(encoding="utf-8") == "atomic-api-done\n" + + +@pytest.mark.parametrize( + ("command_name", "expected_message"), + [ + ("invalid-atomic-append", "Appending to an existing file is not supported"), + ("invalid-atomic-exclusive", "Use the `overwrite`-parameter instead."), + ("invalid-atomic-read", "Atomic writes only make sense with `w`-mode."), + ], +) +def test_atomic_mode_invalid_options( + tmp_path: Path, command_name: str, expected_message: str +) -> None: + output_file = tmp_path / "atomic-invalid-mode.txt" + output_file.write_text("existing-content\n", encoding="utf-8") + + result = subprocess.run( + [ + sys.executable, + "-m", + "coverage", + "run", + mod.__file__, + command_name, + f"--config={output_file}", + ], + capture_output=True, + encoding="utf-8", + ) + + assert result.returncode != 0 + combined_output = f"{result.stdout}\n{result.stderr}" + assert expected_message in combined_output diff --git a/tests/test_cli/test_help.py b/tests/test_cli/test_help.py index 64e5495c9a..e829c5801b 100644 --- a/tests/test_cli/test_help.py +++ b/tests/test_cli/test_help.py @@ -1,6 +1,11 @@ import subprocess import sys +import typer +from typer.testing import CliRunner + +runner = CliRunner() + def test_script_help(): result = subprocess.run( @@ -36,3 +41,119 @@ def test_not_python(): encoding="utf-8", ) assert "Could not import as Python file" in result.stderr + + +def test_short_help() -> None: + app = typer.Typer( + rich_markup_mode=None, + context_settings={"max_content_width": 50}, + ) + + @app.command(help=" \n\t ") + def empty() -> None: + pass # pragma: no cover + + @app.command(help="\b first sentence.") + def marker() -> None: + pass # pragma: no cover + + # Forcing truncation + @app.command(help=f"{'x' * 30} {'y' * 5} z trailing") + def long() -> None: + pass # pragma: no cover + + result = runner.invoke(app, ["--help"], terminal_width=50) + assert result.exit_code == 0 + assert "empty" in result.output + assert "marker" in result.output + assert "long" in result.output + assert "first sentence." in result.output + assert f"{'x' * 30}..." in result.output + + +def test_help_wrapping() -> None: + app = typer.Typer( + rich_markup_mode=None, + context_settings={"max_content_width": 50}, + ) + + @app.command( + help=( + "Wrapped paragraph has enough words to wrap in help output.\n" + "\n" + "\n" + "\b\n" + "RAW-LINE-ONE stays on one line even with many many many words.\n" + "RAW-LINE-TWO keeps original formatting.\n" + "\n" + "Final paragraph wraps normally as well." + ) + ) + def cmd() -> None: + pass # pragma: no cover + + result = runner.invoke(app, ["cmd", "--help"], terminal_width=50) + assert result.exit_code == 0 + assert "Wrapped paragraph has enough words to wrap" in result.output + assert ( + "RAW-LINE-ONE stays on one line even with many many many words." + in result.output + ) + assert "RAW-LINE-TWO keeps original formatting." in result.output + assert "Final paragraph wraps normally as well." in result.output + + +def test_help_wrapping_long_name() -> None: + app = typer.Typer(rich_markup_mode=None) + + @app.command() + def cmd(value: str) -> None: + pass # pragma: no cover + + result = runner.invoke( + app, + ["cmd", "--help"], + terminal_width=40, + prog_name="very-long-program-name-that-forces-wrap", + ) + assert result.exit_code == 0 + + output_lines = result.output.splitlines() + usage_idx = output_lines.index("Usage: very-long-program-name-that-forces-wrap ") + args_line = output_lines[usage_idx + 1] + assert args_line.lstrip() == "[OPTIONS] VALUE" + assert args_line.startswith(" ") + + +def test_format_long_help_option() -> None: + app = typer.Typer(rich_markup_mode=None) + + @app.command() + def cmd( + very_long: str = typer.Option( + ..., + "--this-is-a-very-very-very-long-option-name", + help="Description is rendered in the next line for long option labels.", + ), + ) -> None: + pass # pragma: no cover + + result = runner.invoke(app, ["cmd", "--help"], terminal_width=80) + assert result.exit_code == 0 + + output_lines = result.output.splitlines() + option_idx = next( + i + for i, line in enumerate(output_lines) + if "--this-is-a-very-very-very-long-option-name" in line + ) + assert "Description is rendered" not in output_lines[option_idx] + first_desc_line = output_lines[option_idx + 1] + assert first_desc_line.lstrip().startswith("Description is rendered") + continuation_block = " ".join( + line.strip() for line in output_lines[option_idx + 1 :] if line.startswith(" ") + ) + assert ( + "Description is rendered in the next line for long option labels." + in continuation_block + ) diff --git a/tests/test_cli/test_parser.py b/tests/test_cli/test_parser.py new file mode 100644 index 0000000000..9d9b641e4b --- /dev/null +++ b/tests/test_cli/test_parser.py @@ -0,0 +1,67 @@ +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_double_dash() -> None: + result = subprocess.run( + [ + sys.executable, + "-m", + "coverage", + "run", + "-m", + "typer", + "tests/assets/cli/sample.py", + "run", + "hello", + "--", + "--name", + "Camila", + ], + capture_output=True, + encoding="utf-8", + ) + assert "Got unexpected extra argument" in result.stderr + assert "--name Camila" in result.stderr + + +def test_unknown_short_option() -> None: + result = subprocess.run( + [ + sys.executable, + "-m", + "coverage", + "run", + "-m", + "typer", + "tests/assets/cli/sample.py", + "run", + "hello", + "-x", + ], + capture_output=True, + encoding="utf-8", + ) + assert "No such option: -x" in result.stderr + + +def test_ignore_unknown_short_option() -> None: + app = typer.Typer( + context_settings={"ignore_unknown_options": True, "allow_extra_args": True} + ) + + @app.command() + def main( + ctx: typer.Context, all_: bool = typer.Option(False, "--all", "-a") + ) -> None: + assert all_ + print(ctx.args) + + result = runner.invoke(app, ["-azq"]) + assert result.exit_code == 0 + assert "['-zq']" in result.output diff --git a/tests/test_cli/test_program_name.py b/tests/test_cli/test_program_name.py new file mode 100644 index 0000000000..4c20942596 --- /dev/null +++ b/tests/test_cli/test_program_name.py @@ -0,0 +1,13 @@ +from typer import _click + + +def test_detect_program_name_submodule_path() -> None: + class MainModule: + __package__ = "example" + + program_name = _click.utils._detect_program_name( + path="/tmp/cli.py", + _main=MainModule(), + ) + + assert program_name == "python -m example.cli" diff --git a/tests/test_completion/choice_case_insensitive_example.py b/tests/test_completion/choice_case_insensitive_example.py new file mode 100644 index 0000000000..1f208dd0b5 --- /dev/null +++ b/tests/test_completion/choice_case_insensitive_example.py @@ -0,0 +1,19 @@ +from enum import Enum + +import typer + +app = typer.Typer() + + +class User(str, Enum): + rick = "rick" + morty = "morty" + + +@app.command() +def main(name: User = typer.Option(User.rick, "--name", case_sensitive=False)): + print(name.value) + + +if __name__ == "__main__": + app() diff --git a/tests/test_completion/choice_example.py b/tests/test_completion/choice_example.py new file mode 100644 index 0000000000..2c47d0fdc4 --- /dev/null +++ b/tests/test_completion/choice_example.py @@ -0,0 +1,19 @@ +from enum import Enum + +import typer + +app = typer.Typer() + + +class User(str, Enum): + rick = "rick" + morty = "morty" + + +@app.command() +def main(name: User = typer.Option(User.rick, "--name")): + print(name.value) + + +if __name__ == "__main__": + app() diff --git a/tests/test_completion/completion_option_then_argument.py b/tests/test_completion/completion_option_then_argument.py new file mode 100644 index 0000000000..ae5abd8d2e --- /dev/null +++ b/tests/test_completion/completion_option_then_argument.py @@ -0,0 +1,23 @@ +import typer + +app = typer.Typer() + + +def complete_name(ctx, args, incomplete): + return ["opt-choice"] # pragma: no cover + + +def complete_target(ctx, args, incomplete): + return ["arg-choice"] + + +@app.command() +def main( + name: str = typer.Option(..., "--name", autocompletion=complete_name), + target: str = typer.Argument(..., autocompletion=complete_target), +): + print(name, target) # pragma: no cover + + +if __name__ == "__main__": + app() diff --git a/tests/test_completion/file_example.py b/tests/test_completion/file_example.py new file mode 100644 index 0000000000..56556d2006 --- /dev/null +++ b/tests/test_completion/file_example.py @@ -0,0 +1,12 @@ +import typer + +app = typer.Typer() + + +@app.command() +def main(config: typer.FileText = typer.Option(...)): + print(config.read()) + + +if __name__ == "__main__": + app() diff --git a/tests/test_completion/test_completion.py b/tests/test_completion/test_completion.py index 049ec4f6af..e9be4e25d1 100644 --- a/tests/test_completion/test_completion.py +++ b/tests/test_completion/test_completion.py @@ -3,9 +3,12 @@ import sys from pathlib import Path +from typer._click.shell_completion import CompletionItem + from docs_src.typer_app import tutorial001_py310 as mod from ..utils import needs_bash, needs_linux, requires_completion_permission +from . import completion_option_then_argument as mod_option_arg @needs_bash @@ -164,3 +167,27 @@ def test_completion_source_pwsh(): "Register-ArgumentCompleter -Native -CommandName tutorial001_py310.py -ScriptBlock $scriptblock" in result.stdout ) + + +def test_completion_option_argument() -> None: + file_name = Path(mod_option_arg.__file__).name + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod_option_arg.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + f"_{file_name.upper()}_COMPLETE": "complete_bash", + "COMP_WORDS": f"{file_name} --name chosen ", + "COMP_CWORD": "3", + }, + ) + assert "arg-choice" in result.stdout + assert "opt-choice" not in result.stdout + + +def test_completion_item_getattr() -> None: + item = CompletionItem("demo", source="envvar") + + assert item.source == "envvar" + assert item.missing is None diff --git a/tests/test_completion/test_completion_choice.py b/tests/test_completion/test_completion_choice.py new file mode 100644 index 0000000000..7cc4d81235 --- /dev/null +++ b/tests/test_completion/test_completion_choice.py @@ -0,0 +1,32 @@ +import os +import subprocess +import sys + +from . import choice_example as mod + + +def test_script() -> None: + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--name", "rick"], + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0 + assert "rick" in result.stdout + + +def test_completion_choice_bash() -> None: + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_CHOICE_EXAMPLE.PY_COMPLETE": "complete_bash", + "COMP_WORDS": "choice_example.py --name mo", + "COMP_CWORD": "2", + }, + ) + assert result.returncode == 0 + assert "morty" in result.stdout + assert "rick" not in result.stdout diff --git a/tests/test_completion/test_completion_choice_no_case.py b/tests/test_completion/test_completion_choice_no_case.py new file mode 100644 index 0000000000..f14097761d --- /dev/null +++ b/tests/test_completion/test_completion_choice_no_case.py @@ -0,0 +1,32 @@ +import os +import subprocess +import sys + +from . import choice_case_insensitive_example as mod + + +def test_script() -> None: + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--name", "rick"], + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0 + assert "rick" in result.stdout + + +def test_completion_choice_bash_case_insensitive() -> None: + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_CHOICE_CASE_INSENSITIVE_EXAMPLE.PY_COMPLETE": "complete_bash", + "COMP_WORDS": "choice_case_insensitive_example.py --name MO", + "COMP_CWORD": "2", + }, + ) + assert result.returncode == 0 + assert "morty" in result.stdout + assert "rick" not in result.stdout diff --git a/tests/test_completion/test_completion_file.py b/tests/test_completion/test_completion_file.py new file mode 100644 index 0000000000..782d3a04bc --- /dev/null +++ b/tests/test_completion/test_completion_file.py @@ -0,0 +1,39 @@ +import os +import subprocess +import sys + +from . import file_example as mod + + +def test_script() -> None: + result = subprocess.run( + [ + sys.executable, + "-m", + "coverage", + "run", + mod.__file__, + "--config", + mod.__file__, + ], + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0 + assert "def main(config: typer.FileText = typer.Option(...)):" in result.stdout + + +def test_completion_file_bash() -> None: + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_FILE_EXAMPLE.PY_COMPLETE": "complete_bash", + "COMP_WORDS": "file_example.py --config file_ex", + "COMP_CWORD": "2", + }, + ) + assert result.returncode == 0 + assert "file_ex" in result.stdout diff --git a/tests/test_completion/test_completion_option_colon.py b/tests/test_completion/test_completion_option_colon.py index f106eca862..8818ee4a1a 100644 --- a/tests/test_completion/test_completion_option_colon.py +++ b/tests/test_completion/test_completion_option_colon.py @@ -78,7 +78,9 @@ def test_completion_colon_zsh_all(): }, ) assert "alpine\\\\:hello" in result.stdout + assert "fake image\\\\: for testing" in result.stdout assert "alpine\\\\:latest" in result.stdout + assert "latest alpine image" in result.stdout assert "nvidia/cuda\\\\:10.0-devel-ubuntu18.04" in result.stdout @@ -94,7 +96,9 @@ def test_completion_colon_zsh_partial(): }, ) assert "alpine\\\\:hello" in result.stdout + assert "fake image\\\\: for testing" in result.stdout assert "alpine\\\\:latest" in result.stdout + assert "latest alpine image" in result.stdout assert "nvidia/cuda\\\\:10.0-devel-ubuntu18.04" not in result.stdout @@ -110,7 +114,9 @@ def test_completion_colon_zsh_single(): }, ) assert "alpine\\\\:hello" in result.stdout + assert "fake image\\\\: for testing" in result.stdout assert "alpine\\\\:latest" not in result.stdout + assert "latest alpine image" not in result.stdout assert "nvidia/cuda\\\\:10.0-devel-ubuntu18.04" not in result.stdout @@ -127,7 +133,9 @@ def test_completion_colon_powershell_all(): }, ) assert "alpine:hello" in result.stdout + assert "fake image: for testing" in result.stdout assert "alpine:latest" in result.stdout + assert "latest alpine image" in result.stdout assert "nvidia/cuda:10.0-devel-ubuntu18.04" in result.stdout @@ -144,7 +152,9 @@ def test_completion_colon_powershell_partial(): }, ) assert "alpine:hello" in result.stdout + assert "fake image: for testing" in result.stdout assert "alpine:latest" in result.stdout + assert "latest alpine image" in result.stdout assert "nvidia/cuda:10.0-devel-ubuntu18.04" not in result.stdout @@ -161,10 +171,45 @@ def test_completion_colon_powershell_single(): }, ) assert "alpine:hello" in result.stdout + assert "fake image: for testing" in result.stdout assert "alpine:latest" not in result.stdout + assert "latest alpine image" not in result.stdout assert "nvidia/cuda:10.0-devel-ubuntu18.04" not in result.stdout +def test_completion_powershell_option_equals_value() -> None: + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_COLON_EXAMPLE.PY_COMPLETE": "complete_powershell", + "_TYPER_COMPLETE_ARGS": "colon_example.py --name=alpine", + "_TYPER_COMPLETE_WORD_TO_COMPLETE": "--name=alpine", + }, + ) + assert "alpine:hello" in result.stdout + assert "alpine:latest" in result.stdout + assert "nvidia/cuda:10.0-devel-ubuntu18.04" not in result.stdout + + +def test_completion_powershell_option_equals_only() -> None: + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_COLON_EXAMPLE.PY_COMPLETE": "complete_powershell", + "_TYPER_COMPLETE_ARGS": "colon_example.py --name=", + "_TYPER_COMPLETE_WORD_TO_COMPLETE": "=", + }, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "" + + def test_completion_colon_pwsh_all(): result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], @@ -178,7 +223,9 @@ def test_completion_colon_pwsh_all(): ) assert "alpine:hello" in result.stdout + assert "fake image: for testing" in result.stdout assert "alpine:latest" in result.stdout + assert "latest alpine image" in result.stdout assert "nvidia/cuda:10.0-devel-ubuntu18.04" in result.stdout @@ -195,7 +242,9 @@ def test_completion_colon_pwsh_partial(): }, ) assert "alpine:hello" in result.stdout + assert "fake image: for testing" in result.stdout assert "alpine:latest" in result.stdout + assert "latest alpine image" in result.stdout assert "nvidia/cuda:10.0-devel-ubuntu18.04" not in result.stdout @@ -212,8 +261,64 @@ def test_completion_colon_pwsh_single(): }, ) assert "alpine:hello" in result.stdout + assert "fake image: for testing" in result.stdout assert "alpine:latest" not in result.stdout + assert "latest alpine image" not in result.stdout + assert "nvidia/cuda:10.0-devel-ubuntu18.04" not in result.stdout + + +def test_completion_colon_fish_all(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_COLON_EXAMPLE.PY_COMPLETE": "complete_fish", + "_TYPER_COMPLETE_ARGS": "colon_example.py --name ", + "_TYPER_COMPLETE_FISH_ACTION": "get-args", + }, + ) + assert "alpine:hello" in result.stdout + assert "fake image: for testing" in result.stdout + assert "alpine:latest" in result.stdout + assert "latest alpine image" in result.stdout + assert "nvidia/cuda:10.0-devel-ubuntu18.04" in result.stdout + + +def test_completion_colon_fish_partial(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_COLON_EXAMPLE.PY_COMPLETE": "complete_fish", + "_TYPER_COMPLETE_ARGS": "colon_example.py --name alpine", + "_TYPER_COMPLETE_FISH_ACTION": "get-args", + }, + ) + assert "alpine:hello" in result.stdout + assert "fake image: for testing" in result.stdout + assert "alpine:latest" in result.stdout + assert "latest alpine image" in result.stdout assert "nvidia/cuda:10.0-devel-ubuntu18.04" not in result.stdout -# TODO: tests for complete_fish +def test_completion_colon_fish_single(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_COLON_EXAMPLE.PY_COMPLETE": "complete_fish", + "_TYPER_COMPLETE_ARGS": "colon_example.py --name alpine:hell", + "_TYPER_COMPLETE_FISH_ACTION": "get-args", + }, + ) + assert "alpine:hello" in result.stdout + assert "fake image: for testing" in result.stdout + assert "alpine:latest" not in result.stdout + assert "latest alpine image" not in result.stdout + assert "nvidia/cuda:10.0-devel-ubuntu18.04" not in result.stdout diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000000..2cb759918b --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,379 @@ +from typing import Annotated + +import pytest +import typer +import typer._completion_shared +import typer.completion +from typer import _click +from typer.core import TyperArgument, TyperCommand, TyperGroup, TyperOption, _split_opt +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_human_readable_name() -> None: + app = typer.Typer() + + @app.command() + def main( + my_arg_1: Annotated[str, typer.Argument()], + my_arg_2: Annotated[str, typer.Argument(metavar="META_ARG")], + my_opt: Annotated[str, typer.Option()], + ): + pass # pragma: no cover + + command = typer.main.get_command(app) + params = {param.name: param for param in command.params} + + assert params["my_arg_1"].human_readable_name == "MY_ARG_1" + assert params["my_arg_2"].human_readable_name == "META_ARG" + assert params["my_opt"].human_readable_name == "my_opt" + + +def test_parameter_metavar() -> None: + app = typer.Typer(rich_markup_mode=None) + + @app.command() + def cmd(name: Annotated[str, typer.Option(metavar="CUSTOM")]) -> None: + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "--name CUSTOM" in result.output + + +def test_parameter_nargs_gt_1() -> None: + param = TyperArgument(param_decls=["value"], type=str, nargs=2) + ctx = _click.Context(TyperCommand(name="cmd")) + + assert param.type_cast_value(ctx, ("one", "two")) == ("one", "two") + + with pytest.raises( + _click.exceptions.BadParameter, match="Takes 2 values but 1 given." + ): + param.type_cast_value(ctx, ("one",)) + + +def test_parameter_constructor() -> None: + # no param_decl and expose_value is False: sets name to None + arg = TyperArgument(param_decls=[], expose_value=False) + assert arg.name is None + assert arg.opts == [] + assert arg.secondary_opts == [] + + # no param_decl and expose_value is True: raises + with pytest.raises(TypeError, match="does not have a name."): + TyperArgument(param_decls=[], expose_value=True) + + # len(param_decl) > 1: raises + with pytest.raises(TypeError, match="take exactly one parameter declaration"): + TyperArgument(param_decls=["first", "second"]) + + # duplicated identifier in option declarations: raises + with pytest.raises(TypeError, match="Name 'name' defined twice"): + TyperOption(param_decls=["name", "name"], required=False) + + # same true/false flag in boolean option declaration: raises + with pytest.raises(ValueError, match="cannot use the same flag for true/false"): + TyperOption(param_decls=["flag", "--flag/--flag"], required=False, is_flag=True) + + # inferred name is not a valid identifier: sets name to None + unnamed_option = TyperOption(param_decls=["--123"], required=False) + assert unnamed_option.name is None + + # no param_decl and prompt=True: raises + with pytest.raises(TypeError, match="'name' is required with 'prompt=True'."): + TyperOption(param_decls=[], expose_value=False, prompt=True, required=False) + + # count works + option = TyperOption( + param_decls=["verbose", "--verbose", "-v"], + type=None, + default=0, + required=False, + count=True, + ) + assert isinstance(option.type, _click.types.IntRange) + assert option.type.min == 0 + + +def test_option_error_hint() -> None: + option = TyperOption( + param_decls=["name", "--name"], + required=False, + show_envvar=True, + envvar="APP_NAME", + ) + hint = option.get_error_hint(_click.Context(TyperCommand(name="cmd"))) + assert "(env var: 'APP_NAME')" in hint + + +def test_group_init() -> None: + group_no_commands = TyperGroup(name="root", commands=None) + assert group_no_commands.commands == {} + + named = TyperCommand(name="named") + unnamed = TyperCommand(name=None) + group_command_sequence = TyperGroup(name="root", commands=[named, unnamed]) + assert group_command_sequence.commands == {"named": named} + + +@pytest.mark.parametrize("with_result_callback", [False, True]) +def test_group_result_callback(with_result_callback: bool) -> None: + called = {"child": False, "result_callback": False} + + def child_callback() -> None: + called["child"] = True + return None + + def result_callback(value, **kwargs): # type: ignore[no-untyped-def] + called["result_callback"] = True + return value + + child = TyperCommand(name="child", callback=child_callback) + group = TyperGroup( + name="root", + commands={"child": child}, + result_callback=result_callback if with_result_callback else None, + ) + ctx = group.make_context("root", ["child"]) + + result = group.invoke(ctx) + + assert result is None + assert called["child"] is True + assert called["result_callback"] is with_result_callback + assert ctx.invoked_subcommand == "child" + + +def test_group_add_command() -> None: + group = TyperGroup(name="root") + unnamed_command = TyperCommand(name=None) + + with pytest.raises(TypeError, match="Command has no name."): + group.add_command(unnamed_command) + + +def test_group_click_resolve_command() -> None: + child = TyperCommand(name="child") + group = TyperGroup(name="root", commands={"child": child}) + ctx = group.make_context("root", ["CHILD"], token_normalize_func=str.lower) + + cmd_name, cmd, remaining = group._click_resolve_command(ctx, ["CHILD"]) + + assert cmd_name == "child" + assert cmd is child + assert remaining == [] + + +@pytest.mark.parametrize( + ("envvar", "auto_prefix", "set_env", "expected"), + [ + ("APP_NAME", None, True, "my-precious"), + (None, "APP", True, "my-precious"), + (None, None, False, None), + ], +) +def test_option_resolve_envvar( + monkeypatch: pytest.MonkeyPatch, + envvar: str | None, + auto_prefix: str | None, + set_env: bool, + expected: str | None, +) -> None: + option = TyperOption( + param_decls=["name", "--name"], + required=False, + envvar=envvar, + ) + if set_env: + monkeypatch.setenv("APP_NAME", "my-precious") + + ctx = _click.Context(TyperCommand(name="cmd"), auto_envvar_prefix=auto_prefix) + assert option.resolve_envvar_value(ctx) == expected + + +def test_option_resolve_envvar_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + option = TyperOption( + param_decls=["name", "--name"], + required=False, + envvar=["APP_NAME_1", "APP_NAME_2"], + ) + monkeypatch.delenv("APP_NAME_1", raising=False) + monkeypatch.delenv("APP_NAME_2", raising=False) + ctx = _click.Context(TyperCommand(name="cmd")) + + assert option.resolve_envvar_value(ctx) is None + + +def test_context_auto_envvar() -> None: + app = typer.Typer(context_settings={"auto_envvar_prefix": "APP"}) + sub_app = typer.Typer() + + @sub_app.command() + def clone(ctx: typer.Context) -> None: + print(ctx.auto_envvar_prefix) + + app.add_typer(sub_app, name="beth") + + result = runner.invoke(app, ["beth", "clone"]) + assert result.exit_code == 0 + assert "APP_BETH_CLONE" in result.stdout + + +def test_context_with_resource() -> None: + events: list[str] = [] + + class DemoResource: + def __enter__(self) -> str: + events.append("enter") + return "pickle-rick" + + def __exit__(self, *args: object) -> None: + events.append("exit") + + app = typer.Typer() + + @app.command() + def cmd(ctx: typer.Context) -> None: + value = ctx.with_resource(DemoResource()) + assert value == "pickle-rick" + assert events == ["enter"] + print("I'm a pickle") + + result = runner.invoke(app) + + assert result.exit_code == 0 + assert "I'm a pickle" in result.stdout + assert events == ["enter", "exit"] + + +def test_context_find_root() -> None: + app = typer.Typer() + sub_app = typer.Typer() + + @sub_app.command() + def child(ctx: typer.Context) -> None: + root = ctx.find_root() + assert root.parent is None + assert root is ctx.parent.parent + print("ok") + + app.add_typer(sub_app, name="sub") + + result = runner.invoke(app, ["sub", "child"]) + assert result.exit_code == 0 + assert "ok" in result.stdout + + +def test_context_find_object() -> None: + class Marker: + pass + + marker = Marker() + app = typer.Typer() + + @app.callback() + def callback(ctx: typer.Context) -> None: + ctx.obj = marker + + @app.command() + def child(ctx: typer.Context) -> None: + assert ctx.find_object(Marker) is marker + assert ctx.find_object(str) is None + print("ok") + + result = runner.invoke(app, ["child"]) + assert result.exit_code == 0 + assert "ok" in result.stdout + + +def test_context_lookup_default_callable() -> None: + app = typer.Typer() + + @app.command() + def child(ctx: typer.Context) -> None: + ctx.default_map = {"planet": lambda: "Earth"} + assert ctx.lookup_default("planet") == "Earth" + value = ctx.lookup_default("planet", call=False) + assert callable(value) + print("ok") + + result = runner.invoke(app) + assert result.exit_code == 0 + assert "ok" in result.stdout + + +def test_context_abort() -> None: + app = typer.Typer() + + @app.command() + def cmd(ctx: typer.Context) -> None: + ctx.abort() + + result = runner.invoke(app, standalone_mode=False) + assert result.exit_code == 1 + assert isinstance(result.exception, _click.core.Abort) + + +def test_command_help_disabled() -> None: + app = typer.Typer() + + @app.command(add_help_option=False) + def cmd() -> None: + pass # pragma: no cover + + result = runner.invoke(app, ["--help"], standalone_mode=False) + assert result.exit_code == 1 + assert isinstance(result.exception, _click.exceptions.NoSuchOption) + assert result.exception.option_name == "--help" + + +def test_command_help_deprecated() -> None: + app = typer.Typer(rich_markup_mode=None, epilog="Built with love") + + @app.command(short_help="Shorty", help="Regular help text.", deprecated=True) + def one() -> None: + pass # pragma: no cover + + @app.command() + def two() -> None: + pass # pragma: no cover + + result = runner.invoke(app, ["one", "--help"]) + assert result.exit_code == 0 + assert "Regular help text. (DEPRECATED)" in result.output + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Built with love" in result.output + assert "oneShorty(DEPRECATED)" in result.output.replace(" ", "") + + +@pytest.mark.parametrize( + ("value", "expected_prefix", "expected_opt"), + [ + ("--verbose", "--", "verbose"), + ("//verbose", "//", "verbose"), + ("-verbose", "-", "verbose"), + ("verbose", "", "verbose"), + ], +) +def test_split_opt(value: str, expected_prefix: str, expected_opt: str) -> None: + prefix, opt = _split_opt(value) + assert prefix == expected_prefix + assert opt == expected_opt + + +def test_nargs_default_map(): + app = typer.Typer() + + @app.command() + def main(names: list[str] = typer.Option(None)): + print(names) # pragma: no cover + + result = runner.invoke(app, [], default_map={"names": "not-a-list"}) + assert result.exit_code == 2 + assert "Invalid value" in result.output diff --git a/tests/test_corner_cases.py b/tests/test_corner_cases.py deleted file mode 100644 index dfef7ddb60..0000000000 --- a/tests/test_corner_cases.py +++ /dev/null @@ -1,35 +0,0 @@ -import pytest -import typer.core -from typer.testing import CliRunner - -from tests.assets import corner_cases as mod - -runner = CliRunner() - - -def test_hidden_option(): - result = runner.invoke(mod.app, ["--help"]) - assert result.exit_code == 0 - assert "Say hello" in result.output - assert "--name" not in result.output - assert "/lastname" in result.output - assert "TEST_LASTNAME" in result.output - assert "(dynamic)" in result.output - - -def test_hidden_option_no_rich(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(typer.core, "HAS_RICH", False) - - result = runner.invoke(mod.app, ["--help"]) - assert result.exit_code == 0 - assert "Say hello" in result.output - assert "--name" not in result.output - assert "/lastname" in result.output - assert "TEST_LASTNAME" in result.output - assert "(dynamic)" in result.output - - -def test_coverage_call(): - result = runner.invoke(mod.app) - assert result.exit_code == 0 - assert "Hello John Doe, it seems you have 42" in result.output diff --git a/tests/test_hidden.py b/tests/test_hidden.py new file mode 100644 index 0000000000..0d0874d2ba --- /dev/null +++ b/tests/test_hidden.py @@ -0,0 +1,52 @@ +import pytest +import typer.core +from typer.testing import CliRunner + +from tests.assets import hidden_commands as mod_command +from tests.assets import hidden_option as mod_option +from tests.utils import needs_rich + +runner = CliRunner() + + +@pytest.mark.parametrize( + "use_rich", + [ + pytest.param(False), + pytest.param(True, marks=needs_rich), + ], +) +def test_hidden_option(monkeypatch: pytest.MonkeyPatch, use_rich: bool) -> None: + monkeypatch.setattr(typer.core, "HAS_RICH", use_rich) + + result = runner.invoke(mod_option.app, ["--help"]) + assert result.exit_code == 0 + assert "Say hello" in result.output + assert "--name" not in result.output + assert "/lastname" in result.output + assert "TEST_LASTNAME" in result.output + assert "(dynamic)" in result.output + + +def test_coverage_call() -> None: + result = runner.invoke(mod_option.app) + assert result.exit_code == 0 + assert "Hello John Doe, it seems you have 42" in result.output + + +@pytest.mark.parametrize( + "use_rich", + [ + pytest.param(False), + pytest.param(True, marks=needs_rich), + ], +) +def test_hidden_commands(monkeypatch: pytest.MonkeyPatch, use_rich: bool) -> None: + monkeypatch.setattr(typer.core, "HAS_RICH", use_rich) + + result = runner.invoke(mod_command.app, ["--help"]) + assert result.exit_code == 0 + assert "visible" in result.output + assert "hidden-decorated" not in result.output + assert "hidden-var" not in result.output + assert "hidden-subgroup" not in result.output diff --git a/tests/test_launch.py b/tests/test_launch.py index c15d6c57da..1a97c197aa 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -1,10 +1,11 @@ +import io import subprocess from unittest.mock import patch import pytest import typer -from tests.utils import needs_windows +from tests.utils import needs_linux, needs_macos, needs_windows url = "http://example.com" @@ -51,19 +52,94 @@ def test_launch_url_no_xdg_open(): mock_webbrowser_open.assert_called_once_with(url) -def test_calls_original_launch_when_not_passing_urls(): - with patch("typer.main.click.launch", return_value=0) as launch_mock: - typer.launch("not a url") +@pytest.fixture +def allow_dev_null(monkeypatch): + real_open = open - launch_mock.assert_called_once_with("not a url", wait=False, locate=False) + def fake_open(path, *args, **kwargs): + if path == "/dev/null": + return io.StringIO() + return real_open(path, *args, **kwargs) # pragma: no cover + + monkeypatch.setattr("builtins.open", fake_open) + + +@needs_macos +def test_open_url_macos(monkeypatch, allow_dev_null): + recorded: list[list[str]] = [] + + class Proc: + def wait(self) -> int: + return 42 + + def fake_popen(args, **kwargs): + recorded.append(list(args)) + return Proc() + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + assert typer.launch("/path/to/file", wait=True, locate=True) == 42 + assert recorded[0][:3] == ["open", "-W", "-R"] + assert recorded[0][-1] == "/path/to/file" + + +@needs_windows +def test_launch_files_windows(monkeypatch): + calls: list[list[str]] = [] + + def fake_call(args): + calls.append(list(args)) + return 0 + + monkeypatch.setattr(subprocess, "call", fake_call) + + assert typer.launch("C:/Tools/readme.txt", wait=True, locate=False) == 0 + assert typer.launch("file:///C:/tmp/a.txt", wait=False, locate=True) == 0 + assert calls.pop(0) == ["start", "/WAIT", "", "C:/Tools/readme.txt"] + assert calls.pop(0) == ["explorer", "/select,/C:/tmp/a.txt"] + + monkeypatch.setattr(subprocess, "call", lambda a: (_ for _ in ()).throw(OSError())) + assert typer.launch("D:/no/such/file.txt", wait=False, locate=False) == 127 + + +@needs_linux +def test_open_url_linux_wait(monkeypatch): + class Proc: + def __init__(self, code: int = 0) -> None: + self._code = code + + def wait(self) -> int: + return self._code + + monkeypatch.setattr(subprocess, "Popen", lambda *a, **k: Proc(7)) + + assert typer.launch("/file", wait=True, locate=False) == 7 + + +@needs_linux +def test_open_url_linux_locate(monkeypatch): + recorded: list[list[str]] = [] + + class Proc: + def wait(self) -> int: + return 0 # pragma: no cover + + def fake_popen(args, **kwargs): + recorded.append(list(args)) + return Proc() + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + assert typer.launch("/tmp/sub/file.txt", wait=False, locate=True) == 0 + assert recorded[-1] == ["xdg-open", "/tmp/sub"] @needs_windows def test_launch_file(): with ( - patch("click._termui_impl.sys.platform", "win32"), - patch("click._termui_impl.WIN", True), - patch("click._termui_impl.CYGWIN", False), + patch("typer._click._termui_impl.sys.platform", "win32"), + patch("typer._click._termui_impl.WIN", True), + patch("typer._click._termui_impl.CYGWIN", False), patch("subprocess.call", return_value=0) as call_mock, ): result = typer.launch("C:/tmp/file.txt", locate=True) diff --git a/tests/test_others.py b/tests/test_others.py index b389ed353f..d2cc8696f1 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -6,12 +6,11 @@ from typing import Annotated from unittest import mock -import click import pytest import typer import typer._completion_shared import typer.completion -from typer.core import _split_opt +from typer import _click from typer.main import solve_typer_info_defaults, solve_typer_info_help from typer.models import ParameterInfo, TyperInfo from typer.testing import CliRunner @@ -37,14 +36,14 @@ def test_too_many_parsers(): def custom_parser(value: str) -> int: return int(value) # pragma: no cover - class CustomClickParser(click.ParamType): + class CustomClickParser(_click.types.ParamType): name = "custom_parser" def convert( self, value: str, - param: click.Parameter | None, - ctx: click.Context | None, + param: _click.Parameter | None, + ctx: _click.Context | None, ) -> typing.Any: return int(value) # pragma: no cover @@ -61,14 +60,14 @@ def test_valid_parser_permutations(): def custom_parser(value: str) -> int: return int(value) # pragma: no cover - class CustomClickParser(click.ParamType): + class CustomClickParser(_click.types.ParamType): name = "custom_parser" def convert( self, value: str, - param: click.Parameter | None, - ctx: click.Context | None, + param: _click.Parameter | None, + ctx: _click.Context | None, ) -> typing.Any: return int(value) # pragma: no cover @@ -104,7 +103,7 @@ def name_callback(ctx, param, val1, val2): def main(name: str = typer.Option(..., callback=name_callback)): pass # pragma: no cover - with pytest.raises(click.ClickException) as exc_info: + with pytest.raises(_click.ClickException) as exc_info: runner.invoke(app, ["--name", "Camila"]) assert ( exc_info.value.message == "Too many CLI parameter callback function parameters" @@ -127,6 +126,135 @@ def main(name: str = typer.Option(..., callback=name_callback)): assert "value is: Camila" in result.stdout +@pytest.mark.parametrize( + ("param_hint", "option_decls", "expected_message"), + [ + ("--name", (), "Invalid value for --name"), + (None, ("--name", "-n"), "Invalid value for '--name' / '-n'"), + ], +) +def test_bad_parameter_callback( + param_hint: str | None, option_decls: tuple[str, ...], expected_message: str +) -> None: + app = typer.Typer() + + def my_bad(value: str) -> str: + kwargs = {"param_hint": param_hint} if param_hint is not None else {} + raise typer.BadParameter("custom validation failed", **kwargs) + + @app.command() + def main(name: str = typer.Option(..., *option_decls, callback=my_bad)) -> None: + typer.echo(name) # pragma: no cover + + result = runner.invoke(app, ["--name", "Camila"]) + assert result.exit_code == 2 + assert expected_message in result.stderr + assert "custom validation failed" in result.stderr + + +def test_bad_parameter_main() -> None: + app = typer.Typer() + + @app.command() + def main() -> None: + raise typer.BadParameter("custom validation failed") + + result = runner.invoke(app, []) + assert result.exit_code == 2 + assert "Invalid value: custom validation failed" in result.stderr + + +@pytest.mark.parametrize( + ("kw", "msg"), + [ + ( + {"param_hint": ["--name", "-n"], "param_type": "parameter"}, + "Missing parameter '--name' / '-n'.", + ), + ({"param_type": "value"}, "Missing value."), + ], +) +def test_missing_parameter_msg(kw: dict[str, object], msg: str) -> None: + app = typer.Typer(rich_markup_mode=None) + + @app.command() + def main() -> None: + raise typer._click.exceptions.MissingParameter(**kw) + + result = runner.invoke(app, []) + assert result.exit_code == 2 + assert msg in result.stderr + + +def test_missing_parameter_callback_msg() -> None: + def my_cb(ctx: typer.Context, param: typer.CallbackParam, value: str) -> str: + raise typer._click.exceptions.MissingParameter( + message="My bad", ctx=ctx, param=param, param_type="parameter" + ) + + app = typer.Typer(rich_markup_mode=None) + + @app.command() + def main( + mode: Annotated[ + typing.Literal["alpha", "beta"], + typer.Option(..., "--mode", callback=my_cb), + ], + ) -> None: + typer.echo(mode) # pragma: no cover + + result = runner.invoke(app, ["--mode", "alpha"]) + assert result.exit_code == 2 + assert "Missing parameter '--mode'." in result.stderr + assert "My bad. Choose from:" in result.stderr + assert "alpha" in result.stderr + assert "beta" in result.stderr + result_msg = runner.invoke(app, ["--mode", "alpha"], standalone_mode=False) + assert isinstance(result_msg.exception, typer._click.exceptions.MissingParameter) + assert str(result_msg.exception) == "My bad" + + +def test_missing_parameter_str() -> None: + def my_cb(ctx: typer.Context, param: typer.CallbackParam, value: str) -> str: + raise typer._click.exceptions.MissingParameter(ctx=ctx, param=param) + + app = typer.Typer() + + @app.command() + def main(mode: str = typer.Option(..., "--mode", callback=my_cb)) -> None: + typer.echo(mode) # pragma: no cover + + result2 = runner.invoke(app, ["--mode", "alpha"], standalone_mode=False) + assert isinstance(result2.exception, typer._click.exceptions.MissingParameter) + assert str(result2.exception) == "Missing parameter: mode" + + +def test_click_exception_show_default_file() -> None: + app = typer.Typer(rich_markup_mode=None) + + @app.command() + def main() -> None: + raise typer._click.ClickException("custom click failure") + + result = runner.invoke(app, []) + assert result.exit_code == 1 + assert "custom click" in result.stderr + assert "failure" in result.stderr + + +def test_no_args_is_help_show() -> None: + app = typer.Typer(rich_markup_mode=None) + + @app.callback(invoke_without_command=True, no_args_is_help=True) + def main() -> None: + return None # pragma: no cover + + result = runner.invoke(app, []) + assert result.exit_code == 2 + assert "Usage:" in result.stderr + assert "Show this message and exit." in result.stderr + + def test_callback_3_untyped_parameters(): app = typer.Typer() @@ -169,6 +297,18 @@ def main( assert "Hello World" in result.stdout +def test_multiple_bool_flags() -> None: + app = typer.Typer() + + @app.command() + def main(choices: list[bool] = typer.Option([], "--accept/--reject")) -> None: + print(choices) + + result = runner.invoke(app, ["--accept", "--reject", "--accept"]) + assert result.exit_code == 0 + assert "[True, False, True]" in result.stdout + + def test_empty_list_default_generator(): def empty_list() -> list[str]: return [] @@ -185,6 +325,32 @@ def main( assert "[]" in result.output +def test_option_envvar(): + app = typer.Typer() + + @app.command() + def main(user: Annotated[str, typer.Option(envvar="ME")]): + print(f"Hello {user}") + + result = runner.invoke(app, env={"ME": "rick"}) + assert result.exit_code == 0 + assert "Hello rick" in result.output + + +def test_option_envvar_list(): + app = typer.Typer() + + @app.command() + def main(users: Annotated[list[str], typer.Option(envvar="ME")]): + for u in users: + print(f"Hello {u}") + + result = runner.invoke(app, env={"ME": "rick morty"}) + assert result.exit_code == 0 + assert "Hello rick" in result.output + assert "Hello morty" in result.output + + def test_completion_argument(): file_path = Path(__file__).parent / "assets/completion_argument.py" result = subprocess.run( @@ -266,7 +432,7 @@ def name_callback(ctx, args, incomplete, val2): def main(name: str = typer.Option(..., autocompletion=name_callback)): pass # pragma: no cover - with pytest.raises(click.ClickException) as exc_info: + with pytest.raises(_click.ClickException) as exc_info: runner.invoke(app, ["--name", "Camila"]) assert exc_info.value.message == "Invalid autocompletion callback parameters: val2" @@ -303,24 +469,6 @@ def main(name: str): assert "Show this message and exit." in result.stdout -def test_split_opt(): - prefix, opt = _split_opt("--verbose") - assert prefix == "--" - assert opt == "verbose" - - prefix, opt = _split_opt("//verbose") - assert prefix == "//" - assert opt == "verbose" - - prefix, opt = _split_opt("-verbose") - assert prefix == "-" - assert opt == "verbose" - - prefix, opt = _split_opt("verbose") - assert prefix == "" - assert opt == "verbose" - - def test_options_metadata_typer_default(): app = typer.Typer(options_metavar="[options]") diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py new file mode 100644 index 0000000000..024b125466 --- /dev/null +++ b/tests/test_prepare_release.py @@ -0,0 +1,295 @@ +from datetime import date +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from scripts.prepare_release import ( + BumpType, + app, + bump_version, + get_release_notes_body, + update_release_notes, + update_version_file, +) + +runner = CliRunner() + + +@pytest.mark.parametrize( + ("current_version", "bump", "new_version"), + [ + ("0.26.2", "major", "1.0.0"), + ("0.26.2", "minor", "0.27.0"), + ("0.26.2", "patch", "0.26.3"), + ], +) +def test_bump_version(current_version: str, bump: BumpType, new_version: str) -> None: + assert bump_version(current_version, bump) == new_version + + +def test_update_version_file() -> None: + content = '"""Typer."""\n\n__version__ = "0.26.2"\n' + + new_content = update_version_file(content, "0.26.3", Path("typer/__init__.py")) + + assert new_content == '"""Typer."""\n\n__version__ = "0.26.3"\n' + + +def test_update_version_file_requires_newer_version() -> None: + content = '__version__ = "0.26.2"\n' + + with pytest.raises(RuntimeError, match="must be greater"): + update_version_file(content, "0.26.2", Path("typer/__init__.py")) + + +def test_update_release_notes() -> None: + content = """# Release Notes + +## Latest Changes + +### Fixes + +* Fix something. + +## 0.26.2 (2026-05-27) + +### Fixes + +* Previous fix. +""" + + new_content = update_release_notes( + content, "0.26.3", date(2026, 5, 28), Path("docs/release-notes.md") + ) + + assert ( + new_content + == """# Release Notes + +## Latest Changes + +## 0.26.3 (2026-05-28) + +### Fixes + +* Fix something. + +## 0.26.2 (2026-05-27) + +### Fixes + +* Previous fix. +""" + ) + + +def test_update_release_notes_rejects_existing_version() -> None: + content = """# Release Notes + +## Latest Changes + +## 0.26.3 (2026-05-28) +""" + + with pytest.raises(RuntimeError, match="already contain"): + update_release_notes( + content, "0.26.3", date(2026, 5, 28), Path("docs/release-notes.md") + ) + + +def test_get_release_notes_body_with_dated_heading() -> None: + content = """# Release Notes + +## Latest Changes + +## 0.26.3 (2026-05-28) + +### Fixes + +* Fix something. + +## 0.26.2 (2026-05-27) + +### Fixes + +* Previous fix. +""" + + body = get_release_notes_body(content, "0.26.3", Path("docs/release-notes.md")) + + assert ( + body + == """### Fixes + +* Fix something. +""" + ) + + +def test_get_release_notes_body_with_plain_heading() -> None: + content = """# Release Notes + +## Latest Changes + +## 0.26.3 + +### Fixes + +* Fix something. +""" + + body = get_release_notes_body(content, "0.26.3", Path("docs/release-notes.md")) + + assert body == "### Fixes\n\n* Fix something.\n" + + +def test_get_release_notes_body_allows_non_version_h2_content() -> None: + content = """# Release Notes + +## Latest Changes + +## 0.26.3 + +## Highlights + +* Fix something. + +## 0.26.2 + +* Previous fix. +""" + + body = get_release_notes_body(content, "0.26.3", Path("docs/release-notes.md")) + + assert body == "## Highlights\n\n* Fix something.\n" + + +def test_get_release_notes_body_requires_version_section() -> None: + content = "# Release Notes\n\n## Latest Changes\n" + + with pytest.raises(RuntimeError, match="Could not find"): + get_release_notes_body(content, "0.26.3", Path("docs/release-notes.md")) + + +def test_get_release_notes_body_requires_non_empty_section() -> None: + content = """# Release Notes + +## Latest Changes + +## 0.26.3 + +## 0.26.2 + +* Previous fix. +""" + + with pytest.raises(RuntimeError, match="is empty"): + get_release_notes_body(content, "0.26.3", Path("docs/release-notes.md")) + + +def test_cli_updates_configured_files(tmp_path: Path) -> None: + version_file = tmp_path / "package" / "__init__.py" + version_file.parent.mkdir() + version_file.write_text('__version__ = "0.26.2"\n') + release_notes_file = tmp_path / "release-notes.md" + release_notes_file.write_text( + """# Release Notes + +## Latest Changes + +### Fixes + +* Fix something. +""" + ) + + result = runner.invoke( + app, + [ + "prepare", + "patch", + "--version-file", + str(version_file), + "--release-notes-file", + str(release_notes_file), + "--date", + "2026-05-28", + ], + ) + + assert result.exit_code == 0, result.output + assert "Prepared release 0.26.3 (2026-05-28)" in result.output + assert version_file.read_text() == '__version__ = "0.26.3"\n' + assert "## 0.26.3 (2026-05-28)" in release_notes_file.read_text() + + +def test_cli_accepts_env_vars(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + version_file = tmp_path / "package" / "__init__.py" + version_file.parent.mkdir() + version_file.write_text('__version__ = "0.26.2"\n') + release_notes_file = tmp_path / "docs" / "release-notes.md" + release_notes_file.parent.mkdir() + release_notes_file.write_text("# Release Notes\n\n## Latest Changes\n") + monkeypatch.setenv("PREPARE_RELEASE_BUMP", "minor") + monkeypatch.setenv("PREPARE_RELEASE_VERSION_FILE", str(version_file)) + monkeypatch.setenv("PREPARE_RELEASE_RELEASE_NOTES_FILE", str(release_notes_file)) + monkeypatch.setenv("PREPARE_RELEASE_DATE", "2026-05-28") + + result = runner.invoke(app, ["prepare"]) + + assert result.exit_code == 0, result.output + assert "Prepared release 0.27.0 (2026-05-28)" in result.output + assert version_file.read_text() == '__version__ = "0.27.0"\n' + assert "## 0.27.0 (2026-05-28)" in release_notes_file.read_text() + + +def test_cli_prints_current_version(tmp_path: Path) -> None: + version_file = tmp_path / "package" / "__init__.py" + version_file.parent.mkdir() + version_file.write_text('__version__ = "0.26.2"\n') + + result = runner.invoke( + app, + [ + "current-version", + "--version-file", + str(version_file), + ], + ) + + assert result.exit_code == 0, result.output + assert result.output == "0.26.2\n" + + +def test_cli_prints_release_notes(tmp_path: Path) -> None: + version_file = tmp_path / "package" / "__init__.py" + version_file.parent.mkdir() + version_file.write_text('__version__ = "0.26.3"\n') + release_notes_file = tmp_path / "release-notes.md" + release_notes_file.write_text( + """# Release Notes + +## Latest Changes + +## 0.26.3 (2026-05-28) + +### Fixes + +* Fix something. +""" + ) + + result = runner.invoke( + app, + [ + "release-notes", + "--version-file", + str(version_file), + "--release-notes-file", + str(release_notes_file), + ], + ) + + assert result.exit_code == 0, result.output + assert result.output == "### Fixes\n\n* Fix something.\n" diff --git a/tests/test_progress_bar.py b/tests/test_progress_bar.py new file mode 100644 index 0000000000..c684476dec --- /dev/null +++ b/tests/test_progress_bar.py @@ -0,0 +1,397 @@ +""" +Tests for the Progress bar functionality. +Created after vendoring Click to ensure test coverage is back up to 100%. +""" + +import io +import shutil + +import pytest +import typer +from typer import progressbar +from typer._click import _termui_impl +from typer.testing import CliRunner + +runner = CliRunner() + + +def _fake_clock(monkeypatch: pytest.MonkeyPatch) -> list[float]: + clock = [0.0] + monkeypatch.setattr(_termui_impl.time, "time", lambda: clock[0]) + return clock + + +def _pbar(**kw): + return progressbar(file=kw.pop("file", io.StringIO()), **kw) + + +@pytest.mark.parametrize( + ("iterable", "length", "hidden", "label", "expected_count"), + [ + (["a", "b"], None, False, "Processing", 2), + (None, 3, False, "Counting", 3), + (["x", "y"], None, True, "Hidden", 2), + ], +) +def test_progressbar(iterable, length, hidden, label, expected_count): + app = typer.Typer() + + @app.command() + def main(): + bar_out = io.StringIO() + count = 0 + with progressbar( + iterable, length=length, hidden=hidden, label=label, file=bar_out + ) as bar: + for _ in bar: + count += 1 + typer.echo(f"count={count}") + typer.echo(f"bar={bar_out.getvalue()!r}") + + result = runner.invoke(app, []) + assert result.exit_code == 0, result.output + assert f"count={expected_count}" in result.stdout + assert (label in result.stdout) == (not hidden) + + +@pytest.mark.parametrize( + ("label", "pbar_kw", "must_contain", "must_not_contain"), + [ + pytest.param( + "TTY", + { + "show_pos": True, + "show_percent": True, + "item_show_func": lambda item: f"item={item}", + }, + ("TTY", "1/1", "100%", "item=x"), + (), + ), + pytest.param( + "HeurPct", + {}, + ("HeurPct", "100%"), + ("1/1",), + ), + pytest.param( + "HeurPos", + {"show_pos": True}, + ("HeurPos", "1/1"), + ("100%",), + ), + ], +) +def test_progressbar_tty( + monkeypatch, label: str, pbar_kw: dict, must_contain, must_not_contain +): + monkeypatch.setattr(_termui_impl, "isatty", lambda f: True) + _fake_clock(monkeypatch) + + app = typer.Typer() + + @app.command() + def main(): + bar_out = io.StringIO() + with progressbar( + ["x"], + label=label, + file=bar_out, + bar_template="%(label)s %(info)s", + width=1, + **pbar_kw, + ) as bar: + for _ in bar: + pass + typer.echo(bar_out.getvalue()) + + result = runner.invoke(app, []) + assert result.exit_code == 0, result.output + for part in must_contain: + assert part in result.stdout + for part in must_not_contain: + assert part not in result.stdout + + +def test_progressbar_tty_show_eta(monkeypatch): + monkeypatch.setattr(_termui_impl, "isatty", lambda f: True) + clock = _fake_clock(monkeypatch) + clock[0] = 1_000.0 + + app = typer.Typer() + + @app.command() + def main(): + bar_out = io.StringIO() + with progressbar( + ["a", "b"], + label="ETA", + file=bar_out, + show_pos=True, + show_percent=False, + show_eta=True, + bar_template="%(label)s %(info)s", + width=1, + ) as bar: + for i, _ in enumerate(bar): + if i == 0: + clock[0] = 1_001.0 + typer.echo(bar_out.getvalue()) + + result = runner.invoke(app, []) + assert result.exit_code == 0, result.output + for part in ("ETA", "1/2", "00:00:01"): + assert part in result.stdout + + +def test_progressbar_autowidth(monkeypatch): + monkeypatch.setattr(_termui_impl, "isatty", lambda f: True) + call = [0] + real_get_terminal_size = shutil.get_terminal_size + + def fake_get_terminal_size(*args, **kwargs): + # Pytest (and others) call get_terminal_size(fallback=...); only stub no-arg calls + if args or kwargs: + return real_get_terminal_size(*args, **kwargs) + col = 120 if call[0] == 0 else 40 + call[0] += 1 + return type("TS", (), {"columns": col, "lines": 24})() + + monkeypatch.setattr(shutil, "get_terminal_size", fake_get_terminal_size) + + state: dict[str, object] = {} + + app = typer.Typer() + + @app.command() + def main(): + out = io.StringIO() + with progressbar(["a", "b"], width=0, label="AW", file=out) as bar: + state["autowidth"] = bar.autowidth + for _ in bar: + pass + state["call_count"] = call[0] + state["out"] = out.getvalue() + typer.echo("done") + + result = runner.invoke(app, []) + assert result.exit_code == 0, result.output + assert state["autowidth"] is True + assert state["call_count"] >= 2 + out = str(state["out"]) + assert "\r" in out and "AW" in out + assert "0%" in out and "50%" in out and "100%" in out + + +def test_progress_bar_iter(): + not_entered = _pbar(iterable=[1, 2], length=2) + with pytest.raises(RuntimeError, match="with block"): + iter(not_entered) + + entered = _pbar(iterable=[10, 20], length=2) + with entered: + iterator = iter(entered) + assert next(iterator) == 10 + assert next(entered) == 20 + with pytest.raises(StopIteration): + next(iterator) + + +def test_progress_bar_time(monkeypatch): + clock = _fake_clock(monkeypatch) + state: dict[str, object] = {} + clock[0] = 1_000.0 + + app = typer.Typer() + + @app.command() + def main(): + bar = _pbar(iterable=None, length=10) + state["tpi0"] = bar.time_per_iteration + clock[0] = 1_000.5 + bar.make_step(1) + state["avg_after_one"] = list(bar.avg) + state["tpi_after_one"] = bar.time_per_iteration + clock[0] = 1_001.0 + bar.make_step(1) + state["pos2"] = bar.pos + state["avg2"] = list(bar.avg) + state["tpi2"] = bar.time_per_iteration + clock[0] = 1_002.0 + bar.make_step(1) + state["avg3"] = list(bar.avg) + state["tpi3"] = bar.time_per_iteration + typer.echo("ok") + + result = runner.invoke(app, []) + assert result.exit_code == 0, result.output + assert state["tpi0"] == 0.0 + assert state["avg_after_one"] == [] and state["tpi_after_one"] == 0.0 + assert state["pos2"] == 2 + assert state["avg2"] == [(1_001.0 - 1_000.0) / 2.0] + assert state["tpi2"] == pytest.approx(0.5) + assert state["avg3"] == [0.5, (1_002.0 - 1_000.0) / 3.0] + assert state["tpi3"] == pytest.approx(sum(state["avg3"]) / 2.0) # type: ignore[arg-type] + + +def test_progress_bar_time_zero_steps(monkeypatch): + clock = _fake_clock(monkeypatch) + state: dict[str, object] = {} + clock[0] = 2_000.0 + + app = typer.Typer() + + @app.command() + def main(): + bar = _pbar(iterable=None, length=3) + clock[0] = 2_001.0 + bar.make_step(0) + state["pos"] = bar.pos + state["avg"] = list(bar.avg) + state["tpi"] = bar.time_per_iteration + typer.echo("ok") + + result = runner.invoke(app, []) + assert result.exit_code == 0, result.output + assert state["pos"] == 0 + assert state["avg"] == [1.0] + assert state["tpi"] == pytest.approx(1.0) + + +def test_progress_bar_eta(monkeypatch): + state: dict[str, object] = {} + + app = typer.Typer() + + @app.command() + def main(): + state["eta0"] = _pbar(iterable=[1, 2], length=None).eta + + done = _pbar(iterable=None, length=5) + done.pos, done.finished = 2, True + state["eta_done"] = done.eta + + fresh = _pbar(iterable=None, length=5) + state["eta_known_fresh"] = fresh.eta_known + state["fmt_eta_fresh"] = fresh.format_eta() + + clock = _fake_clock(monkeypatch) + clock[0] = 5_000.0 + bar = _pbar(iterable=None, length=10) + clock[0] = 5_001.0 + bar.make_step(3) + state["pos3"] = bar.pos + state["eta_after"] = bar.eta + state["tpi"] = bar.time_per_iteration + + cases_out = [] + for t0, t1, length, n_steps, _expected_fmt, expected_eta_int in ( + (9_000.0, 9_001.0, 10, 1, "00:00:09", None), + (1_000.0, 100_000.0, 2, 1, "1d 03:30:00", 99_000), + ): + clock2 = _fake_clock(monkeypatch) + clock2[0] = t0 + b = _pbar(iterable=None, length=length) + clock2[0] = t1 + b.make_step(n_steps) + cases_out.append( + ( + b.eta_known, + b.format_eta(), + int(b.eta) if expected_eta_int is not None else None, + ) + ) + + state["cases"] = cases_out + + clock3 = _fake_clock(monkeypatch) + clock3[0] = 3_000.0 + b2 = _pbar(iterable=None, length=2) + clock3[0] = 3_001.0 + b2.make_step(1) + state["fmt_before_finish"] = b2.format_eta() + b2.finish() + state["fmt_after_finish"] = b2.format_eta() + typer.echo("ok") + + result = runner.invoke(app, []) + assert result.exit_code == 0, result.output + assert state["eta0"] == 0.0 + assert state["eta_done"] == 0.0 + assert not state["eta_known_fresh"] and state["fmt_eta_fresh"] == "" + assert state["pos3"] == 3 + assert state["eta_after"] == pytest.approx(state["tpi"] * (10 - 3)) # type: ignore[operator] + + (ek1, fmt1, ei1), (ek2, fmt2, ei2) = state["cases"] # type: ignore[misc] + assert ek1 and fmt1 == "00:00:09" and ei1 is None + assert ek2 and fmt2 == "1d 03:30:00" and ei2 == 99_000 + + assert state["fmt_before_finish"] != "" + assert state["fmt_after_finish"] == "" + + +@pytest.mark.parametrize( + ("width", "fill_char", "empty_char", "expected_bar", "finished", "sample_timing"), + [ + pytest.param(4, "X", "-", "XXXX", True, False, id="finished"), + pytest.param(4, "#", "-", "----", False, False, id="no_timing_yet"), + pytest.param(5, "*", ".", None, False, True, id="indeterminate"), + ], +) +def test_progress_bar_unknown_length( + monkeypatch, + width: int, + fill_char: str, + empty_char: str, + expected_bar: str | None, + finished: bool, + sample_timing: bool, +): + clock: list[float] | None = _fake_clock(monkeypatch) if sample_timing else None + if clock is not None: + clock[0] = 100.0 + + state: dict[str, object] = {} + + class _IterableWithoutLength: + def __iter__(self): + return iter((1, 2, 3)) + + app = typer.Typer() + + @app.command() + def main(): + bar = _pbar( + iterable=_IterableWithoutLength(), + length=None, + width=width, + fill_char=fill_char, + empty_char=empty_char, + ) + assert bar.length is None + + if sample_timing: + assert clock is not None + clock[0] = 101.0 + bar.make_step(1) + assert bar.time_per_iteration > 0 + rendered = bar.format_bar() + assert len(rendered) == width + assert rendered.count(fill_char) == 1 + assert rendered.count(empty_char) == width - 1 + state["branch"] = "sample_timing" + elif finished: + bar.finished = True + state["bar"] = bar.format_bar() + state["branch"] = "finished" + else: + assert bar.time_per_iteration == 0.0 + state["bar"] = bar.format_bar() + state["branch"] = "no_timing" + typer.echo("ok") + + result = runner.invoke(app, []) + assert result.exit_code == 0, result.output + if sample_timing: + assert state["branch"] == "sample_timing" + else: + assert state["bar"] == expected_bar diff --git a/tests/test_termui.py b/tests/test_termui.py new file mode 100644 index 0000000000..8f908923db --- /dev/null +++ b/tests/test_termui.py @@ -0,0 +1,437 @@ +""" +Tests for the termui, echo, and CliRunner isolation functionality. +Created after vendoring Click to ensure test coverage is back up to 100%. +""" + +import io +import os +from contextlib import contextmanager +from typing import Literal + +import pytest +import typer +from typer._click import _termui_impl, termui +from typer.testing import CliRunner + +from tests.utils import needs_windows, skip_if_windows + + +def test_raw_terminal(monkeypatch): + runner = CliRunner() + app = typer.Typer() + state = {"entered": 0, "exited": 0} + + @contextmanager + def fake_raw_terminal(): + state["entered"] += 1 + try: + yield 42 + finally: + state["exited"] += 1 + + monkeypatch.setattr(_termui_impl, "raw_terminal", fake_raw_terminal) + + @app.command() + def main(): + with termui.raw_terminal() as fd: + typer.echo(f"fd={fd}") + + result = runner.invoke(app, []) + assert result.exit_code == 0, result.output + assert "fd=42" in result.stdout + assert state["entered"] == 1 + assert state["exited"] == 1 + + +def test_getchar(monkeypatch): + # Cached path: call the existing _getchar directly. + cached_state = {"echo": None} + + def cached_getchar(echo: bool) -> str: + cached_state["echo"] = echo + return "x" + + monkeypatch.setattr(termui, "_getchar", cached_getchar) + assert termui.getchar(echo=True) == "x" + assert cached_state["echo"] is True + + # Lazy-load path: _getchar is None, so import/cache _termui_impl.getchar. + lazy_state = {"calls": 0} + + def lazy_getchar(echo: bool) -> str: + lazy_state["calls"] += 1 + return "y" if not echo else "z" + + monkeypatch.setattr(termui, "_getchar", None) + monkeypatch.setattr(_termui_impl, "getchar", lazy_getchar) + + assert termui.getchar(echo=False) == "y" + assert termui._getchar is lazy_getchar + assert termui.getchar(echo=True) == "z" + assert lazy_state["calls"] == 2 + + +def test_clirunner_getchar(monkeypatch) -> None: + runner = CliRunner() + app = typer.Typer() + + @app.command() + def main() -> None: + first = termui.getchar(echo=False) + second = termui.getchar(echo=True) + typer.echo(f"\nfirst={first};second={second}") + + monkeypatch.setattr(termui, "_getchar", None) + result = runner.invoke(app, [], input="ab") + assert result.exit_code == 0, result.output + assert result.stdout.splitlines() == ["b", "first=a;second=b"] + + +def test_clirunner_env_none(monkeypatch) -> None: + runner = CliRunner() + app = typer.Typer() + env_key = "TYPER_TEST_ENV_REMOVE" + monkeypatch.setenv(env_key, "present") + + @app.command() + def main() -> None: + typer.echo(f"inside={os.environ.get(env_key)}") + + result = runner.invoke(app, [], env={env_key: None}) + assert result.exit_code == 0, result.output + assert "inside=None" in result.stdout + assert os.environ.get(env_key) == "present" + + +@pytest.mark.parametrize( + ("exit_value", "expected_exit_code", "expected_stdout"), + [ + (None, 0, ""), + ("bad-exit", 1, "bad-exit\n"), + ], +) +def test_clirunner_invoke_system_exit_branches( + exit_value: object, + expected_exit_code: int, + expected_stdout: str, +) -> None: + runner = CliRunner() + app = typer.Typer() + + @app.command() + def main() -> None: + raise SystemExit(exit_value) + + result = runner.invoke(app, []) + assert result.exit_code == expected_exit_code + assert result.stdout == expected_stdout + if expected_exit_code: + assert isinstance(result.exception, SystemExit) + else: + assert result.exception is None + + +@needs_windows +def test_termui_impl_windows_raw_terminal(): + with _termui_impl.raw_terminal() as fd: + assert fd == -1 + with termui.raw_terminal() as fd: + assert fd == -1 + + +@needs_windows +def test_termui_impl_windows_getchar(monkeypatch): + monkeypatch.setattr(_termui_impl.msvcrt, "getwch", lambda: "a") + monkeypatch.setattr(_termui_impl.msvcrt, "getwche", lambda: "b") + assert _termui_impl.getchar(echo=False) == "a" + assert _termui_impl.getchar(echo=True) == "b" + + seq_null = iter(["\x00", "K"]) + monkeypatch.setattr(_termui_impl.msvcrt, "getwch", lambda: next(seq_null)) + assert _termui_impl.getchar(echo=False) == "\x00K" + + seq_e0 = iter(["\xe0", "H"]) + monkeypatch.setattr(_termui_impl.msvcrt, "getwch", lambda: next(seq_e0)) + assert _termui_impl.getchar(echo=False) == "\xe0H" + + seq_echo = iter(["\x00", "M"]) + monkeypatch.setattr(_termui_impl.msvcrt, "getwche", lambda: next(seq_echo)) + assert _termui_impl.getchar(echo=True) == "\x00M" + + seq_e0_echo = iter(["\xe0", "Z"]) + monkeypatch.setattr(_termui_impl.msvcrt, "getwche", lambda: next(seq_e0_echo)) + assert _termui_impl.getchar(echo=True) == "\xe0Z" + + monkeypatch.setattr(_termui_impl.msvcrt, "getwch", lambda: "\x03") + with pytest.raises(KeyboardInterrupt): + _termui_impl.getchar(echo=False) + + monkeypatch.setattr(_termui_impl.msvcrt, "getwch", lambda: "\x1a") + with pytest.raises(EOFError): + _termui_impl.getchar(echo=False) + + +@skip_if_windows +@pytest.mark.parametrize("use_stdin_tty", [True, False]) +def test_termui_impl_posix_raw_terminal(monkeypatch, use_stdin_tty: bool): + state: dict[str, object] = {} + flushed: list[None] = [] + fake_tty = None + + if use_stdin_tty: + expected_fd = 14 + old_termios = "old_settings" + monkeypatch.setattr( + _termui_impl, "isatty", lambda s: s is _termui_impl.sys.stdin + ) + monkeypatch.setattr(_termui_impl.sys.stdin, "fileno", lambda: expected_fd) + else: + expected_fd = 27 + old_termios = "old" + monkeypatch.setattr( + _termui_impl, + "isatty", + lambda s: s is not _termui_impl.sys.stdin, + ) + + class FakeTTY: + def __init__(self) -> None: + self.closed = False + + def fileno(self) -> int: + return expected_fd + + def close(self) -> None: + self.closed = True + + fake_tty = FakeTTY() + real_open = open + + def fake_open(path, *args, **kwargs): + if path == "/dev/tty": + return fake_tty + return real_open(path, *args, **kwargs) # pragma: no cover + + monkeypatch.setattr("builtins.open", fake_open) + + def tcgetattr(fd: int) -> str: + state["tcgetattr_fd"] = fd + return old_termios + + def setraw(fd: int) -> None: + state["setraw_fd"] = fd + + def tcsetattr(fd: int, when: int, old: str) -> None: + state["tcsetattr"] = (fd, when, old) + + monkeypatch.setattr(_termui_impl.termios, "tcgetattr", tcgetattr) + monkeypatch.setattr(_termui_impl.tty, "setraw", setraw) + monkeypatch.setattr(_termui_impl.termios, "tcsetattr", tcsetattr) + monkeypatch.setattr( + _termui_impl.sys.stdout, "flush", lambda *a, **k: flushed.append(None) + ) + + with _termui_impl.raw_terminal() as fd: + assert fd == expected_fd + + assert state["tcgetattr_fd"] == expected_fd + assert state["setraw_fd"] == expected_fd + assert state["tcsetattr"] == ( + expected_fd, + _termui_impl.termios.TCSADRAIN, + old_termios, + ) + assert flushed == [None] + if fake_tty is not None: + assert fake_tty.closed is True + + +@skip_if_windows +def test_termui_impl_posix_getchar(monkeypatch): + @contextmanager + def fake_raw(): + yield 7 + + monkeypatch.setattr(_termui_impl, "raw_terminal", fake_raw) + monkeypatch.setattr(_termui_impl.os, "read", lambda fd, n: b"q") + monkeypatch.setattr(_termui_impl, "get_best_encoding", lambda stdin: "utf-8") + monkeypatch.setattr(_termui_impl, "isatty", lambda f: f is _termui_impl.sys.stdout) + written: list[str] = [] + monkeypatch.setattr(_termui_impl.sys.stdout, "write", lambda s: written.append(s)) + + assert _termui_impl.getchar(echo=True) == "q" + assert written == ["q"] + + +@skip_if_windows +def test_termui_impl_posix_getchar_eof(monkeypatch): + @contextmanager + def fake_raw(): + yield 5 + + monkeypatch.setattr(_termui_impl, "raw_terminal", fake_raw) + monkeypatch.setattr(_termui_impl.os, "read", lambda fd, n: b"\x04") + monkeypatch.setattr(_termui_impl, "get_best_encoding", lambda stdin: "utf-8") + monkeypatch.setattr(_termui_impl, "isatty", lambda f: False) + + with pytest.raises(EOFError): + _termui_impl.getchar(echo=False) + + +def test_prompt(): + runner = CliRunner() + app = typer.Typer() + fake_file = io.StringIO("data") + fake_file.name = "demo.txt" + + @app.command() + def main( + accept: bool = typer.Option(True, prompt=True), + name: str = typer.Option(..., prompt=True), + flavor: Literal["a", "b"] = typer.Option(..., prompt=True), + city: str = typer.Option("London", prompt=True), + config: str = typer.Option(fake_file, prompt=True), + password: str = typer.Option( + ..., + prompt=True, + hide_input=True, + confirmation_prompt=True, + ), + ): + typer.echo( + f"accept={accept};name={name};flavor={flavor};city={city};config={config};pass_len={len(password)}" + ) + + result = runner.invoke(app, [], input="\nAda\na\n\ncustom.ini\nsecret\nsecret\n") + assert result.exit_code == 0, result.output + assert ( + "accept=True;name=Ada;flavor=a;city=London;config=custom.ini;pass_len=6" + in result.stdout + ) + assert "(a, b): " in result.stdout + assert "[demo.txt]: " in result.stdout + + +def test_hidden_prompt_func(monkeypatch): + monkeypatch.setattr("getpass.getpass", lambda prompt: "secret") + assert termui.hidden_prompt_func("Password: ") == "secret" + + +def test_echo_stdout_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.stdout", None) + typer.echo("ignored") + + +def test_echo_stringifies() -> None: + stream = io.StringIO() + typer.echo(123, file=stream, nl=False) + assert stream.getvalue() == "123" + + +def test_echo_bytes() -> None: + buffer = io.BytesIO() + stream = io.TextIOWrapper(buffer, encoding="utf-8") + typer.echo(b"abc", file=stream, nl=True) + assert buffer.getvalue() == b"abc\n" + + +def test_echo_empty_output() -> None: + class FlushTrackingTextStream(io.StringIO): + def __init__(self) -> None: + super().__init__() + self.flush_count = 0 + + def flush(self) -> None: + self.flush_count += 1 + super().flush() + + def write(self, s: str) -> int: + raise AssertionError("Empty output") # pragma: no cover + + stream = FlushTrackingTextStream() + typer.echo("", file=stream, nl=False) + assert stream.flush_count == 1 + + +@needs_windows +def test_echo_windows_color_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class TtyStream(io.StringIO): + def isatty(self) -> bool: + return True + + stream = TtyStream() + monkeypatch.setattr("typer._click.utils.auto_wrap_for_ansi", None) + typer.echo("\x1b[31mred\x1b[0m", file=stream, nl=False, color=None) + assert stream.getvalue() == "red" + + +@pytest.mark.parametrize( + ("flag", "true_code", "false_code"), + [ + ("bold", "\x1b[1m", "\x1b[22m"), + ("dim", "\x1b[2m", "\x1b[22m"), + ("underline", "\x1b[4m", "\x1b[24m"), + ("overline", "\x1b[53m", "\x1b[55m"), + ("italic", "\x1b[3m", "\x1b[23m"), + ("blink", "\x1b[5m", "\x1b[25m"), + ("reverse", "\x1b[7m", "\x1b[27m"), + ("strikethrough", "\x1b[9m", "\x1b[29m"), + ], +) +def test_style(flag, true_code, false_code): + runner = CliRunner() + app = typer.Typer() + + @app.command() + def main(): + # testing an int and a str on purpose + typer.echo("TRUE=" + typer.style("42", **{flag: True}), color=True) + typer.echo("FALSE=" + typer.style(666, **{flag: False}), color=True) + + result = runner.invoke(app, []) + assert result.exit_code == 0, result.output + lines = [line for line in result.stdout.splitlines() if line] + true_line = next(line for line in lines if line.startswith("TRUE=")) + false_line = next(line for line in lines if line.startswith("FALSE=")) + assert "42" in true_line + assert "666" in false_line + + assert true_code in true_line + assert true_code not in false_line + assert false_code in false_line + assert false_code not in true_line + + +def test_style_color(): + fg_int = typer.style("x", fg=123) + assert "\x1b[38;5;123m" in fg_int + + bg_list = typer.style("x", bg=[1, 2, 3]) + assert "\x1b[48;2;1;2;3m" in bg_list + + with pytest.raises(TypeError, match="Unknown color"): + typer.style("x", fg="not-a-color") + + with pytest.raises(TypeError, match="Unknown color"): + typer.style("x", bg="not-a-color") + + +def test_termui_launch(monkeypatch): + captured = {} + + def fake_open_url(url, wait=False, locate=False): + captured["url"] = url + captured["wait"] = wait + captured["locate"] = locate + return 7 + + monkeypatch.setattr(_termui_impl, "open_url", fake_open_url) + rv = termui.launch("https://example.com", wait=True, locate=True) + assert rv == 7 + assert captured == { + "url": "https://example.com", + "wait": True, + "locate": True, + } diff --git a/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial001.py index 6941a35c37..32c07214c0 100644 --- a/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial001.py @@ -4,6 +4,7 @@ from types import ModuleType import pytest +import typer from typer.testing import CliRunner runner = CliRunner() @@ -22,6 +23,12 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: return mod +def test_type_repr(mod: ModuleType): + command = typer.main.get_command(mod.app) + force_param = next(param for param in command.params if param.name == "force") + assert repr(force_param.type) == "BOOL" + + def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 diff --git a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py index 9dfcdf19e3..eea63c5a8b 100644 --- a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py @@ -1,6 +1,8 @@ import subprocess import sys +from datetime import datetime +import typer from typer.testing import CliRunner from docs_src.parameter_types.datetime import tutorial001_py310 as mod @@ -9,6 +11,12 @@ app = mod.app +def test_type_repr(): + command = typer.main.get_command(app) + birth_param = next(param for param in command.params if param.name == "birth") + assert repr(birth_param.type) == "DateTime" + + def test_help(): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 @@ -22,6 +30,15 @@ def test_main(): assert "Birth hour: 10" in result.output +def test_main_datetime_object(): + result = runner.invoke( + app, [], default_map={"birth": datetime(1956, 1, 31, 10, 0, 0)} + ) + assert result.exit_code == 0 + assert "Interesting day to be born: 1956-01-31 10:00:00" in result.output + assert "Birth hour: 10" in result.output + + def test_invalid(): result = runner.invoke(app, ["july-19-1989"]) assert result.exit_code != 0 diff --git a/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py index 4b2f422fbb..ee0daa9f06 100644 --- a/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py @@ -1,6 +1,7 @@ import subprocess import sys +import typer from typer.testing import CliRunner from docs_src.parameter_types.index import tutorial001_py310 as mod @@ -9,6 +10,16 @@ app = mod.app +def test_type_repr(): + command = typer.main.get_command(app) + age_param = next(param for param in command.params if param.name == "age") + height_meters_param = next( + param for param in command.params if param.name == "height_meters" + ) + assert repr(age_param.type) == "INT" + assert repr(height_meters_param.type) == "FLOAT" + + def test_help(): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 diff --git a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py index 6b9445a97f..ffe3ad09a0 100644 --- a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py @@ -24,6 +24,19 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: return mod +def test_type_repr(mod: ModuleType): + command = typer.main.get_command(mod.app) + + id_param = next(param for param in command.params if param.name == "id") + assert repr(id_param.type) == "" + + age_param = next(param for param in command.params if param.name == "age") + assert repr(age_param.type) == "=18>" + + score_param = next(param for param in command.params if param.name == "score") + assert repr(score_param.type) == "" + + def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 diff --git a/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py index cad8c69cc4..7b79e81405 100644 --- a/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py @@ -1,6 +1,8 @@ import subprocess import sys +import uuid +import typer from typer.testing import CliRunner from docs_src.parameter_types.uuid import tutorial001_py310 as mod @@ -9,6 +11,12 @@ app = mod.app +def test_type_repr(): + command = typer.main.get_command(app) + user_id_param = next(param for param in command.params if param.name == "user_id") + assert repr(user_id_param.type) == "UUID" + + def test_main(): result = runner.invoke(app, ["d48edaa6-871a-4082-a196-4daab372d4a1"]) assert result.exit_code == 0 @@ -16,6 +24,14 @@ def test_main(): assert "UUID version is: 4" in result.output +def test_main_with_uuid_object(): + user_id = uuid.UUID("d48edaa6-871a-4082-a196-4daab372d4a1") + result = runner.invoke(app, [], default_map={"user_id": user_id}) + assert result.exit_code == 0 + assert "USER_ID is d48edaa6-871a-4082-a196-4daab372d4a1" in result.output + assert "UUID version is: 4" in result.output + + def test_invalid_uuid(): result = runner.invoke(app, ["7479706572-72756c6573"]) assert result.exit_code != 0 diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 83edd1ecb5..26709db0e2 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -1,12 +1,15 @@ +import os from enum import Enum from pathlib import Path from typing import Any -import click import pytest import typer +from typer import _click, models from typer.testing import CliRunner +from tests.utils import needs_linux, needs_windows + runner = CliRunner() @@ -133,6 +136,18 @@ def tuple_recursive_conversion(container: type_annotation): assert result.exit_code == 0 +def test_tuple_wrong_arity(): + app = typer.Typer() + + @app.command() + def tuple_arity(value: tuple[str, str] = typer.Option(...)): + print(value) # pragma: no cover + + result = runner.invoke(app, [], default_map={"value": ("only-one",)}) + assert result.exit_code == 2 + assert "2 values are required, but 1 given." in result.output + + def test_custom_parse(): app = typer.Typer() @@ -146,15 +161,29 @@ def custom_parser( assert result.exit_code == 0 +def test_custom_parse_value_error(): + app = typer.Typer() + + @app.command() + def custom_parser( + hex_value: int = typer.Argument(None, parser=lambda x: int(x, 0)), + ): + print(hex_value) # pragma: no cover + + result = runner.invoke(app, ["not-a-hex"]) + assert result.exit_code == 2 + assert "Invalid value" in result.output + + def test_custom_click_type(): - class BaseNumberParamType(click.ParamType): + class BaseNumberParamType(_click.types.ParamType): name = "base_integer" def convert( self, value: Any, - param: click.Parameter | None, - ctx: click.Context | None, + param: _click.Parameter | None, + ctx: _click.Context | None, ) -> Any: return int(value, 0) @@ -168,3 +197,204 @@ def custom_click_type( result = runner.invoke(app, ["0x56"]) assert result.exit_code == 0 + + +def test_int_range_open_bound_clamp(): + app = typer.Typer() + + @app.command() + def custom_click_type( + value: int = typer.Argument( + ..., + click_type=_click.types.IntRange(min=1, min_open=True, clamp=True), + ), + ): + print(value) + + result = runner.invoke(app, ["1"]) + assert result.exit_code == 0 + assert "2" in result.output + + +def test_bool_convert_invalid(): + app = typer.Typer() + + @app.command() + def main(value: bool): + print(value) # pragma: no cover + + result = runner.invoke(app, ["maybe"]) + assert result.exit_code == 2 + assert "is not a valid boolean" in result.output + assert "yes" in result.output + assert "false" in result.output + + +@pytest.mark.parametrize( + ("arg_enc", "system_enc", "raw_value", "expected_output"), + [ + pytest.param("latin-1", "utf-8", b"\xff", "ÿ"), + pytest.param("ascii", "latin-1", b"\xff", "ÿ"), + pytest.param("ascii", "utf-16", b"\xff", "�"), + pytest.param("ascii", "ascii", b"\xff", "�"), + ], +) +def test_string_param_type_converts_bytes( + monkeypatch: pytest.MonkeyPatch, + arg_enc: str, + system_enc: str, + raw_value: bytes, + expected_output: str, +): + app = typer.Typer() + + @app.command() + def show(name: str = typer.Option(...)): + print(name) + + command = typer.main.get_command(app) + name_param = next(param for param in command.params if param.name == "name") + assert repr(name_param.type) == "STRING" + + monkeypatch.setattr(_click.types, "_get_argv_encoding", lambda: arg_enc) + monkeypatch.setattr(_click.types.sys, "getfilesystemencoding", lambda: system_enc) + + result = runner.invoke(app, [], default_map={"name": raw_value}) + assert result.exit_code == 0 + assert expected_output in result.output + + +@pytest.mark.parametrize("path_type", [str, bytes, Path]) +def test_path_coerced(path_type) -> None: + # Ensure coerce_path_result works correctly + app = typer.Typer() + + @app.command() + def show(path: Any = typer.Option(..., path_type=path_type)): + print(path) + + result = runner.invoke(app, ["--path", "dir/my_awesome_file.txt"]) + assert result.exit_code == 0 + assert "my_awesome_file" in result.output + + +@pytest.mark.parametrize( + ("create_file", "option_kwargs", "deny_mode", "expected_error"), + [ + (True, {"file_okay": False, "dir_okay": True}, None, "is a file"), + (False, {"file_okay": True, "dir_okay": False}, None, "is a directory"), + (True, {"readable": True}, os.R_OK, "is not readable"), + (True, {"readable": False, "writable": True}, os.W_OK, "is not writable"), + ], +) +def test_path_convert_failures( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + create_file: bool, + option_kwargs: dict[str, bool], + deny_mode: int | None, + expected_error: str, +) -> None: + app = typer.Typer() + + @app.command() + def show(path: Path = typer.Option(..., **option_kwargs)): + print(path) # pragma: no cover + + if deny_mode is not None: + original_access = os.access + + def fake_access(path: str, mode: int) -> bool: + if mode == deny_mode: + return False + return original_access(path, mode) # pragma: no cover + + monkeypatch.setattr(models.os, "access", fake_access) + + path = tmp_path / "some_path" + if create_file: + path.write_text("hello") + else: + path.mkdir() + result = runner.invoke(app, ["--path", str(path)]) + + assert result.exit_code != 0 + assert expected_error in result.output + + +def test_convert_type(): + from typer._click.types import convert_type + + # str + assert convert_type(str) is _click.types.STRING + assert convert_type(None) is _click.types.STRING + assert convert_type(None, default=["a"]) is _click.types.STRING + + # tuples + tuple_type = convert_type((str, int)) + assert isinstance(tuple_type, _click.types.Tuple) + assert [type(item) for item in tuple_type.types] == [ + type(_click.types.STRING), + type(_click.types.INT), + ] + + guessed_tuple = convert_type(None, default=[(1, "x")]) + assert isinstance(guessed_tuple, _click.types.Tuple) + assert [type(item) for item in guessed_tuple.types] == [ + type(_click.types.INT), + type(_click.types.STRING), + ] + + # numbers + assert convert_type(int) is _click.types.INT + assert convert_type(float) is _click.types.FLOAT + assert convert_type(bool) is _click.types.BOOL + + param_type = _click.types.IntRange(min=0, max=10) + assert convert_type(param_type) is param_type + + guessed_int = convert_type(None, default=42) + assert guessed_int is _click.types.INT + + # custom type + class CustomType: + pass + + guessed_unknown = convert_type(None, default=CustomType()) + assert guessed_unknown is _click.types.STRING + + func_type = convert_type(CustomType) + assert isinstance(func_type, _click.types.FuncParamType) + assert func_type.name == "CustomType" + + +@pytest.mark.parametrize( + ("platform_case", "stdin_encoding", "filesystem_encoding"), + [ + pytest.param("windows", None, "utf-8", marks=needs_windows), + pytest.param("linux", "latin-1", "utf-8", marks=needs_linux), + pytest.param("linux", None, "latin-1", marks=needs_linux), + ], +) +def test_argv_encoding( + monkeypatch: pytest.MonkeyPatch, + platform_case: str, + stdin_encoding: str | None, + filesystem_encoding: str, +) -> None: + sys = _click._compat.sys + if platform_case == "windows": + import locale + + monkeypatch.setattr(locale, "getpreferredencoding", lambda: "latin-1") + else: + + class FakeStdin: + def __init__(self, encoding: str | None) -> None: + self.encoding = encoding + + monkeypatch.setattr(sys, "stdin", FakeStdin(stdin_encoding)) + monkeypatch.setattr(sys, "getfilesystemencoding", lambda: filesystem_encoding) + + converted = _click.types.STRING.convert(b"\xff", None, None) + assert converted == "ÿ" diff --git a/tests/test_types.py b/tests/test_types.py index adb100eb82..caeef451aa 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,6 +1,8 @@ from enum import Enum +import pytest import typer +from typer import _click from typer.testing import CliRunner app = typer.Typer(context_settings={"token_normalize_func": str.lower}) @@ -12,23 +14,108 @@ class User(str, Enum): @app.command() -def hello(name: User = User.rick) -> None: +def hello_option(name: User = User.rick) -> None: print(f"Hello {name.value}!") +@app.command() +def hello_argument(name: User) -> None: + print(f"Hello {name.value}!") + + +@app.command() +def hello_no_choices( + name: User = typer.Option(..., "--name", show_choices=False), +): + print(f"Hello {name.value}!") + + +@app.command() +def hello_all(names: list[str] = typer.Argument(["World"], envvar="NAMES")) -> None: + for name in names: + print(f"Hello {name}!") + + +@app.command() +def split_variadic_and_pair(items: list[str], pair: tuple[str, str]) -> None: + print(f"items={items}") + print(f"pair={pair}") + + runner = CliRunner() def test_enum_choice() -> None: - # This test is only for coverage of the new custom TyperChoice class - result = runner.invoke(app, ["--name", "morty"], catch_exceptions=False) + result = runner.invoke( + app, ["hello-option", "--name", "morty"], catch_exceptions=False + ) assert result.exit_code == 0 assert "Hello Morty!" in result.output - result = runner.invoke(app, ["--name", "Rick"]) + result = runner.invoke(app, ["hello-option", "--name", "Rick"]) + assert result.exit_code == 0 + assert "Hello Rick!" in result.output + + result = runner.invoke(app, ["hello-option", "--name", "RICK"]) assert result.exit_code == 0 assert "Hello Rick!" in result.output - result = runner.invoke(app, ["--name", "RICK"]) + result = runner.invoke(app, ["hello-no-choices", "--name", "RICK"]) assert result.exit_code == 0 assert "Hello Rick!" in result.output + + result = runner.invoke(app, ["hello-argument", "RICK"]) + assert result.exit_code == 0 + assert "Hello Rick!" in result.output + + +def test_enum_choice_repr() -> None: + root_command = typer.main.get_command(app) + command = root_command.commands["hello-option"] + name_param = next(param for param in command.params if param.name == "name") + assert repr(name_param.type).startswith("Choice([") + + +def test_enum_choice_help() -> None: + result = runner.invoke(app, ["hello-argument", "--help"]) + assert result.exit_code == 0 + assert "{rick|morty}" in result.output + + result = runner.invoke(app, ["hello-option", "--help"]) + assert result.exit_code == 0 + assert "[rick|morty]" in result.output + + result = runner.invoke(app, ["hello-no-choices", "--help"]) + assert result.exit_code == 0 + assert "--name" in result.output + assert "rick|morty" not in result.output + + +def test_enum_choice_missing_message() -> None: + result = runner.invoke(app, ["hello-argument"]) + assert result.exit_code != 0 + assert "Missing argument" in result.output + assert "Choose from:" in result.output + assert "rick" in result.output + assert "morty" in result.output + + +def test_split_envvar_value(monkeypatch) -> None: + # This will use split_envvar_value to produce two strings from the envvar + monkeypatch.setenv("NAMES", "Rick Morty") + result = runner.invoke(app, ["hello-all"]) + assert result.exit_code == 0 + assert "Hello Rick!" in result.output + assert "Hello Morty!" in result.output + + +def test_list_pair() -> None: + result = runner.invoke(app, ["split-variadic-and-pair", "a", "b", "c", "x", "y"]) + assert result.exit_code == 0 + assert "items=['a', 'b', 'c']" in result.output + assert "pair=('x', 'y')" in result.output + + +def test_float_range_open_bounds_with_clamp_not_allowed(): + with pytest.raises(TypeError, match="Clamping is not supported for open bounds."): + _click.types.FloatRange(min=0.0, min_open=True, clamp=True) diff --git a/tests/test_types_file.py b/tests/test_types_file.py new file mode 100644 index 0000000000..e67c9b34ab --- /dev/null +++ b/tests/test_types_file.py @@ -0,0 +1,384 @@ +import subprocess +import sys +from io import BytesIO, StringIO +from pathlib import Path + +import pytest +import typer +from typer._click._compat import get_best_encoding, should_strip_ansi +from typer._click.utils import PacifyFlushWrapper +from typer.testing import CliRunner + +from tests.utils import needs_linux, needs_windows + +app = typer.Typer() + + +@app.command() +def read_text(file_in: typer.FileText = typer.Option(..., lazy=True)): + data = file_in.read() + typer.echo(f"text-len={len(data)}") + + +@app.command() +def write_text(file_out: typer.FileTextWrite = typer.Option(..., lazy=None)): + file_out.write("This is a single line\n") + typer.echo("1 line written") + + +@app.command() +def write_lazy(file_out: typer.FileTextWrite = typer.Option(..., lazy=True)): + file_out.write("This is a single lazy line\n") + typer.echo("1 line written") + + +@app.command() +def probe_lazy_file_behaviors( + file_in: typer.FileText = typer.Option(..., lazy=True), + file_out: typer.FileTextWrite = typer.Option(..., lazy=True), +): + typer.echo(f"repr-before={repr(file_out)}") + file_out.write("repr-opened\n") + typer.echo(f"repr-after={repr(file_out)}") + with file_in as stream: + typer.echo(f"context-len={len(stream.read())}") + stream.seek(0) + first_line = next(iter(stream), "") + typer.echo(f"first-line={first_line.rstrip()}") + + +@app.command() +def write_binary(file_out: typer.FileBinaryWrite = typer.Option(...)): + file_out.write(b"binary-written\n") + + +@app.command() +def write_binary_stderr(): + stream = typer.get_binary_stream("stderr") + stream.write(b"binary-stderr\n") + stream.flush() + + +@app.command() +def read_binary(file_in: typer.FileBinaryRead = typer.Option(...)): + data = file_in.read() + typer.echo(f"binary-len={len(data)}") + + +runner = CliRunner() + + +def test_text_stdin_dash() -> None: + result = runner.invoke(app, ["read-text", "--file-in=-"], input="hello\n") + assert result.exit_code == 0 + assert "text-len=6" in result.output + + +def test_lazy_file(tmp_path: Path) -> None: + # dash: written to stdout + result = runner.invoke(app, ["write-text", "--file-out=-"]) + assert result.exit_code == 0 + assert "This is a single line" in result.output + assert "1 line written" in result.output + + # lazy + file + file_path = tmp_path / "example.txt" + result = runner.invoke(app, ["write-lazy", f"--file-out={file_path}"]) + assert result.exit_code == 0 + assert "This is a single lazy line" not in result.output + assert "1 line written" in result.output + assert file_path.exists() + assert file_path.read_text() == "This is a single lazy line\n" + + # lazy probe: unopened/opened repr, context manager, and iteration. + result = runner.invoke( + app, + [ + "probe-lazy-file-behaviors", + f"--file-in={file_path}", + f"--file-out={tmp_path / 'repr-opened.txt'}", + ], + ) + assert result.exit_code == 0 + assert "repr-before= None: + stream = StringIO() + result = runner.invoke( + app, ["write-text"], default_map={"write-text": {"file_out": stream}} + ) + assert result.exit_code == 0 + assert "1 line written" in result.output + assert stream.getvalue() == "This is a single line\n" + + +def test_binary_dash() -> None: + result = runner.invoke(app, ["write-binary", "--file-out=-"]) + assert result.exit_code == 0 + assert result.stdout_bytes == b"binary-written\n" + + result = runner.invoke( + app, ["read-binary", "--file-in=-"], input=b"\x00\x01\x02abc" + ) + assert result.exit_code == 0 + assert "binary-len=6" in result.output + + +def test_binary_stderr() -> None: + result = subprocess.run( + [ + sys.executable, + "-c", + "from tests.test_types_file import app; app()", + "write-binary-stderr", + ], + capture_output=True, + ) + assert result.returncode == 0 + assert result.stderr == b"binary-stderr\n" + + +@pytest.mark.parametrize( + ("errors_arg", "expected_errors"), + [ + (None, "replace"), + ("strict", "strict"), + ], +) +def test_get_text_stream_errors( + monkeypatch, + errors_arg: str | None, + expected_errors: str, +) -> None: + class BinaryStdout(BytesIO): + pass + + binary_stdout = BinaryStdout() + monkeypatch.setattr(sys, "stdout", binary_stdout) + + text_stream = typer.get_text_stream("stdout", encoding=None, errors=errors_arg) + text_stream.write("stream-text") + text_stream.flush() + + assert text_stream.errors == expected_errors + assert text_stream.writable() is True + assert binary_stdout.getvalue() == b"stream-text" + + +def test_get_best_encoding() -> None: + """Test that ASCII is being transformed into UTF-8""" + + class AsciiStream: + encoding = "ascii" + + class Utf8Stream: + encoding = "utf-8" + + class UnknownStream: + encoding = "unknown" + + assert get_best_encoding(AsciiStream()) == "utf-8" + assert get_best_encoding(Utf8Stream()) == "utf-8" + assert get_best_encoding(UnknownStream()) == "unknown" + + +def test_pacify_flush_wrapper() -> None: + class Wrapped: + def __init__(self) -> None: + self.name = "wrapped-stream" + + def flush(self) -> None: + return None # pragma: no cover + + wrapped = PacifyFlushWrapper(Wrapped()) + assert wrapped.name == "wrapped-stream" + + +def test_text_stream_isatty(monkeypatch) -> None: + class BinaryStdout(BytesIO): + def isatty(self) -> bool: + return True + + binary_stdout = BinaryStdout() + monkeypatch.setattr(sys, "stdout", binary_stdout) + text_stream = typer.get_text_stream("stdout", encoding="utf-8", errors=None) + assert text_stream.isatty() is True + + +def test_text_stream_buffer_read1(monkeypatch) -> None: + class BinaryStdinNoRead1: + def __init__(self, data: bytes) -> None: + self._data = data + self._pos = 0 + + def read(self, size: int = -1) -> bytes: + if size < 0: + size = len(self._data) - self._pos # pragma: no cover + chunk = self._data[self._pos : self._pos + size] + self._pos += len(chunk) + return chunk + + binary_stdin = BinaryStdinNoRead1(b"hello") + monkeypatch.setattr(sys, "stdin", binary_stdin) + text_stream = typer.get_text_stream("stdin", encoding="utf-8", errors=None) + assert text_stream._stream.read1(4) == b"hell" + + +def test_binary_stream(monkeypatch) -> None: + binary_stdin = BytesIO(b"hello") + binary_stdout = BytesIO() + monkeypatch.setattr(sys, "stdin", binary_stdin) + monkeypatch.setattr(sys, "stdout", binary_stdout) + + assert typer.get_binary_stream("stdin") is binary_stdin + assert typer.get_binary_stream("stdout") is binary_stdout + + +def test_binary_stream_raises(monkeypatch) -> None: + class TextOnlyStdin: + def read(self, n: int = -1) -> str: + return "hello" + + monkeypatch.setattr(sys, "stdin", TextOnlyStdin()) + with pytest.raises(RuntimeError, match="Was not able to determine binary stream"): + typer.get_binary_stream("stdin") + + +def test_stream_unknown() -> None: + with pytest.raises(TypeError, match="Unknown standard stream 'Plumbus'"): + typer.get_binary_stream("Plumbus") # type: ignore[arg-type] + + with pytest.raises(TypeError, match="Unknown standard stream 'Fleeb'"): + typer.get_text_stream("Fleeb") # type: ignore[arg-type] + + +def test_format_filename() -> None: + filename = b"folder/subdir/demo.txt" + assert typer.format_filename(filename, shorten=True) == "demo.txt" + + +def test_file_error(monkeypatch, tmp_path: Path) -> None: + file_path = tmp_path / "cannot-open.txt" + + def fake_open(path, *args, **kwargs): + if Path(path) == file_path: + raise OSError() + + monkeypatch.setattr("builtins.open", fake_open) + result = runner.invoke(app, ["write-text", f"--file-out={file_path}"]) + assert result.exit_code == 1 + assert "Could not open file" in result.output + assert "cannot-open.txt" in result.output + assert "unknown error" in result.output + + +@needs_windows +def test_app_dir_windows_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("APPDATA", raising=False) + monkeypatch.setattr("os.path.expanduser", lambda _path: r"C:\Users\Tester") + + assert typer.get_app_dir("My App", roaming=True) == r"C:\Users\Tester\My App" + + +@needs_linux +def test_app_dir_force_posix(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("os.path.expanduser", lambda _path: "/home/tester/.my-app") + + assert typer.get_app_dir("My App", force_posix=True) == "/home/tester/.my-app" + + +def test_text_stream_binary_buffer(monkeypatch) -> None: + class TextStdinWithBinaryBuffer: + def __init__(self, data: bytes) -> None: + self.buffer = BytesIO(data) + self.encoding = "latin-1" + + def read(self, n: int = -1) -> str: + raise OSError("text stream is not readable directly") + + class TextStdoutWithBinaryBuffer: + def __init__(self) -> None: + self.buffer = BytesIO() + self.encoding = "latin-1" + + def write(self, s: str) -> int: + raise OSError("text stream is not writable directly") + + stdin = TextStdinWithBinaryBuffer(b"hello") + stdout = TextStdoutWithBinaryBuffer() + + monkeypatch.setattr(sys, "stdin", stdin) + monkeypatch.setattr(sys, "stdout", stdout) + + text_stdin = typer.get_text_stream("stdin", encoding="utf-8", errors=None) + text_stdout = typer.get_text_stream("stdout", encoding="utf-8", errors=None) + + assert text_stdin.read() == "hello" + text_stdout.write("ok") + text_stdout.flush() + assert stdout.buffer.getvalue() == b"ok" + + +def test_text_stream_binary_stream(monkeypatch) -> None: + binary_stdout = BytesIO() + monkeypatch.setattr(sys, "stdout", binary_stdout) + text_stream = typer.get_text_stream("stdout", encoding="utf-8", errors=None) + text_stream.write("ok") + text_stream.flush() + assert binary_stdout.getvalue() == b"ok" + + +def test_text_stream_stdout_no_binary( + monkeypatch, +) -> None: + class TextStdoutNoBinaryFallback: + encoding = "utf-8" + errors = "strict" + + def write(self, s: str) -> int: + if isinstance(s, bytes): + raise TypeError("bytes not supported") + return len(s) + + stdout = TextStdoutNoBinaryFallback() + monkeypatch.setattr(sys, "stdout", stdout) + text_stream = typer.get_text_stream("stdout", encoding="utf-8", errors="replace") + assert text_stream is stdout + + +def test_jupyter_wrapped_stream(monkeypatch) -> None: + class JupyterLikeStdout(BytesIO): + __module__ = "ipykernel.iostream" + + def isatty(self) -> bool: + return False + + binary_stdout = JupyterLikeStdout() + monkeypatch.setattr(sys, "stdout", binary_stdout) + text_stream = typer.get_text_stream("stdout", encoding="utf-8", errors=None) + assert should_strip_ansi(text_stream, color=None) is False + + +def test_should_strip_ansi(monkeypatch) -> None: + class NonTtyStdin(BytesIO): + def isatty(self) -> bool: + return False + + stdin = NonTtyStdin() + monkeypatch.setattr(sys, "stdin", stdin) + assert should_strip_ansi(stream=None, color=None) is True + assert should_strip_ansi(stream=None, color=True) is False + assert should_strip_ansi(stream=None, color=False) is True diff --git a/tests/test_win_console.py b/tests/test_win_console.py new file mode 100644 index 0000000000..13c390cfe2 --- /dev/null +++ b/tests/test_win_console.py @@ -0,0 +1,326 @@ +""" +Tests for the Windows console functionality. +Created after vendoring Click to ensure test coverage is back up to 100%. +""" + +import ctypes +import io +import sys + +import pytest +import typer +from typer.testing import CliRunner + +from .utils import needs_windows + +pytestmark = needs_windows + + +if sys.platform == "win32": + from typer._click import _compat, _winconsole + + +def _identity_buffer(obj, writable=False): # noqa: ARG001 + return obj + + +def _route_console_stream(target_name, wrapper, state=None): + def patched_windows_stream(stream, encoding, errors): # noqa: ARG001 + current_target = getattr(sys, target_name) + if stream is current_target: + if state is not None and target_name == "stderr": + state["stderr_wrap_calls"] += 1 + buffer = getattr(stream, "buffer", None) + return wrapper(buffer) if buffer else None + return None + + return patched_windows_stream + + +def _capture_write_console(state): + def fake_write_console(handle, buffer, units_to_write, units_written_ptr, reserved): # noqa: ARG001 + state["write_calls"] += 1 + bytes_to_write = units_to_write * 2 + state["written"].extend(buffer[:bytes_to_write]) + units_written_ptr._obj.value = units_to_write + return 1 + + return fake_write_console + + +def test_winconsole_stdin(monkeypatch): + runner = CliRunner() + app = typer.Typer() + + @app.command() + def read_name(config: typer.FileText = typer.Option(...)) -> None: + name = config.readline().strip() + typer.echo(f"Hello {name}") + + utf16_data = bytearray("Rick\r\n".encode("utf-16-le")) + state = {"pos": 0, "read_calls": 0} + + def fake_read_console(handle, buffer, units_to_read, units_read_ptr, reserved): # noqa: ARG001 + state["read_calls"] += 1 + max_bytes = units_to_read * 2 + chunk = utf16_data[state["pos"] : state["pos"] + max_bytes] + if chunk: + buffer[0 : len(chunk)] = chunk + state["pos"] += len(chunk) + units_read_ptr._obj.value = len(chunk) // 2 + return 1 + + return 1 # pragma: no cover + + monkeypatch.setattr(_winconsole, "get_buffer", _identity_buffer) + monkeypatch.setattr(_winconsole, "ReadConsoleW", fake_read_console) + monkeypatch.setattr(_winconsole, "GetLastError", lambda: 0) + monkeypatch.setattr( + _compat, + "_get_windows_console_stream", + _route_console_stream("stdin", _winconsole._get_text_stdin), + ) + + result = runner.invoke(app, ["--config", "-"]) + assert result.exit_code == 0, result.output + assert "Hello Rick" in result.stdout + assert state["read_calls"] > 0 + + +def test_winconsole_stdout(monkeypatch): + runner = CliRunner() + app = typer.Typer() + state = {"write_calls": 0, "written": bytearray()} + + @app.command() + def write_message(out: typer.FileTextWrite = typer.Option(...)) -> None: + out.write("Hello Summer\n") + + monkeypatch.setattr(_winconsole, "get_buffer", _identity_buffer) + monkeypatch.setattr(_winconsole, "WriteConsoleW", _capture_write_console(state)) + monkeypatch.setattr(_winconsole, "GetLastError", lambda: 0) + monkeypatch.setattr( + _compat, + "_get_windows_console_stream", + _route_console_stream("stdout", _winconsole._get_text_stdout), + ) + + result = runner.invoke(app, ["--out", "-"]) + assert result.exit_code == 0, result.output + assert state["write_calls"] > 0 + assert _winconsole._WindowsConsoleWriter(1).isatty() is True + decoded = state["written"].decode("utf-16-le", errors="ignore") + assert "Hello Summer\r\n" in decoded + + +def test_winconsole_stderr(monkeypatch): + runner = CliRunner() + app = typer.Typer() + state = {"write_calls": 0, "written": bytearray(), "stderr_wrap_calls": 0} + + @app.command() + def main() -> None: + typer.echo("Ran out of adventure time!", err=True) + + monkeypatch.setattr(_winconsole, "get_buffer", _identity_buffer) + monkeypatch.setattr(_winconsole, "WriteConsoleW", _capture_write_console(state)) + monkeypatch.setattr(_winconsole, "GetLastError", lambda: 0) + monkeypatch.setattr( + _compat, + "_get_windows_console_stream", + _route_console_stream("stderr", _winconsole._get_text_stderr, state), + ) + + result = runner.invoke(app) + assert result.exit_code == 0, result.output + assert state["stderr_wrap_calls"] > 0 + assert state["write_calls"] > 0 + decoded = state["written"].decode("utf-16-le", errors="ignore") + assert "Ran out of adventure time!\r\n" in decoded + + +@pytest.mark.parametrize( + ("writable", "source", "expected_flags"), + [ + (True, bytearray(b"python"), 1), # PyBUF_WRITABLE + (False, b"python", 0), # PyBUF_SIMPLE + ], +) +def test_get_buffer(monkeypatch, writable, source, expected_flags): + state = {"flags": None, "released": 0} + if writable: + backing = (_winconsole.c_char * len(source)).from_buffer(source) + else: + backing = (_winconsole.c_char * len(source)).from_buffer_copy(source) + backing_ptr = _winconsole.c_void_p(ctypes.addressof(backing)) + + def fake_object_get_buffer(obj, buf_ref, flags): # noqa: ARG001 + state["flags"] = flags + buf = buf_ref._obj + buf.buf = backing_ptr + buf.len = len(source) + + def fake_buffer_release(buf_ref): # noqa: ARG001 + state["released"] += 1 + + monkeypatch.setattr(_winconsole, "PyObject_GetBuffer", fake_object_get_buffer) + monkeypatch.setattr(_winconsole, "PyBuffer_Release", fake_buffer_release) + + probe = source if writable else b"x" + out = _winconsole.get_buffer(probe, writable=writable) + if writable: + # mutate the first byte of "python" to obtain another beloved programming language + out[0] = b"c" + assert source == bytearray(b"cython") + else: + assert bytes(out[: len(source)]) == source + assert state["flags"] == expected_flags + assert state["released"] == 1 + + +def test_isatty(): + assert _winconsole._WindowsConsoleRawIOBase(None).isatty() is True + assert _winconsole._WindowsConsoleReader(0).isatty() is True + assert _winconsole._WindowsConsoleReader(1).isatty() is True + + +def test_console_stream(): + class NamedBytesIO(io.BytesIO): + name = "fake-buffer" + + def isatty(self): + return False + + stream = _winconsole.ConsoleStream( + io.TextIOWrapper(io.BytesIO(), encoding="utf-8"), NamedBytesIO() + ) + assert stream.isatty() is False + assert stream.name == "fake-buffer" + assert "fake-buffer" in repr(stream) + assert "utf-8" in repr(stream) + + # test writelines + stream.writelines(["hello", " ", "world"]) + stream._text_stream.flush() + assert stream._text_stream.buffer.getvalue().decode("utf-8") == "hello world" + + # Cover bytes write path. + assert stream.write(b"!") == 1 + assert stream.buffer.getvalue().endswith(b"!") + + +@pytest.mark.parametrize( + ("error", "msg"), + [ + (0, "ERROR_SUCCESS"), # ERROR_SUCCESS + (8, "ERROR_NOT_ENOUGH_MEMORY"), # ERROR_NOT_ENOUGH_MEMORY + (342, "Windows error 342"), + ], +) +def test_error_message(error, msg): + writer = _winconsole._WindowsConsoleWriter + assert writer._get_error_message(error) == msg + + +def test_is_console(): + assert _winconsole._is_console(object()) is False + + +def test_get_windows_console_stream_factory_and_buffer_paths(monkeypatch): + monkeypatch.setattr(_winconsole, "_is_console", lambda f: True) + monkeypatch.setattr(_winconsole, "get_buffer", object()) + + class FakeStream: + def __init__(self, fd, buffer=None): + self._fd = fd + self.buffer = buffer + + def fileno(self): + return self._fd + + wrapped = {"called": False, "buffer": None} + + def fake_factory(buffer): + wrapped["called"] = True + wrapped["buffer"] = buffer + return "wrapped", buffer + + monkeypatch.setattr(_winconsole, "_stream_factories", {7: fake_factory}) + + # Known console stream preconditions pass, but no stream factory for this fd. + get_stream = _winconsole._get_windows_console_stream + assert get_stream(FakeStream(99, object()), "utf-16-le", "strict") is None + + # Factory exists, but stream has no usable .buffer. + assert get_stream(FakeStream(7, None), "utf-16-le", "strict") is None + + # Factory exists and buffer is present, so wrapper result is returned. + raw_buffer = object() + out = get_stream(FakeStream(7, raw_buffer), "utf-16-le", "strict") + assert out == ("wrapped", raw_buffer) + assert wrapped["called"] is True + assert wrapped["buffer"] is raw_buffer + + +def test_windows_console_reader(monkeypatch): + reader = _winconsole._WindowsConsoleReader(42) + + # Empty input buffer returns early + assert reader.readinto(bytearray()) == 0 + + # Require an even number of bytes + with pytest.raises(ValueError): + reader.readinto(bytearray(3)) + + def writable_buffer(obj, writable=False): # noqa: ARG001 + return (ctypes.c_char * len(obj)).from_buffer(obj) + + monkeypatch.setattr(_winconsole, "get_buffer", writable_buffer) + + def patch_console(read_console, error): + monkeypatch.setattr(_winconsole, "ReadConsoleW", read_console) + monkeypatch.setattr(_winconsole, "GetLastError", lambda: error) + + def make_read(payload=b"", rv=1, units_read=None): + def read_console(handle, buffer, units_to_read, units_read_ptr, reserved): # noqa: ARG001 + bytes_to_copy = min(len(payload), units_to_read * 2) + if bytes_to_copy: + buffer[0:bytes_to_copy] = payload[:bytes_to_copy] + read_units = units_read if units_read is not None else bytes_to_copy // 2 + units_read_ptr._obj.value = read_units + return rv + + return read_console + + # Normal successful read returns the number of bytes read + patch_console(make_read(payload=b"A\x00B\x00"), _winconsole.ERROR_SUCCESS) + assert reader.readinto(bytearray(4)) == 4 + + # CTRL+Z (EOF) should be translated into an empty read + patch_console( + make_read(payload=_winconsole.EOF + b"\x00", units_read=1), + _winconsole.ERROR_SUCCESS, + ) + assert reader.readinto(bytearray(2)) == 0 + + # An aborted read should sleep briefly while waiting for KeyboardInterrupt + sleep_state = {"calls": 0} + + def fake_sleep(seconds): + sleep_state["calls"] += 1 + assert seconds == 0.1 + + monkeypatch.setattr( + _winconsole, "time", type("FakeTime", (), {"sleep": fake_sleep}) + ) + patch_console( + make_read(payload=b"Z\x00", units_read=1), + _winconsole.ERROR_OPERATION_ABORTED, + ) + assert reader.readinto(bytearray(2)) == 2 + assert sleep_state["calls"] == 1 + + # Failed reads propagate a Windows error + patch_console(make_read(rv=0), _winconsole.ERROR_NOT_ENOUGH_MEMORY) + with pytest.raises(OSError, match="Windows error"): + reader.readinto(bytearray(2)) diff --git a/tests/utils.py b/tests/utils.py index 35c441d365..7cb285bc53 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -9,9 +9,16 @@ needs_linux = pytest.mark.skipif( not sys.platform.startswith("linux"), reason="Test requires Linux" ) +needs_macos = pytest.mark.skipif( + not sys.platform.startswith("darwin"), reason="Test requires macOS" +) needs_windows = pytest.mark.skipif( not sys.platform.startswith("win"), reason="Test requires Windows" ) +skip_if_windows = pytest.mark.skipif( + sys.platform == "win32", + reason="Test should not be run on Windows", +) needs_rich = pytest.mark.skipif(not HAS_RICH, reason="Test requires Rich") diff --git a/typer-cli/README.md b/typer-cli/README.md deleted file mode 100644 index d20b1ee8fe..0000000000 --- a/typer-cli/README.md +++ /dev/null @@ -1,57 +0,0 @@ -

- Typer -

-

- Typer, build great CLIs. Easy to code. Based on Python type hints. -

-

- - Test - - - Publish - - - Coverage - - Package version - -

- ---- - -**Documentation**: https://typer.tiangolo.com/tutorial/typer-command/ - -**Source Code**: https://github.com/fastapi/typer - ---- - -Typer is a library for building CLI applications that users will **love using** and developers will **love creating**. Based on Python type hints. - -It's also a command line tool to run scripts, automatically converting them to CLI applications. - -## Typer CLI - -⚠️ Do not install this package. ⚠️ - -This package, `typer-cli`, does nothing other than depend on `typer`. - -All the functionality has been integrated into `typer`. - -The only reason this package exists is as a migration path for old projects that used to depend on `typer-cli`, so that they can get the latest version of `typer`. - -You **should not** install this package. - -Install instead: - -```bash -pip install typer -``` - -That includes the `typer` command. - -This package is deprecated and will stop receiving any updates and published versions. - -## License - -This project is licensed under the terms of the MIT license. diff --git a/typer-slim/README.md b/typer-slim/README.md deleted file mode 100644 index 279cd6736c..0000000000 --- a/typer-slim/README.md +++ /dev/null @@ -1,59 +0,0 @@ -

- Typer -

-

- Typer, build great CLIs. Easy to code. Based on Python type hints. -

-

- - Test - - - Publish - - - Coverage - - Package version - -

- ---- - -**Documentation**: https://typer.tiangolo.com/tutorial/typer-command/ - -**Source Code**: https://github.com/fastapi/typer - ---- - -Typer is a library for building CLI applications that users will **love using** and developers will **love creating**. Based on Python type hints. - -It's also a command line tool to run scripts, automatically converting them to CLI applications. - -## `typer-slim` - -⚠️ Do not install this package. ⚠️ - -This package, `typer-slim`, does nothing other than depend on `typer`. - -There used to be a slimmed-down version of Typer called `typer-slim`, which didn't include the dependencies `rich` and `shellingham`, nor the `typer` command. - -However, since version 0.22.0, we have stopped supporting this, and `typer-slim` now simply installs (all of) Typer. - -If you want to disable Rich globally, you can set an environmental variable `TYPER_USE_RICH` to `False` or `0`. - -The only reason this package exists is as a migration path for old projects that used to depend on `typer-slim`, so that they can get the latest version of `typer`. - -You **should not** install this package. - -Install instead: - -```bash -pip install typer -``` - -This package is deprecated and will stop receiving any updates and published versions. - -## License - -This project is licensed under the terms of the MIT license. diff --git a/typer/.agents/skills/typer/SKILL.md b/typer/.agents/skills/typer/SKILL.md index 19b63c491f..60cf47a391 100644 --- a/typer/.agents/skills/typer/SKILL.md +++ b/typer/.agents/skills/typer/SKILL.md @@ -112,7 +112,7 @@ def main(name: str = typer.Argument(default="World")): print(f"Hello {name}") ``` -Similarly, the old style could use ellipsis (...) to explicitely mark an argument as required. +Similarly, the old style could use ellipsis (...) to explicitly mark an argument as required. ```python # DO NOT DO THIS: old style. Use Annotated without a default value instead. @@ -125,7 +125,29 @@ def main(name: str = typer.Argument(default=...)): ## CLI Options -CLI options are declared in a similar fashion as arguments, but will be called on the CLI with a single dash (single letter) or 2 dashes (full name): +CLI options are declared in a similar fashion as arguments, but will be called on the CLI with a single dash (single letter) or 2 dashes (full name). + +The CLI option name is automatically generated from the variable name, so `user_name` becomes `--user-name` automatically: + +```python +from typing import Annotated + +import typer + +app = typer.Typer() + + +@app.command() +def main(user_name: Annotated[str, typer.Option()]): + # On the CLI, the required user name can be specified with --user-name + print(f"Hello {user_name}") + + +if __name__ == "__main__": + app() +``` + +If you want to specify a different name, or want to add a short version, declare them in the `typer.Option`: ```python from typing import Annotated @@ -259,7 +281,7 @@ if __name__ == "__main__": ## Click -Originally, Typer was built on Click. However, going forward Typer will vendor Click. As such, Click extensions should not be used anymore. +Originally, Typer was built on Click. However, since version 0.26.0, Typer has vendored Click. As such, Click extensions should not be used anymore. Other settings of `Option` and `Argument` that came from Click but shouldn't be used in Typer anymore, include: `expose_value`, `shell_complete`, `show_choices`, `errors`, `prompt_required`, `is_flag`, `flag_value` and `allow_from_autoenv`. diff --git a/typer/__init__.py b/typer/__init__.py index 548408fe98..70f2dd167e 100644 --- a/typer/__init__.py +++ b/typer/__init__.py @@ -1,31 +1,24 @@ """Typer, build great CLIs. Easy to code. Based on Python type hints.""" -__version__ = "0.25.1" +__version__ = "0.26.5" from shutil import get_terminal_size as get_terminal_size -from click.exceptions import Abort as Abort -from click.exceptions import BadParameter as BadParameter -from click.exceptions import Exit as Exit -from click.termui import clear as clear -from click.termui import confirm as confirm -from click.termui import echo_via_pager as echo_via_pager -from click.termui import edit as edit -from click.termui import getchar as getchar -from click.termui import pause as pause -from click.termui import progressbar as progressbar -from click.termui import prompt as prompt -from click.termui import secho as secho -from click.termui import style as style -from click.termui import unstyle as unstyle -from click.utils import echo as echo -from click.utils import format_filename as format_filename -from click.utils import get_app_dir as get_app_dir -from click.utils import get_binary_stream as get_binary_stream -from click.utils import get_text_stream as get_text_stream -from click.utils import open_file as open_file - from . import colors as colors +from ._click.exceptions import Abort as Abort +from ._click.exceptions import BadParameter as BadParameter +from ._click.exceptions import Exit as Exit +from ._click.termui import confirm as confirm +from ._click.termui import getchar as getchar +from ._click.termui import progressbar as progressbar +from ._click.termui import prompt as prompt +from ._click.termui import secho as secho +from ._click.termui import style as style +from ._click.utils import echo as echo +from ._click.utils import format_filename as format_filename +from ._click.utils import get_app_dir as get_app_dir +from ._click.utils import get_binary_stream as get_binary_stream +from ._click.utils import get_text_stream as get_text_stream from .main import Typer as Typer from .main import launch as launch from .main import run as run diff --git a/typer/_click/LICENSE.txt b/typer/_click/LICENSE.txt new file mode 100644 index 0000000000..d12a849186 --- /dev/null +++ b/typer/_click/LICENSE.txt @@ -0,0 +1,28 @@ +Copyright 2014 Pallets + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/typer/_click/__init__.py b/typer/_click/__init__.py new file mode 100644 index 0000000000..48c9edcddd --- /dev/null +++ b/typer/_click/__init__.py @@ -0,0 +1,11 @@ +""" +Code taken and adapted from Click: https://github.com/pallets/click/releases/tag/8.3.1 +""" + +from .core import Command as Command +from .core import Context as Context +from .core import Parameter as Parameter +from .exceptions import ClickException as ClickException +from .formatting import HelpFormatter as HelpFormatter +from .termui import launch as launch +from .utils import echo as echo diff --git a/typer/_click/_compat.py b/typer/_click/_compat.py new file mode 100644 index 0000000000..6ed0ceb8ac --- /dev/null +++ b/typer/_click/_compat.py @@ -0,0 +1,569 @@ +import codecs +import io +import os +import re +import sys +from collections.abc import Callable, Mapping, MutableMapping +from types import TracebackType +from typing import ( + IO, + Any, + BinaryIO, + TextIO, + cast, +) +from weakref import WeakKeyDictionary + +CYGWIN = sys.platform.startswith("cygwin") +WIN = sys.platform.startswith("win") +auto_wrap_for_ansi: Callable[[TextIO], TextIO] | None = None +_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") + + +def _make_text_stream( + stream: BinaryIO, + encoding: str | None, + errors: str, +) -> TextIO: + if encoding is None: + encoding = get_best_encoding(stream) + return _NonClosingTextIOWrapper( + stream, + encoding, + errors, + line_buffering=True, + ) + + +def is_ascii_encoding(encoding: str) -> bool: + """Checks if a given encoding is ascii.""" + try: + return codecs.lookup(encoding).name == "ascii" + except LookupError: + return False + + +def get_best_encoding(stream: IO[Any]) -> str: + """Returns the default stream encoding if not found.""" + rv = getattr(stream, "encoding", None) or sys.getdefaultencoding() + if is_ascii_encoding(rv): + return "utf-8" + return rv + + +class _NonClosingTextIOWrapper(io.TextIOWrapper): + def __init__( + self, + stream: BinaryIO, + encoding: str | None, + errors: str | None, + **extra: Any, + ) -> None: + self._stream = stream = cast(BinaryIO, _FixupStream(stream)) + super().__init__(stream, encoding, errors, **extra) + + def __del__(self) -> None: + try: + self.detach() + except Exception: # pragma: no cover + pass + + def isatty(self) -> bool: + # https://bitbucket.org/pypy/pypy/issue/1803 + return self._stream.isatty() + + +class _FixupStream: + """The new io interface needs more from streams than streams + traditionally implement. As such, this fix-up code is necessary in + some circumstances. + """ + + def __init__( + self, + stream: BinaryIO, + ): + self._stream = stream + + def __getattr__(self, name: str) -> Any: + return getattr(self._stream, name) + + def read1(self, size: int) -> bytes: + f = getattr(self._stream, "read1", None) + + if f is not None: + return cast(bytes, f(size)) + + return self._stream.read(size) + + def readable(self) -> bool: + return True + + def writable(self) -> bool: + return True + + def seekable(self) -> bool: + x = getattr(self._stream, "seekable", None) + if x is not None: + return cast(bool, x()) + return False + + +def _is_binary_reader(stream: IO[Any], default: bool = False) -> bool: + try: + return isinstance(stream.read(0), bytes) + except Exception: # pragma: no cover + return default + # This happens in some cases where the stream was already + # closed. In this case, we assume the default. + + +def _is_binary_writer(stream: IO[Any], default: bool = False) -> bool: + try: + stream.write(b"") + except Exception: # pragma: no cover + try: + stream.write("") + return False + except Exception: + pass + return default + return True + + +def _find_binary_reader(stream: IO[Any]) -> BinaryIO | None: + # We need to figure out if the given stream is already binary. + # This can happen because the official docs recommend detaching + # the streams to get binary streams. Some code might do this, so + # we need to deal with this case explicitly. + if _is_binary_reader(stream, False): + return cast(BinaryIO, stream) + + buf = getattr(stream, "buffer", None) + + # Same situation here; this time we assume that the buffer is + # actually binary in case it's closed. + if buf is not None and _is_binary_reader(buf, True): + return cast(BinaryIO, buf) + + return None + + +def _find_binary_writer(stream: IO[Any]) -> BinaryIO | None: + # We need to figure out if the given stream is already binary. + # This can happen because the official docs recommend detaching + # the streams to get binary streams. Some code might do this, so + # we need to deal with this case explicitly. + if _is_binary_writer(stream, False): + return cast(BinaryIO, stream) + + buf = getattr(stream, "buffer", None) + + # Same situation here; this time we assume that the buffer is + # actually binary in case it's closed. + if buf is not None and _is_binary_writer(buf, True): + return cast(BinaryIO, buf) + + return None + + +def _stream_is_misconfigured(stream: TextIO) -> bool: + """A stream is misconfigured if its encoding is ASCII.""" + # If the stream does not have an encoding set, we assume it's set + # to ASCII. This appears to happen in certain unittest + # environments. It's not quite clear what the correct behavior is + # but this at least will force Click to recover somehow. + return is_ascii_encoding(getattr(stream, "encoding", None) or "ascii") + + +def _is_compat_stream_attr(stream: TextIO, attr: str, value: str | None) -> bool: + """A stream attribute is compatible if it is equal to the + desired value or the desired value is unset and the attribute + has a value. + """ + stream_value = getattr(stream, attr, None) + return stream_value == value or (value is None and stream_value is not None) + + +def _is_compatible_text_stream( + stream: TextIO, encoding: str | None, errors: str | None +) -> bool: + """Check if a stream's encoding and errors attributes are + compatible with the desired values. + """ + return _is_compat_stream_attr( + stream, "encoding", encoding + ) and _is_compat_stream_attr(stream, "errors", errors) + + +def _force_correct_text_stream( + text_stream: IO[Any], + encoding: str | None, + errors: str | None, + is_binary: Callable[[IO[Any], bool], bool], + find_binary: Callable[[IO[Any]], BinaryIO | None], +) -> TextIO: + if is_binary(text_stream, False): + binary_reader = cast(BinaryIO, text_stream) + else: + text_stream = cast(TextIO, text_stream) + # If the stream looks compatible, and won't default to a + # misconfigured ascii encoding, return it as-is. + if _is_compatible_text_stream(text_stream, encoding, errors) and not ( + encoding is None and _stream_is_misconfigured(text_stream) + ): + return text_stream + + # Otherwise, get the underlying binary reader. + possible_binary_reader = find_binary(text_stream) + + # If that's not possible, silently use the original reader + # and get mojibake instead of exceptions. + if possible_binary_reader is None: + return text_stream + + binary_reader = possible_binary_reader + + # Default errors to replace instead of strict in order to get + # something that works. + if errors is None: + errors = "replace" + + # Wrap the binary stream in a text stream with the correct + # encoding parameters. + return _make_text_stream( + binary_reader, + encoding, + errors, + ) + + +def _force_correct_text_reader( + text_reader: IO[Any], + encoding: str | None, + errors: str | None, +) -> TextIO: + return _force_correct_text_stream( + text_reader, + encoding, + errors, + _is_binary_reader, + _find_binary_reader, + ) + + +def _force_correct_text_writer( + text_writer: IO[Any], + encoding: str | None, + errors: str | None, +) -> TextIO: + return _force_correct_text_stream( + text_writer, + encoding, + errors, + _is_binary_writer, + _find_binary_writer, + ) + + +def get_binary_stdin() -> BinaryIO: + reader = _find_binary_reader(sys.stdin) + if reader is None: # pragma: no cover + raise RuntimeError("Was not able to determine binary stream for sys.stdin.") + return reader + + +def get_binary_stdout() -> BinaryIO: + writer = _find_binary_writer(sys.stdout) + if writer is None: # pragma: no cover + raise RuntimeError("Was not able to determine binary stream for sys.stdout.") + return writer + + +def get_binary_stderr() -> BinaryIO: + writer = _find_binary_writer(sys.stderr) + if writer is None: # pragma: no cover + raise RuntimeError("Was not able to determine binary stream for sys.stderr.") + return writer + + +def get_text_stdin(encoding: str | None = None, errors: str | None = None) -> TextIO: + rv = _get_windows_console_stream(sys.stdin, encoding, errors) + if rv is not None: + return rv + return _force_correct_text_reader(sys.stdin, encoding, errors) + + +def get_text_stdout(encoding: str | None = None, errors: str | None = None) -> TextIO: + rv = _get_windows_console_stream(sys.stdout, encoding, errors) + if rv is not None: + return rv + return _force_correct_text_writer(sys.stdout, encoding, errors) + + +def get_text_stderr(encoding: str | None = None, errors: str | None = None) -> TextIO: + rv = _get_windows_console_stream(sys.stderr, encoding, errors) + if rv is not None: + return rv + return _force_correct_text_writer(sys.stderr, encoding, errors) + + +def _wrap_io_open( + file: str | os.PathLike[str] | int, + mode: str, + encoding: str | None, + errors: str | None, +) -> IO[Any]: + """Handles not passing ``encoding`` and ``errors`` in binary mode.""" + if "b" in mode: + return open(file, mode) + + return open(file, mode, encoding=encoding, errors=errors) + + +def open_stream( + filename: str | os.PathLike[str], + mode: str = "r", + encoding: str | None = None, + errors: str | None = "strict", + atomic: bool = False, +) -> tuple[IO[Any], bool]: + binary = "b" in mode + filename = os.fspath(filename) + + # Standard streams first, ignoring the atomic flag. + if os.fsdecode(filename) == "-": + if any(m in mode for m in ["w", "a", "x"]): + if binary: + return get_binary_stdout(), False + return get_text_stdout(encoding=encoding, errors=errors), False + if binary: + return get_binary_stdin(), False + return get_text_stdin(encoding=encoding, errors=errors), False + + # Non-atomic writes directly go out through the regular open functions. + if not atomic: + return _wrap_io_open(filename, mode, encoding, errors), True + + # Some usability stuff for atomic writes + if "a" in mode: + raise ValueError( + "Appending to an existing file is not supported, because that" + " would involve an expensive `copy`-operation to a temporary" + " file. Open the file in normal `w`-mode and copy explicitly" + " if that's what you're after." + ) + if "x" in mode: + raise ValueError("Use the `overwrite`-parameter instead.") + if "w" not in mode: + raise ValueError("Atomic writes only make sense with `w`-mode.") + + # Atomic writes are more complicated. They work by opening a file + # as a proxy in the same folder and then using the fdopen + # functionality to wrap it in a Python file. Then we wrap it in an + # atomic file that moves the file over on close. + import errno + import random + + try: + perm: int | None = os.stat(filename).st_mode + except OSError: # pragma: no cover + perm = None + + flags = os.O_RDWR | os.O_CREAT | os.O_EXCL + + if binary: + flags |= getattr(os, "O_BINARY", 0) + + while True: + tmp_filename = os.path.join( + os.path.dirname(filename), + f".__atomic-write{random.randrange(1 << 32):08x}", + ) + try: + fd = os.open(tmp_filename, flags, 0o666 if perm is None else perm) + break + except OSError as e: # pragma: no cover + if e.errno == errno.EEXIST or ( + os.name == "nt" + and e.errno == errno.EACCES + and os.path.isdir(e.filename) + and os.access(e.filename, os.W_OK) + ): + continue + raise + + if perm is not None: + os.chmod(tmp_filename, perm) # in case perm includes bits in umask + + f = _wrap_io_open(fd, mode, encoding, errors) + af = _AtomicFile(f, tmp_filename, os.path.realpath(filename)) + return cast(IO[Any], af), True + + +class _AtomicFile: + def __init__(self, f: IO[Any], tmp_filename: str, real_filename: str) -> None: + self._f = f + self._tmp_filename = tmp_filename + self._real_filename = real_filename + self.closed = False + + @property + def name(self) -> str: + return self._real_filename + + def close(self, delete: bool = False) -> None: + if self.closed: + return # pragma: no cover + self._f.close() + os.replace(self._tmp_filename, self._real_filename) + self.closed = True + + def __getattr__(self, name: str) -> Any: + return getattr(self._f, name) + + def __enter__(self) -> "_AtomicFile": + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close(delete=exc_type is not None) + + def __repr__(self) -> str: + return repr(self._f) + + +def strip_ansi(value: str) -> str: + return _ansi_re.sub("", value) + + +def _is_jupyter_kernel_output(stream: IO[Any]) -> bool: + while isinstance(stream, (_FixupStream, _NonClosingTextIOWrapper)): + stream = stream._stream + + return stream.__class__.__module__.startswith("ipykernel.") + + +def should_strip_ansi(stream: IO[Any] | None = None, color: bool | None = None) -> bool: + if color is None: + if stream is None: + stream = sys.stdin + return not isatty(stream) and not _is_jupyter_kernel_output(stream) + return not color + + +# On Windows, wrap the output streams with colorama to support ANSI +# color codes. +# NOTE: double check is needed so mypy does not analyze this on Linux +if sys.platform.startswith("win") and WIN: + from ._winconsole import _get_windows_console_stream + + def _get_argv_encoding() -> str: + import locale + + return locale.getpreferredencoding() + + _ansi_stream_wrappers: MutableMapping[TextIO, TextIO] = WeakKeyDictionary() + + def auto_wrap_for_ansi(stream: TextIO, color: bool | None = None) -> TextIO: + """Support ANSI color and style codes on Windows by wrapping a + stream with colorama. + """ + try: + cached = _ansi_stream_wrappers.get(stream) + except Exception: # pragma: no cover + cached = None + + if cached is not None: + return cached + + import colorama + + strip = should_strip_ansi(stream, color) + ansi_wrapper = colorama.AnsiToWin32(stream, strip=strip) + rv = cast(TextIO, ansi_wrapper.stream) + _write = rv.write + + def _safe_write(s: str) -> int: + try: + return _write(s) + except BaseException: # pragma: no cover + ansi_wrapper.reset_all() + raise + + rv.write = _safe_write # type: ignore[method-assign] # ty: ignore[invalid-assignment] + + try: + _ansi_stream_wrappers[stream] = rv + except Exception: # pragma: no cover + pass + + return rv + +else: + + def _get_argv_encoding() -> str: + return getattr(sys.stdin, "encoding", None) or sys.getfilesystemencoding() + + def _get_windows_console_stream( + f: TextIO, encoding: str | None, errors: str | None + ) -> TextIO | None: + return None + + +def term_len(x: str) -> int: + return len(strip_ansi(x)) + + +def isatty(stream: IO[Any]) -> bool: + try: + return stream.isatty() + except Exception: # pragma: no cover + return False + + +def _make_cached_stream_func( + src_func: Callable[[], TextIO], + wrapper_func: Callable[[], TextIO], +) -> Callable[[], TextIO]: + cache: MutableMapping[TextIO, TextIO] = WeakKeyDictionary() + + def func() -> TextIO: + stream = src_func() + + try: + rv = cache.get(stream) + except Exception: # pragma: no cover + rv = None + if rv is not None: + return rv + rv = wrapper_func() + try: + cache[stream] = rv + except Exception: # pragma: no cover + pass + return rv + + return func + + +_default_text_stdin = _make_cached_stream_func(lambda: sys.stdin, get_text_stdin) +_default_text_stdout = _make_cached_stream_func(lambda: sys.stdout, get_text_stdout) +_default_text_stderr = _make_cached_stream_func(lambda: sys.stderr, get_text_stderr) + + +binary_streams: Mapping[str, Callable[[], BinaryIO]] = { + "stdin": get_binary_stdin, + "stdout": get_binary_stdout, + "stderr": get_binary_stderr, +} + +text_streams: Mapping[str, Callable[[str | None, str | None], TextIO]] = { + "stdin": get_text_stdin, + "stdout": get_text_stdout, + "stderr": get_text_stderr, +} diff --git a/typer/_click/_termui_impl.py b/typer/_click/_termui_impl.py new file mode 100644 index 0000000000..c621810cbf --- /dev/null +++ b/typer/_click/_termui_impl.py @@ -0,0 +1,522 @@ +""" +To keep the import times down, some infrequently used termui functionality +is placed here and only imported as needed. +""" + +import contextlib +import math +import os +import sys +import time +from collections.abc import Callable, Iterable, Iterator +from io import StringIO +from types import TracebackType +from typing import Generic, TextIO, TypeVar, cast + +from ._compat import ( + CYGWIN, + WIN, + _default_text_stdout, + get_best_encoding, + isatty, + term_len, +) +from .utils import echo + +V = TypeVar("V") + +if os.name == "nt": + BEFORE_BAR = "\r" + AFTER_BAR = "\n" +else: + BEFORE_BAR = "\r\033[?25l" + AFTER_BAR = "\033[?25h\n" + + +class ProgressBar(Generic[V]): + def __init__( + self, + iterable: Iterable[V] | None, + length: int | None = None, + fill_char: str = "#", + empty_char: str = " ", + bar_template: str = "%(bar)s", + info_sep: str = " ", + hidden: bool = False, + show_eta: bool = True, + show_percent: bool | None = None, + show_pos: bool = False, + item_show_func: Callable[[V | None], str | None] | None = None, + label: str | None = None, + file: TextIO | None = None, + color: bool | None = None, + update_min_steps: int = 1, + width: int = 30, + ) -> None: + self.fill_char = fill_char + self.empty_char = empty_char + self.bar_template = bar_template + self.info_sep = info_sep + self.hidden = hidden + self.show_eta = show_eta + self.show_percent = show_percent + self.show_pos = show_pos + self.item_show_func = item_show_func + self.label: str = label or "" + + if file is None: + file = _default_text_stdout() + + # There are no standard streams attached to write to. For example, + # pythonw on Windows. + if file is None: # pragma: no cover + file = StringIO() + + self.file = file + self.color = color + self.update_min_steps = update_min_steps + self._completed_intervals = 0 + self.width: int = width + self.autowidth: bool = width == 0 + + if length is None: + from operator import length_hint + + length = length_hint(iterable, -1) + + if length == -1: # pragma: no cover + length = None + if iterable is None: + if length is None: # pragma: no cover + raise TypeError("iterable or length is required") + iterable = cast("Iterable[V]", range(length)) + self.iter: Iterable[V] = iter(iterable) + self.length = length + self.pos: int = 0 + self.avg: list[float] = [] + self.last_eta: float + self.start: float + self.start = self.last_eta = time.time() + self.eta_known: bool = False + self.finished: bool = False + self.max_width: int | None = None + self.entered: bool = False + self.current_item: V | None = None + self._is_atty = isatty(self.file) + self._last_line: str | None = None + + def __enter__(self) -> "ProgressBar[V]": + self.entered = True + self.render_progress() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.render_finish() + + def __iter__(self) -> Iterator[V]: + if not self.entered: + raise RuntimeError("You need to use progress bars in a with block.") + self.render_progress() + return self.generator() + + def __next__(self) -> V: + # Iteration is defined in terms of a generator function, + # returned by iter(self); use that to define next(). This works + # because `self.iter` is an iterable consumed by that generator, + # so it is re-entry safe. Calling `next(self.generator())` + # twice works and does "what you want". + return next(iter(self)) + + def render_finish(self) -> None: + if self.hidden or not self._is_atty: + return + self.file.write(AFTER_BAR) + self.file.flush() + + @property + def pct(self) -> float: + if self.finished: + return 1.0 + return min(self.pos / (float(self.length or 1) or 1), 1.0) + + @property + def time_per_iteration(self) -> float: + if not self.avg: + return 0.0 + return sum(self.avg) / float(len(self.avg)) + + @property + def eta(self) -> float: + if self.length is not None and not self.finished: + return self.time_per_iteration * (self.length - self.pos) + return 0.0 + + def format_eta(self) -> str: + if self.eta_known: + t = int(self.eta) + seconds = t % 60 + t //= 60 + minutes = t % 60 + t //= 60 + hours = t % 24 + t //= 24 + if t > 0: + return f"{t}d {hours:02}:{minutes:02}:{seconds:02}" + else: + return f"{hours:02}:{minutes:02}:{seconds:02}" + return "" + + def format_pos(self) -> str: + pos = str(self.pos) + if self.length is not None: + pos += f"/{self.length}" + return pos + + def format_pct(self) -> str: + return f"{int(self.pct * 100): 4}%"[1:] + + def format_bar(self) -> str: + if self.length is not None: + bar_length = int(self.pct * self.width) + bar = self.fill_char * bar_length + bar += self.empty_char * (self.width - bar_length) + elif self.finished: + bar = self.fill_char * self.width + else: + chars = list(self.empty_char * (self.width or 1)) + if self.time_per_iteration != 0: + chars[ + int( + (math.cos(self.pos * self.time_per_iteration) / 2.0 + 0.5) + * self.width + ) + ] = self.fill_char + bar = "".join(chars) + return bar + + def format_progress_line(self) -> str: + show_percent = self.show_percent + + info_bits = [] + if self.length is not None and show_percent is None: + show_percent = not self.show_pos + + if self.show_pos: + info_bits.append(self.format_pos()) + if show_percent: + info_bits.append(self.format_pct()) + if self.show_eta and self.eta_known and not self.finished: + info_bits.append(self.format_eta()) + if self.item_show_func is not None: + item_info = self.item_show_func(self.current_item) + if item_info is not None: + info_bits.append(item_info) + + return ( + self.bar_template + % { + "label": self.label, + "bar": self.format_bar(), + "info": self.info_sep.join(info_bits), + } + ).rstrip() + + def render_progress(self) -> None: + if self.hidden: + return + + if not self._is_atty: + # Only output the label once if the output is not a TTY. + if self._last_line != self.label: + self._last_line = self.label + echo(self.label, file=self.file, color=self.color) + return + + buf = [] + # Update width in case the terminal has been resized + if self.autowidth: + import shutil + + old_width = self.width + self.width = 0 + clutter_length = term_len(self.format_progress_line()) + new_width = max(0, shutil.get_terminal_size().columns - clutter_length) + if new_width < old_width and self.max_width is not None: + buf.append(BEFORE_BAR) + buf.append(" " * self.max_width) + self.max_width = new_width + self.width = new_width + + clear_width = self.width + if self.max_width is not None: + clear_width = self.max_width + + buf.append(BEFORE_BAR) + line = self.format_progress_line() + line_len = term_len(line) + if self.max_width is None or self.max_width < line_len: + self.max_width = line_len + + buf.append(line) + buf.append(" " * (clear_width - line_len)) + line = "".join(buf) + # Render the line only if it changed. + + if line != self._last_line: + self._last_line = line + echo(line, file=self.file, color=self.color, nl=False) + self.file.flush() + + def make_step(self, n_steps: int) -> None: + self.pos += n_steps + if self.length is not None and self.pos >= self.length: + self.finished = True + + if (time.time() - self.last_eta) < 1.0: + return + + self.last_eta = time.time() + + # self.avg is a rolling list of length <= 7 of steps where steps are + # defined as time elapsed divided by the total progress through + # self.length. + if self.pos: + step = (time.time() - self.start) / self.pos + else: + step = time.time() - self.start + + self.avg = self.avg[-6:] + [step] + + self.eta_known = self.length is not None + + def update(self, n_steps: int) -> None: + """Update the progress bar by advancing a specified number of steps.""" + self._completed_intervals += n_steps + + if self._completed_intervals >= self.update_min_steps: + self.make_step(self._completed_intervals) + self.render_progress() + self._completed_intervals = 0 + + def finish(self) -> None: + self.eta_known = False + self.current_item = None + self.finished = True + + def generator(self) -> Iterator[V]: + """Return a generator which yields the items added to the bar + during construction, and updates the progress bar *after* the + yielded block returns. + """ + # WARNING: the iterator interface for `ProgressBar` relies on + # this and only works because this is a simple generator which + # doesn't create or manage additional state. If this function + # changes, the impact should be evaluated both against + # `iter(bar)` and `next(bar)`. `next()` in particular may call + # `self.generator()` repeatedly, and this must remain safe in + # order for that interface to work. + if not self.entered: # pragma: no cover + raise RuntimeError("You need to use progress bars in a with block.") + + if not self._is_atty: + yield from self.iter + else: + for rv in self.iter: + self.current_item = rv + + # This allows show_item_func to be updated before the + # item is processed. Only trigger at the beginning of + # the update interval. + if self._completed_intervals == 0: + self.render_progress() + + yield rv + self.update(1) + + self.finish() + self.render_progress() + + +def open_url(url: str, wait: bool = False, locate: bool = False) -> int: + import subprocess + + def _unquote_file(url: str) -> str: + from urllib.parse import unquote + + if url.startswith("file://"): + url = unquote(url[7:]) + + return url + + if sys.platform == "darwin": + args = ["open"] + if wait: + args.append("-W") + if locate: + args.append("-R") + args.append(_unquote_file(url)) + null = open("/dev/null", "w") + try: + return subprocess.Popen(args, stderr=null).wait() + finally: + null.close() + elif WIN: + if locate: + url = _unquote_file(url) + args = ["explorer", f"/select,{url}"] + else: + args = ["start"] + if wait: + args.append("/WAIT") + args.append("") + args.append(url) + try: + return subprocess.call(args) + except OSError: + # Command not found + return 127 + elif CYGWIN: # pragma: no cover + if locate: + url = _unquote_file(url) + args = ["cygstart", os.path.dirname(url)] + else: + args = ["cygstart"] + if wait: + args.append("-w") + args.append(url) + try: + return subprocess.call(args) + except OSError: + # Command not found + return 127 + + try: + if locate: + url = os.path.dirname(_unquote_file(url)) or "." + else: + url = _unquote_file(url) + c = subprocess.Popen(["xdg-open", url]) + if wait: + return c.wait() + return 0 + except OSError: # pragma: no cover + # TODO: remove this part, doesn't get hit by Typer code paths? + if url.startswith(("http://", "https://")) and not locate and not wait: + import webbrowser + + webbrowser.open(url) + return 0 + return 1 + + +def _translate_ch_to_exc(ch: str) -> None: + if ch == "\x03": + raise KeyboardInterrupt() + + if ch == "\x04" and not WIN: # Unix-like, Ctrl+D + raise EOFError() + + if ch == "\x1a" and WIN: # Windows, Ctrl+Z + raise EOFError() + + return None + + +if sys.platform == "win32": + import msvcrt + + @contextlib.contextmanager + def raw_terminal() -> Iterator[int]: + yield -1 + + def getchar(echo: bool) -> str: + # The function `getch` will return a bytes object corresponding to + # the pressed character. Since Windows 10 build 1803, it will also + # return \x00 when called a second time after pressing a regular key. + # + # `getwch` does not share this probably-bugged behavior. Moreover, it + # returns a Unicode object by default, which is what we want. + # + # Either of these functions will return \x00 or \xe0 to indicate + # a special key, and you need to call the same function again to get + # the "rest" of the code. The fun part is that \u00e0 is + # "latin small letter a with grave", so if you type that on a French + # keyboard, you _also_ get a \xe0. + # E.g., consider the Up arrow. This returns \xe0 and then \x48. The + # resulting Unicode string reads as "a with grave" + "capital H". + # This is indistinguishable from when the user actually types + # "a with grave" and then "capital H". + # + # When \xe0 is returned, we assume it's part of a special-key sequence + # and call `getwch` again, but that means that when the user types + # the \u00e0 character, `getchar` doesn't return until a second + # character is typed. + # The alternative is returning immediately, but that would mess up + # cross-platform handling of arrow keys and others that start with + # \xe0. Another option is using `getch`, but then we can't reliably + # read non-ASCII characters, because return values of `getch` are + # limited to the current 8-bit codepage. + # + # Anyway, Click doesn't claim to do this Right(tm), and using `getwch` + # is doing the right thing in more situations than with `getch`. + + if echo: + func = cast(Callable[[], str], msvcrt.getwche) + else: + func = cast(Callable[[], str], msvcrt.getwch) + + rv = func() + + if rv in ("\x00", "\xe0"): + # \x00 and \xe0 are control characters that indicate special key, + # see above. + rv += func() + + _translate_ch_to_exc(rv) + return rv + +else: + import termios + import tty + + @contextlib.contextmanager + def raw_terminal() -> Iterator[int]: + f: TextIO | None + fd: int + + if not isatty(sys.stdin): + f = open("/dev/tty") + fd = f.fileno() + else: + fd = sys.stdin.fileno() + f = None + + try: + old_settings = termios.tcgetattr(fd) + + try: + tty.setraw(fd) + yield fd + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + sys.stdout.flush() + + if f is not None: + f.close() + except termios.error: # pragma: no cover + pass + + def getchar(echo: bool) -> str: + with raw_terminal() as fd: + ch = os.read(fd, 32).decode(get_best_encoding(sys.stdin), "replace") + + if echo and isatty(sys.stdout): # pragma: no cover + sys.stdout.write(ch) + + _translate_ch_to_exc(ch) + return ch diff --git a/typer/_click/_textwrap.py b/typer/_click/_textwrap.py new file mode 100644 index 0000000000..9f9636b31f --- /dev/null +++ b/typer/_click/_textwrap.py @@ -0,0 +1,46 @@ +import textwrap +from collections.abc import Iterator +from contextlib import contextmanager + + +class TextWrapper(textwrap.TextWrapper): + def _handle_long_word( + self, + reversed_chunks: list[str], + cur_line: list[str], + cur_len: int, + width: int, + ) -> None: + space_left = max(width - cur_len, 1) + + last = reversed_chunks[-1] + cut = last[:space_left] + res = last[space_left:] + cur_line.append(cut) + reversed_chunks[-1] = res + + @contextmanager + def extra_indent(self, indent: str) -> Iterator[None]: + old_initial_indent = self.initial_indent + old_subsequent_indent = self.subsequent_indent + self.initial_indent += indent + self.subsequent_indent += indent + + try: + yield + finally: + self.initial_indent = old_initial_indent + self.subsequent_indent = old_subsequent_indent + + def indent_only(self, text: str) -> str: + rv = [] + + for idx, line in enumerate(text.splitlines()): + indent = self.initial_indent + + if idx > 0: + indent = self.subsequent_indent + + rv.append(f"{indent}{line}") + + return "\n".join(rv) diff --git a/typer/_click/_winconsole.py b/typer/_click/_winconsole.py new file mode 100644 index 0000000000..f6dedbfd6e --- /dev/null +++ b/typer/_click/_winconsole.py @@ -0,0 +1,300 @@ +# This module is based on the excellent work by Adam Bartoš who +# provided a lot of what went into the implementation here in +# the discussion to issue1602 in the Python bug tracker. +# +# There are some general differences in regards to how this works +# compared to the original patches as we do not need to patch +# the entire interpreter but just work in our little world of +# echo and prompt. +import io +import sys +import time +from collections.abc import Callable, Iterable, Mapping +from ctypes import ( + POINTER, + Array, + Structure, + byref, + c_char, + c_char_p, + c_int, + c_ssize_t, + c_ulong, + c_void_p, + py_object, +) +from ctypes.wintypes import DWORD, HANDLE, LPCWSTR, LPWSTR +from typing import ( + TYPE_CHECKING, + Any, + AnyStr, + BinaryIO, + Literal, + TextIO, + cast, +) + +from ._compat import _NonClosingTextIOWrapper + +assert sys.platform == "win32" +import msvcrt # noqa: E402 +from ctypes import WINFUNCTYPE, windll # noqa: E402 + +c_ssize_p = POINTER(c_ssize_t) + +kernel32 = windll.kernel32 +GetStdHandle = kernel32.GetStdHandle +ReadConsoleW = kernel32.ReadConsoleW +WriteConsoleW = kernel32.WriteConsoleW +GetConsoleMode = kernel32.GetConsoleMode +GetLastError = kernel32.GetLastError +GetCommandLineW = WINFUNCTYPE(LPWSTR)(("GetCommandLineW", windll.kernel32)) +CommandLineToArgvW = WINFUNCTYPE(POINTER(LPWSTR), LPCWSTR, POINTER(c_int))( + ("CommandLineToArgvW", windll.shell32) +) +LocalFree = WINFUNCTYPE(c_void_p, c_void_p)(("LocalFree", windll.kernel32)) + +STDIN_HANDLE = GetStdHandle(-10) +STDOUT_HANDLE = GetStdHandle(-11) +STDERR_HANDLE = GetStdHandle(-12) + +PyBUF_SIMPLE = 0 +PyBUF_WRITABLE = 1 + +ERROR_SUCCESS = 0 +ERROR_NOT_ENOUGH_MEMORY = 8 +ERROR_OPERATION_ABORTED = 995 + +STDIN_FILENO = 0 +STDOUT_FILENO = 1 +STDERR_FILENO = 2 + +EOF = b"\x1a" +MAX_BYTES_WRITTEN = 32767 + +if TYPE_CHECKING: + try: + # Using `typing_extensions.Buffer` instead of `collections.abc` + # on Windows for some reason does not have `Sized` implemented. + from collections.abc import Buffer # type: ignore + except ImportError: + from typing_extensions import Buffer + +try: + from ctypes import pythonapi +except ImportError: # pragma: no cover + # On PyPy we cannot get buffers so our ability to operate here is + # severely limited. + get_buffer = None +else: + + class Py_buffer(Structure): + _fields_ = [ # noqa: RUF012 + ("buf", c_void_p), + ("obj", py_object), + ("len", c_ssize_t), + ("itemsize", c_ssize_t), + ("readonly", c_int), + ("ndim", c_int), + ("format", c_char_p), + ("shape", c_ssize_p), + ("strides", c_ssize_p), + ("suboffsets", c_ssize_p), + ("internal", c_void_p), + ] + + PyObject_GetBuffer = pythonapi.PyObject_GetBuffer + PyBuffer_Release = pythonapi.PyBuffer_Release + + def get_buffer(obj: "Buffer", writable: bool = False) -> Array[c_char]: + buf = Py_buffer() + flags: int = PyBUF_WRITABLE if writable else PyBUF_SIMPLE + PyObject_GetBuffer(py_object(obj), byref(buf), flags) + + try: + buffer_type = c_char * buf.len + out: Array[c_char] = buffer_type.from_address(buf.buf) + return out + finally: + PyBuffer_Release(byref(buf)) + + +class _WindowsConsoleRawIOBase(io.RawIOBase): + def __init__(self, handle: int | None) -> None: + self.handle = handle + + def isatty(self) -> Literal[True]: + super().isatty() + return True + + +class _WindowsConsoleReader(_WindowsConsoleRawIOBase): + def readable(self) -> Literal[True]: + return True + + def readinto(self, b: "Buffer") -> int: + bytes_to_be_read = len(b) + if not bytes_to_be_read: + return 0 + elif bytes_to_be_read % 2: + raise ValueError( + "cannot read odd number of bytes from UTF-16-LE encoded console" + ) + + buffer = get_buffer(b, writable=True) + code_units_to_be_read = bytes_to_be_read // 2 + code_units_read = c_ulong() + + rv = ReadConsoleW( + HANDLE(self.handle), + buffer, + code_units_to_be_read, + byref(code_units_read), + None, + ) + if GetLastError() == ERROR_OPERATION_ABORTED: + # wait for KeyboardInterrupt + time.sleep(0.1) + if not rv: + raise OSError(f"Windows error: {GetLastError()}") + + if buffer[0] == EOF: + return 0 + return 2 * code_units_read.value + + +class _WindowsConsoleWriter(_WindowsConsoleRawIOBase): + def writable(self) -> Literal[True]: + return True + + @staticmethod + def _get_error_message(errno: int) -> str: + if errno == ERROR_SUCCESS: + return "ERROR_SUCCESS" + elif errno == ERROR_NOT_ENOUGH_MEMORY: + return "ERROR_NOT_ENOUGH_MEMORY" + return f"Windows error {errno}" + + def write(self, b: "Buffer") -> int: + bytes_to_be_written = len(b) + buf = get_buffer(b) + code_units_to_be_written = min(bytes_to_be_written, MAX_BYTES_WRITTEN) // 2 + code_units_written = c_ulong() + + WriteConsoleW( + HANDLE(self.handle), + buf, + code_units_to_be_written, + byref(code_units_written), + None, + ) + bytes_written = 2 * code_units_written.value + + if bytes_written == 0 and bytes_to_be_written > 0: + raise OSError(self._get_error_message(GetLastError())) # pragma: no cover + return bytes_written + + +class ConsoleStream: + def __init__(self, text_stream: TextIO, byte_stream: BinaryIO) -> None: + self._text_stream = text_stream + self.buffer = byte_stream + + @property + def name(self) -> str: + return self.buffer.name + + def write(self, x: AnyStr) -> int: + if isinstance(x, str): + return self._text_stream.write(x) + try: + self.flush() + except Exception: # pragma: no cover + pass + return self.buffer.write(x) + + def writelines(self, lines: Iterable[AnyStr]) -> None: + for line in lines: + self.write(line) + + def __getattr__(self, name: str) -> Any: + return getattr(self._text_stream, name) + + def isatty(self) -> bool: + return self.buffer.isatty() + + def __repr__(self) -> str: + return f"" + + +def _get_text_stdin(buffer_stream: BinaryIO) -> TextIO: + text_stream = _NonClosingTextIOWrapper( + io.BufferedReader(_WindowsConsoleReader(STDIN_HANDLE)), + "utf-16-le", + "strict", + line_buffering=True, + ) + return cast(TextIO, ConsoleStream(text_stream, buffer_stream)) + + +def _get_text_stdout(buffer_stream: BinaryIO) -> TextIO: + text_stream = _NonClosingTextIOWrapper( + io.BufferedWriter(_WindowsConsoleWriter(STDOUT_HANDLE)), + "utf-16-le", + "strict", + line_buffering=True, + ) + return cast(TextIO, ConsoleStream(text_stream, buffer_stream)) + + +def _get_text_stderr(buffer_stream: BinaryIO) -> TextIO: + text_stream = _NonClosingTextIOWrapper( + io.BufferedWriter(_WindowsConsoleWriter(STDERR_HANDLE)), + "utf-16-le", + "strict", + line_buffering=True, + ) + return cast(TextIO, ConsoleStream(text_stream, buffer_stream)) + + +_stream_factories: Mapping[int, Callable[[BinaryIO], TextIO]] = { + 0: _get_text_stdin, + 1: _get_text_stdout, + 2: _get_text_stderr, +} + + +def _is_console(f: TextIO) -> bool: + if not hasattr(f, "fileno"): + return False + + try: + fileno = f.fileno() + except (OSError, io.UnsupportedOperation): + return False + + handle = msvcrt.get_osfhandle(fileno) + return bool(GetConsoleMode(handle, byref(DWORD()))) + + +def _get_windows_console_stream( + f: TextIO, encoding: str | None, errors: str | None +) -> TextIO | None: + if ( + get_buffer is None + or encoding not in {"utf-16-le", None} + or errors not in {"strict", None} + or not _is_console(f) + ): + return None + + func = _stream_factories.get(f.fileno()) + if func is None: + return None + + b = getattr(f, "buffer", None) + + if b is None: + return None + + return func(b) diff --git a/typer/_click/core.py b/typer/_click/core.py new file mode 100644 index 0000000000..580b558b9f --- /dev/null +++ b/typer/_click/core.py @@ -0,0 +1,1111 @@ +import enum +import inspect +import os +from abc import ABC, abstractmethod +from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence +from contextlib import AbstractContextManager, ExitStack, contextmanager +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Any, + Literal, + NoReturn, + TypeVar, + Union, + cast, + overload, +) + +from . import types +from .exceptions import ( + Abort, + BadParameter, + Exit, + MissingParameter, + NoArgsIsHelpError, + UsageError, +) +from .formatting import HelpFormatter +from .globals import pop_context, push_context +from .parser import _OptionParser +from .termui import style +from .utils import echo, make_default_short_help + +if TYPE_CHECKING: + from ..core import TyperOption + from .shell_completion import CompletionItem + +F = TypeVar("F", bound="Callable[..., Any]") +V = TypeVar("V") + + +def _complete_visible_commands( + ctx: "Context", incomplete: str +) -> Iterator[tuple[str, "Command"]]: + """List all the subcommands of a group that start with the + incomplete value and aren't hidden. + """ + # avoid circular imports + from ..core import TyperGroup + + multi = cast(TyperGroup, ctx.command) + + for name in multi.list_commands(ctx): + if name.startswith(incomplete): + command = multi.get_command(ctx, name) + + if command is not None and not command.hidden: + yield name, command + + +@contextmanager +def augment_usage_errors( + ctx: "Context", param: Union["Parameter", None] = None +) -> Iterator[None]: + """Context manager that attaches extra information to exceptions.""" + try: + yield + except BadParameter as e: + if e.ctx is None: + e.ctx = ctx + if param is not None and e.param is None: + e.param = param + raise + except UsageError as e: # pragma: no cover + if e.ctx is None: + e.ctx = ctx + raise + + +def iter_params_for_processing( + invocation_order: Sequence["Parameter"], + declaration_order: Sequence["Parameter"], +) -> list["Parameter"]: + """Returns all declared parameters in the order they should be processed. + + The declared parameters are re-shuffled depending on the order in which + they were invoked, as well as the eagerness of each parameters. + + The invocation order takes precedence over the declaration order. I.e. the + order in which the user provided them to the CLI is respected. + + This behavior and its effect on callback evaluation is detailed at: + https://click.palletsprojects.com/en/stable/advanced/#callback-evaluation-order + """ + + def sort_key(item: Parameter) -> tuple[bool, float]: + try: + idx: float = invocation_order.index(item) + except ValueError: + idx = float("inf") + + return not item.is_eager, idx + + return sorted(declaration_order, key=sort_key) + + +class ParameterSource(enum.Enum): + """This is an `Enum` that indicates the source of a + parameter's value. + """ + + COMMANDLINE = enum.auto() + """The value was provided by the command line args.""" + ENVIRONMENT = enum.auto() + """The value was provided with an environment variable.""" + DEFAULT = enum.auto() + """Used the default specified by the parameter.""" + DEFAULT_MAP = enum.auto() + """Used a default provided by `Context.default_map`.""" + PROMPT = enum.auto() + """Used a prompt to confirm a default or provide a value.""" + + +class Context: + """The context is a special internal object that holds state relevant + for the script execution at every single level. It's normally invisible + to commands unless they opt-in to getting access to it. + + The context is useful as it can pass internal objects around and can + control special execution features such as reading data from + environment variables. + + A context can be used as context manager in which case it will call + `close` on teardown. + """ + + formatter_class: type[HelpFormatter] = HelpFormatter + + def __init__( + self, + command: "Command", + parent: Union["Context", None] = None, + info_name: str | None = None, + obj: Any | None = None, + auto_envvar_prefix: str | None = None, + default_map: MutableMapping[str, Any] | None = None, + terminal_width: int | None = None, + max_content_width: int | None = None, + resilient_parsing: bool = False, + allow_extra_args: bool | None = None, + allow_interspersed_args: bool | None = None, + ignore_unknown_options: bool | None = None, + help_option_names: list[str] | None = None, + token_normalize_func: Callable[[str], str] | None = None, + color: bool | None = None, + show_default: bool | None = None, + ) -> None: + self.parent = parent + self.command = command + self.info_name = info_name + # Map of parameter names to their parsed values. + self.params: dict[str, Any] = {} + # the leftover arguments. + self.args: list[str] = [] + # protected arguments. used to implement nested parsing. + self._protected_args: list[str] = [] + # the collected prefixes of the command's options. + self._opt_prefixes: set[str] = set(parent._opt_prefixes) if parent else set() + + if obj is None and parent is not None: + obj = parent.obj + + self.obj: Any = obj + self._meta: dict[str, Any] = getattr(parent, "meta", {}) + + # A dictionary (-like object) with defaults for parameters. + if ( + default_map is None + and info_name is not None + and parent is not None + and parent.default_map is not None + ): + default_map = parent.default_map.get(info_name) + + self.default_map: MutableMapping[str, Any] | None = default_map + + # This flag indicates if a subcommand is going to be executed. + self.invoked_subcommand: str | None = None + + if terminal_width is None and parent is not None: + terminal_width = parent.terminal_width + + # The width of the terminal (None is autodetection). + self.terminal_width: int | None = terminal_width + + if max_content_width is None and parent is not None: + max_content_width = parent.max_content_width + + self.max_content_width: int | None = max_content_width + + if allow_extra_args is None: + allow_extra_args = command.allow_extra_args + + self.allow_extra_args = allow_extra_args + + if allow_interspersed_args is None: + allow_interspersed_args = command.allow_interspersed_args + + self.allow_interspersed_args: bool = allow_interspersed_args + + if ignore_unknown_options is None: + ignore_unknown_options = command.ignore_unknown_options + + self.ignore_unknown_options: bool = ignore_unknown_options + + if help_option_names is None: + if parent is not None: + help_option_names = parent.help_option_names + else: + help_option_names = ["--help"] + + self.help_option_names: list[str] = help_option_names + + if token_normalize_func is None and parent is not None: + token_normalize_func = parent.token_normalize_func + + # An optional normalization function for tokens. (options, choices, commands etc.) + self.token_normalize_func: Callable[[str], str] | None = token_normalize_func + + # Indicates if resilient parsing is enabled. + self.resilient_parsing: bool = resilient_parsing + + # If there is no envvar prefix yet, but the parent has one and + # the command on this level has a name, we can expand the envvar + # prefix automatically. + if auto_envvar_prefix is None: + if ( + parent is not None + and parent.auto_envvar_prefix is not None + and self.info_name is not None + ): + auto_envvar_prefix = ( + f"{parent.auto_envvar_prefix}_{self.info_name.upper()}" + ) + else: + auto_envvar_prefix = auto_envvar_prefix.upper() + + if auto_envvar_prefix is not None: + auto_envvar_prefix = auto_envvar_prefix.replace("-", "_") + + self.auto_envvar_prefix: str | None = auto_envvar_prefix + + if color is None and parent is not None: + color = parent.color + + # Controls if styling output is wanted or not. + self.color: bool | None = color + + if show_default is None and parent is not None: + show_default = parent.show_default + + # Show option default values when formatting help text. + self.show_default: bool | None = show_default + + self._close_callbacks: list[Callable[[], Any]] = [] + self._depth = 0 + self._parameter_source: dict[str, ParameterSource] = {} + self._exit_stack = ExitStack() + + def __enter__(self) -> "Context": + self._depth += 1 + push_context(self) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> bool | None: + self._depth -= 1 + exit_result: bool | None = None + if self._depth == 0: + exit_result = self._close_with_exception_info(exc_type, exc_value, tb) + pop_context() + + return exit_result + + @contextmanager + def scope(self, cleanup: bool = True) -> Iterator["Context"]: + """This helper method can be used with the context object to promote + it to the current thread local (see `get_current_context`). + The default behavior of this is to invoke the cleanup functions which + can be disabled by setting `cleanup` to `False`. The cleanup + functions are typically used for things such as closing file handles. + + If the cleanup is intended the context object can also be directly + used as a context manager. + """ + if not cleanup: + self._depth += 1 + try: + with self as rv: + yield rv + finally: + if not cleanup: + self._depth -= 1 + + @property + def meta(self) -> dict[str, Any]: + """This is a dictionary which is shared with all the contexts + that are nested. It exists so that click utilities can store some + state here if they need to. It is however the responsibility of + that code to manage this dictionary well. + + The keys are supposed to be unique dotted strings. For instance + module paths are a good choice for it. What is stored in there is + irrelevant for the operation of click. However what is important is + that code that places data here adheres to the general semantics of + the system. + """ + return self._meta + + def make_formatter(self) -> HelpFormatter: + """Creates the HelpFormatter for the help and + usage output. + """ + return self.formatter_class( + width=self.terminal_width, max_width=self.max_content_width + ) + + def with_resource(self, context_manager: AbstractContextManager[V]) -> V: + """Register a resource as if it were used in a ``with`` + statement. The resource will be cleaned up when the context is + popped. + + Uses `contextlib.ExitStack.enter_context`. It calls the + resource's ``__enter__()`` method and returns the result. When + the context is popped, it closes the stack, which calls the + resource's ``__exit__()`` method. + + To register a cleanup function for something that isn't a + context manager, use `call_on_close`. Or use something + from `contextlib` to turn it into a context manager first. + """ + return self._exit_stack.enter_context(context_manager) + + def call_on_close(self, f: Callable[..., Any]) -> Callable[..., Any]: + """Register a function to be called when the context tears down. + + This can be used to close resources opened during the script + execution. Resources that support Python's context manager + protocol which would be used in a ``with`` statement should be + registered with `with_resource` instead. + """ + return self._exit_stack.callback(f) + + def close(self) -> None: + """Invoke all close callbacks registered with `call_on_close`, + and exit all context managers entered with `with_resource`. + """ + self._close_with_exception_info(None, None, None) + + def _close_with_exception_info( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> bool | None: + """Unwind the exit stack by calling its `__exit__` providing the exception + information to allow for exception handling by the various resources registered + using `with_resource` + """ + exit_result = self._exit_stack.__exit__(exc_type, exc_value, tb) + # In case the context is reused, create a new exit stack. + self._exit_stack = ExitStack() + + return exit_result + + @property + def command_path(self) -> str: + """The computed command path. This is used for the ``usage`` + information on the help page. It's automatically created by + combining the info names of the chain of contexts to the root. + """ + rv = "" + if self.info_name is not None: + rv = self.info_name + if self.parent is not None: + parent_command_path = [self.parent.command_path] + + if isinstance(self.parent.command, Command): + for param in self.parent.command.get_params(self): + parent_command_path.extend(param.get_usage_pieces(self)) + + rv = f"{' '.join(parent_command_path)} {rv}" + return rv.lstrip() + + def find_root(self) -> "Context": + """Finds the outermost context.""" + node = self + while node.parent is not None: + node = node.parent + return node + + def find_object(self, object_type: type[V]) -> V | None: + """Finds the closest object of a given type.""" + node: Context | None = self + + while node is not None: + if isinstance(node.obj, object_type): + return node.obj + + node = node.parent + + return None + + def ensure_object(self, object_type: type[V]) -> V: + """Like `find_object` but sets the innermost object to a + new instance of `object_type` if it does not exist. + """ + rv = self.find_object(object_type) + if rv is None: + self.obj = rv = object_type() + return rv + + @overload + def lookup_default(self, name: str, call: Literal[True] = True) -> Any | None: ... + + @overload + def lookup_default( + self, name: str, call: Literal[False] = ... + ) -> Any | Callable[[], Any] | None: ... + + def lookup_default(self, name: str, call: bool = True) -> Any | None: + """Get the default for a parameter from `default_map`.""" + if self.default_map is not None: + value = self.default_map.get(name) + + if call and callable(value): + return value() + + return value + + return None + + def fail(self, message: str) -> NoReturn: + """Aborts the execution of the program with a specific error + message. + """ + raise UsageError(message, self) + + def abort(self) -> NoReturn: + """Aborts the script.""" + raise Abort() + + def exit(self, code: int = 0) -> NoReturn: + """Exits the application with a given exit code.""" + self.close() + raise Exit(code) + + def get_usage(self) -> str: + """Helper method to get formatted usage string for the current + context and command. + """ + return self.command.get_usage(self) + + def get_help(self) -> str: + """Helper method to get formatted help page for the current + context and command. + """ + return self.command.get_help(self) + + def invoke(self, callback: Callable[..., V], /, *args: Any, **kwargs: Any) -> V: + """Invokes a command callback in exactly the way it expects. There + are two ways to invoke this method: + + 1. the first argument can be a callback and all other arguments and + keyword arguments are forwarded directly to the function. + 2. the first argument is a click command object. In that case all + arguments are forwarded as well but proper click parameters + (options and click arguments) must be keyword arguments and Click + will fill in defaults. + """ + ctx = self + + with augment_usage_errors(self): + with ctx: + return callback(*args, **kwargs) + + def set_parameter_source(self, name: str, source: ParameterSource) -> None: + """Set the source of a parameter. This indicates the location + from which the value of the parameter was obtained. + """ + self._parameter_source[name] = source + + def get_parameter_source(self, name: str) -> ParameterSource | None: + """Get the source of a parameter. This indicates the location + from which the value of the parameter was obtained. + + This can be useful for determining when a user specified a value + on the command line that is the same as the default value. It + will be `ParameterSource.DEFAULT` only if the + value was actually taken from the default. + """ + return self._parameter_source.get(name) + + +class Command(ABC): + """Commands are the basic building block of command line interfaces in + Click. A basic command handles command line parsing and might dispatch + more parsing to commands nested below it. + """ + + context_class: type[Context] = Context + allow_extra_args = False + allow_interspersed_args = True + ignore_unknown_options = False + + def __init__( + self, + name: str | None, + context_settings: MutableMapping[str, Any] | None = None, + callback: Callable[..., Any] | None = None, + params: list["Parameter"] | None = None, + help: str | None = None, + epilog: str | None = None, + short_help: str | None = None, + options_metavar: str | None = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool | str = False, + ) -> None: + self.name = name + + if context_settings is None: + context_settings = {} + + self.context_settings: MutableMapping[str, Any] = context_settings + + self.callback = callback + self.params: list[Parameter] = params or [] + self.help = help + self.epilog = epilog + self.options_metavar = options_metavar + self.short_help = short_help + self.add_help_option = add_help_option + self._help_option: TyperOption | None = None + self.no_args_is_help = no_args_is_help + self.hidden = hidden + self.deprecated = deprecated + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self.name}>" + + def get_usage(self, ctx: Context) -> str: + """Formats the usage line into a string and returns it.""" + formatter = ctx.make_formatter() + self.format_usage(ctx, formatter) + return formatter.getvalue().rstrip("\n") + + def get_params(self, ctx: Context) -> list["Parameter"]: + params = self.params + help_option = self.get_help_option(ctx) + + if help_option is not None: + params = [*params, help_option] + + return params + + def format_usage(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the usage line into the formatter.""" + pieces = self.collect_usage_pieces(ctx) + formatter.write_usage(ctx.command_path, " ".join(pieces)) + + def collect_usage_pieces(self, ctx: Context) -> list[str]: + """Returns all the pieces that go into the usage line and returns + it as a list of strings. + """ + rv = [self.options_metavar] if self.options_metavar else [] + + for param in self.get_params(ctx): + rv.extend(param.get_usage_pieces(ctx)) + + return rv + + def get_help_option_names(self, ctx: Context) -> list[str]: + """Returns the names for the help option.""" + all_names = set(ctx.help_option_names) + for param in self.params: + all_names.difference_update(param.opts) + all_names.difference_update(param.secondary_opts) + return list(all_names) + + def get_help_option(self, ctx: Context) -> Union["TyperOption", None]: + """Returns the help option object.""" + help_option_names = self.get_help_option_names(ctx) + + if not help_option_names or not self.add_help_option: + return None + + # Cache the help option object in private _help_option attribute to + # avoid creating it multiple times. Not doing this will break the + # callback ordering by iter_params_for_processing(), which relies on + # object comparison. + if self._help_option is None: + # Avoid circular import. + from .decorators import help_option + + # Apply help_option decorator and pop resulting option + help_option(help_option_names)(self) + self._help_option = cast("TyperOption", self.params.pop()) + + return self._help_option + + def make_parser(self, ctx: Context) -> _OptionParser: + """Creates the underlying option parser for this command.""" + parser = _OptionParser(ctx) + for param in self.get_params(ctx): + param.add_to_parser(parser, ctx) + return parser + + def get_help(self, ctx: Context) -> str: + """Formats the help into a string and returns it.""" + formatter = ctx.make_formatter() + self.format_help(ctx, formatter) + return formatter.getvalue().rstrip("\n") + + def get_short_help_str(self, limit: int = 45) -> str: + """Gets short help for the command or makes it by shortening the + long help string. + """ + if self.short_help: + text = inspect.cleandoc(self.short_help) + elif self.help: + text = make_default_short_help(self.help, limit) + else: + text = "" + + if self.deprecated: + deprecated_message = ( + f"(DEPRECATED: {self.deprecated})" + if isinstance(self.deprecated, str) + else "(DEPRECATED)" + ) + text = f"{text} {deprecated_message}" + + return text.strip() + + def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the help into the formatter if it exists.""" + self.format_usage(ctx, formatter) + self.format_help_text(ctx, formatter) + self.format_options(ctx, formatter) + self.format_epilog(ctx, formatter) + + def format_help_text(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the help text to the formatter if it exists.""" + if self.help is not None: + # truncate the help text to the first form feed + text = inspect.cleandoc(self.help).partition("\f")[0] + else: + text = "" + + if self.deprecated: + deprecated_message = ( + f"(DEPRECATED: {self.deprecated})" + if isinstance(self.deprecated, str) + else "(DEPRECATED)" + ) + text = f"{text} {deprecated_message}" + + if text: + formatter.write_paragraph() + + with formatter.indentation(): + formatter.write_text(text) + + @abstractmethod + def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: + pass # pragma: no cover + + def format_epilog(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the epilog into the formatter if it exists.""" + if self.epilog: + epilog = inspect.cleandoc(self.epilog) + formatter.write_paragraph() + + with formatter.indentation(): + formatter.write_text(epilog) + + def make_context( + self, + info_name: str | None, + args: list[str], + parent: Context | None = None, + **extra: Any, + ) -> Context: + """This function when given an info name and arguments will kick + off the parsing and create a new `Context`. It does not + invoke the actual command callback though. + + To quickly customize the context class used without overriding + this method, set the `context_class` attribute. + """ + for key, value in self.context_settings.items(): + if key not in extra: + extra[key] = value + + ctx = self.context_class(self, info_name=info_name, parent=parent, **extra) + + with ctx.scope(cleanup=False): + self.parse_args(ctx, args) + return ctx + + def parse_args(self, ctx: Context, args: list[str]) -> list[str]: + if not args and self.no_args_is_help and not ctx.resilient_parsing: + raise NoArgsIsHelpError(ctx) # pragma: no cover + + parser = self.make_parser(ctx) + opts, args, param_order = parser.parse_args(args=args) + + for param in iter_params_for_processing(param_order, self.get_params(ctx)): + _, args = param.handle_parse_result(ctx, opts, args) + + if args and not ctx.allow_extra_args and not ctx.resilient_parsing: + ctx.fail(f"Got unexpected extra argument(s) ({' '.join(map(str, args))})") + + ctx.args = args + ctx._opt_prefixes.update(parser._opt_prefixes) + return args + + def invoke(self, ctx: Context) -> Any: + """Given a context, this invokes the attached callback (if it exists) + in the right way. + """ + if self.deprecated: + extra_message = ( + f" {self.deprecated}" if isinstance(self.deprecated, str) else "" + ) + message = f"DeprecationWarning: The command {self.name!r} is deprecated.{extra_message}" + echo(style(message, fg="red"), err=True) + + if self.callback is not None: + return ctx.invoke(self.callback, **ctx.params) + + def shell_complete(self, ctx: Context, incomplete: str) -> list["CompletionItem"]: + """Return a list of completions for the incomplete value. Looks + at the names of options and chained multi-commands. + + Any command could be part of a chained multi-command, so sibling + commands are valid at any point during command completion. + """ + # avoid circular imports + from .shell_completion import CompletionItem + + results: list[CompletionItem] = [] + + if incomplete and not incomplete[0].isalnum(): + # avoid circular imports + from ..core import TyperOption + + for param in self.get_params(ctx): + if ( + not isinstance(param, TyperOption) + or param.hidden + or ( + not param.multiple + and ctx.get_parameter_source(param.name) # type: ignore + is ParameterSource.COMMANDLINE + ) + ): + continue + + results.extend( + CompletionItem(name, help=param.help) + for name in [*param.opts, *param.secondary_opts] + if name.startswith(incomplete) + ) + + return results + + @abstractmethod + def main( + self, + args: Sequence[str] | None = None, + prog_name: str | None = None, + complete_var: str | None = None, + standalone_mode: bool = True, + windows_expand_args: bool = True, + **extra: Any, + ) -> Any: + pass # pragma: no cover + + @abstractmethod + def _main_shell_completion( + self, + ctx_args: MutableMapping[str, Any], + prog_name: str, + complete_var: str | None = None, + ) -> None: + pass # pragma: no cover + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + """Alias for self.main""" + return self.main(*args, **kwargs) + + +class Parameter(ABC): + r"""A parameter to a command comes in two versions: they are either + `Option`\s or `Argument`\s. + + Some settings are supported by both options and arguments. + """ + + param_type_name = "parameter" + + def __init__( + self, + param_decls: Sequence[str] | None = None, + type: types.ParamType | Any | None = None, + required: bool = False, + default: Any | Callable[[], Any] | None = None, + callback: Callable[[Context, "Parameter", Any], Any] | None = None, + nargs: int | None = None, + multiple: bool = False, + metavar: str | None = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: str | Sequence[str] | None = None, + shell_complete: Callable[ + [Context, "Parameter", str], list["CompletionItem"] | list[str] + ] + | None = None, + ) -> None: + self.name: str | None + self.opts: list[str] + self.secondary_opts: list[str] + self.name, self.opts, self.secondary_opts = self._parse_decls( + param_decls or (), expose_value + ) + self.type: types.ParamType = types.convert_type(type, default) + + # Default nargs to what the type tells us if we have that + # information available. + if nargs is None: + if self.type.is_composite: + nargs = self.type.arity + else: + nargs = 1 + + self.required = required + self.callback = callback + self.nargs = nargs + self.multiple = multiple + self.expose_value = expose_value + self.default: Any | Callable[[], Any] | None = default + self.is_eager = is_eager + self.metavar = metavar + self.envvar = envvar + self._custom_shell_complete = shell_complete + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self.name}>" + + @abstractmethod + def _parse_decls( + self, decls: Sequence[str], expose_value: bool + ) -> tuple[str | None, list[str], list[str]]: + pass # pragma: no cover + + @property + def human_readable_name(self) -> str: + """Returns the human readable name of this parameter. This is the + same as the name for options, but the metavar for arguments. + """ + assert self.name is not None, "self.name should be set" + return self.name + + def make_metavar(self, ctx: Context) -> str: + if self.metavar is not None: + return self.metavar + + metavar = self.type.get_metavar(param=self, ctx=ctx) + + if metavar is None: + metavar = self.type.name.upper() + + if self.nargs != 1: + metavar += "..." + + return metavar + + @overload + def get_default(self, ctx: Context, call: Literal[True] = True) -> Any | None: ... + + @overload + def get_default( + self, ctx: Context, call: bool = ... + ) -> Any | Callable[[], Any] | None: ... + + def get_default( + self, ctx: Context, call: bool = True + ) -> Any | Callable[[], Any] | None: + """Get the default for the parameter""" + value = ctx.lookup_default(self.name, call=False) # type: ignore + + if value is None: + value = self.default + + if call and callable(value): + value = value() + + return value + + @abstractmethod + def add_to_parser(self, parser: _OptionParser, ctx: Context) -> None: + pass # pragma: no cover + + def consume_value( + self, ctx: Context, opts: Mapping[str, Any] + ) -> tuple[Any, ParameterSource]: + value = opts.get(self.name) # type: ignore + source = ParameterSource.COMMANDLINE + + if value is None: + value = self.value_from_envvar(ctx) + source = ParameterSource.ENVIRONMENT + + if value is None: + value = ctx.lookup_default(self.name) # type: ignore + source = ParameterSource.DEFAULT_MAP + + if value is None: + value = self.get_default(ctx) + source = ParameterSource.DEFAULT + + return value, source + + def type_cast_value(self, ctx: Context, value: Any) -> Any: + """Convert and validate a value against the parameter's + `type`, `multiple`, and `nargs`. + """ + if value is None: + return () if self.multiple or self.nargs == -1 else None + + def check_iter(value: Any) -> Iterator[Any]: + if isinstance(value, str): + raise BadParameter("Value must be an iterable.", ctx=ctx, param=self) + else: + return iter(value) + + # Define the conversion function based on nargs and type. + if self.nargs == 1 or self.type.is_composite: + + def convert(value: Any) -> Any: + return self.type(value, param=self, ctx=ctx) + + elif self.nargs == -1: + + def convert(value: Any) -> Any: # tuple[t.Any, ...] + return tuple(self.type(x, self, ctx) for x in check_iter(value)) + + # TODO: evaluate whether we need to keep this in Typer + else: # nargs > 1 + + def convert(value: Any) -> Any: # tuple[t.Any, ...] + value = tuple(check_iter(value)) + + if len(value) != self.nargs: + raise BadParameter( + f"Takes {self.nargs} values but {len(value)} given.", + ctx=ctx, + param=self, + ) + + return tuple(self.type(x, self, ctx) for x in value) + + if self.multiple: + return tuple(convert(x) for x in check_iter(value)) + + return convert(value) + + @abstractmethod + def value_is_missing(self, value: Any) -> bool: + pass # pragma: no cover + + def process_value(self, ctx: Context, value: Any) -> Any: + """Process the value of this parameter""" + value = self.type_cast_value(ctx, value) + + if self.required and self.value_is_missing(value): + raise MissingParameter(ctx=ctx, param=self) + + if self.callback is not None: + value = self.callback(ctx, self, value) + + return value + + def resolve_envvar_value(self, ctx: Context) -> str | None: + """Returns the value found in the environment variable(s) attached to this + parameter. + + Environment variables values are `always returned as strings + `_. + + This method returns ``None`` if: + + - the `envvar` property is not set on `Parameter`, + - the environment variable is not found in the environment, + - the variable is found in the environment but its value is empty (i.e. the + environment variable is present but has an empty string). + + If `envvar` is setup with multiple environment variables, + then only the first non-empty value is returned. + """ + if self.envvar is None: + return None + + if isinstance(self.envvar, str): + rv = os.environ.get(self.envvar) + + if rv: + return rv + else: + for envvar in self.envvar: + rv = os.environ.get(envvar) + + # Return the first non-empty value of the list of environment variables. + if rv: + return rv + # Else, absence of value is interpreted as an environment variable that + # is not set, so proceed to the next one. + + return None + + def value_from_envvar(self, ctx: Context) -> str | Sequence[str] | None: + """Process the raw environment variable string for this parameter. + + Returns the string as-is or splits it into a sequence of strings if the + parameter is expecting multiple values (i.e. its `nargs` property is set + to a value other than ``1``). + """ + rv: Any | None = self.resolve_envvar_value(ctx) + + if rv is not None and self.nargs != 1: + rv = self.type.split_envvar_value(rv) + + return rv + + def handle_parse_result( + self, ctx: Context, opts: Mapping[str, Any], args: list[str] + ) -> tuple[Any, list[str]]: + """Process the value produced by the parser from user input. + + Always process the value through the Parameter's `type`, wherever it + comes from. + + If the parameter is deprecated, this method warn the user about it. But only if + the value has been explicitly set by the user (and as such, is not coming from + a default). + """ + with augment_usage_errors(ctx, param=self): + value, source = self.consume_value(ctx, opts) + + ctx.set_parameter_source(self.name, source) # type: ignore + + # Process the value through the parameter's type. + try: + value = self.process_value(ctx, value) + except Exception: + if not ctx.resilient_parsing: + raise + value = None + + if self.expose_value: + ctx.params[self.name] = value # type: ignore + + return value, args + + @abstractmethod + def get_help_record(self, ctx: Context) -> tuple[str, str] | None: + pass # pragma: no cover + + def get_usage_pieces(self, ctx: Context) -> list[str]: + return [] + + def get_error_hint(self, ctx: Context) -> str: + """Get a stringified version of the param for use in error messages to + indicate which param caused the error. + """ + hint_list = self.opts or [self.human_readable_name] + return " / ".join(f"'{x}'" for x in hint_list) + + def shell_complete(self, ctx: Context, incomplete: str) -> list["CompletionItem"]: + """Return a list of completions for the incomplete value. If a + ``shell_complete`` function was given during init, it is used. + Otherwise, the `type` `ParamType.shell_complete` function is used. + """ + if self._custom_shell_complete is not None: + results = self._custom_shell_complete(ctx, self, incomplete) + + if results and isinstance(results[0], str): + from .shell_completion import CompletionItem + + results = [CompletionItem(c) for c in results] + + return cast("list[CompletionItem]", results) + + return self.type.shell_complete(ctx, self, incomplete) diff --git a/typer/_click/decorators.py b/typer/_click/decorators.py new file mode 100644 index 0000000000..28ad656a8c --- /dev/null +++ b/typer/_click/decorators.py @@ -0,0 +1,60 @@ +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar + +from .core import Command, Context, Parameter +from .utils import echo + +if TYPE_CHECKING: + from ..core import TyperGroup, TyperOption + + GrpType = TypeVar("GrpType", bound=TyperGroup) + + +P = ParamSpec("P") + +R = TypeVar("R") +T = TypeVar("T") +_AnyCallable = Callable[..., Any] + + +CmdType = TypeVar("CmdType", bound=Command) + + +def option( + param_decls: list[str], cls: type["TyperOption"] | None = None, **attrs: Any +) -> Callable[[Command], Command]: + """Attaches an option to the command.""" + if cls is None: + # avoid circular imports + from ..core import TyperOption + + cls = TyperOption + + def decorator(f: Command) -> Command: + param = cls(param_decls=param_decls, **attrs) + f.params.append(param) + return f + + return decorator + + +def help_option(param_decls: list[str]) -> Callable[[Command], Command]: + """Help option which prints the help page and exits the program.""" + + def show_help(ctx: Context, param: Parameter, value: bool) -> None: + """Callback that print the help page on ```` and exits.""" + if value and not ctx.resilient_parsing: + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + assert len(param_decls) > 0, "At least one help option should be provided" + + return option( + param_decls, + is_flag=True, + expose_value=False, + is_eager=True, + help="Show this message and exit.", + callback=show_help, + required=False, + ) diff --git a/typer/_click/exceptions.py b/typer/_click/exceptions.py new file mode 100644 index 0000000000..af2af260a6 --- /dev/null +++ b/typer/_click/exceptions.py @@ -0,0 +1,260 @@ +from collections.abc import Sequence +from typing import IO, TYPE_CHECKING, Any, Union + +from ._compat import get_text_stderr +from .globals import resolve_color_default +from .utils import echo, format_filename + +if TYPE_CHECKING: + from .core import Command, Context, Parameter + + +def _join_param_hints(param_hint: Sequence[str] | str | None) -> str | None: + if param_hint is not None and not isinstance(param_hint, str): + return " / ".join(repr(x) for x in param_hint) + + return param_hint + + +class ClickException(Exception): + """An exception that Click can handle and show to the user.""" + + exit_code = 1 + + def __init__(self, message: str) -> None: + super().__init__(message) + # The context will be removed by the time we print the message, so cache + # the color settings here to be used later on (in `show`) + self.show_color: bool | None = resolve_color_default() + self.message = message + + def format_message(self) -> str: + return self.message + + def __str__(self) -> str: + return self.message + + def show(self, file: IO[Any] | None = None) -> None: + if file is None: + file = get_text_stderr() + + echo( + f"Error: {self.format_message()}", + file=file, + color=self.show_color, + ) + + +class UsageError(ClickException): + """An internal exception that signals a usage error. This typically + aborts any further handling. + """ + + exit_code = 2 + + def __init__(self, message: str, ctx: Union["Context", None] = None) -> None: + super().__init__(message) + self.ctx = ctx + self.cmd: Command | None = self.ctx.command if self.ctx else None + + def show(self, file: IO[Any] | None = None) -> None: + if file is None: + file = get_text_stderr() + color = None + hint = "" + if ( + self.ctx is not None + and self.ctx.command.get_help_option(self.ctx) is not None + ): + command = self.ctx.command_path + option = self.ctx.help_option_names[0] + hint = f"Try '{command} {option}' for help.\n" + if self.ctx is not None: + color = self.ctx.color + echo(f"{self.ctx.get_usage()}\n{hint}", file=file, color=color) + echo( + f"Error: {self.format_message()}", + file=file, + color=color, + ) + + +class BadParameter(UsageError): + """An exception that formats out a standardized error message for a + bad parameter. This is useful when thrown from a callback or type as + Click will attach contextual information to it (for instance, which + parameter it is). + """ + + def __init__( + self, + message: str, + ctx: Union["Context", None] = None, + param: Union["Parameter", None] = None, + param_hint: Sequence[str] | str | None = None, + ) -> None: + super().__init__(message, ctx) + self.param = param + self.param_hint = param_hint + + def format_message(self) -> str: + if self.param_hint is not None: + param_hint = self.param_hint + elif self.param is not None: + param_hint = self.param.get_error_hint(self.ctx) # type: ignore + else: + return f"Invalid value: {self.message}" + + hint = _join_param_hints(param_hint) + return f"Invalid value for {hint}: {self.message}" + + +class MissingParameter(BadParameter): + """Raised if click required an option or argument but it was not + provided when invoking the script. + """ + + def __init__( + self, + message: str | None = None, + ctx: Union["Context", None] = None, + param: Union["Parameter", None] = None, + param_hint: Sequence[str] | str | None = None, + param_type: str | None = None, + ) -> None: + super().__init__(message or "", ctx, param, param_hint) + self.param_type = param_type + + def format_message(self) -> str: + if self.param_hint is not None: + param_hint: Sequence[str] | str | None = self.param_hint + elif self.param is not None: + param_hint = self.param.get_error_hint(self.ctx) # type: ignore + else: + param_hint = None + + param_hint = _join_param_hints(param_hint) + param_hint = f" {param_hint}" if param_hint else "" + + param_type = self.param_type + if param_type is None and self.param is not None: + param_type = self.param.param_type_name + + msg = self.message + if self.param is not None: + msg_extra = self.param.type.get_missing_message( + param=self.param, ctx=self.ctx + ) + if msg_extra: + if msg: + msg += f". {msg_extra}" + else: + msg = msg_extra + + msg = f" {msg}" if msg else "" + + # Translate param_type for known types. + if param_type == "argument": + missing = "Missing argument" + elif param_type == "option": + missing = "Missing option" + elif param_type == "parameter": + missing = "Missing parameter" + else: + missing = f"Missing {param_type}" + + return f"{missing}{param_hint}.{msg}" + + def __str__(self) -> str: + if not self.message: + param_name = self.param.name if self.param else None + return f"Missing parameter: {param_name}" + else: + return self.message + + +class NoSuchOption(UsageError): + """Raised if click attempted to handle an option that does not + exist. + """ + + def __init__( + self, + option_name: str, + message: str | None = None, + possibilities: Sequence[str] | None = None, + ctx: Union["Context", None] = None, + ) -> None: + if message is None: + message = f"No such option: {option_name}" + + super().__init__(message, ctx) + self.option_name = option_name + self.possibilities = possibilities + + def format_message(self) -> str: + if not self.possibilities: + return self.message + + possibility_str = ", ".join(sorted(self.possibilities)) + suggest = (f"(Possible options: {possibility_str})",) + return f"{self.message} {suggest}" + + +class BadOptionUsage(UsageError): + """Raised if an option is generally supplied but the use of the option + was incorrect. This is for instance raised if the number of arguments + for an option is not correct. + """ + + def __init__( + self, option_name: str, message: str, ctx: Union["Context", None] = None + ) -> None: + super().__init__(message, ctx) + self.option_name = option_name + + +class BadArgumentUsage(UsageError): + """Raised if an argument is generally supplied but the use of the argument + was incorrect. This is for instance raised if the number of values + for an argument is not correct. + """ + + +class NoArgsIsHelpError(UsageError): + def __init__(self, ctx: "Context") -> None: + self.ctx: Context + super().__init__(ctx.get_help(), ctx=ctx) + + def show(self, file: IO[Any] | None = None) -> None: + echo(self.format_message(), file=file, err=True, color=self.ctx.color) + + +class FileError(ClickException): + """Raised if a file cannot be opened.""" + + def __init__(self, filename: str, hint: str | None = None) -> None: + if hint is None: + hint = "unknown error" + + super().__init__(hint) + self.ui_filename: str = format_filename(filename) + self.filename = filename + + def format_message(self) -> str: + return f"Could not open file {self.ui_filename!r}: {self.message}" + + +class Abort(RuntimeError): + """An internal signalling exception that signals Click to abort.""" + + +class Exit(RuntimeError): + """An exception that indicates that the application should exit with some + status code. + """ + + __slots__ = ("exit_code",) + + def __init__(self, code: int = 0) -> None: + self.exit_code: int = code diff --git a/typer/_click/formatting.py b/typer/_click/formatting.py new file mode 100644 index 0000000000..b5eaab3bd0 --- /dev/null +++ b/typer/_click/formatting.py @@ -0,0 +1,272 @@ +from collections.abc import Iterable, Iterator, Sequence +from contextlib import contextmanager + +from ._compat import term_len +from .parser import _split_opt + +# Can force a width. This is used by the test system +FORCED_WIDTH: int | None = None + + +def measure_table(rows: Iterable[tuple[str, str]]) -> tuple[int, ...]: + widths: dict[int, int] = {} + + for row in rows: + for idx, col in enumerate(row): + widths[idx] = max(widths.get(idx, 0), term_len(col)) + + return tuple(y for x, y in sorted(widths.items())) + + +def iter_rows( + rows: Iterable[tuple[str, str]], col_count: int +) -> Iterator[tuple[str, ...]]: + for row in rows: + yield row + ("",) * (col_count - len(row)) + + +def wrap_text( + text: str, + width: int = 78, + initial_indent: str = "", + subsequent_indent: str = "", + preserve_paragraphs: bool = False, +) -> str: + """A helper function that intelligently wraps text. By default, it + assumes that it operates on a single paragraph of text but if the + `preserve_paragraphs` parameter is provided it will intelligently + handle paragraphs (defined by two empty lines). + + If paragraphs are handled, a paragraph can be prefixed with an empty + line containing the ``\\b`` character (``\\x08``) to indicate that + no rewrapping should happen in that block. + """ + from ._textwrap import TextWrapper + + text = text.expandtabs() + wrapper = TextWrapper( + width, + initial_indent=initial_indent, + subsequent_indent=subsequent_indent, + replace_whitespace=False, + ) + if not preserve_paragraphs: + return wrapper.fill(text) + + p: list[tuple[int, bool, str]] = [] + buf: list[str] = [] + indent = None + + def _flush_par() -> None: + if not buf: + return + if buf[0].strip() == "\b": + p.append((indent or 0, True, "\n".join(buf[1:]))) + else: + p.append((indent or 0, False, " ".join(buf))) + del buf[:] + + for line in text.splitlines(): + if not line: + _flush_par() + indent = None + else: + if indent is None: + orig_len = term_len(line) + line = line.lstrip() + indent = orig_len - term_len(line) + buf.append(line) + _flush_par() + + rv = [] + for indent, raw, text in p: + with wrapper.extra_indent(" " * indent): + if raw: + rv.append(wrapper.indent_only(text)) + else: + rv.append(wrapper.fill(text)) + + return "\n\n".join(rv) + + +class HelpFormatter: + """This class helps with formatting text-based help pages. It's + usually just needed for very special internal cases, but it's also + exposed so that developers can write their own fancy outputs. + + At present, it always writes into memory. + """ + + def __init__( + self, + indent_increment: int = 2, + width: int | None = None, + max_width: int | None = None, + ) -> None: + self.indent_increment = indent_increment + if max_width is None: + max_width = 80 + if width is None: + import shutil + + width = FORCED_WIDTH + if width is None: + width = max(min(shutil.get_terminal_size().columns, max_width) - 2, 50) + self.width = width + self.current_indent: int = 0 + self.buffer: list[str] = [] + + def write(self, string: str) -> None: + """Writes a unicode string into the internal buffer.""" + self.buffer.append(string) + + def indent(self) -> None: + """Increases the indentation.""" + self.current_indent += self.indent_increment + + def dedent(self) -> None: + """Decreases the indentation.""" + self.current_indent -= self.indent_increment + + def write_usage(self, prog: str, args: str = "", prefix: str | None = None) -> None: + """Writes a usage line into the buffer.""" + if prefix is None: + prefix = "Usage: " + + usage_prefix = f"{prefix:>{self.current_indent}}{prog} " + text_width = self.width - self.current_indent + + if text_width >= (term_len(usage_prefix) + 20): + # The arguments will fit to the right of the prefix. + indent = " " * term_len(usage_prefix) + self.write( + wrap_text( + args, + text_width, + initial_indent=usage_prefix, + subsequent_indent=indent, + ) + ) + else: + # The prefix is too long, put the arguments on the next line. + self.write(usage_prefix) + self.write("\n") + indent = " " * (max(self.current_indent, term_len(prefix)) + 4) + self.write( + wrap_text( + args, text_width, initial_indent=indent, subsequent_indent=indent + ) + ) + + self.write("\n") + + def write_heading(self, heading: str) -> None: + """Writes a heading into the buffer.""" + self.write(f"{'':>{self.current_indent}}{heading}:\n") + + def write_paragraph(self) -> None: + """Writes a paragraph into the buffer.""" + if self.buffer: + self.write("\n") + + def write_text(self, text: str) -> None: + """Writes re-indented text into the buffer. This rewraps and + preserves paragraphs. + """ + indent = " " * self.current_indent + self.write( + wrap_text( + text, + self.width, + initial_indent=indent, + subsequent_indent=indent, + preserve_paragraphs=True, + ) + ) + self.write("\n") + + def write_dl( + self, + rows: Sequence[tuple[str, str]], + col_max: int = 30, + col_spacing: int = 2, + ) -> None: + """Writes a definition list into the buffer. This is how options + and commands are usually formatted. + """ + rows = list(rows) + widths = measure_table(rows) + if len(widths) != 2: # pragma: no cover + raise TypeError("Expected two columns for definition list") + + first_col = min(widths[0], col_max) + col_spacing + + for first, second in iter_rows(rows, len(widths)): + self.write(f"{'':>{self.current_indent}}{first}") + if not second: + self.write("\n") + continue + if term_len(first) <= first_col - col_spacing: + self.write(" " * (first_col - term_len(first))) + else: + self.write("\n") + self.write(" " * (first_col + self.current_indent)) + + text_width = max(self.width - first_col - 2, 10) + wrapped_text = wrap_text(second, text_width, preserve_paragraphs=True) + lines = wrapped_text.splitlines() + + if lines: + self.write(f"{lines[0]}\n") + + for line in lines[1:]: + self.write(f"{'':>{first_col + self.current_indent}}{line}\n") + else: # pragma: no cover + self.write("\n") + + @contextmanager + def section(self, name: str) -> Iterator[None]: + """Helpful context manager that writes a paragraph, a heading, + and the indents. + """ + self.write_paragraph() + self.write_heading(name) + self.indent() + try: + yield + finally: + self.dedent() + + @contextmanager + def indentation(self) -> Iterator[None]: + """A context manager that increases the indentation.""" + self.indent() + try: + yield + finally: + self.dedent() + + def getvalue(self) -> str: + """Returns the buffer contents.""" + return "".join(self.buffer) + + +def join_options(options: Sequence[str]) -> tuple[str, bool]: + """Given a list of option strings this joins them in the most appropriate + way and returns them in the form ``(formatted_string, + any_prefix_is_slash)`` where the second item in the tuple is a flag that + indicates if any of the option prefixes was a slash. + """ + rv = [] + any_prefix_is_slash = False + + for opt in options: + prefix = _split_opt(opt)[0] + + if prefix == "/": + any_prefix_is_slash = True + + rv.append((len(prefix), opt)) + + rv.sort(key=lambda x: x[0]) + return ", ".join(x[1] for x in rv), any_prefix_is_slash diff --git a/typer/_click/globals.py b/typer/_click/globals.py new file mode 100644 index 0000000000..372dc40749 --- /dev/null +++ b/typer/_click/globals.py @@ -0,0 +1,61 @@ +from threading import local +from typing import TYPE_CHECKING, Literal, Union, cast, overload + +if TYPE_CHECKING: + from .core import Context + +_local = local() + + +@overload +def get_current_context(silent: Literal[False] = False) -> "Context": ... + + +@overload +def get_current_context(silent: bool = ...) -> Union["Context", None]: ... + + +def get_current_context(silent: bool = False) -> Union["Context", None]: + """Returns the current click context. This can be used as a way to + access the current context object from anywhere. This is a more implicit + alternative to the `pass_context` decorator. This function is + primarily useful for helpers such as `echo` which might be + interested in changing its behavior based on the current context. + + To push the current context, `Context.scope` can be used. + """ + try: + return cast("Context", _local.stack[-1]) + except (AttributeError, IndexError) as e: + if not silent: + raise RuntimeError( + "There is no active click context." + ) from e # pragma: no cover + + return None + + +def push_context(ctx: "Context") -> None: + """Pushes a new context to the current stack.""" + _local.__dict__.setdefault("stack", []).append(ctx) + + +def pop_context() -> None: + """Removes the top level from the stack.""" + _local.stack.pop() + + +def resolve_color_default(color: bool | None = None) -> bool | None: + """Internal helper to get the default value of the color flag. If a + value is passed it's returned unchanged, otherwise it's looked up from + the current context. + """ + if color is not None: + return color + + ctx = get_current_context(silent=True) + + if ctx is not None: + return ctx.color + + return None diff --git a/typer/_click/parser.py b/typer/_click/parser.py new file mode 100644 index 0000000000..71eb3003cc --- /dev/null +++ b/typer/_click/parser.py @@ -0,0 +1,459 @@ +""" +This module started out as largely a copy paste from the stdlib's +optparse module with the features removed that we do not need from +optparse because we implement them in Click on a higher level (for +instance type handling, help formatting and a lot more). + +The plan is to remove more and more from here over time. + +The reason this is a different module and not optparse from the stdlib +is that there are differences in 2.x and 3.x about the error messages +generated and optparse in the stdlib uses gettext for no good reason +and might cause us issues. + +Click uses parts of optparse written by Gregory P. Ward and maintained +by the Python Software Foundation. This is limited to code in parser.py. + +Copyright 2001-2006 Gregory P. Ward. All rights reserved. +Copyright 2002-2006 Python Software Foundation. All rights reserved. +""" + +# This code uses parts of optparse written by Gregory P. Ward and +# maintained by the Python Software Foundation. +# Copyright 2001-2006 Gregory P. Ward +# Copyright 2002-2006 Python Software Foundation +from collections import deque +from collections.abc import Sequence +from typing import ( + TYPE_CHECKING, + Any, + TypeVar, + Union, +) + +from .exceptions import BadArgumentUsage, BadOptionUsage, NoSuchOption, UsageError + +if TYPE_CHECKING: + from typer.core import TyperArgument as CoreArgument + from typer.core import TyperOption as CoreOption + + from .core import Context + from .core import Parameter as CoreParameter + +V = TypeVar("V") + + +def _unpack_args( + args: Sequence[str], nargs_spec: Sequence[int] +) -> tuple[Sequence[str | Sequence[str | None] | None], list[str]]: + """Given an iterable of arguments and an iterable of nargs specifications, + it returns a tuple with all the unpacked arguments at the first index + and all remaining arguments as the second. + + The nargs specification is the number of arguments that should be consumed + or `-1` to indicate that this position should eat up all the remainders. + """ + args = deque(args) + nargs_spec = deque(nargs_spec) + rv: list[str | tuple[str | None, ...] | None] = [] + spos: int | None = None + + def _fetch(c: deque[V]) -> V | None: + try: + if spos is None: + return c.popleft() + else: + return c.pop() + except IndexError: + return None + + while nargs_spec: + nargs = _fetch(nargs_spec) + assert nargs is not None + + if nargs == 1: + rv.append(_fetch(args)) + elif nargs > 1: + x = [_fetch(args) for _ in range(nargs)] + + # If we're reversed, we're pulling in the arguments in reverse, + # so we need to turn them around. + if spos is not None: + x.reverse() + + rv.append(tuple(x)) + elif nargs < 0: + if spos is not None: # pragma: no cover + raise TypeError("Cannot have two nargs < 0") + + spos = len(rv) + rv.append(None) + + # spos is the position of the wildcard (star). If it's not `None`, + # we fill it with the remainder. + if spos is not None: + rv[spos] = tuple(args) + args = [] + rv[spos + 1 :] = reversed(rv[spos + 1 :]) + + return tuple(rv), list(args) + + +def _split_opt(opt: str) -> tuple[str, str]: + first = opt[:1] + if first.isalnum(): + return "", opt + if opt[1:2] == first: + return opt[:2], opt[2:] + return first, opt[1:] + + +def _normalize_opt(opt: str, ctx: Union["Context", None]) -> str: + if ctx is None or ctx.token_normalize_func is None: + return opt + prefix, opt = _split_opt(opt) + return f"{prefix}{ctx.token_normalize_func(opt)}" + + +class _Option: + def __init__( + self, + obj: "CoreOption", + opts: Sequence[str], + dest: str | None, + action: str = "store", + nargs: int = 1, + const: Any | None = None, + ): + self._short_opts = [] + self._long_opts = [] + self.prefixes: set[str] = set() + + for opt in opts: + prefix, value = _split_opt(opt) + if not prefix: # pragma: no cover + raise ValueError(f"Invalid start character for option ({opt})") + self.prefixes.add(prefix[0]) + if len(prefix) == 1 and len(value) == 1: + self._short_opts.append(opt) + else: + self._long_opts.append(opt) + self.prefixes.add(prefix) + + self.dest = dest + self.action = action + self.nargs = nargs + self.const = const + self.obj = obj + + @property + def takes_value(self) -> bool: + return self.action in ("store", "append") + + def process(self, value: Any, state: "_ParsingState") -> None: + if self.action == "store": + state.opts[self.dest] = value # type: ignore + elif self.action == "store_const": + state.opts[self.dest] = self.const # type: ignore + elif self.action == "append": + state.opts.setdefault(self.dest, []).append(value) # type: ignore + elif self.action == "append_const": + state.opts.setdefault(self.dest, []).append(self.const) # type: ignore + elif self.action == "count": + state.opts[self.dest] = state.opts.get(self.dest, 0) + 1 # type: ignore + else: # pragma: no cover + raise ValueError(f"unknown action '{self.action}'") + state.order.append(self.obj) + + +class _Argument: + def __init__(self, obj: "CoreArgument", dest: str | None, nargs: int = 1): + self.dest = dest + self.nargs = nargs + self.obj = obj + + def process( + self, + value: str | Sequence[str | None] | None, + state: "_ParsingState", + ) -> None: + if self.nargs > 1: + assert value is not None + holes = sum(1 for x in value if x is None) + if holes == len(value): + value = None + elif holes != 0: + raise BadArgumentUsage( + f"Argument {self.dest!r} takes {self.nargs} values." + ) + + if self.nargs == -1 and self.obj.envvar is not None and value == (): + # Replace empty tuple with None so that a value from the + # environment may be tried. + value = None + + state.opts[self.dest] = value # type: ignore + state.order.append(self.obj) + + +class _ParsingState: + def __init__(self, rargs: list[str]) -> None: + self.opts: dict[str, Any] = {} + self.largs: list[str] = [] + self.rargs = rargs + self.order: list[CoreParameter] = [] + + +class _OptionParser: + """The option parser is an internal class that is ultimately used to + parse options and arguments. It's modelled after optparse and brings + a similar but vastly simplified API. It should generally not be used + directly as the high level Click classes wrap it for you. + + It's not nearly as extensible as optparse or argparse as it does not + implement features that are implemented on a higher level (such as + types or defaults). + """ + + def __init__(self, ctx: Union["Context", None] = None) -> None: + self.ctx = ctx + # This controls how the parser deals with interspersed arguments. + # If this is set to `False`, the parser will stop on the first + # non-option. Click uses this to implement nested subcommands + # safely. + self.allow_interspersed_args: bool = True + # This tells the parser how to deal with unknown options. By + # default it will error out (which is sensible), but there is a + # second mode where it will ignore it and continue processing + # after shifting all the unknown options into the resulting args. + self.ignore_unknown_options: bool = False + + if ctx is not None: + self.allow_interspersed_args = ctx.allow_interspersed_args + self.ignore_unknown_options = ctx.ignore_unknown_options + + self._short_opt: dict[str, _Option] = {} + self._long_opt: dict[str, _Option] = {} + self._opt_prefixes = {"-", "--"} + self._args: list[_Argument] = [] + + def add_option( + self, + obj: "CoreOption", + opts: Sequence[str], + dest: str | None, + action: str = "store", + nargs: int = 1, + const: Any | None = None, + ) -> None: + """Adds a new option named `dest` to the parser. The destination + is not inferred (unlike with optparse) and needs to be explicitly + provided. Action can be any of ``store``, ``store_const``, + ``append``, ``append_const`` or ``count``. + + The `obj` can be used to identify the option in the order list + that is returned from the parser. + """ + opts = [_normalize_opt(opt, self.ctx) for opt in opts] + option = _Option(obj, opts, dest, action=action, nargs=nargs, const=const) + self._opt_prefixes.update(option.prefixes) + for opt in option._short_opts: + self._short_opt[opt] = option + for opt in option._long_opts: + self._long_opt[opt] = option + + def add_argument( + self, obj: "CoreArgument", dest: str | None, nargs: int = 1 + ) -> None: + """Adds a positional argument named `dest` to the parser. + + The `obj` can be used to identify the option in the order list + that is returned from the parser. + """ + self._args.append(_Argument(obj, dest=dest, nargs=nargs)) + + def parse_args( + self, args: list[str] + ) -> tuple[dict[str, Any], list[str], list["CoreParameter"]]: + """Parses positional arguments and returns ``(values, args, order)`` + for the parsed options and arguments as well as the leftover + arguments if there are any. The order is a list of objects as they + appear on the command line. If arguments appear multiple times they + will be memorized multiple times as well. + """ + state = _ParsingState(args) + try: + self._process_args_for_options(state) + self._process_args_for_args(state) + except UsageError: + if self.ctx is None or not self.ctx.resilient_parsing: + raise + return state.opts, state.largs, state.order + + def _process_args_for_args(self, state: _ParsingState) -> None: + pargs, args = _unpack_args( + state.largs + state.rargs, [x.nargs for x in self._args] + ) + + for idx, arg in enumerate(self._args): + arg.process(pargs[idx], state) + + state.largs = args + state.rargs = [] + + def _process_args_for_options(self, state: _ParsingState) -> None: + while state.rargs: + arg = state.rargs.pop(0) + arglen = len(arg) + # Double dashes always handled explicitly regardless of what + # prefixes are valid. + if arg == "--": + return + elif arg[:1] in self._opt_prefixes and arglen > 1: + self._process_opts(arg, state) + elif self.allow_interspersed_args: + state.largs.append(arg) + else: + state.rargs.insert(0, arg) + return + + # Say this is the original argument list: + # [arg0, arg1, ..., arg(i-1), arg(i), arg(i+1), ..., arg(N-1)] + # ^ + # (we are about to process arg(i)). + # + # Then rargs is [arg(i), ..., arg(N-1)] and largs is a *subset* of + # [arg0, ..., arg(i-1)] (any options and their arguments will have + # been removed from largs). + # + # The while loop will usually consume 1 or more arguments per pass. + # If it consumes 1 (eg. arg is an option that takes no arguments), + # then after _process_arg() is done the situation is: + # + # largs = subset of [arg0, ..., arg(i)] + # rargs = [arg(i+1), ..., arg(N-1)] + # + # If allow_interspersed_args is false, largs will always be + # *empty* -- still a subset of [arg0, ..., arg(i-1)], but + # not a very interesting subset! + + def _match_long_opt( + self, opt: str, explicit_value: str | None, state: _ParsingState + ) -> None: + if opt not in self._long_opt: + from difflib import get_close_matches + + possibilities = get_close_matches(opt, self._long_opt) + raise NoSuchOption(opt, possibilities=possibilities, ctx=self.ctx) + + option = self._long_opt[opt] + if option.takes_value: + # At this point it's safe to modify rargs by injecting the + # explicit value, because no exception is raised in this + # branch. This means that the inserted value will be fully + # consumed. + if explicit_value is not None: + state.rargs.insert(0, explicit_value) + + value = self._get_value_from_state(opt, option, state) + + elif explicit_value is not None: # pragma: no cover + raise BadOptionUsage(opt, f"Option {opt!r} does not take a value.") + + else: + value = None + + option.process(value, state) + + def _match_short_opt(self, arg: str, state: _ParsingState) -> None: + stop = False + i = 1 + prefix = arg[0] + unknown_options = [] + + for ch in arg[1:]: + opt = _normalize_opt(f"{prefix}{ch}", self.ctx) + option = self._short_opt.get(opt) + i += 1 + + if not option: + if self.ignore_unknown_options: + unknown_options.append(ch) + continue + raise NoSuchOption(opt, ctx=self.ctx) + if option.takes_value: + # Any characters left in arg? Pretend they're the + # next arg, and stop consuming characters of arg. + if i < len(arg): + state.rargs.insert(0, arg[i:]) + stop = True + + value = self._get_value_from_state(opt, option, state) + + else: + value = None + + option.process(value, state) + + if stop: + break + + # If we got any unknown options we recombine the string of the + # remaining options and re-attach the prefix, then report that + # to the state as new 'largs'. This way there is basic combinatorics + # that can be achieved while still ignoring unknown arguments. + if self.ignore_unknown_options and unknown_options: + state.largs.append(f"{prefix}{''.join(unknown_options)}") + + def _get_value_from_state( + self, option_name: str, option: _Option, state: _ParsingState + ) -> str | Sequence[str]: + nargs = option.nargs + + value: str | Sequence[str] + + if len(state.rargs) < nargs: + msg = "an argument." if nargs == 1 else f"{nargs} arguments." + raise BadOptionUsage( + option_name, + f"Option {option_name!r} requires {msg}", + ) + elif nargs == 1: + value = state.rargs.pop(0) + else: + value = tuple(state.rargs[:nargs]) + del state.rargs[:nargs] + + return value + + def _process_opts(self, arg: str, state: _ParsingState) -> None: + explicit_value = None + # Long option handling happens in two parts. The first part is + # supporting explicitly attached values. In any case, we will try + # to long match the option first. + if "=" in arg: + long_opt, explicit_value = arg.split("=", 1) + else: + long_opt = arg + norm_long_opt = _normalize_opt(long_opt, self.ctx) + + # At this point we will match the (assumed) long option through + # the long option matching code. Note that this allows options + # like "-foo" to be matched as long options. + try: + self._match_long_opt(norm_long_opt, explicit_value, state) + except NoSuchOption: + # At this point the long option matching failed, and we need + # to try with short options. However there is a special rule + # which says, that if we have a two character options prefix + # (applies to "--foo" for instance), we do not dispatch to the + # short option code and will instead raise the no option + # error. + if arg[:2] not in self._opt_prefixes: + self._match_short_opt(arg, state) + return + + if not self.ignore_unknown_options: + raise + + state.largs.append(arg) diff --git a/typer/_click/py.typed b/typer/_click/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer/_click/shell_completion.py b/typer/_click/shell_completion.py new file mode 100644 index 0000000000..4490f18810 --- /dev/null +++ b/typer/_click/shell_completion.py @@ -0,0 +1,306 @@ +import re +from abc import ABC, abstractmethod +from collections.abc import MutableMapping +from typing import Any, ClassVar, TypeVar + +from .core import Command, Context, Parameter, ParameterSource + + +class CompletionItem: + """Represents a completion value and metadata about the value. The + default metadata is ``type`` to indicate special shell handling, + and ``help`` if a shell supports showing a help string next to the + value. + + Arbitrary parameters can be passed when creating the object, and + accessed using ``item.attr``. If an attribute wasn't passed, + accessing it returns ``None``. + """ + + __slots__ = ("value", "type", "help", "_info") + + def __init__( + self, + value: Any, + type: str = "plain", + help: str | None = None, + **kwargs: Any, + ) -> None: + self.value: Any = value + self.type: str = type + self.help: str | None = help + self._info = kwargs + + def __getattr__(self, name: str) -> Any: + return self._info.get(name) + + +class ShellComplete(ABC): + """Base class for providing shell completion support. A subclass for + a given shell will override attributes and methods to implement the + completion instructions (``source`` and ``complete``). + """ + + name: ClassVar[str] + """Name to register the shell as with `add_completion_class`. + This is used in completion instructions (``{name}_source`` and + ``{name}_complete``). + """ + + source_template: ClassVar[str] + """Completion script template formatted by `source`. This must + be provided by subclasses. + """ + + def __init__( + self, + cli: Command, + ctx_args: MutableMapping[str, Any], + prog_name: str, + complete_var: str, + ) -> None: + self.cli = cli + self.ctx_args = ctx_args + self.prog_name = prog_name + self.complete_var = complete_var + + @property + def func_name(self) -> str: + """The name of the shell function defined by the completion + script. + """ + safe_name = re.sub(r"\W*", "", self.prog_name.replace("-", "_"), flags=re.ASCII) + return f"_{safe_name}_completion" + + @abstractmethod + def source_vars(self) -> dict[str, Any]: + """Vars for formatting `source_template`.""" + pass # pragma: no cover + + def source(self) -> str: + """Produce the shell script that defines the completion + function. By default this ``%``-style formats + `source_template` with the dict returned by `source_vars`. + """ + return self.source_template % self.source_vars() + + @abstractmethod + def get_completion_args(self) -> tuple[list[str], str]: + """Use the env vars defined by the shell script to return a + tuple of ``args, incomplete``. This must be implemented by + subclasses. + """ + pass # pragma: no cover + + def get_completions(self, args: list[str], incomplete: str) -> list[CompletionItem]: + """Determine the context and last complete command or parameter + from the complete args. Call that object's ``shell_complete`` + method to get the completions for the incomplete value. + """ + ctx = _resolve_context(self.cli, self.ctx_args, self.prog_name, args) + obj, incomplete = _resolve_incomplete(ctx, args, incomplete) + return obj.shell_complete(ctx, incomplete) + + @abstractmethod + def format_completion(self, item: CompletionItem) -> str: + """Format a completion item into the form recognized by the + shell script. This must be implemented by subclasses. + """ + pass # pragma: no cover + + def complete(self) -> str: + """Produce the completion data to send back to the shell. + + By default this calls `get_completion_args`, gets the + completions, then calls `format_completion` for each + completion. + """ + args, incomplete = self.get_completion_args() + completions = self.get_completions(args, incomplete) + out = [self.format_completion(item) for item in completions] + return "\n".join(out) + + +ShellCompleteType = TypeVar("ShellCompleteType", bound="type[ShellComplete]") + + +_available_shells: dict[str, type[ShellComplete]] = {} + + +def add_completion_class(cls: ShellCompleteType, name: str) -> ShellCompleteType: + """Register a `ShellComplete` subclass under the given name. + The name will be provided by the completion instruction environment + variable during completion. + """ + _available_shells[name] = cls + + return cls + + +def get_completion_class(shell: str) -> type[ShellComplete] | None: + """Look up a registered `ShellComplete` subclass by the name + provided by the completion instruction environment variable. If the + name isn't registered, returns ``None``. + """ + return _available_shells.get(shell) + + +def split_arg_string(string: str) -> list[str]: + """Split an argument string as with `shlex.split`, but don't + fail if the string is incomplete. Ignores a missing closing quote or + incomplete escape sequence and uses the partial token as-is. + """ + import shlex + + lex = shlex.shlex(string, posix=True) + lex.whitespace_split = True + lex.commenters = "" + out = [] + + try: + for token in lex: + out.append(token) + except ValueError: # pragma: no cover + # Raised when end-of-string is reached in an invalid state. Use + # the partial token as-is. The quote or escape character is in + # lex.state, not lex.token. + out.append(lex.token) + + return out + + +def _is_incomplete_argument(ctx: Context, param: Parameter) -> bool: + """Determine if the given parameter is an argument that can still + accept values. + """ + # avoid circular imports + from ..core import TyperArgument + + if not isinstance(param, TyperArgument): + return False + + assert param.name is not None + # Will be None if expose_value is False. + value = ctx.params.get(param.name) + return ( + param.nargs == -1 + or ctx.get_parameter_source(param.name) is not ParameterSource.COMMANDLINE + or ( + param.nargs > 1 + and isinstance(value, (tuple, list)) + and len(value) < param.nargs + ) + ) + + +def _start_of_option(ctx: Context, value: str) -> bool: + """Check if the value looks like the start of an option.""" + if not value: + return False + + c = value[0] + return c in ctx._opt_prefixes + + +def _is_incomplete_option(ctx: Context, args: list[str], param: Parameter) -> bool: + """Determine if the given parameter is an option that needs a value.""" + # avoid circular imports + from ..core import TyperOption + + if not isinstance(param, TyperOption): + return False + + if param.is_flag or param.count: + return False + + last_option = None + + for index, arg in enumerate(reversed(args)): + if index + 1 > param.nargs: + break + + if _start_of_option(ctx, arg): + last_option = arg + break + + return last_option is not None and last_option in param.opts + + +def _resolve_context( + cli: Command, + ctx_args: MutableMapping[str, Any], + prog_name: str, + args: list[str], +) -> Context: + """Produce the context hierarchy starting with the command and + traversing the complete arguments. This only follows the commands, + it doesn't trigger input prompts or callbacks. + """ + # avoid circular imports + from ..core import TyperGroup + + ctx_args["resilient_parsing"] = True + with cli.make_context(prog_name, args.copy(), **ctx_args) as ctx: + args = ctx._protected_args + ctx.args + + while args: + command = ctx.command + + if isinstance(command, TyperGroup): + # if not command.chain: + name, cmd, args = command.resolve_command(ctx, args) + + if cmd is None: + return ctx + + with cmd.make_context( + name, args, parent=ctx, resilient_parsing=True + ) as sub_ctx: + ctx = sub_ctx + args = ctx._protected_args + ctx.args + else: # pragma: no cover + break + + return ctx + + +def _resolve_incomplete( + ctx: Context, args: list[str], incomplete: str +) -> tuple[Command | Parameter, str]: + """Find the Click object that will handle the completion of the + incomplete value. Return the object and the incomplete value. + """ + # Different shells treat an "=" between a long option name and + # value differently. Might keep the value joined, return the "=" + # as a separate item, or return the split name and value. Always + # split and discard the "=" to make completion easier. + if incomplete == "=": + incomplete = "" + elif "=" in incomplete and _start_of_option(ctx, incomplete): + name, _, incomplete = incomplete.partition("=") + args.append(name) + + # The "--" marker tells Click to stop treating values as options + # even if they start with the option character. If it hasn't been + # given and the incomplete arg looks like an option, the current + # command will provide option name completions. + if "--" not in args and _start_of_option(ctx, incomplete): + return ctx.command, incomplete + + params = ctx.command.get_params(ctx) + + # If the last complete arg is an option name with an incomplete + # value, the option will provide value completions. + for param in params: + if _is_incomplete_option(ctx, args, param): + return param, incomplete + + # It's not an option name or value. The first argument without a + # parsed value will provide value completions. + for param in params: + if _is_incomplete_argument(ctx, param): + return param, incomplete + + # There were no unparsed arguments, the command may be a group that + # will provide command name completions. + return ctx.command, incomplete diff --git a/typer/_click/termui.py b/typer/_click/termui.py new file mode 100644 index 0000000000..0a8c82574d --- /dev/null +++ b/typer/_click/termui.py @@ -0,0 +1,430 @@ +import io +from collections.abc import Callable, Iterable +from contextlib import AbstractContextManager +from typing import IO, TYPE_CHECKING, Any, AnyStr, TextIO, TypeVar, overload + +from .exceptions import Abort, UsageError +from .globals import resolve_color_default +from .types import ParamType, convert_type +from .utils import LazyFile, echo + +if TYPE_CHECKING: + from ._termui_impl import ProgressBar + +V = TypeVar("V") + +# The prompt functions to use. The doc tools currently override these +# functions to customize how they work. +visible_prompt_func: Callable[[str], str] = input + +_ansi_colors = { + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, + "reset": 39, + "bright_black": 90, + "bright_red": 91, + "bright_green": 92, + "bright_yellow": 93, + "bright_blue": 94, + "bright_magenta": 95, + "bright_cyan": 96, + "bright_white": 97, +} +_ansi_reset_all = "\033[0m" + + +def hidden_prompt_func(prompt: str) -> str: + import getpass + + return getpass.getpass(prompt) + + +def _build_prompt( + text: str, + suffix: str, + show_default: bool = False, + default: Any | None = None, + show_choices: bool = True, + type: ParamType | None = None, +) -> str: + # prevent circular imports + from .._types import TyperChoice + + prompt = text + if type is not None and show_choices and isinstance(type, TyperChoice): + prompt += f" ({', '.join(map(str, type.choices))})" + if default is not None and show_default: + prompt = f"{prompt} [{_format_default(default)}]" + return f"{prompt}{suffix}" + + +def _format_default(default: Any) -> Any: + if isinstance(default, (io.IOBase, LazyFile)) and hasattr(default, "name"): + return default.name + + return default + + +def prompt( + text: str, + default: Any | None = None, + hide_input: bool = False, + confirmation_prompt: bool | str = False, + type: ParamType | Any | None = None, + value_proc: Callable[[str], Any] | None = None, + prompt_suffix: str = ": ", + show_default: bool = True, + err: bool = False, + show_choices: bool = True, +) -> Any: + """Prompts a user for input. This is a convenience function that can + be used to prompt a user for input later. + + If the user aborts the input by sending an interrupt signal, this + function will catch it and raise an `Abort` exception. + """ + + def prompt_func(text: str) -> str: + f = hidden_prompt_func if hide_input else visible_prompt_func + try: + # Write the prompt separately so that we get nice + # coloring through colorama on Windows + echo(text[:-1], nl=False, err=err) + # Echo the last character to stdout to work around an issue where + # readline causes backspace to clear the whole line. + return f(text[-1:]) + except (KeyboardInterrupt, EOFError): # pragma: no cover + # getpass doesn't print a newline if the user aborts input with ^C. + # Allegedly this behavior is inherited from getpass(3). + # A doc bug has been filed at https://bugs.python.org/issue24711 + if hide_input: + echo(None, err=err) + raise Abort() from None + + if value_proc is None: + value_proc = convert_type(type, default) + + prompt = _build_prompt( + text, prompt_suffix, show_default, default, show_choices, type + ) + + if confirmation_prompt: + if confirmation_prompt is True: + confirmation_prompt = "Repeat for confirmation" + + confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix) + + while True: + while True: + value = prompt_func(prompt) + if value: + break + elif default is not None: + value = default + break + try: + result = value_proc(value) + except UsageError as e: # pragma: no cover + if hide_input: + echo("Error: The value you entered was invalid.", err=err) + else: + echo(f"Error: {e.message}", err=err) + continue + if not confirmation_prompt: + return result + while True: + value2 = prompt_func(confirmation_prompt) + is_empty = not value and not value2 + if value2 or is_empty: + break + if value == value2: + return result + echo("Error: The two entered values do not match.", err=err) + + +def confirm( + text: str, + default: bool | None = False, + abort: bool = False, + prompt_suffix: str = ": ", + show_default: bool = True, + err: bool = False, +) -> bool: + """Prompts for confirmation (yes/no question). + + If the user aborts the input by sending a interrupt signal this + function will catch it and raise an `Abort` exception. + """ + prompt = _build_prompt( + text, + prompt_suffix, + show_default, + "y/n" if default is None else ("Y/n" if default else "y/N"), + ) + + while True: + try: + # Write the prompt separately so that we get nice + # coloring through colorama on Windows + echo(prompt[:-1], nl=False, err=err) + # Echo the last character to stdout to work around an issue where + # readline causes backspace to clear the whole line. + value = visible_prompt_func(prompt[-1:]).lower().strip() + except (KeyboardInterrupt, EOFError): # pragma: no cover + raise Abort() from None + if value in ("y", "yes"): + rv = True + elif value in ("n", "no"): + rv = False + elif default is not None and value == "": + rv = default + else: # pragma: no cover + echo("Error: invalid input", err=err) + continue + break + if abort and not rv: + raise Abort() + return rv + + +@overload +def progressbar( + *, + length: int, + label: str | None = None, + hidden: bool = False, + show_eta: bool = True, + show_percent: bool | None = None, + show_pos: bool = False, + fill_char: str = "#", + empty_char: str = "-", + bar_template: str = "%(label)s [%(bar)s] %(info)s", + info_sep: str = " ", + width: int = 36, + file: TextIO | None = None, + color: bool | None = None, + update_min_steps: int = 1, +) -> "ProgressBar[int]": ... + + +@overload +def progressbar( + iterable: Iterable[V] | None = None, + length: int | None = None, + label: str | None = None, + hidden: bool = False, + show_eta: bool = True, + show_percent: bool | None = None, + show_pos: bool = False, + item_show_func: Callable[[V | None], str | None] | None = None, + fill_char: str = "#", + empty_char: str = "-", + bar_template: str = "%(label)s [%(bar)s] %(info)s", + info_sep: str = " ", + width: int = 36, + file: TextIO | None = None, + color: bool | None = None, + update_min_steps: int = 1, +) -> "ProgressBar[V]": ... + + +def progressbar( + iterable: Iterable[V] | None = None, + length: int | None = None, + label: str | None = None, + hidden: bool = False, + show_eta: bool = True, + show_percent: bool | None = None, + show_pos: bool = False, + item_show_func: Callable[[V | None], str | None] | None = None, + fill_char: str = "#", + empty_char: str = "-", + bar_template: str = "%(label)s [%(bar)s] %(info)s", + info_sep: str = " ", + width: int = 36, + file: TextIO | None = None, + color: bool | None = None, + update_min_steps: int = 1, +) -> "ProgressBar[V]": + """This function creates an iterable context manager that can be used + to iterate over something while showing a progress bar. It will + either iterate over the `iterable` or `length` items (that are counted + up). While iteration happens, this function will print a rendered + progress bar to the given `file` (defaults to stdout) and will attempt + to calculate remaining time and more. By default, this progress bar + will not be rendered if the file is not a terminal. + + The context manager creates the progress bar. When the context + manager is entered the progress bar is already created. With every + iteration over the progress bar, the iterable passed to the bar is + advanced and the bar is updated. When the context manager exits, + a newline is printed and the progress bar is finalized on screen. + + Note: The progress bar is currently designed for use cases where the + total progress can be expected to take at least several seconds. + Because of this, the ProgressBar class object won't display + progress that is considered too fast, and progress where the time + between steps is less than a second. + + No printing must happen or the progress bar will be unintentionally + destroyed. + """ + from ._termui_impl import ProgressBar + + color = resolve_color_default(color) + return ProgressBar( + iterable=iterable, + length=length, + hidden=hidden, + show_eta=show_eta, + show_percent=show_percent, + show_pos=show_pos, + item_show_func=item_show_func, + fill_char=fill_char, + empty_char=empty_char, + bar_template=bar_template, + info_sep=info_sep, + file=file, + label=label, + width=width, + color=color, + update_min_steps=update_min_steps, + ) + + +def _interpret_color(color: int | tuple[int, int, int] | str, offset: int = 0) -> str: + if isinstance(color, int): + return f"{38 + offset};5;{color:d}" + + if isinstance(color, (tuple, list)): + r, g, b = color + return f"{38 + offset};2;{r:d};{g:d};{b:d}" + + return str(_ansi_colors[color] + offset) + + +def style( + text: Any, + fg: int | tuple[int, int, int] | str | None = None, + bg: int | tuple[int, int, int] | str | None = None, + bold: bool | None = None, + dim: bool | None = None, + underline: bool | None = None, + overline: bool | None = None, + italic: bool | None = None, + blink: bool | None = None, + reverse: bool | None = None, + strikethrough: bool | None = None, + reset: bool = True, +) -> str: + """Styles a text with ANSI styles and returns the new string. By + default the styling is self contained which means that at the end + of the string a reset code is issued. This can be prevented by + passing ``reset=False``. + """ + if not isinstance(text, str): + text = str(text) + + bits = [] + + if fg: + try: + bits.append(f"\033[{_interpret_color(fg)}m") + except KeyError: + raise TypeError(f"Unknown color {fg!r}") from None + + if bg: + try: + bits.append(f"\033[{_interpret_color(bg, 10)}m") + except KeyError: + raise TypeError(f"Unknown color {bg!r}") from None + + if bold is not None: + bits.append(f"\033[{1 if bold else 22}m") + if dim is not None: + bits.append(f"\033[{2 if dim else 22}m") + if underline is not None: + bits.append(f"\033[{4 if underline else 24}m") + if overline is not None: + bits.append(f"\033[{53 if overline else 55}m") + if italic is not None: + bits.append(f"\033[{3 if italic else 23}m") + if blink is not None: + bits.append(f"\033[{5 if blink else 25}m") + if reverse is not None: + bits.append(f"\033[{7 if reverse else 27}m") + if strikethrough is not None: + bits.append(f"\033[{9 if strikethrough else 29}m") + bits.append(text) + if reset: + bits.append(_ansi_reset_all) + return "".join(bits) + + +def secho( + message: Any | None = None, + file: IO[AnyStr] | None = None, + nl: bool = True, + err: bool = False, + color: bool | None = None, + **styles: Any, +) -> None: + """This function combines `echo` and `style` into one call.""" + if message is not None and not isinstance(message, (bytes, bytearray)): + message = style(message, **styles) + + return echo(message, file=file, nl=nl, err=err, color=color) + + +def launch(url: str, wait: bool = False, locate: bool = False) -> int: + """This function launches the given URL (or filename) in the default + viewer application for this file type. If this is an executable, it + might launch the executable in a new session. The return value is + the exit code of the launched application. Usually, ``0`` indicates + success. + """ + from ._termui_impl import open_url + + return open_url(url, wait=wait, locate=locate) + + +# If this is provided, getchar() calls into this instead. This is used +# for unittesting purposes. +_getchar: Callable[[bool], str] | None = None + + +def getchar(echo: bool = False) -> str: + """Fetches a single character from the terminal and returns it. This + will always return a unicode character and under certain rare + circumstances this might return more than one character. The + situations which more than one character is returned is when for + whatever reason multiple characters end up in the terminal buffer or + standard input was not actually a terminal. + + Note that this will always read from the terminal, even if something + is piped into the standard input. + + Note for Windows: in rare cases when typing non-ASCII characters, this + function might wait for a second character and then return both at once. + This is because certain Unicode characters look like special-key markers. + """ + global _getchar + + if _getchar is None: + from ._termui_impl import getchar as f + + _getchar = f + + return _getchar(echo) + + +def raw_terminal() -> AbstractContextManager[int]: + from ._termui_impl import raw_terminal as f + + return f() diff --git a/typer/_click/types.py b/typer/_click/types.py new file mode 100644 index 0000000000..5ccf15fe1b --- /dev/null +++ b/typer/_click/types.py @@ -0,0 +1,695 @@ +import os +import sys +from collections.abc import Callable, Sequence +from datetime import datetime +from typing import ( + IO, + TYPE_CHECKING, + Any, + ClassVar, + Literal, + NoReturn, + TypedDict, + TypeGuard, + TypeVar, + Union, + cast, +) + +from ._compat import _get_argv_encoding, open_stream +from .exceptions import BadParameter +from .utils import LazyFile, format_filename, safecall + +if TYPE_CHECKING: + from .core import Context, Parameter + from .shell_completion import CompletionItem + +ParamTypeValue = TypeVar("ParamTypeValue") + + +class ParamType: + """Represents the type of a parameter. Validates and converts values + from the command line or Python into the correct type. + + To implement a custom type, subclass and implement at least the + following: + + - The `name` class attribute must be set. + - Calling an instance of the type with ``None`` must return + ``None``. This is already implemented by default. + - `convert` must convert string values to the correct type. + - `convert` must accept values that are already the correct + type. + - It must be able to convert a value if the ``ctx`` and ``param`` + arguments are ``None``. This can occur when converting prompt + input. + """ + + is_composite: ClassVar[bool] = False + arity: ClassVar[int] = 1 + name: str + + # if a list of this type is expected and the value is pulled from a + # string environment variable, this is what splits it up. `None` + # means any whitespace. For all parameters the general rule is that + # whitespace splits them up. The exception are paths and files which + # are split by ``os.path.pathsep`` by default (":" on Unix and ";" on + # Windows). + envvar_list_splitter: ClassVar[str | None] = None + + def __call__( + self, + value: Any, + param: Union["Parameter", None] = None, + ctx: Union["Context", None] = None, + ) -> Any: + if value is not None: + return self.convert(value, param, ctx) + + def get_metavar(self, param: "Parameter", ctx: "Context") -> str | None: + """Returns the metavar default for this param if it provides one.""" + pass # pragma: no cover + + def get_missing_message( + self, param: "Parameter", ctx: Union["Context", None] + ) -> str | None: + """Optionally might return extra information about a missing + parameter. + """ + pass # pragma: no cover + + def convert( + self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] + ) -> Any: + pass # pragma: no cover + + def split_envvar_value(self, rv: str) -> Sequence[str]: + """Given a value from an environment variable this splits it up + into small chunks depending on the defined envvar list splitter. + + If the splitter is set to `None`, which means that whitespace splits, + then leading and trailing whitespace is ignored. Otherwise, leading + and trailing splitters usually lead to empty items being included. + """ + return (rv or "").split(self.envvar_list_splitter) + + def fail( + self, + message: str, + param: Union["Parameter", None] = None, + ctx: Union["Context", None] = None, + ) -> NoReturn: + """Helper method to fail with an invalid value message.""" + raise BadParameter(message, ctx=ctx, param=param) + + def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> list["CompletionItem"]: + """Return a list of `CompletionItem` objects for the + incomplete value. Most types do not provide completions, but + some do, and this allows custom types to provide custom + completions as well. + """ + return [] + + +class CompositeParamType(ParamType): + is_composite = True + + @property + def arity(self) -> int: # type: ignore + raise NotImplementedError() # pragma: no cover + + +class FuncParamType(ParamType): + def __init__(self, func: Callable[[Any], Any]) -> None: + self.name: str = getattr(func, "__name__", "function") + self.func = func + + def convert( + self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] + ) -> Any: + try: + return self.func(value) + except ValueError: + try: + value = str(value) + except UnicodeError: # pragma: no cover + assert isinstance(value, bytes) + value = value.decode("utf-8", "replace") + + self.fail(value, param, ctx) + + +class StringParamType(ParamType): + name = "text" + + def convert( + self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] + ) -> Any: + if isinstance(value, bytes): + enc = _get_argv_encoding() + try: + value = value.decode(enc) + except UnicodeError: + fs_enc = sys.getfilesystemencoding() + if fs_enc != enc: + try: + value = value.decode(fs_enc) + except UnicodeError: + value = value.decode("utf-8", "replace") + else: + value = value.decode("utf-8", "replace") + return value + return str(value) + + def __repr__(self) -> str: + return "STRING" + + +class DateTime(ParamType): + """The DateTime type converts date strings into `datetime` objects. + + The format strings which are checked are configurable, but default to some + common (non-timezone aware) ISO 8601 formats. + + When specifying *DateTime* formats, you should only pass a list or a tuple. + Other iterables, like generators, may lead to surprising results. + + The format strings are processed using ``datetime.strptime``, and this + consequently defines the format strings which are allowed. + + Parsing is tried using each format, in order, and the first format which + parses successfully is used. + """ + + name = "datetime" + + def __init__(self, formats: Sequence[str] | None = None): + self.formats: Sequence[str] = formats or [ + "%Y-%m-%d", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", + ] + + def get_metavar(self, param: "Parameter", ctx: "Context") -> str | None: + return f"[{'|'.join(self.formats)}]" + + def _try_to_convert_date(self, value: Any, format: str) -> datetime | None: + try: + return datetime.strptime(value, format) + except ValueError: + return None + + def convert( + self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] + ) -> Any: + if isinstance(value, datetime): + return value + + for format in self.formats: + converted = self._try_to_convert_date(value, format) + + if converted is not None: + return converted + + formats_str = ", ".join(map(repr, self.formats)) + self.fail( + f"{value!r} does not match the formats {formats_str}.", + param, + ctx, + ) + + def __repr__(self) -> str: + return "DateTime" + + +class _NumberParamTypeBase(ParamType): + _number_class: ClassVar[type[Any]] + + def convert( + self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] + ) -> Any: + try: + return self._number_class(value) + except ValueError: + self.fail( + f"{value!r} is not a valid {self.name}.", + param, + ctx, + ) + + +class _NumberRangeBase(_NumberParamTypeBase): + def __init__( + self, + min: float | None = None, + max: float | None = None, + min_open: bool = False, + max_open: bool = False, + clamp: bool = False, + ) -> None: + self.min = min + self.max = max + self.min_open = min_open + self.max_open = max_open + self.clamp = clamp + + def convert( + self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] + ) -> Any: + import operator + + rv = super().convert(value, param, ctx) + lt_min: bool = self.min is not None and ( + operator.le if self.min_open else operator.lt + )(rv, self.min) + gt_max: bool = self.max is not None and ( + operator.ge if self.max_open else operator.gt + )(rv, self.max) + + if self.clamp: + if lt_min: + return self._clamp(self.min, 1, self.min_open) # type: ignore[arg-type] + + if gt_max: + return self._clamp(self.max, -1, self.max_open) # type: ignore[arg-type] + + if lt_min or gt_max: + self.fail( + f"{rv} is not in the range {self._describe_range()}.", + param, + ctx, + ) + + return rv + + def _clamp(self, bound: float, dir: Literal[1, -1], open: bool) -> float: + """Find the valid value to clamp to bound in the given + direction. + """ + raise NotImplementedError # pragma: no cover + + def _describe_range(self) -> str: + """Describe the range for use in help text.""" + if self.min is None: + op = "<" if self.max_open else "<=" + return f"x{op}{self.max}" + + if self.max is None: + op = ">" if self.min_open else ">=" + return f"x{op}{self.min}" + + lop = "<" if self.min_open else "<=" + rop = "<" if self.max_open else "<=" + return f"{self.min}{lop}x{rop}{self.max}" + + def __repr__(self) -> str: + clamp = " clamped" if self.clamp else "" + return f"<{type(self).__name__} {self._describe_range()}{clamp}>" + + +class IntParamType(_NumberParamTypeBase): + name = "integer" + _number_class = int + + def __repr__(self) -> str: + return "INT" + + +class IntRange(_NumberRangeBase, IntParamType): + """Restrict an `INT` value to a range of accepted values. See + + If ``min`` or ``max`` are not passed, any value is accepted in that + direction. If ``min_open`` or ``max_open`` are enabled, the + corresponding boundary is not included in the range. + + If ``clamp`` is enabled, a value outside the range is clamped to the + boundary instead of failing. + """ + + name = "integer range" + + def _clamp( # type: ignore + self, bound: int, dir: Literal[1, -1], open: bool + ) -> int: + if not open: + return bound + + return bound + dir + + +class FloatParamType(_NumberParamTypeBase): + name = "float" + _number_class = float + + def __repr__(self) -> str: + return "FLOAT" + + +class FloatRange(_NumberRangeBase, FloatParamType): + """Restrict a `FLOAT` value to a range of accepted + values. See `ranges`. + + If ``min`` or ``max`` are not passed, any value is accepted in that + direction. If ``min_open`` or ``max_open`` are enabled, the + corresponding boundary is not included in the range. + + If ``clamp`` is enabled, a value outside the range is clamped to the + boundary instead of failing. This is not supported if either + boundary is marked ``open``. + """ + + name = "float range" + + def __init__( + self, + min: float | None = None, + max: float | None = None, + min_open: bool = False, + max_open: bool = False, + clamp: bool = False, + ) -> None: + super().__init__( + min=min, max=max, min_open=min_open, max_open=max_open, clamp=clamp + ) + + if (min_open or max_open) and clamp: + raise TypeError("Clamping is not supported for open bounds.") + + def _clamp(self, bound: float, dir: Literal[1, -1], open: bool) -> float: + if not open: + return bound + + # Could use math.nextafter here, but clamping an + # open float range doesn't seem to be particularly useful. It's + # left up to the user to write a callback to do it if needed. + raise RuntimeError( + "Clamping is not supported for open bounds." + ) # pragma: no cover + + +class BoolParamType(ParamType): + name = "boolean" + + bool_states: dict[str, bool] = { + "1": True, + "0": False, + "yes": True, + "no": False, + "true": True, + "false": False, + "on": True, + "off": False, + "t": True, + "f": False, + "y": True, + "n": False, + # Absence of value is considered False. + "": False, + } + """A mapping of string values to boolean states. + + Mapping is inspired by `configparser.ConfigParser.BOOLEAN_STATES` + and extends it. + """ + + @staticmethod + def str_to_bool(value: str | bool) -> bool | None: + """Convert a string to a boolean value. + + If the value is already a boolean, it is returned as-is. If the value is a + string, it is stripped of whitespaces and lower-cased, then checked against + the known boolean states pre-defined in the `BoolParamType.bool_states` mapping + above. + + Returns `None` if the value does not match any known boolean state. + """ + if isinstance(value, bool): + return value + return BoolParamType.bool_states.get(value.strip().lower()) + + def convert( + self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] + ) -> bool: + normalized = self.str_to_bool(value) + if normalized is None: + states = ", ".join(sorted(self.bool_states)) + self.fail( + f"{value!r} is not a valid boolean. Recognized values: {states}", + param, + ctx, + ) + return normalized + + def __repr__(self) -> str: + return "BOOL" + + +class UUIDParameterType(ParamType): + name = "uuid" + + def convert( + self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] + ) -> Any: + import uuid + + if isinstance(value, uuid.UUID): + return value + + value = value.strip() + + try: + return uuid.UUID(value) + except ValueError: + self.fail(f"{value!r} is not a valid UUID.", param, ctx) + + def __repr__(self) -> str: + return "UUID" + + +class File(ParamType): + """Declares a parameter to be a file for reading or writing. The file + is automatically closed once the context tears down (after the command + finished working). + + Files can be opened for reading or writing. The special value ``-`` + indicates stdin or stdout depending on the mode. + + By default, the file is opened for reading text data, but it can also be + opened in binary mode or for writing. The encoding parameter can be used + to force a specific encoding. + + The `lazy` flag controls if the file should be opened immediately or upon + first IO. The default is to be non-lazy for standard input and output + streams as well as files opened for reading, `lazy` otherwise. When opening a + file lazily for reading, it is still opened temporarily for validation, but + will not be held open until first IO. lazy is mainly useful when opening + for writing to avoid creating the file until it is needed. + + Files can also be opened atomically in which case all writes go into a + separate file in the same folder and upon completion the file will + be moved over to the original location. This is useful if a file + regularly read by other users is modified. + """ + + name = "filename" + envvar_list_splitter: ClassVar[str] = os.path.pathsep + + def __init__( + self, + mode: str = "r", + encoding: str | None = None, + errors: str | None = "strict", + lazy: bool | None = None, + atomic: bool = False, + ) -> None: + self.mode = mode + self.encoding = encoding + self.errors = errors + self.lazy = lazy + self.atomic = atomic + + def resolve_lazy_flag(self, value: str | os.PathLike[str]) -> bool: + if self.lazy is not None: + return self.lazy + if os.fspath(value) == "-": + return False + elif "w" in self.mode: + return True + return False + + def convert( + self, + value: str | os.PathLike[str] | IO[Any], + param: Union["Parameter", None], + ctx: Union["Context", None], + ) -> IO[Any]: + if _is_file_like(value): + return value + + value = cast("str | os.PathLike[str]", value) + + try: + lazy = self.resolve_lazy_flag(value) + + if lazy: + lf = LazyFile( + value, self.mode, self.encoding, self.errors, atomic=self.atomic + ) + + if ctx is not None: + ctx.call_on_close(lf.close_intelligently) + + return cast("IO[Any]", lf) + + f, should_close = open_stream( + value, self.mode, self.encoding, self.errors, atomic=self.atomic + ) + + # If a context is provided, we automatically close the file + # at the end of the context execution (or flush out). If a + # context does not exist, it's the caller's responsibility to + # properly close the file. This for instance happens when the + # type is used with prompts. + if ctx is not None: + if should_close: + ctx.call_on_close(safecall(f.close)) + else: + ctx.call_on_close(safecall(f.flush)) + + return f + except OSError as e: # pragma: no cover + self.fail(f"'{format_filename(value)}': {e.strerror}", param, ctx) + + def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> list["CompletionItem"]: + """Return a special completion marker that tells the completion + system to use the shell to provide file path completions. + """ + from .shell_completion import CompletionItem + + return [CompletionItem(incomplete, type="file")] + + +def _is_file_like(value: Any) -> TypeGuard[IO[Any]]: + return hasattr(value, "read") or hasattr(value, "write") + + +class Tuple(CompositeParamType): + """The default behavior of Click is to apply a type on a value directly. + This works well in most cases, except for when `nargs` is set to a fixed + count and different types should be used for different items. In this + case the `Tuple` type can be used. This type can only be used + if `nargs` is set to a fixed number. + + For more information see `tuple-type`. + + This can be selected by using a Python tuple literal as a type. + """ + + def __init__(self, types: Sequence[type[Any] | ParamType]) -> None: + self.types: Sequence[ParamType] = [convert_type(ty) for ty in types] + + @property + def name(self) -> str: # type: ignore[override] + return f"<{' '.join(ty.name for ty in self.types)}>" + + @property + def arity(self) -> int: # type: ignore + return len(self.types) + + def convert( + self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] + ) -> Any: + len_type = len(self.types) + len_value = len(value) + + if len_value != len_type: + self.fail( + f"{len_type} values are required, but {len_value} given.", + param=param, + ctx=ctx, + ) + + return tuple( + ty(x, param, ctx) for ty, x in zip(self.types, value, strict=False) + ) + + +def convert_type(ty: Any | None, default: Any | None = None) -> ParamType: + """Find the most appropriate `ParamType` for the given Python + type. If the type isn't provided, it can be inferred from a default + value. + """ + guessed_type = False + + if ty is None and default is not None: + if isinstance(default, (tuple, list)): + # If the default is empty, ty will remain None and will + # return STRING. + if default: + item = default[0] + + # A tuple of tuples needs to detect the inner types. + # Can't call convert recursively because that would + # incorrectly unwind the tuple to a single type. + if isinstance(item, (tuple, list)): + ty = tuple(map(type, item)) + else: + ty = type(item) + else: + ty = type(default) + + guessed_type = True + + if isinstance(ty, tuple): + return Tuple(ty) + + if isinstance(ty, ParamType): + return ty + + if ty is str or ty is None: + return STRING + + if ty is int: + return INT + + if ty is float: + return FLOAT + + if ty is bool: + return BOOL + + if guessed_type: + return STRING + + return FuncParamType(ty) + + +# A unicode string parameter type which is the implicit default. This +# can also be selected by using ``str`` as type. +STRING = StringParamType() + +# An integer parameter. This can also be selected by using ``int`` as +# type. +INT = IntParamType() + +# A floating point value parameter. This can also be selected by using +# ``float`` as type. +FLOAT = FloatParamType() + +# A boolean parameter. This is the default for boolean flags. This can +# also be selected by using ``bool`` as a type. +BOOL = BoolParamType() + +# A UUID parameter. +UUID = UUIDParameterType() + + +class OptionHelpExtra(TypedDict, total=False): + envvars: tuple[str, ...] + default: str + range: str + required: str diff --git a/typer/_click/utils.py b/typer/_click/utils.py new file mode 100644 index 0000000000..ac8e5ba3f2 --- /dev/null +++ b/typer/_click/utils.py @@ -0,0 +1,470 @@ +import os +import re +import sys +from collections.abc import Callable, Iterable, Iterator +from functools import update_wrapper +from types import ModuleType, TracebackType +from typing import ( + IO, + Any, + AnyStr, + BinaryIO, + Literal, + ParamSpec, + TextIO, + TypeVar, + cast, +) + +from ._compat import ( + WIN, + _default_text_stderr, + _default_text_stdout, + _find_binary_writer, + auto_wrap_for_ansi, + binary_streams, + open_stream, + should_strip_ansi, + strip_ansi, + text_streams, +) +from .globals import resolve_color_default + +P = ParamSpec("P") +R = TypeVar("R") + + +def _posixify(name: str) -> str: + return "-".join(name.split()).lower() + + +def safecall(func: Callable[P, R]) -> Callable[P, R | None]: + """Wraps a function so that it swallows exceptions.""" + + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None: + try: + return func(*args, **kwargs) + except Exception: # pragma: no cover + pass + return None # pragma: no cover + + return update_wrapper(wrapper, func) + + +def make_default_short_help(help: str, max_length: int = 45) -> str: + """Returns a condensed version of help string.""" + # Consider only the first paragraph. + paragraph_end = help.find("\n\n") + + if paragraph_end != -1: + help = help[:paragraph_end] + + # Collapse newlines, tabs, and spaces. + words = help.split() + + if not words: + return "" + + # The first paragraph started with a "no rewrap" marker, ignore it. + if words[0] == "\b": + words = words[1:] + + total_length = 0 + last_index = len(words) - 1 + + for i, word in enumerate(words): + total_length += len(word) + (i > 0) + + if total_length > max_length: # too long, truncate + break + + if word[-1] == ".": # sentence end, truncate without "..." + return " ".join(words[: i + 1]) + + if total_length == max_length and i != last_index: + break # not at sentence end, truncate with "..." + else: + return " ".join(words) # no truncation needed + + # Account for the length of the suffix. + total_length += len("...") + + # remove words until the length is short enough + while i > 0: + total_length -= len(words[i]) + (i > 0) + + if total_length <= max_length: + break + + i -= 1 + + return " ".join(words[:i]) + "..." + + +class LazyFile: + """A lazy file works like a regular file but it does not fully open + the file but it does perform some basic checks early to see if the + filename parameter does make sense. This is useful for safely opening + files for writing. + """ + + def __init__( + self, + filename: str | os.PathLike[str], + mode: str = "r", + encoding: str | None = None, + errors: str | None = "strict", + atomic: bool = False, + ): + self.name: str = os.fspath(filename) + self.mode = mode + self.encoding = encoding + self.errors = errors + self.atomic = atomic + self._f: IO[Any] | None + self.should_close: bool + + if self.name == "-": + self._f, self.should_close = open_stream(filename, mode, encoding, errors) + else: + if "r" in mode: + # Open and close the file in case we're opening it for + # reading so that we can catch at least some errors in + # some cases early. + open(filename, mode).close() + self._f = None + self.should_close = True + + def __getattr__(self, name: str) -> Any: + return getattr(self.open(), name) + + def __repr__(self) -> str: + if self._f is not None: + return repr(self._f) + return f"" + + def open(self) -> IO[Any]: + """Opens the file if it's not yet open. This call might fail with + a `FileError`. Not handling this error will produce an error + that Click shows. + """ + if self._f is not None: + return self._f + try: + rv, self.should_close = open_stream( + self.name, self.mode, self.encoding, self.errors, atomic=self.atomic + ) + except OSError as e: + from .exceptions import FileError + + raise FileError(self.name, hint=e.strerror) from e + self._f = rv + return rv + + def close(self) -> None: + """Closes the underlying file, no matter what.""" + if self._f is not None: + self._f.close() + + def close_intelligently(self) -> None: + """This function only closes the file if it was opened by the lazy + file wrapper. For instance this will never close stdin. + """ + if self.should_close: + self.close() + + def __enter__(self) -> "LazyFile": + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close_intelligently() + + def __iter__(self) -> Iterator[AnyStr]: + self.open() + return iter(self._f) # type: ignore + + +def echo( + message: Any | None = None, + file: IO[Any] | None = None, + nl: bool = True, + err: bool = False, + color: bool | None = None, +) -> None: + """Print a message and newline to stdout or a file. This should be + used instead of `print` because it provides better support + for different data, files, and environments. + + Compared to `print`, this does the following: + + - Ensures that the output encoding is not misconfigured on Linux. + - Supports Unicode in the Windows console. + - Supports writing to binary outputs, and supports writing bytes + to text outputs. + - Supports colors and styles on Windows. + - Removes ANSI color and style codes if the output does not look + like an interactive terminal. + - Always flushes the output. + """ + if file is None: + if err: + file = _default_text_stderr() + else: + file = _default_text_stdout() + + # There are no standard streams attached to write to. For example, + # pythonw on Windows. + if file is None: + return + + # Convert non bytes/text into the native string type. + if message is not None and not isinstance(message, (str, bytes, bytearray)): + out: str | bytes | bytearray | None = str(message) + else: + out = message + + if nl: + out = out or "" + if isinstance(out, str): + out += "\n" + else: + out += b"\n" + + if not out: + file.flush() + return + + # If there is a message and the value looks like bytes, we manually + # need to find the binary stream and write the message in there. + # This is done separately so that most stream types will work as you + # would expect. Eg: you can write to StringIO for other cases. + if isinstance(out, (bytes, bytearray)): + binary_file = _find_binary_writer(file) + + if binary_file is not None: + file.flush() + binary_file.write(out) + binary_file.flush() + return + + # ANSI style code support. For no message or bytes, nothing happens. + # When outputting to a file instead of a terminal, strip codes. + else: + color = resolve_color_default(color) + + if should_strip_ansi(file, color): + out = strip_ansi(out) + elif WIN: + if auto_wrap_for_ansi is not None: + file = auto_wrap_for_ansi(file, color) # type: ignore[arg-type,call-arg] + elif not color: + out = strip_ansi(out) + + file.write(out) # type: ignore[arg-type] + file.flush() + + +def get_binary_stream(name: Literal["stdin", "stdout", "stderr"]) -> BinaryIO: + """Returns a system stream for byte processing.""" + opener = binary_streams.get(name) + if opener is None: + raise TypeError(f"Unknown standard stream '{name}'") + return opener() + + +def get_text_stream( + name: Literal["stdin", "stdout", "stderr"], + encoding: str | None = None, + errors: str | None = "strict", +) -> TextIO: + """Returns a system stream for text processing. This usually returns + a wrapped stream around a binary stream returned from + `get_binary_stream` but it also can take shortcuts for already + correctly configured streams. + """ + opener = text_streams.get(name) + if opener is None: + raise TypeError(f"Unknown standard stream '{name}'") + return opener(encoding, errors) + + +def format_filename( + filename: str | bytes | os.PathLike[str] | os.PathLike[bytes], + shorten: bool = False, +) -> str: + """Format a filename as a string for display. Ensures the filename can be + displayed by replacing any invalid bytes or surrogate escapes in the name + with the replacement character ``�``. + + Invalid bytes or surrogate escapes will raise an error when written to a + stream with ``errors="strict"``. This will typically happen with ``stdout`` + when the locale is something like ``en_GB.UTF-8``. + + Many scenarios *are* safe to write surrogates though, due to PEP 538 and + PEP 540, including: + + - Writing to ``stderr``, which uses ``errors="backslashreplace"``. + - The system has ``LANG=C.UTF-8``, ``C``, or ``POSIX``. Python opens + stdout and stderr with ``errors="surrogateescape"``. + - None of ``LANG/LC_*`` are set. Python assumes ``LANG=C.UTF-8``. + - Python is started in UTF-8 mode with ``PYTHONUTF8=1`` or ``-X utf8``. + Python opens stdout and stderr with ``errors="surrogateescape"``. + """ + if shorten: + filename = os.path.basename(filename) + else: + filename = os.fspath(filename) + + if isinstance(filename, bytes): + filename = filename.decode(sys.getfilesystemencoding(), "replace") + else: + filename = filename.encode("utf-8", "surrogateescape").decode( + "utf-8", "replace" + ) + + return filename + + +def get_app_dir(app_name: str, roaming: bool = True, force_posix: bool = False) -> str: + r"""Returns the config folder for the application. The default behavior + is to return whatever is most appropriate for the operating system. + + To give you an idea, for an app called ``"Foo Bar"``, something like + the following folders could be returned: + + Mac OS X: + ``~/Library/Application Support/Foo Bar`` + Mac OS X (POSIX): + ``~/.foo-bar`` + Unix: + ``~/.config/foo-bar`` + Unix (POSIX): + ``~/.foo-bar`` + Windows (roaming): + ``C:\Users\\AppData\Roaming\Foo Bar`` + Windows (not roaming): + ``C:\Users\\AppData\Local\Foo Bar`` + """ + if WIN: + key = "APPDATA" if roaming else "LOCALAPPDATA" + folder = os.environ.get(key) + if folder is None: + folder = os.path.expanduser("~") + return os.path.join(folder, app_name) + if force_posix: + return os.path.join(os.path.expanduser(f"~/.{_posixify(app_name)}")) + if sys.platform == "darwin": + return os.path.join( + os.path.expanduser("~/Library/Application Support"), app_name + ) + return os.path.join( + os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), + _posixify(app_name), + ) + + +class PacifyFlushWrapper: + """This wrapper is used to catch and suppress BrokenPipeErrors resulting + from ``.flush()`` being called on broken pipe during the shutdown/final-GC + of the Python interpreter. Notably ``.flush()`` is always called on + ``sys.stdout`` and ``sys.stderr``. So as to have minimal impact on any + other cleanup code, and the case where the underlying file is not a broken + pipe, all calls and attributes are proxied. + """ + + def __init__(self, wrapped: IO[Any]) -> None: + self.wrapped = wrapped + + def flush(self) -> None: + try: + self.wrapped.flush() + except OSError as e: # pragma: no cover + import errno + + if e.errno != errno.EPIPE: + raise + + def __getattr__(self, attr: str) -> Any: + return getattr(self.wrapped, attr) + + +def _detect_program_name( + path: str | None = None, _main: ModuleType | None = None +) -> str: + """Determine the command used to run the program, for use in help + text. If a file or entry point was executed, the file name is + returned. If ``python -m`` was used to execute a module or package, + ``python -m name`` is returned. + + This doesn't try to be too precise, the goal is to give a concise + name for help text. Files are only shown as their name without the + path. ``python`` is only shown for modules, and the full path to + ``sys.executable`` is not shown. + """ + if _main is None: + _main = sys.modules["__main__"] + + if not path: + path = sys.argv[0] + + # The value of __package__ indicates how Python was called. It may + # not exist if a setuptools script is installed as an egg. It may be + # set incorrectly for entry points created with pip on Windows. + # It is set to "" inside a Shiv or PEX zipapp. + if getattr(_main, "__package__", None) in {None, ""} or ( + os.name == "nt" + and _main.__package__ == "" + and not os.path.exists(path) + and os.path.exists(f"{path}.exe") + ): + # Executed a file, like "python app.py". + return os.path.basename(path) + + # Executed a module, like "python -m example". + # Rewritten by Python from "-m script" to "/path/to/script.py". + # Need to look at main module to determine how it was executed. + py_module = cast(str, _main.__package__) + name = os.path.splitext(os.path.basename(path))[0] + + # A submodule like "example.cli". + if name != "__main__": + py_module = f"{py_module}.{name}" + + return f"python -m {py_module.lstrip('.')}" + + +def _expand_args( + args: Iterable[str], + *, + user: bool = True, + env: bool = True, + glob_recursive: bool = True, +) -> list[str]: + """Simulate Unix shell expansion with Python functions.""" + from glob import glob + + out = [] + + for arg in args: + if user: + arg = os.path.expanduser(arg) + + if env: + arg = os.path.expandvars(arg) + + try: + matches = glob(arg, recursive=glob_recursive) + except re.error: # pragma: no cover + matches = [] + + if not matches: + out.append(arg) + else: + out.extend(matches) + + return out diff --git a/typer/_completion_classes.py b/typer/_completion_classes.py index 8548fb4d6a..cfae02c9bf 100644 --- a/typer/_completion_classes.py +++ b/typer/_completion_classes.py @@ -4,11 +4,9 @@ import sys from typing import Any -import click -import click.parser -import click.shell_completion -from click.shell_completion import split_arg_string as click_split_arg_string - +from . import _click +from ._click.shell_completion import CompletionItem, ShellComplete, add_completion_class +from ._click.shell_completion import split_arg_string as click_split_arg_string from ._completion_shared import ( COMPLETION_SCRIPT_BASH, COMPLETION_SCRIPT_FISH, @@ -27,7 +25,7 @@ def _sanitize_help_text(text: str) -> str: return rich_utils.rich_render_text(text) -class BashComplete(click.shell_completion.BashComplete): +class BashComplete(ShellComplete): name = Shells.bash.value source_template = COMPLETION_SCRIPT_BASH @@ -50,7 +48,7 @@ def get_completion_args(self) -> tuple[list[str], str]: return args, incomplete - def format_completion(self, item: click.shell_completion.CompletionItem) -> str: + def format_completion(self, item: CompletionItem) -> str: # TODO: Explore replicating the new behavior from Click, with item types and # triggering completion for files and directories # return f"{item.type},{item.value}" @@ -62,8 +60,42 @@ def complete(self) -> str: out = [self.format_completion(item) for item in completions] return "\n".join(out) + @staticmethod + def _check_version() -> None: + import shutil + import subprocess + + bash_exe = shutil.which("bash") + + if bash_exe is None: + match = None # pragma: no cover + else: + output = subprocess.run( + [bash_exe, "--norc", "-c", 'echo "${BASH_VERSION}"'], + stdout=subprocess.PIPE, + ) + match = re.search(r"^(\d+)\.(\d+)\.\d+", output.stdout.decode()) + + if match is not None: + major, minor = match.groups() + + if major < "4" or major == "4" and minor < "4": + _click.utils.echo( + "Shell completion is not supported for Bash versions older than 4.4.", + err=True, + ) + else: + _click.utils.echo( + "Couldn't detect Bash version, shell completion is not supported.", + err=True, + ) # pragma: no cover + + def source(self) -> str: + self._check_version() + return super().source() + -class ZshComplete(click.shell_completion.ZshComplete): +class ZshComplete(ShellComplete): name = Shells.zsh.value source_template = COMPLETION_SCRIPT_ZSH @@ -85,7 +117,7 @@ def get_completion_args(self) -> tuple[list[str], str]: incomplete = "" return args, incomplete - def format_completion(self, item: click.shell_completion.CompletionItem) -> str: + def format_completion(self, item: CompletionItem) -> str: def escape(s: str) -> str: return ( s.replace('"', '""') @@ -114,7 +146,7 @@ def complete(self) -> str: return "_files" -class FishComplete(click.shell_completion.FishComplete): +class FishComplete(ShellComplete): name = Shells.fish.value source_template = COMPLETION_SCRIPT_FISH @@ -136,7 +168,7 @@ def get_completion_args(self) -> tuple[list[str], str]: incomplete = "" return args, incomplete - def format_completion(self, item: click.shell_completion.CompletionItem) -> str: + def format_completion(self, item: CompletionItem) -> str: # TODO: Explore replicating the new behavior from Click, pay attention to # the difference with and without formatted help # if item.help: @@ -167,7 +199,7 @@ def complete(self) -> str: return "" # pragma: no cover -class PowerShellComplete(click.shell_completion.ShellComplete): +class PowerShellComplete(ShellComplete): name = Shells.powershell.value source_template = COMPLETION_SCRIPT_POWER_SHELL @@ -185,15 +217,13 @@ def get_completion_args(self) -> tuple[list[str], str]: args = cwords[1:-1] if incomplete else cwords[1:] return args, incomplete - def format_completion(self, item: click.shell_completion.CompletionItem) -> str: + def format_completion(self, item: CompletionItem) -> str: return f"{item.value}:::{_sanitize_help_text(item.help) if item.help else ' '}" def completion_init() -> None: - click.shell_completion.add_completion_class(BashComplete, Shells.bash.value) - click.shell_completion.add_completion_class(ZshComplete, Shells.zsh.value) - click.shell_completion.add_completion_class(FishComplete, Shells.fish.value) - click.shell_completion.add_completion_class( - PowerShellComplete, Shells.powershell.value - ) - click.shell_completion.add_completion_class(PowerShellComplete, Shells.pwsh.value) + add_completion_class(BashComplete, Shells.bash.value) + add_completion_class(ZshComplete, Shells.zsh.value) + add_completion_class(FishComplete, Shells.fish.value) + add_completion_class(PowerShellComplete, Shells.powershell.value) + add_completion_class(PowerShellComplete, Shells.pwsh.value) diff --git a/typer/_completion_shared.py b/typer/_completion_shared.py index 5a81dcf68c..8d2c19715c 100644 --- a/typer/_completion_shared.py +++ b/typer/_completion_shared.py @@ -4,9 +4,11 @@ from enum import Enum from pathlib import Path -import click import shellingham +from . import _click +from ._click.globals import get_current_context + class Shells(str, Enum): bash = "bash" @@ -78,8 +80,8 @@ def get_completion_script(*, prog_name: str, complete_var: str, shell: str) -> s cf_name = _invalid_ident_char_re.sub("", prog_name.replace("-", "_")) script = _completion_scripts.get(shell) if script is None: - click.echo(f"Shell {shell} not supported.", err=True) - raise click.exceptions.Exit(1) + _click.echo(f"Shell {shell} not supported.", err=True) + raise _click.exceptions.Exit(1) return ( script % { @@ -172,8 +174,8 @@ def install_powershell(*, prog_name: str, complete_var: str, shell: str) -> Path stdout=subprocess.PIPE, ) if result.returncode != 0: # pragma: no cover - click.echo("Couldn't get PowerShell user profile", err=True) - raise click.exceptions.Exit(result.returncode) + _click.echo("Couldn't get PowerShell user profile", err=True) + raise _click.exceptions.Exit(result.returncode) path_str = "" if isinstance(result.stdout, str): # pragma: no cover path_str = result.stdout @@ -185,8 +187,8 @@ def install_powershell(*, prog_name: str, complete_var: str, shell: str) -> Path except UnicodeDecodeError: # pragma: no cover pass if not path_str: # pragma: no cover - click.echo("Couldn't decode the path automatically", err=True) - raise click.exceptions.Exit(1) + _click.echo("Couldn't decode the path automatically", err=True) + raise _click.exceptions.Exit(1) path_obj = Path(path_str.strip()) parent_dir: Path = path_obj.parent parent_dir.mkdir(parents=True, exist_ok=True) @@ -203,7 +205,7 @@ def install( prog_name: str | None = None, complete_var: str | None = None, ) -> tuple[str, Path]: - prog_name = prog_name or click.get_current_context().find_root().info_name + prog_name = prog_name or get_current_context().find_root().info_name assert prog_name if complete_var is None: complete_var = "_{}_COMPLETE".format(prog_name.replace("-", "_").upper()) @@ -231,8 +233,8 @@ def install( ) return shell, installed_path else: - click.echo(f"Shell {shell} is not supported.") - raise click.exceptions.Exit(1) + _click.echo(f"Shell {shell} is not supported.") + raise _click.exceptions.Exit(1) def _get_shell_name() -> str | None: diff --git a/typer/_types.py b/typer/_types.py index dc9fc63220..09b38afb3f 100644 --- a/typer/_types.py +++ b/typer/_types.py @@ -1,21 +1,42 @@ +from collections.abc import Iterable, Mapping, Sequence from enum import Enum -from typing import TypeVar +from typing import Any, Generic, TypeVar -import click +from . import _click +from ._click import types +from ._click.shell_completion import CompletionItem ParamTypeValue = TypeVar("ParamTypeValue") -class TyperChoice(click.Choice[ParamTypeValue]): +class TyperChoice(types.ParamType, Generic[ParamTypeValue]): + # Code adapted from Click 8.3.1, with Typer using enum values in normalize_choice + name = "choice" + + def __init__( + self, choices: Iterable[ParamTypeValue], case_sensitive: bool = True + ) -> None: + self.choices: Sequence[ParamTypeValue] = tuple(choices) + self.case_sensitive = case_sensitive + + def _normalized_mapping( + self, ctx: _click.Context | None = None + ) -> Mapping[ParamTypeValue, str]: + """ + Returns mapping where keys are the original choices and the values are + the normalized values that are accepted via the command line. + """ + return { + choice: self.normalize_choice( + choice=choice, + ctx=ctx, + ) + for choice in self.choices + } + def normalize_choice( - self, choice: ParamTypeValue, ctx: click.Context | None + self, choice: ParamTypeValue, ctx: _click.Context | None ) -> str: - # Click 8.2.0 added a new method `normalize_choice` to the `Choice` class - # to support enums, but it uses the enum names, while Typer has always used the - # enum values. - # This class overrides that method to maintain the previous behavior. - # In Click: - # normed_value = choice.name if isinstance(choice, Enum) else str(choice) normed_value = str(choice.value) if isinstance(choice, Enum) else str(choice) if ctx is not None and ctx.token_normalize_func is not None: @@ -25,3 +46,75 @@ def normalize_choice( normed_value = normed_value.casefold() return normed_value + + def get_metavar(self, param: _click.Parameter, ctx: _click.Context) -> str | None: + if param.param_type_name == "option" and not param.show_choices: # type: ignore + choice_metavars = [ + types.convert_type(type(choice)).name.upper() for choice in self.choices + ] + choices_str = "|".join([*dict.fromkeys(choice_metavars)]) + else: + choices_str = "|".join( + [str(i) for i in self._normalized_mapping(ctx=ctx).values()] + ) + + # Use curly braces to indicate a required argument. + if param.required and param.param_type_name == "argument": + return f"{{{choices_str}}}" + + # Use square braces to indicate an option or optional argument. + return f"[{choices_str}]" + + def get_missing_message( + self, param: _click.Parameter, ctx: _click.Context | None + ) -> str: + """Message shown when no choice is passed.""" + choices = ",\n\t".join(self._normalized_mapping(ctx=ctx).values()) + return f"Choose from:\n\t{choices}" + + def convert( + self, value: Any, param: _click.Parameter | None, ctx: _click.Context | None + ) -> ParamTypeValue: + """ + For a given value from the parser, normalize it and find its + matching normalized value in the list of choices. Then return the + matched "original" choice. + """ + normed_value = self.normalize_choice(choice=value, ctx=ctx) + normalized_mapping = self._normalized_mapping(ctx=ctx) + + try: + return next( + original + for original, normalized in normalized_mapping.items() + if normalized == normed_value + ) + except StopIteration: + self.fail( + self.get_invalid_choice_message(value=value, ctx=ctx), + param=param, + ctx=ctx, + ) + + def get_invalid_choice_message(self, value: Any, ctx: _click.Context | None) -> str: + """Get the error message when the given choice is invalid.""" + choices_str = ", ".join(map(repr, self._normalized_mapping(ctx=ctx).values())) + return f"{value!r} is not one of {choices_str}." + + def __repr__(self) -> str: + return f"Choice({list(self.choices)})" + + def shell_complete( + self, ctx: _click.Context, param: _click.Parameter, incomplete: str + ) -> list[CompletionItem]: + """Complete choices that start with the incomplete value.""" + + str_choices = map(str, self.choices) + + if self.case_sensitive: + matched = (c for c in str_choices if c.startswith(incomplete)) + else: + incomplete = incomplete.lower() + matched = (c for c in str_choices if c.lower().startswith(incomplete)) + + return [CompletionItem(c) for c in matched] diff --git a/typer/cli.py b/typer/cli.py index 2a7d78c3a4..665bcf5a59 100644 --- a/typer/cli.py +++ b/typer/cli.py @@ -4,13 +4,12 @@ from pathlib import Path from typing import Any -import click import typer import typer.core -from click import Command, Group, Option -from . import __version__ -from .core import HAS_RICH, MARKUP_MODE_KEY +from . import __version__, _click +from ._click import Command +from .core import HAS_RICH, MARKUP_MODE_KEY, TyperGroup, TyperOption default_app_names = ("app", "cli", "main") default_func_names = ("main", "cli", "app") @@ -31,7 +30,7 @@ def __init__(self) -> None: state = State() -def maybe_update_state(ctx: click.Context) -> None: +def maybe_update_state(ctx: _click.Context) -> None: path_or_module = ctx.params.get("path_or_module") if path_or_module: file_path = Path(path_or_module) @@ -53,19 +52,19 @@ def maybe_update_state(ctx: click.Context) -> None: class TyperCLIGroup(typer.core.TyperGroup): - def list_commands(self, ctx: click.Context) -> list[str]: + def list_commands(self, ctx: _click.Context) -> list[str]: self.maybe_add_run(ctx) return super().list_commands(ctx) - def get_command(self, ctx: click.Context, name: str) -> Command | None: # ty: ignore[invalid-method-override] + def get_command(self, ctx: _click.Context, name: str) -> Command | None: # ty: ignore[invalid-method-override] self.maybe_add_run(ctx) return super().get_command(ctx, name) - def invoke(self, ctx: click.Context) -> Any: + def invoke(self, ctx: _click.Context) -> Any: self.maybe_add_run(ctx) return super().invoke(ctx) - def maybe_add_run(self, ctx: click.Context) -> None: + def maybe_add_run(self, ctx: _click.Context) -> None: maybe_update_state(ctx) maybe_add_run_to_cli(self) @@ -138,7 +137,7 @@ def get_typer_from_state() -> typer.Typer | None: return obj -def maybe_add_run_to_cli(cli: click.Group) -> None: +def maybe_add_run_to_cli(cli: TyperGroup) -> None: if "run" not in cli.commands: if state.file or state.module: obj = get_typer_from_state() @@ -151,7 +150,7 @@ def maybe_add_run_to_cli(cli: click.Group) -> None: cli.add_command(click_obj) -def print_version(ctx: click.Context, param: Option, value: bool) -> None: +def print_version(ctx: _click.Context, param: TyperOption, value: bool) -> None: if not value or ctx.resilient_parsing: return typer.echo(f"Typer version: {__version__}") @@ -242,7 +241,7 @@ def get_docs_for_click( docs += "\n" if obj.epilog: docs += f"{obj.epilog}\n\n" - if isinstance(obj, Group): + if isinstance(obj, TyperGroup): group = obj commands = group.list_commands(ctx) if commands: diff --git a/typer/completion.py b/typer/completion.py index 0d621e411d..f63692ddf3 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -3,8 +3,8 @@ from collections.abc import MutableMapping from typing import Any -import click - +from . import _click +from ._click import shell_completion from ._completion_classes import completion_init from ._completion_shared import Shells, _get_shell_name, get_completion_script, install from .models import ParamMeta @@ -27,19 +27,19 @@ def get_completion_inspect_parameters() -> tuple[ParamMeta, ParamMeta]: return install_param, show_param -def install_callback(ctx: click.Context, param: click.Parameter, value: Any) -> Any: +def install_callback(ctx: _click.Context, param: _click.Parameter, value: Any) -> Any: if not value or ctx.resilient_parsing: return value # pragma: no cover if isinstance(value, str): shell, path = install(shell=value) else: shell, path = install() - click.secho(f"{shell} completion installed in {path}", fg="green") - click.echo("Completion will take effect once you restart the terminal") + _click.termui.secho(f"{shell} completion installed in {path}", fg="green") + _click.echo("Completion will take effect once you restart the terminal") sys.exit(0) -def show_callback(ctx: click.Context, param: click.Parameter, value: Any) -> Any: +def show_callback(ctx: _click.Context, param: _click.Parameter, value: Any) -> Any: if not value or ctx.resilient_parsing: return value # pragma: no cover prog_name = ctx.find_root().info_name @@ -56,7 +56,7 @@ def show_callback(ctx: click.Context, param: click.Parameter, value: Any) -> Any script_content = get_completion_script( prog_name=prog_name, complete_var=complete_var, shell=shell ) - click.echo(script_content) + _click.echo(script_content) sys.exit(0) @@ -103,17 +103,16 @@ def _install_completion_no_auto_placeholder_function( # And to add extra error messages, for compatibility with Typer in previous versions # This is only called in new Command method, only used by Click 8.x+ def shell_complete( - cli: click.Command, + cli: _click.Command, ctx_args: MutableMapping[str, Any], prog_name: str, complete_var: str, instruction: str, ) -> int: - import click - import click.shell_completion + from . import _click if "_" not in instruction: - click.echo("Invalid completion instruction.", err=True) + _click.echo("Invalid completion instruction.", err=True) return 1 # Click 8 changed the order/style of shell instructions from e.g. @@ -124,23 +123,23 @@ def shell_complete( instruction, _, shell = instruction.partition("_") # Typer override end - comp_cls = click.shell_completion.get_completion_class(shell) + comp_cls = shell_completion.get_completion_class(shell) if comp_cls is None: - click.echo(f"Shell {shell} not supported.", err=True) + _click.echo(f"Shell {shell} not supported.", err=True) return 1 comp = comp_cls(cli, ctx_args, prog_name, complete_var) if instruction == "source": - click.echo(comp.source()) + _click.echo(comp.source()) return 0 # Typer override to print the completion help msg with Rich if instruction == "complete": - click.echo(comp.complete()) + _click.echo(comp.complete()) return 0 # Typer override end - click.echo(f'Completion instruction "{instruction}" not supported.', err=True) + _click.echo(f'Completion instruction "{instruction}" not supported.', err=True) return 1 diff --git a/typer/core.py b/typer/core.py index 48fee64e34..6868ab4355 100644 --- a/typer/core.py +++ b/typer/core.py @@ -2,7 +2,7 @@ import inspect import os import sys -from collections.abc import Callable, MutableMapping, Sequence +from collections.abc import Callable, Mapping, MutableMapping, Sequence from difflib import get_close_matches from enum import Enum from gettext import gettext as _ @@ -13,13 +13,10 @@ cast, ) -import click -import click.core -import click.formatting -import click.shell_completion -import click.types -import click.utils - +from . import _click +from ._click import types +from ._click.parser import _OptionParser +from ._click.shell_completion import CompletionItem from ._typing import Literal from .utils import parse_boolean_env_var @@ -34,7 +31,7 @@ DEFAULT_MARKUP_MODE = None -# Copy from click.parser._split_opt +# Copy from _click.parser._split_opt def _split_opt(opt: str) -> tuple[str, str]: first = opt[:1] if first.isalnum(): @@ -45,10 +42,10 @@ def _split_opt(opt: str) -> tuple[str, str]: def _typer_param_setup_autocompletion_compat( - self: click.Parameter, + self: _click.Parameter, *, autocompletion: Callable[ - [click.Context, list[str], str], list[tuple[str, str] | str] + [_click.Context, list[str], str], list[tuple[str, str] | str] ] | None = None, ) -> None: @@ -65,10 +62,8 @@ def _typer_param_setup_autocompletion_compat( if autocompletion is not None: def compat_autocompletion( - ctx: click.Context, param: click.core.Parameter, incomplete: str - ) -> list["click.shell_completion.CompletionItem"]: - from click.shell_completion import CompletionItem - + ctx: _click.Context, param: _click.core.Parameter, incomplete: str + ) -> list[CompletionItem]: out = [] for c in autocompletion(ctx, [], incomplete): @@ -89,11 +84,11 @@ def compat_autocompletion( def _get_default_string( obj: Union["TyperArgument", "TyperOption"], *, - ctx: click.Context, + ctx: _click.Context, show_default_is_str: bool, default_value: list[Any] | tuple[Any, ...] | str | Callable[..., Any] | Any, ) -> str: - # Extracted from click.core.Option.get_help_record() to be reused by + # Extracted from _click.core.Option.get_help_record() to be reused by # rich_utils avoiding RegEx hacks if show_default_is_str: default_string = f"({obj.show_default})" @@ -112,7 +107,7 @@ def _get_default_string( # For boolean flags that have distinct True/False opts, # use the opt without prefix instead of the value. # Typer override, original commented - # default_string = click.parser.split_opt( + # default_string = _click.parser.split_opt( # (self.opts if self.default else self.secondary_opts)[0] # )[1] if obj.default: @@ -136,9 +131,9 @@ def _get_default_string( def _extract_default_help_str( - obj: Union["TyperArgument", "TyperOption"], *, ctx: click.Context + obj: Union["TyperArgument", "TyperOption"], *, ctx: _click.Context ) -> Any | Callable[[], Any] | None: - # Extracted from click.core.Option.get_help_record() to be reused by + # Extracted from _click.core.Option.get_help_record() to be reused by # rich_utils avoiding RegEx hacks # Temporarily enable resilient parsing to avoid type casting # failing for the default. Might be possible to extend this to @@ -154,7 +149,7 @@ def _extract_default_help_str( def _main( - self: click.Command, + self: _click.Command, *, args: Sequence[str] | None = None, prog_name: str | None = None, @@ -164,7 +159,7 @@ def _main( rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, **extra: Any, ) -> Any: - # Typer override, duplicated from click.main() to handle custom rich exceptions + # Typer override, duplicated from _click.main() to handle custom rich exceptions # Verify that the environment is configured correctly, or reject # further execution to avoid a broken script. if args is None: @@ -172,12 +167,12 @@ def _main( # Covered in Click tests if os.name == "nt" and windows_expand_args: # pragma: no cover - args = click.utils._expand_args(args) + args = _click.utils._expand_args(args) else: args = list(args) if prog_name is None: - prog_name = click.utils._detect_program_name() + prog_name = _click.utils._detect_program_name() # Process shell completion requests and exit early. self._main_shell_completion(extra, prog_name, complete_var) @@ -197,11 +192,11 @@ def _main( # by its truthiness/falsiness ctx.exit() except EOFError as e: - click.echo(file=sys.stderr) - raise click.Abort() from e + _click.echo(file=sys.stderr) + raise _click.exceptions.Abort() from e except KeyboardInterrupt as e: - raise click.exceptions.Exit(130) from e - except click.ClickException as e: + raise _click.exceptions.Exit(130) from e + except _click.exceptions.ClickException as e: if not standalone_mode: raise # Typer override @@ -215,12 +210,12 @@ def _main( sys.exit(e.exit_code) except OSError as e: if e.errno == errno.EPIPE: - sys.stdout = cast(TextIO, click.utils.PacifyFlushWrapper(sys.stdout)) - sys.stderr = cast(TextIO, click.utils.PacifyFlushWrapper(sys.stderr)) + sys.stdout = cast(TextIO, _click.utils.PacifyFlushWrapper(sys.stdout)) + sys.stderr = cast(TextIO, _click.utils.PacifyFlushWrapper(sys.stderr)) sys.exit(1) else: raise - except click.exceptions.Exit as e: + except _click.exceptions.Exit as e: if standalone_mode: sys.exit(e.exit_code) else: @@ -233,7 +228,7 @@ def _main( # `ctx.exit(1)` and to `return 1`, the caller won't be able to # tell the difference between the two return e.exit_code - except click.Abort: + except _click.exceptions.Abort: if not standalone_mode: raise # Typer override @@ -242,19 +237,21 @@ def _main( rich_utils.rich_abort_error() else: - click.echo(_("Aborted!"), file=sys.stderr) + _click.echo(_("Aborted!"), file=sys.stderr) # Typer override end sys.exit(1) -class TyperArgument(click.core.Argument): +class TyperArgument(_click.core.Parameter): + param_type_name = "argument" + def __init__( self, *, # Parameter param_decls: list[str], type: Any | None = None, - required: bool | None = None, + required: bool = False, default: Any | None = None, callback: Callable[..., Any] | None = None, nargs: int | None = None, @@ -265,8 +262,8 @@ def __init__( # Note that shell_complete is not fully supported and will be removed in future versions # TODO: Remove shell_complete in a future version (after 0.16.0) shell_complete: Callable[ - [click.Context, click.Parameter, str], - list["click.shell_completion.CompletionItem"] | list[str], + [_click.Context, _click.Parameter, str], + list[CompletionItem] | list[str], ] | None = None, autocompletion: Callable[..., Any] | None = None, @@ -301,10 +298,17 @@ def __init__( ) _typer_param_setup_autocompletion_compat(self, autocompletion=autocompletion) + @property + def human_readable_name(self) -> str: + if self.metavar is not None: + return self.metavar + assert self.name is not None, "self.name or self.metavar should be set" + return self.name.upper() + def _get_default_string( self, *, - ctx: click.Context, + ctx: _click.Context, show_default_is_str: bool, default_value: list[Any] | tuple[Any, ...] | str | Callable[..., Any] | Any, ) -> str: @@ -316,12 +320,12 @@ def _get_default_string( ) def _extract_default_help_str( - self, *, ctx: click.Context + self, *, ctx: _click.Context ) -> Any | Callable[[], Any] | None: return _extract_default_help_str(self, ctx=ctx) - def get_help_record(self, ctx: click.Context) -> tuple[str, str] | None: - # Modified version of click.core.Option.get_help_record() + def get_help_record(self, ctx: _click.Context) -> tuple[str, str] | None: + # Modified version of _click.core.Option.get_help_record() # to support Arguments if self.hidden: return None @@ -376,8 +380,8 @@ def get_help_record(self, ctx: click.Context) -> tuple[str, str] | None: help = f"{help} {extra_str}" if help else f"{extra_str}" return name, help - def make_metavar(self, ctx: click.Context) -> str: - # Modified version of click.core.Argument.make_metavar() + def make_metavar(self, ctx: _click.Context) -> str: + # Modified version of _click.core.Argument.make_metavar() # to include Argument name if self.metavar is not None: var = self.metavar @@ -397,15 +401,45 @@ def make_metavar(self, ctx: click.Context) -> str: def value_is_missing(self, value: Any) -> bool: return _value_is_missing(self, value) + def _parse_decls( + self, decls: Sequence[str], expose_value: bool + ) -> tuple[str | None, list[str], list[str]]: + if not decls: + if not expose_value: + return None, [], [] + raise TypeError("Argument is marked as exposed, but does not have a name.") + if len(decls) == 1: + name = arg = decls[0] + name = name.replace("-", "_").lower() + else: + raise TypeError( + "Arguments take exactly one parameter declaration, got" + f" {len(decls)}: {decls}." + ) + return name, [arg], [] + + def get_usage_pieces(self, ctx: _click.Context) -> list[str]: + return [self.make_metavar(ctx)] + + def get_error_hint(self, ctx: _click.Context) -> str: + return f"'{self.make_metavar(ctx)}'" + + def add_to_parser(self, parser: _OptionParser, ctx: _click.Context) -> None: + parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) + + +class TyperOption(_click.Parameter): + param_type_name = "option" + + _depr_flag_value: bool | None -class TyperOption(click.core.Option): def __init__( self, *, # Parameter param_decls: list[str], - type: click.types.ParamType | Any | None = None, - required: bool | None = None, + type: types.ParamType | Any | None = None, + required: bool = False, default: Any | None = None, callback: Callable[..., Any] | None = None, nargs: int | None = None, @@ -416,8 +450,8 @@ def __init__( # Note that shell_complete is not fully supported and will be removed in future versions # TODO: Remove shell_complete in a future version (after 0.16.0) shell_complete: Callable[ - [click.Context, click.Parameter, str], - list["click.shell_completion.CompletionItem"] | list[str], + [_click.Context, _click.Parameter, str], + list[CompletionItem] | list[str], ] | None = None, autocompletion: Callable[..., Any] | None = None, @@ -438,9 +472,13 @@ def __init__( # Rich settings rich_help_panel: str | None = None, ): + if help: + help = inspect.cleandoc(help) + super().__init__( - param_decls=param_decls, + param_decls, type=type, + multiple=multiple, required=required, default=default, callback=callback, @@ -449,28 +487,246 @@ def __init__( expose_value=expose_value, is_eager=is_eager, envvar=envvar, - show_default=show_default, - prompt=prompt, - confirmation_prompt=confirmation_prompt, - hide_input=hide_input, - is_flag=is_flag, - multiple=multiple, - count=count, - allow_from_autoenv=allow_from_autoenv, - help=help, - hidden=hidden, - show_choices=show_choices, - show_envvar=show_envvar, - prompt_required=prompt_required, shell_complete=shell_complete, ) + + if prompt is True: + if self.name is None: + raise TypeError("'name' is required with 'prompt=True'.") + + prompt_text: str | None = self.name.replace("_", " ").capitalize() + elif prompt is False: + prompt_text = None + else: + prompt_text = prompt + + self.prompt = prompt_text + self.confirmation_prompt = confirmation_prompt + self.prompt_required = prompt_required + self.hide_input = hide_input + self.hidden = hidden + + # TODO: revisit all of this flag stuff + if is_flag and type is None: + self.type: types.ParamType = types.BoolParamType() + + self.is_flag: bool = bool(is_flag) + self.is_bool_flag: bool = bool( + is_flag and isinstance(self.type, types.BoolParamType) + ) + + if self.is_flag: + self._depr_flag_value = True + else: + self._depr_flag_value = None + + # Counting. TODO: test or remove? Not currently in coverage. + self.count = count + if count and type is None: + self.type = types.IntRange(min=0) + + self.allow_from_autoenv = allow_from_autoenv + self.help = help + self.show_default = show_default + self.show_choices = show_choices + self.show_envvar = show_envvar + _typer_param_setup_autocompletion_compat(self, autocompletion=autocompletion) self.rich_help_panel = rich_help_panel + def get_error_hint(self, ctx: _click.Context) -> str: + result = super().get_error_hint(ctx) + if self.show_envvar and self.envvar is not None: + result += f" (env var: '{self.envvar}')" + return result + + def _parse_decls( + self, decls: Sequence[str], expose_value: bool + ) -> tuple[str | None, list[str], list[str]]: + opts = [] + secondary_opts = [] + name = None + possible_names = [] + + for decl in decls: + if decl.isidentifier(): + if name is not None: + raise TypeError(f"Name '{name}' defined twice") + name = decl + else: + split_char = ";" if decl[:1] == "/" else "/" + if split_char in decl: + first, second = decl.split(split_char, 1) + first = first.rstrip() + if first: + possible_names.append(_split_opt(first)) + opts.append(first) + second = second.lstrip() + if second: + secondary_opts.append(second.lstrip()) + if first == second: + raise ValueError( + f"Boolean option {decl!r} cannot use the" + " same flag for true/false." + ) + else: + possible_names.append(_split_opt(decl)) + opts.append(decl) + + if name is None and possible_names: + possible_names.sort(key=lambda x: -len(x[0])) # group long options first + name = possible_names[0][1].replace("-", "_").lower() + if not name.isidentifier(): + name = None + + return name, opts, secondary_opts + + def add_to_parser(self, parser: _OptionParser, ctx: _click.Context) -> None: + if self.multiple: + action = "append" + elif self.count: + action = "count" + else: + action = "store" + + if self.is_flag: + action = f"{action}_const" + + if self.is_bool_flag and self.secondary_opts: + parser.add_option( + obj=self, opts=self.opts, dest=self.name, action=action, const=True + ) + parser.add_option( + obj=self, + opts=self.secondary_opts, + dest=self.name, + action=action, + const=False, + ) + else: + parser.add_option( + obj=self, + opts=self.opts, + dest=self.name, + action=action, + const=self._depr_flag_value, + ) + else: + parser.add_option( + obj=self, + opts=self.opts, + dest=self.name, + action=action, + nargs=self.nargs, + ) + + def prompt_for_value(self, ctx: _click.Context) -> Any: + """This is an alternative flow that can be activated in the full + value processing if a value does not exist. It will prompt the + user until a valid value exists and then returns the processed + value as result. + """ + assert self.prompt is not None + + # Calculate the default before prompting anything to lock in the value before + # attempting any user interaction. + default = self.get_default(ctx) + + # A boolean flag can use a simplified [y/n] confirmation prompt. + if self.is_bool_flag: + # Nothing prevent you to declare an option that is simultaneously: + # 1) auto-detected as a boolean flag, + # 2) allowed to prompt, and + # 3) still declare a non-boolean default. + # This forced casting into a boolean is necessary to align any non-boolean + # default to the prompt, which is going to be a [y/n]-style confirmation + # because the option is still a boolean flag. That way, instead of [y/n], + # we get [Y/n] or [y/N] depending on the truthy value of the default. + # Refs: https://github.com/pallets/click/pull/3030#discussion_r2289180249 + if default is not None: + default = bool(default) + return _click.termui.confirm(self.prompt, default) + + # If show_default is set to True/False, provide this to `prompt` as well. For + # non-bool values of `show_default`, we use `prompt`'s default behavior + prompt_kwargs: Any = {} + if isinstance(self.show_default, bool): + prompt_kwargs["show_default"] = self.show_default + + return _click.termui.prompt( + self.prompt, + # Use ``None`` to inform the prompt() function to reiterate until a valid + # value is provided by the user if we have no default. + default=default, + type=self.type, + hide_input=self.hide_input, + show_choices=self.show_choices, + confirmation_prompt=self.confirmation_prompt, + value_proc=lambda x: self.process_value(ctx, x), + **prompt_kwargs, + ) + + def value_from_envvar(self, ctx: _click.Context) -> Any: + # TODO: clean up + rv = self.resolve_envvar_value(ctx) + + # Absent environment variable or an empty string is interpreted as unset. + if rv is None: + return None + + if self.nargs != 1 or self.multiple: + return self.type.split_envvar_value(rv) + + return rv + + def resolve_envvar_value(self, ctx: _click.Context) -> str | None: + rv = super().resolve_envvar_value(ctx) + + if rv is not None: + return rv + + if ( + self.allow_from_autoenv + and ctx.auto_envvar_prefix is not None + and self.name is not None + ): + envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" + rv = os.environ.get(envvar) + + if rv: + return rv + + return None + + def consume_value( + self, ctx: _click.Context, opts: Mapping[str, _click.Parameter] + ) -> tuple[Any, _click.core.ParameterSource]: + """For `Option`, the value can be collected from an interactive prompt + if the option is a flag that needs a value (and the `prompt` property is + set). + + Additionally, this method handles flag option that are activated without a + value, in which case the `flag_value` is returned. + """ + value, source = super().consume_value(ctx, opts) + + # The value wasn't set, or used the param's default, prompt for one to the user + # if prompting is enabled. + if ( + source in {None, _click.core.ParameterSource.DEFAULT} + and self.prompt is not None + and (self.required or self.prompt_required) + and not ctx.resilient_parsing + ): + value = self.prompt_for_value(ctx) + source = _click.core.ParameterSource.PROMPT + + return value, source + def _get_default_string( self, *, - ctx: click.Context, + ctx: _click.Context, show_default_is_str: bool, default_value: list[Any] | tuple[Any, ...] | str | Callable[..., Any] | Any, ) -> str: @@ -482,14 +738,14 @@ def _get_default_string( ) def _extract_default_help_str( - self, *, ctx: click.Context + self, *, ctx: _click.Context ) -> Any | Callable[[], Any] | None: return _extract_default_help_str(self, ctx=ctx) - def make_metavar(self, ctx: click.Context) -> str: + def make_metavar(self, ctx: _click.Context) -> str: return super().make_metavar(ctx=ctx) - def get_help_record(self, ctx: click.Context) -> tuple[str, str] | None: + def get_help_record(self, ctx: _click.Context) -> tuple[str, str] | None: # Duplicate all of Click's logic only to modify a single line, to allow boolean # flags with only names for False values as it's currently supported by Typer # Ref: https://typer.tiangolo.com/tutorial/parameter-types/bool/#only-names-for-false @@ -501,7 +757,7 @@ def get_help_record(self, ctx: click.Context) -> tuple[str, str] | None: def _write_opts(opts: Sequence[str]) -> str: nonlocal any_prefix_is_slash - rv, any_slashes = click.formatting.join_options(opts) + rv, any_slashes = _click.formatting.join_options(opts) if any_slashes: any_prefix_is_slash = True @@ -559,7 +815,7 @@ def _write_opts(opts: Sequence[str]) -> str: if default_string: extra.append(_("default: {default}").format(default=default_string)) - if isinstance(self.type, click.types._NumberRangeBase): + if isinstance(self.type, types._NumberRangeBase): range_str = self.type._describe_range() if range_str: @@ -588,14 +844,10 @@ def value_is_missing(self, value: Any) -> bool: return _value_is_missing(self, value) -def _value_is_missing(param: click.Parameter, value: Any) -> bool: +def _value_is_missing(param: _click.Parameter, value: Any) -> bool: if value is None: return True - # Click 8.3 and beyond - # if value is UNSET: - # return True - if (param.nargs != 1 or param.multiple) and value == (): return True # pragma: no cover @@ -603,7 +855,7 @@ def _value_is_missing(param: click.Parameter, value: Any) -> bool: def _typer_format_options( - self: click.core.Command, *, ctx: click.Context, formatter: click.HelpFormatter + self: _click.core.Command, *, ctx: _click.Context, formatter: _click.HelpFormatter ) -> None: args = [] opts = [] @@ -624,7 +876,7 @@ def _typer_format_options( def _typer_main_shell_completion( - self: click.core.Command, + self: _click.core.Command, *, ctx_args: MutableMapping[str, Any], prog_name: str, @@ -644,14 +896,14 @@ def _typer_main_shell_completion( sys.exit(rv) -class TyperCommand(click.core.Command): +class TyperCommand(_click.core.Command): def __init__( self, name: str | None, *, context_settings: dict[str, Any] | None = None, callback: Callable[..., Any] | None = None, - params: list[click.Parameter] | None = None, + params: list[_click.Parameter] | None = None, help: str | None = None, epilog: str | None = None, short_help: str | None = None, @@ -682,7 +934,7 @@ def __init__( self.rich_help_panel = rich_help_panel def format_options( - self, ctx: click.Context, formatter: click.HelpFormatter + self, ctx: _click.Context, formatter: _click.HelpFormatter ) -> None: _typer_format_options(self, ctx=ctx, formatter=formatter) @@ -716,7 +968,7 @@ def main( **extra, ) - def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + def format_help(self, ctx: _click.Context, formatter: _click.HelpFormatter) -> None: if not HAS_RICH or self.rich_markup_mode is None: if not hasattr(ctx, "obj") or ctx.obj is None: ctx.ensure_object(dict) @@ -732,25 +984,153 @@ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> Non ) -class TyperGroup(click.core.Group): +class TyperGroup(_click.Command): + allow_extra_args = True + allow_interspersed_args = False + command_class: type[_click.Command] | None = None + group_class: type["TyperGroup"] | type[type] | None = None + def __init__( self, *, name: str | None = None, - commands: dict[str, click.Command] | Sequence[click.Command] | None = None, + commands: dict[str, _click.Command] | Sequence[_click.Command] | None = None, # Rich settings rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, rich_help_panel: str | None = None, suggest_commands: bool = True, + # Click settings + invoke_without_command: bool = False, + no_args_is_help: bool = False, + subcommand_metavar: str | None = None, + result_callback: Callable[..., Any] | None = None, **attrs: Any, ) -> None: - super().__init__(name=name, commands=commands, **attrs) + super().__init__(name=name, **attrs) self.rich_markup_mode: MarkupMode = rich_markup_mode self.rich_help_panel = rich_help_panel self.suggest_commands = suggest_commands + # copied from Click's init + if commands is None: + commands = {} + elif isinstance(commands, Sequence): + commands = { + c.name: c + for c in commands + if isinstance(c, _click.Command) and c.name is not None + } + + self.commands = cast(MutableMapping[str, _click.Command], commands) + self.no_args_is_help = no_args_is_help + self.invoke_without_command = invoke_without_command + + if subcommand_metavar is None: + subcommand_metavar = "COMMAND [ARGS]..." + + self.subcommand_metavar = subcommand_metavar + self._result_callback = result_callback + + def add_command(self, cmd: _click.Command, name: str | None = None) -> None: + name = name or cmd.name + if name is None: + raise TypeError("Command has no name.") + self.commands[name] = cmd + + def get_command(self, ctx: _click.Context, cmd_name: str) -> _click.Command | None: + return self.commands.get(cmd_name) + + def collect_usage_pieces(self, ctx: _click.Context) -> list[str]: + rv = super().collect_usage_pieces(ctx) + rv.append(self.subcommand_metavar) + return rv + + def format_commands( + self, ctx: _click.Context, formatter: _click.HelpFormatter + ) -> None: + commands = [] + for subcommand in self.list_commands(ctx): + cmd = self.get_command(ctx, subcommand) + if cmd is None or cmd.hidden: + continue + + commands.append((subcommand, cmd)) + + # allow for 3 times the default spacing + if len(commands): + limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands) + + rows = [] + for subcommand, cmd in commands: + assert cmd is not None + help = cmd.get_short_help_str(limit) + rows.append((subcommand, help)) + + if rows: + with formatter.section(_("Commands")): + formatter.write_dl(rows) + + def parse_args(self, ctx: _click.Context, args: list[str]) -> list[str]: + if not args and self.no_args_is_help and not ctx.resilient_parsing: + raise _click.exceptions.NoArgsIsHelpError(ctx) + + rest = super().parse_args(ctx, args) + + if rest: + ctx._protected_args, ctx.args = rest[:1], rest[1:] + + return ctx.args + + def invoke(self, ctx: _click.Context) -> Any: + def _process_result(value: Any) -> Any: + if self._result_callback is not None: + value = ctx.invoke(self._result_callback, value, **ctx.params) + return value + + if not ctx._protected_args: + if self.invoke_without_command: + # No subcommand was invoked, so the result callback is + # invoked with the group return value for regular + # groups, or an empty list for chained groups. + with ctx: + rv = super().invoke(ctx) + # return _process_result([] if self.chain else rv) + return _process_result(rv) + ctx.fail(_("Missing command.")) + + # Fetch args back out + args = [*ctx._protected_args, *ctx.args] + ctx.args = [] + ctx._protected_args = [] + + # Make sure the context is entered so we do not clean up + # resources until the result processor has worked. + with ctx: + cmd_name, cmd, args = self.resolve_command(ctx, args) + assert cmd is not None + ctx.invoked_subcommand = cmd_name + super().invoke(ctx) + sub_ctx = cmd.make_context(cmd_name, args, parent=ctx) + with sub_ctx: + return _process_result(sub_ctx.command.invoke(sub_ctx)) + + def shell_complete( + self, ctx: _click.Context, incomplete: str + ) -> list[CompletionItem]: + """Return a list of completions for the incomplete value. Looks + at the names of options, subcommands, and chained + multi-commands. + """ + + results = [ + CompletionItem(name, help=command.get_short_help_str()) + for name, command in _click.core._complete_visible_commands(ctx, incomplete) + ] + results.extend(super().shell_complete(ctx, incomplete)) + return results + def format_options( - self, ctx: click.Context, formatter: click.HelpFormatter + self, ctx: _click.Context, formatter: _click.HelpFormatter ) -> None: _typer_format_options(self, ctx=ctx, formatter=formatter) self.format_commands(ctx, formatter) @@ -765,12 +1145,31 @@ def _main_shell_completion( self, ctx_args=ctx_args, prog_name=prog_name, complete_var=complete_var ) + def _click_resolve_command( + self, ctx: _click.Context, args: list[str] + ) -> tuple[str | None, _click.Command | None, list[str]]: + cmd_name = args[0] + original_cmd_name = cmd_name + + # Get the command + cmd = self.get_command(ctx, cmd_name) + + if cmd is None and ctx.token_normalize_func is not None: + cmd_name = ctx.token_normalize_func(cmd_name) + cmd = self.get_command(ctx, cmd_name) + + if cmd is None and not ctx.resilient_parsing: + if _split_opt(cmd_name)[0]: + self.parse_args(ctx, args) + ctx.fail(_("No such command {name!r}.").format(name=original_cmd_name)) + return cmd_name if cmd else None, cmd, args[1:] + def resolve_command( - self, ctx: click.Context, args: list[str] - ) -> tuple[str | None, click.Command | None, list[str]]: + self, ctx: _click.Context, args: list[str] + ) -> tuple[str | None, _click.Command | None, list[str]]: try: - return super().resolve_command(ctx, args) - except click.UsageError as e: + return self._click_resolve_command(ctx, args) + except _click.exceptions.UsageError as e: if self.suggest_commands: available_commands = list(self.commands.keys()) if available_commands and args: @@ -802,7 +1201,7 @@ def main( **extra, ) - def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + def format_help(self, ctx: _click.Context, formatter: _click.HelpFormatter) -> None: if not HAS_RICH or self.rich_markup_mode is None: return super().format_help(ctx, formatter) from . import rich_utils @@ -813,8 +1212,6 @@ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> Non markup_mode=self.rich_markup_mode, ) - def list_commands(self, ctx: click.Context) -> list[str]: - """Returns a list of subcommand names. - Note that in Click's Group class, these are sorted. - In Typer, we wish to maintain the original order of creation (cf Issue #933)""" + def list_commands(self, ctx: _click.Context) -> list[str]: + """Returns a list of subcommand names, maintaining the original order of creation (cf Issue #933)""" return [n for n, c in self.commands.items()] diff --git a/typer/main.py b/typer/main.py index ebcf639a2c..43c7a921f7 100644 --- a/typer/main.py +++ b/typer/main.py @@ -15,10 +15,12 @@ from typing import Annotated, Any from uuid import UUID -import click from annotated_doc import Doc from typer._types import TyperChoice +from . import _click +from ._click import types +from ._click.globals import get_current_context from ._typing import get_args, get_origin, is_literal_type, is_union, literal_values from .completion import get_completion_inspect_parameters from .core import ( @@ -73,7 +75,7 @@ def except_hook( _original_except_hook(exc_type, exc_value, tb) return typer_path = os.path.dirname(__file__) - click_path = os.path.dirname(click.__file__) + click_path = os.path.dirname(_click.__file__) internal_dir_names = [typer_path, click_path] exc = exc_value if HAS_RICH: @@ -107,7 +109,7 @@ def except_hook( return -def get_install_completion_arguments() -> tuple[click.Parameter, click.Parameter]: +def get_install_completion_arguments() -> tuple[_click.Parameter, _click.Parameter]: install_param, show_param = get_completion_inspect_parameters() click_install_param, _ = get_click_param(install_param) click_show_param, _ = get_click_param(show_param) @@ -1168,7 +1170,7 @@ def get_group(typer_instance: Typer) -> TyperGroup: return group -def get_command(typer_instance: Typer) -> click.Command: +def get_command(typer_instance: Typer) -> _click.Command: if typer_instance._add_completion: click_install_param, click_show_param = get_install_completion_arguments() if ( @@ -1178,7 +1180,7 @@ def get_command(typer_instance: Typer) -> click.Command: or len(typer_instance.registered_commands) > 1 ): # Create a Group - click_command: click.Command = get_group(typer_instance) + click_command: _click.Command = get_group(typer_instance) if typer_instance._add_completion: click_command.params.append(click_install_param) click_command.params.append(click_show_param) @@ -1288,7 +1290,7 @@ def get_group_from_info( assert group_info.typer_instance, ( "A Typer instance is needed to generate a Click Group" ) - commands: dict[str, click.Command] = {} + commands: dict[str, _click.Command] = {} for command_info in group_info.typer_instance.registered_commands: command = get_command_from_info( command_info=command_info, @@ -1330,7 +1332,6 @@ def get_group_from_info( invoke_without_command=solved_info.invoke_without_command, no_args_is_help=solved_info.no_args_is_help, subcommand_metavar=solved_info.subcommand_metavar, - chain=solved_info.chain, result_callback=solved_info.result_callback, context_settings=solved_info.context_settings, callback=get_callback( @@ -1362,14 +1363,14 @@ def get_command_name(name: str) -> str: def get_params_convertors_ctx_param_name_from_function( callback: Callable[..., Any] | None, -) -> tuple[list[click.Argument | click.Option], dict[str, Any], str | None]: +) -> tuple[list[TyperArgument | TyperOption], dict[str, Any], str | None]: params = [] convertors = {} context_param_name = None if callback: parameters = get_params_from_function(callback) for param_name, param in parameters.items(): - if lenient_issubclass(param.annotation, click.Context): + if lenient_issubclass(param.annotation, _click.Context): context_param_name = param_name continue click_param, convertor = get_click_param(param) @@ -1384,7 +1385,7 @@ def get_command_from_info( *, pretty_exceptions_short: bool, rich_markup_mode: MarkupMode, -) -> click.Command: +) -> _click.Command: assert command_info.callback, "A command must have a callback function" name = command_info.name or get_command_name(command_info.callback.__name__) # ty: ignore use_help = command_info.help @@ -1486,7 +1487,7 @@ def internal_convertor( def get_callback( *, callback: Callable[..., Any] | None = None, - params: Sequence[click.Parameter] = [], + params: Sequence[_click.Parameter] = [], convertors: dict[str, Callable[[str], Any]] | None = None, context_param_name: str | None = None, pretty_exceptions_short: bool, @@ -1510,7 +1511,7 @@ def wrapper(**kwargs: Any) -> Any: else: use_params[k] = v if context_param_name: - use_params[context_param_name] = click.get_current_context() + use_params[context_param_name] = get_current_context() return callback(**use_params) update_wrapper(wrapper, callback) @@ -1519,15 +1520,15 @@ def wrapper(**kwargs: Any) -> Any: def get_click_type( *, annotation: Any, parameter_info: ParameterInfo -) -> click.ParamType: +) -> types.ParamType: if parameter_info.click_type is not None: return parameter_info.click_type elif parameter_info.parser is not None: - return click.types.FuncParamType(parameter_info.parser) + return types.FuncParamType(parameter_info.parser) elif annotation is str: - return click.STRING + return types.STRING elif annotation is int: if parameter_info.min is not None or parameter_info.max is not None: min_ = None @@ -1536,24 +1537,24 @@ def get_click_type( min_ = int(parameter_info.min) if parameter_info.max is not None: max_ = int(parameter_info.max) - return click.IntRange(min=min_, max=max_, clamp=parameter_info.clamp) + return types.IntRange(min=min_, max=max_, clamp=parameter_info.clamp) else: - return click.INT + return types.INT elif annotation is float: if parameter_info.min is not None or parameter_info.max is not None: - return click.FloatRange( + return types.FloatRange( min=parameter_info.min, max=parameter_info.max, clamp=parameter_info.clamp, ) else: - return click.FLOAT + return types.FLOAT elif annotation is bool: - return click.BOOL + return types.BOOL elif annotation == UUID: - return click.UUID + return types.UUID elif annotation == datetime: - return click.DateTime(formats=parameter_info.formats) + return types.DateTime(formats=parameter_info.formats) elif ( annotation == Path or parameter_info.allow_dash @@ -1571,7 +1572,7 @@ def get_click_type( path_type=parameter_info.path_type, ) elif lenient_issubclass(annotation, FileTextWrite): - return click.File( + return types.File( mode=parameter_info.mode or "w", encoding=parameter_info.encoding, errors=parameter_info.errors, @@ -1579,7 +1580,7 @@ def get_click_type( atomic=parameter_info.atomic, ) elif lenient_issubclass(annotation, FileText): - return click.File( + return types.File( mode=parameter_info.mode or "r", encoding=parameter_info.encoding, errors=parameter_info.errors, @@ -1587,7 +1588,7 @@ def get_click_type( atomic=parameter_info.atomic, ) elif lenient_issubclass(annotation, FileBinaryRead): - return click.File( + return types.File( mode=parameter_info.mode or "rb", encoding=parameter_info.encoding, errors=parameter_info.errors, @@ -1595,7 +1596,7 @@ def get_click_type( atomic=parameter_info.atomic, ) elif lenient_issubclass(annotation, FileBinaryWrite): - return click.File( + return types.File( mode=parameter_info.mode or "wb", encoding=parameter_info.encoding, errors=parameter_info.errors, @@ -1603,17 +1604,12 @@ def get_click_type( atomic=parameter_info.atomic, ) elif lenient_issubclass(annotation, Enum): - # The custom TyperChoice is only needed for Click < 8.2.0, to parse the - # command line values matching them to the enum values. Click 8.2.0 added - # support for enum values but reading enum names. - # Passing here the list of enum values (instead of just the enum) accounts for - # Click < 8.2.0. return TyperChoice( [item.value for item in annotation], case_sensitive=parameter_info.case_sensitive, ) elif is_literal_type(annotation): - return click.Choice( + return TyperChoice( literal_values(annotation), case_sensitive=parameter_info.case_sensitive, ) @@ -1626,7 +1622,7 @@ def lenient_issubclass(cls: Any, class_or_tuple: AnyType | tuple[AnyType, ...]) def get_click_param( param: ParamMeta, -) -> tuple[click.Argument | click.Option, Any]: +) -> tuple[TyperArgument | TyperOption, Any]: # First, find out what will be: # * ParamInfo (ArgumentInfo or OptionInfo) # * default_value @@ -1784,7 +1780,7 @@ def get_click_param( ), convertor, ) - raise AssertionError("A click.Parameter should be returned") # pragma: no cover + raise AssertionError("A _click.Parameter should be returned") # pragma: no cover def get_param_callback( @@ -1800,9 +1796,9 @@ def get_param_callback( value_name = None untyped_names: list[str] = [] for param_name, param_sig in parameters.items(): - if lenient_issubclass(param_sig.annotation, click.Context): + if lenient_issubclass(param_sig.annotation, _click.Context): ctx_name = param_name - elif lenient_issubclass(param_sig.annotation, click.Parameter): + elif lenient_issubclass(param_sig.annotation, _click.Parameter): click_param_name = param_name else: untyped_names.append(param_name) @@ -1817,11 +1813,11 @@ def get_param_callback( if untyped_names: click_param_name = untyped_names.pop(0) if untyped_names: - raise click.ClickException( + raise _click.ClickException( "Too many CLI parameter callback function parameters" ) - def wrapper(ctx: click.Context, param: click.Parameter, value: Any) -> Any: + def wrapper(ctx: _click.Context, param: _click.Parameter, value: Any) -> Any: use_params: dict[str, Any] = {} if ctx_name: use_params[ctx_name] = ctx @@ -1851,7 +1847,7 @@ def get_param_completion( unassigned_params = list(parameters.values()) for param_sig in unassigned_params[:]: origin = get_origin(param_sig.annotation) - if lenient_issubclass(param_sig.annotation, click.Context): + if lenient_issubclass(param_sig.annotation, _click.Context): ctx_name = param_sig.name unassigned_params.remove(param_sig) elif lenient_issubclass(origin, list): @@ -1874,11 +1870,11 @@ def get_param_completion( # Extract value param name first if unassigned_params: show_params = " ".join([param.name for param in unassigned_params]) - raise click.ClickException( + raise _click.ClickException( f"Invalid autocompletion callback parameters: {show_params}" ) - def wrapper(ctx: click.Context, args: list[str], incomplete: str | None) -> Any: + def wrapper(ctx: _click.Context, args: list[str], incomplete: str | None) -> Any: use_params: dict[str, Any] = {} if ctx_name: use_params[ctx_name] = ctx @@ -2010,4 +2006,4 @@ def launch( return 0 else: - return click.launch(url, wait=wait, locate=locate) + return _click.launch(url, wait=wait, locate=locate) diff --git a/typer/models.py b/typer/models.py index 3285a96a24..00385c38ce 100644 --- a/typer/models.py +++ b/typer/models.py @@ -1,15 +1,20 @@ import inspect import io +import os +import stat from collections.abc import Callable, Sequence from typing import ( TYPE_CHECKING, Any, + ClassVar, Optional, TypeVar, + cast, ) -import click -import click.shell_completion +from . import _click +from ._click import types +from ._click.shell_completion import CompletionItem if TYPE_CHECKING: # pragma: no cover from .core import TyperCommand, TyperGroup @@ -23,7 +28,7 @@ Required = ... -class Context(click.Context): +class Context(_click.Context): """ The [`Context`](https://click.palletsprojects.com/en/stable/api/#click.Context) has some additional data about the current execution of your program. When declaring it in a [callback](https://typer.tiangolo.com/tutorial/options/callback-and-context/) function, @@ -153,7 +158,7 @@ def main(file: Annotated[typer.FileBinaryWrite, typer.Option()]): pass -class CallbackParam(click.Parameter): +class CallbackParam(_click.Parameter): """ In a callback function, you can declare a function parameter with type `CallbackParam` to access the specific Click [`Parameter`](https://click.palletsprojects.com/en/stable/api/#click.Parameter) object. @@ -286,15 +291,15 @@ def __init__( # Note that shell_complete is not fully supported and will be removed in future versions # TODO: Remove shell_complete in a future version (after 0.16.0) shell_complete: Callable[ - [click.Context, click.Parameter, str], - list["click.shell_completion.CompletionItem"] | list[str], + [_click.Context, _click.Parameter, str], + list["CompletionItem"] | list[str], ] | None = None, autocompletion: Callable[..., Any] | None = None, default_factory: Callable[[], Any] | None = None, # Custom type parser: Callable[[str], Any] | None = None, - click_type: click.ParamType | None = None, + click_type: types.ParamType | None = None, # TyperArgument show_default: bool | str = True, show_choices: bool = True, @@ -395,15 +400,15 @@ def __init__( # Note that shell_complete is not fully supported and will be removed in future versions # TODO: Remove shell_complete in a future version (after 0.16.0) shell_complete: Callable[ - [click.Context, click.Parameter, str], - list["click.shell_completion.CompletionItem"] | list[str], + [_click.Context, _click.Parameter, str], + list["CompletionItem"] | list[str], ] | None = None, autocompletion: Callable[..., Any] | None = None, default_factory: Callable[[], Any] | None = None, # Custom type parser: Callable[[str], Any] | None = None, - click_type: click.ParamType | None = None, + click_type: types.ParamType | None = None, # Option show_default: bool | str = True, prompt: bool | str = False, @@ -523,15 +528,15 @@ def __init__( # Note that shell_complete is not fully supported and will be removed in future versions # TODO: Remove shell_complete in a future version (after 0.16.0) shell_complete: Callable[ - [click.Context, click.Parameter, str], - list["click.shell_completion.CompletionItem"] | list[str], + [_click.Context, _click.Parameter, str], + list["CompletionItem"] | list[str], ] | None = None, autocompletion: Callable[..., Any] | None = None, default_factory: Callable[[], Any] | None = None, # Custom type parser: Callable[[str], Any] | None = None, - click_type: click.ParamType | None = None, + click_type: types.ParamType | None = None, # TyperArgument show_default: bool | str = True, show_choices: bool = True, @@ -640,11 +645,98 @@ def __init__( self.pretty_exceptions_short = pretty_exceptions_short -class TyperPath(click.Path): - # Overwrite Click's behaviour to be compatible with Typer's autocompletion system +class TyperPath(types.ParamType): + # Based originally on code from Click 8.3.1 + # Partly rewritten and added an override for shell_complete + + envvar_list_splitter: ClassVar[str] = os.path.pathsep + + def __init__( + self, + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: type[Any] | None = None, + ): + self.exists = exists + self.file_okay = file_okay + self.dir_okay = dir_okay + self.readable = readable + self.writable = writable + self.resolve_path = resolve_path + self.allow_dash = allow_dash + self.type = path_type + + if self.file_okay and not self.dir_okay: + self.name = "file" + elif self.dir_okay and not self.file_okay: + self.name = "directory" + else: + self.name = "path" + + def coerce_path_result( + self, value: str | os.PathLike[str] + ) -> str | bytes | os.PathLike[str]: + if self.type is not None and not isinstance(value, self.type): + if ( + self.type is str + ): # pragma: no cover # TODO: perhaps this branch can't be hit and should be removed + return os.fsdecode(value) + elif self.type is bytes: + return os.fsencode(value) + else: + return cast("os.PathLike[str]", self.type(value)) + + return value + + def convert( # ty: ignore[invalid-method-override] + self, + value: str | os.PathLike[str], + param: _click.Parameter | None, + ctx: Context | None, # type: ignore[override] + ) -> str | bytes | os.PathLike[str]: + rv = value + + is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-") + + if not is_dash: + if self.resolve_path: + rv = os.path.realpath(rv) + + try: + st = os.stat(rv) + except OSError: + if not self.exists: + return self.coerce_path_result(rv) + self.fail( + f"{self.name.title()} {_click.utils.format_filename(value)!r} does not exist.", + param, + ctx, + ) + + name = self.name.title() + loc = repr(_click.utils.format_filename(value)) + if not self.file_okay and stat.S_ISREG(st.st_mode): + self.fail(f"{name} {loc} is a file.", param, ctx) + + if not self.dir_okay and stat.S_ISDIR(st.st_mode): + self.fail(f"{name} {loc} is a directory.", param, ctx) + + if self.readable and not os.access(rv, os.R_OK): + self.fail(f"{name} {loc} is not readable.", param, ctx) + + if self.writable and not os.access(rv, os.W_OK): + self.fail(f"{name} {loc} is not writable.", param, ctx) + + return self.coerce_path_result(rv) + def shell_complete( - self, ctx: click.Context, param: click.Parameter, incomplete: str - ) -> list[click.shell_completion.CompletionItem]: + self, ctx: _click.Context, param: _click.Parameter, incomplete: str + ) -> list[CompletionItem]: """Return an empty list so that the autocompletion functionality will work properly from the commandline. """ diff --git a/typer/params.py b/typer/params.py index b325b273c4..833461fa78 100644 --- a/typer/params.py +++ b/typer/params.py @@ -1,13 +1,15 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Annotated, Any, overload -import click from annotated_doc import Doc +from . import _click +from ._click import types +from ._click.shell_completion import CompletionItem from .models import ArgumentInfo, OptionInfo if TYPE_CHECKING: # pragma: no cover - import click.shell_completion + pass # Overload for Option created with custom type 'parser' @@ -24,8 +26,8 @@ def Option( # Note that shell_complete is not fully supported and will be removed in future versions # TODO: Remove shell_complete in a future version (after 0.16.0) shell_complete: Callable[ - [click.Context, click.Parameter, str], - list["click.shell_completion.CompletionItem"] | list[str], + [_click.Context, _click.Parameter, str], + list["CompletionItem"] | list[str], ] | None = None, autocompletion: Callable[..., Any] | None = None, @@ -89,14 +91,14 @@ def Option( # Note that shell_complete is not fully supported and will be removed in future versions # TODO: Remove shell_complete in a future version (after 0.16.0) shell_complete: Callable[ - [click.Context, click.Parameter, str], - list["click.shell_completion.CompletionItem"] | list[str], + [_click.Context, _click.Parameter, str], + list["CompletionItem"] | list[str], ] | None = None, autocompletion: Callable[..., Any] | None = None, default_factory: Callable[[], Any] | None = None, # Custom type - click_type: click.ParamType | None = None, + click_type: types.ParamType | None = None, # Option show_default: bool | str = True, prompt: bool | str = False, @@ -265,8 +267,8 @@ def main(user: Annotated[str, typer.Option(envvar="ME")]): # TODO: Remove shell_complete in a future version (after 0.16.0) shell_complete: Annotated[ Callable[ - [click.Context, click.Parameter, str], - list["click.shell_completion.CompletionItem"] | list[str], + [_click.Context, _click.Parameter, str], + list["CompletionItem"] | list[str], ] | None, Doc( @@ -343,7 +345,7 @@ def main(opt: Annotated[CustomClass, typer.Option(parser=my_parser)] = "Foo"): ), ] = None, click_type: Annotated[ - click.ParamType | None, + types.ParamType | None, Doc( """ Define this parameter to use a [custom Click type](https://click.palletsprojects.com/en/stable/parameters/#implementing-custom-types) in your Typer applications. @@ -1014,8 +1016,8 @@ def Argument( # Note that shell_complete is not fully supported and will be removed in future versions # TODO: Remove shell_complete in a future version (after 0.16.0) shell_complete: Callable[ - [click.Context, click.Parameter, str], - list["click.shell_completion.CompletionItem"] | list[str], + [_click.Context, _click.Parameter, str], + list["CompletionItem"] | list[str], ] | None = None, autocompletion: Callable[..., Any] | None = None, @@ -1070,14 +1072,14 @@ def Argument( # Note that shell_complete is not fully supported and will be removed in future versions # TODO: Remove shell_complete in a future version (after 0.16.0) shell_complete: Callable[ - [click.Context, click.Parameter, str], - list["click.shell_completion.CompletionItem"] | list[str], + [_click.Context, _click.Parameter, str], + list["CompletionItem"] | list[str], ] | None = None, autocompletion: Callable[..., Any] | None = None, default_factory: Callable[[], Any] | None = None, # Custom type - click_type: click.ParamType | None = None, + click_type: types.ParamType | None = None, # TyperArgument show_default: bool | str = True, show_choices: bool = True, @@ -1219,8 +1221,8 @@ def main(name: Annotated[str, typer.Argument(envvar="ME")]): # TODO: Remove shell_complete in a future version (after 0.16.0) shell_complete: Annotated[ Callable[ - [click.Context, click.Parameter, str], - list["click.shell_completion.CompletionItem"] | list[str], + [_click.Context, _click.Parameter, str], + list["CompletionItem"] | list[str], ] | None, Doc( @@ -1297,7 +1299,7 @@ def main(arg: Annotated[CustomClass, typer.Argument(parser=my_parser): ), ] = None, click_type: Annotated[ - click.ParamType | None, + types.ParamType | None, Doc( """ Define this parameter to use a [custom Click type](https://click.palletsprojects.com/en/stable/parameters/#implementing-custom-types) in your Typer applications. diff --git a/typer/rich_utils.py b/typer/rich_utils.py index 69be631207..e5b106126e 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -8,7 +8,6 @@ from os import getenv from typing import Any, Literal -import click from rich import box from rich.align import Align from rich.columns import Columns @@ -25,6 +24,10 @@ from rich.traceback import Traceback from typer.models import DeveloperExceptionConfig +from . import _click +from ._click import types +from .core import TyperArgument, TyperGroup, TyperOption + # Default styles STYLE_OPTION = "bold cyan" STYLE_SWITCH = "bold green" @@ -184,7 +187,7 @@ def _make_rich_text( @group() def _get_help_text( *, - obj: click.Command | click.Group, + obj: _click.Command | TyperGroup, markup_mode: MarkupModeStrict, ) -> Iterable[Markdown | Text]: """Build primary help text for a click command or group. @@ -231,8 +234,8 @@ def _get_help_text( def _get_parameter_help( *, - param: click.Option | click.Argument | click.Parameter, - ctx: click.Context, + param: TyperOption | TyperArgument | _click.Parameter, + ctx: _click.Context, markup_mode: MarkupModeStrict, ) -> Columns: """Build primary help text for a click option or argument. @@ -348,8 +351,8 @@ def _make_command_help( def _print_options_panel( *, name: str, - params: list[click.Option] | list[click.Argument], - ctx: click.Context, + params: list[TyperOption] | list[TyperArgument], + ctx: _click.Context, markup_mode: MarkupModeStrict, console: Console, ) -> None: @@ -377,7 +380,7 @@ def _print_options_panel( metavar_str = param.make_metavar(ctx=ctx) # Do it ourselves if this is a positional argument if ( - isinstance(param, click.Argument) + isinstance(param, TyperArgument) and param.name and metavar_str == param.name.upper() ): @@ -391,8 +394,8 @@ def _print_options_panel( # https://github.com/pallets/click/blob/c63c70dabd3f86ca68678b4f00951f78f52d0270/src/click/core.py#L2698-L2706 # noqa: E501 # skip count with default range type if ( - isinstance(param.type, click.types._NumberRangeBase) - and isinstance(param, click.Option) + isinstance(param.type, types._NumberRangeBase) + and isinstance(param, TyperOption) and not (param.count and param.type.min == 0 and param.type.max is None) ): range_str = param.type._describe_range() @@ -459,7 +462,7 @@ def _print_options_panel( def _print_commands_panel( *, name: str, - commands: list[click.Command], + commands: list[_click.Command], markup_mode: MarkupModeStrict, console: Console, cmd_len: int, @@ -534,8 +537,8 @@ def _print_commands_panel( def rich_format_help( *, - obj: click.Command | click.Group, - ctx: click.Context, + obj: _click.Command | TyperGroup, + ctx: _click.Context, markup_mode: MarkupModeStrict, ) -> None: """Print nicely formatted help text using rich. @@ -568,18 +571,18 @@ def rich_format_help( (0, 1, 1, 1), ) ) - panel_to_arguments: defaultdict[str, list[click.Argument]] = defaultdict(list) - panel_to_options: defaultdict[str, list[click.Option]] = defaultdict(list) + panel_to_arguments: defaultdict[str, list[TyperArgument]] = defaultdict(list) + panel_to_options: defaultdict[str, list[TyperOption]] = defaultdict(list) for param in obj.get_params(ctx): # Skip if option is hidden if getattr(param, "hidden", False): continue - if isinstance(param, click.Argument): + if isinstance(param, TyperArgument): panel_name = ( getattr(param, _RICH_HELP_PANEL_NAME, None) or ARGUMENTS_PANEL_TITLE ) panel_to_arguments[panel_name].append(param) - elif isinstance(param, click.Option): + elif isinstance(param, TyperOption): panel_name = ( getattr(param, _RICH_HELP_PANEL_NAME, None) or OPTIONS_PANEL_TITLE ) @@ -623,8 +626,8 @@ def rich_format_help( console=console, ) - if isinstance(obj, click.Group): - panel_to_commands: defaultdict[str, list[click.Command]] = defaultdict(list) + if isinstance(obj, TyperGroup): + panel_to_commands: defaultdict[str, list[_click.Command]] = defaultdict(list) for command_name in obj.list_commands(ctx): command = obj.get_command(ctx, command_name) if command and not command.hidden: @@ -674,18 +677,18 @@ def rich_format_help( console.print(Padding(Align(epilogue_text, pad=False), 1)) -def rich_format_error(self: click.ClickException) -> None: +def rich_format_error(self: _click.ClickException) -> None: """Print richly formatted click errors. Called by custom exception handler to print richly formatted click errors. - Mimics original click.ClickException.echo() function but with rich formatting. + Mimics original _click.ClickException.echo() function but with rich formatting. """ # Don't do anything when it's a NoArgsIsHelpError (without importing it, cf. #1278) if self.__class__.__name__ == "NoArgsIsHelpError": return console = _get_rich_console(stderr=True) - ctx: click.Context | None = getattr(self, "ctx", None) + ctx: _click.Context | None = getattr(self, "ctx", None) if ctx is not None: console.print(ctx.get_usage()) diff --git a/typer/testing.py b/typer/testing.py index 09711e66fd..7ecc0e693c 100644 --- a/typer/testing.py +++ b/typer/testing.py @@ -1,30 +1,342 @@ -from collections.abc import Mapping, Sequence -from typing import IO, Any +import contextlib +import io +import os +import shlex +import sys +from collections.abc import Iterator, Mapping, Sequence +from types import TracebackType +from typing import IO, TYPE_CHECKING, Any, BinaryIO, cast -from click.testing import CliRunner as ClickCliRunner # noqa -from click.testing import Result from typer.main import Typer from typer.main import get_command as _get_command +from . import _click +from ._click import _compat, formatting, termui, utils -class CliRunner(ClickCliRunner): - def invoke( # type: ignore +if TYPE_CHECKING: + from _typeshed import ReadableBuffer + + +def make_input_stream(input: str | bytes | None, charset: str) -> BinaryIO: + if input is None: + input = b"" + elif isinstance(input, str): + input = input.encode(charset) + + return io.BytesIO(input) + + +class BytesIOCopy(io.BytesIO): + """Patch ``io.BytesIO`` to let the written stream be copied to another.""" + + def __init__(self, copy_to: io.BytesIO) -> None: + super().__init__() + self.copy_to = copy_to + + def flush(self) -> None: + super().flush() + self.copy_to.flush() + + def write(self, b: "ReadableBuffer") -> int: + self.copy_to.write(b) + return super().write(b) + + +class StreamMixer: + """Mixes `` and `` streams. + + The result is available in the ``output`` attribute. + """ + + def __init__(self) -> None: + self.output: io.BytesIO = io.BytesIO() + self.stdout: io.BytesIO = BytesIOCopy(copy_to=self.output) + self.stderr: io.BytesIO = BytesIOCopy(copy_to=self.output) + + def __del__(self) -> None: + """Guarantee that file-like objects are closed in a predictable order""" + self.stderr.close() + self.stdout.close() + self.output.close() + + +class _NamedTextIOWrapper(io.TextIOWrapper): + def __init__(self, buffer: BinaryIO, name: str, mode: str, **kwargs: Any) -> None: + super().__init__(buffer, **kwargs) + self._name = name + self._mode = mode + + @property + def name(self) -> str: + return self._name # pragma: no cover + + @property + def mode(self) -> str: + return self._mode # pragma: no cover + + +class Result: + """Holds the captured result of an invoked CLI script.""" + + def __init__( + self, + runner: "CliRunner", + stdout_bytes: bytes, + stderr_bytes: bytes, + output_bytes: bytes, + return_value: Any, + exit_code: int, + exception: BaseException | None, + exc_info: tuple[type[BaseException], BaseException, TracebackType] + | None = None, + ): + self.runner = runner + self.stdout_bytes = stdout_bytes + self.stderr_bytes = stderr_bytes + self.output_bytes = output_bytes + self.return_value = return_value + self.exit_code = exit_code + self.exception = exception + self.exc_info = exc_info + + @property + def output(self) -> str: + """The terminal output as unicode string, as the user would see it.""" + return self.output_bytes.decode(self.runner.charset, "replace").replace( + "\r\n", "\n" + ) + + @property + def stdout(self) -> str: + """The standard output as unicode string.""" + return self.stdout_bytes.decode(self.runner.charset, "replace").replace( + "\r\n", "\n" + ) + + @property + def stderr(self) -> str: + """The standard error as unicode string.""" + return self.stderr_bytes.decode(self.runner.charset, "replace").replace( + "\r\n", "\n" + ) + + def __repr__(self) -> str: + exc_str = repr(self.exception) if self.exception else "okay" + return f"<{type(self).__name__} {exc_str}>" + + +class CliRunner: + """The CLI runner provides functionality to invoke a command line + script for unittesting purposes in an isolated environment. This only + works in single-threaded systems without any concurrency as it changes the + global interpreter state. Based on functionality from Click. + """ + + def __init__( + self, + charset: str = "utf-8", + env: Mapping[str, str | None] | None = None, + ) -> None: + self.charset = charset + self.env: Mapping[str, str | None] = env or {} + + def get_default_prog_name(self, cli: _click.Command) -> str: + """Return the default program name for a command. + The default is the `name` attribute or ``"root"`` if not set. + """ + return cli.name or "root" + + def make_env( + self, overrides: Mapping[str, str | None] | None = None + ) -> Mapping[str, str | None]: + """Returns the environment overrides for invoking a script.""" + rv = dict(self.env) + if overrides: + rv.update(overrides) + return rv + + @contextlib.contextmanager + def isolation( + self, + input: str | bytes | None = None, + env: Mapping[str, str | None] | None = None, + color: bool = False, + ) -> Iterator[tuple[io.BytesIO, io.BytesIO, io.BytesIO]]: + """A context manager that sets up the isolation for invoking of a + command line tool. This sets up `` with the given input data + and `os.environ` with the overrides from the given dictionary. + """ + bytes_input = make_input_stream(input, self.charset) + + old_stdin = sys.stdin + old_stdout = sys.stdout + old_stderr = sys.stderr + old_forced_width = formatting.FORCED_WIDTH + formatting.FORCED_WIDTH = 80 + + env = self.make_env(env) + + stream_mixer = StreamMixer() + + sys.stdin = text_input = _NamedTextIOWrapper( + bytes_input, encoding=self.charset, name="", mode="r" + ) + + sys.stdout = _NamedTextIOWrapper( + stream_mixer.stdout, encoding=self.charset, name="", mode="w" + ) + + sys.stderr = _NamedTextIOWrapper( + stream_mixer.stderr, + encoding=self.charset, + name="", + mode="w", + errors="backslashreplace", + ) + + def visible_input(prompt: str | None = None) -> str: + sys.stdout.write(prompt or "") + try: + val = next(text_input).rstrip("\r\n") + except StopIteration as e: # pragma: no cover + raise EOFError() from e + sys.stdout.write(f"{val}\n") + sys.stdout.flush() + return val + + def hidden_input(prompt: str | None = None) -> str: + sys.stdout.write(f"{prompt or ''}\n") + sys.stdout.flush() + try: + return next(text_input).rstrip("\r\n") + except StopIteration as e: # pragma: no cover + raise EOFError() from e + + def _getchar(echo: bool) -> str: + char = sys.stdin.read(1) + + if echo: + sys.stdout.write(char) + + sys.stdout.flush() + return char + + default_color = color + + def should_strip_ansi( + stream: IO[Any] | None = None, color: bool | None = None + ) -> bool: + if color is None: + return not default_color + return not color + + old_visible_prompt_func = termui.visible_prompt_func + old_hidden_prompt_func = termui.hidden_prompt_func + old__getchar_func = termui._getchar + old_should_strip_ansi = utils.should_strip_ansi # type: ignore[attr-defined] + old__compat_should_strip_ansi = _compat.should_strip_ansi + termui.visible_prompt_func = visible_input + termui.hidden_prompt_func = hidden_input # ty: ignore[invalid-assignment] + termui._getchar = _getchar + utils.should_strip_ansi = should_strip_ansi # type: ignore + _compat.should_strip_ansi = should_strip_ansi # ty: ignore[invalid-assignment] + + old_env = {} + try: + for key, value in env.items(): + old_env[key] = os.environ.get(key) + if value is None: + try: + del os.environ[key] + except Exception: # pragma: no cover + pass + else: + os.environ[key] = value + yield (stream_mixer.stdout, stream_mixer.stderr, stream_mixer.output) + finally: + for key, value in old_env.items(): + if value is None: + try: + del os.environ[key] + except Exception: # pragma: no cover + pass + else: + os.environ[key] = value + sys.stdout = old_stdout + sys.stderr = old_stderr + sys.stdin = old_stdin + termui.visible_prompt_func = old_visible_prompt_func + termui.hidden_prompt_func = old_hidden_prompt_func + termui._getchar = old__getchar_func + utils.should_strip_ansi = old_should_strip_ansi # type: ignore[attr-defined] + _compat.should_strip_ansi = old__compat_should_strip_ansi + formatting.FORCED_WIDTH = old_forced_width + + def invoke( self, app: Typer, args: str | Sequence[str] | None = None, - input: bytes | str | IO[Any] | None = None, + input: bytes | str | None = None, env: Mapping[str, str | None] | None = None, catch_exceptions: bool = True, color: bool = False, **extra: Any, ) -> Result: - use_cli = _get_command(app) - return super().invoke( - use_cli, - args=args, - input=input, - env=env, - catch_exceptions=catch_exceptions, - color=color, - **extra, + cli = _get_command(app) + exc_info = None + + with self.isolation(input=input, env=env, color=color) as outstreams: + return_value = None + exception: BaseException | None = None + exit_code = 0 + + if isinstance(args, str): + args = shlex.split(args) + + try: + prog_name = extra.pop("prog_name") + except KeyError: + prog_name = self.get_default_prog_name(cli) + + try: + return_value = cli.main(args=args or (), prog_name=prog_name, **extra) + except SystemExit as e: + exc_info = sys.exc_info() + e_code = cast("int | Any | None", e.code) + + if e_code is None: + e_code = 0 + + if e_code != 0: + exception = e + + if not isinstance(e_code, int): + sys.stdout.write(str(e_code)) + sys.stdout.write("\n") + e_code = 1 + + exit_code = e_code + + except Exception as e: + if not catch_exceptions: + raise + exception = e + exit_code = 1 + exc_info = sys.exc_info() + finally: + sys.stdout.flush() + sys.stderr.flush() + stdout = outstreams[0].getvalue() + stderr = outstreams[1].getvalue() + output = outstreams[2].getvalue() + + return Result( + runner=self, + stdout_bytes=stdout, + stderr_bytes=stderr, + output_bytes=output, + return_value=return_value, + exit_code=exit_code, + exception=exception, + exc_info=exc_info, # type: ignore ) diff --git a/uv.lock b/uv.lock index 3b258b16bf..beeca3c91d 100644 --- a/uv.lock +++ b/uv.lock @@ -39,26 +39,41 @@ wheels = [ ] [[package]] -name = "babel" -version = "2.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, -] - -[[package]] -name = "backrefs" -version = "6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" }, - { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" }, - { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" }, - { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" }, - { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, +name = "ast-serialize" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" }, + { url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" }, + { url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" }, + { url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" }, + { url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" }, + { url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" }, + { url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" }, + { url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" }, + { url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" }, + { url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" }, + { url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" }, + { url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" }, + { url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" }, ] [[package]] @@ -476,6 +491,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/c0/9f59d2ebd9d585e1681c51767eb138bcd9d0ea770f6fc003cd875c7f5e62/cyclic-1.0.0-py3-none-any.whl", hash = "sha256:32d8181d7698f426bce6f14f4c3921ef95b6a84af9f96192b59beb05bc00c3ed", size = 2547, upload-time = "2018-09-26T16:47:05.609Z" }, ] +[[package]] +name = "deepmerge" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -561,15 +585,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "hjson" -version = "3.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/e5/0b56d723a76ca67abadbf7fb71609fb0ea7e6926e94fcca6c65a85b36a0e/hjson-3.1.0.tar.gz", hash = "sha256:55af475a27cf83a7969c808399d7bccdec8fb836a07ddbd574587593b9cdcf75", size = 40541, upload-time = "2022-08-13T02:53:01.919Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/7f/13cd798d180af4bf4c0ceddeefba2b864a63c71645abc0308b768d67bb81/hjson-3.1.0-py3-none-any.whl", hash = "sha256:65713cdcf13214fb554eb8b4ef803419733f4f5e551047c9b711098ab7186b89", size = 54018, upload-time = "2022-08-13T02:52:59.899Z" }, -] - [[package]] name = "httpcore" version = "1.0.9" @@ -600,11 +615,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -630,87 +645,87 @@ wheels = [ [[package]] name = "librt" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, - { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, - { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, - { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, - { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, - { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, - { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, - { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, - { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, - { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, - { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, - { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, - { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, - { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, - { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, - { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, - { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, - { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, - { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, - { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, - { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, - { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, - { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] [[package]] @@ -915,73 +930,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, ] -[[package]] -name = "mkdocs-macros-plugin" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "hjson" }, - { name = "jinja2" }, - { name = "mkdocs" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "python-dateutil" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "super-collections" }, - { name = "termcolor" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/92/15/e6a44839841ebc9c5872fa0e6fad1c3757424e4fe026093b68e9f386d136/mkdocs_macros_plugin-1.5.0.tar.gz", hash = "sha256:12aa45ce7ecb7a445c66b9f649f3dd05e9b92e8af6bc65e4acd91d26f878c01f", size = 37730, upload-time = "2025-11-13T08:08:55.545Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/62/9fffba5bb9ed3d31a932ad35038ba9483d59850256ee0fea7f1187173983/mkdocs_macros_plugin-1.5.0-py3-none-any.whl", hash = "sha256:c10fabd812bf50f9170609d0ed518e54f1f0e12c334ac29141723a83c881dd6f", size = 44626, upload-time = "2025-11-13T08:08:53.878Z" }, -] - -[[package]] -name = "mkdocs-material" -version = "9.7.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "backrefs" }, - { name = "colorama" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "mkdocs" }, - { name = "mkdocs-material-extensions" }, - { name = "paginate" }, - { name = "pygments" }, - { name = "pymdown-extensions" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, -] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, -] - -[[package]] -name = "mkdocs-redirects" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mkdocs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f1/a8/6d44a6cf07e969c7420cb36ab287b0669da636a2044de38a7d2208d5a758/mkdocs_redirects-1.2.2.tar.gz", hash = "sha256:3094981b42ffab29313c2c1b8ac3969861109f58b2dd58c45fc81cd44bfa0095", size = 7162, upload-time = "2024-11-07T14:57:21.109Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ec/38443b1f2a3821bbcb24e46cd8ba979154417794d54baf949fefde1c2146/mkdocs_redirects-1.2.2-py3-none-any.whl", hash = "sha256:7dbfa5647b79a3589da4401403d69494bd1f4ad03b9c15136720367e1f340ed5", size = 6142, upload-time = "2024-11-07T14:57:19.143Z" }, -] - [[package]] name = "mkdocstrings" -version = "0.30.1" +version = "1.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, @@ -991,9 +942,9 @@ dependencies = [ { name = "mkdocs-autorefs" }, { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/33/2fa3243439f794e685d3e694590d28469a9b8ea733af4b48c250a3ffc9a0/mkdocstrings-0.30.1.tar.gz", hash = "sha256:84a007aae9b707fb0aebfc9da23db4b26fc9ab562eb56e335e9ec480cb19744f", size = 106350, upload-time = "2025-09-19T10:49:26.446Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/2c/f0dc4e1ee7f618f5bff7e05898d20bf8b6e7fa612038f768bfa295f136a4/mkdocstrings-0.30.1-py3-none-any.whl", hash = "sha256:41bd71f284ca4d44a668816193e4025c950b002252081e387433656ae9a70a82", size = 36704, upload-time = "2025-09-19T10:49:24.805Z" }, + { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, ] [package.optional-dependencies] @@ -1018,60 +969,61 @@ wheels = [ [[package]] name = "mypy" -version = "1.20.2" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" }, - { url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" }, - { url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" }, - { url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" }, - { url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" }, - { url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" }, - { url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" }, - { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, - { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, - { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, - { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, - { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, - { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, - { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, - { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, - { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, - { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, - { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, - { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, - { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, - { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, - { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, - { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, - { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, - { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, - { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, - { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, + { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] @@ -1092,15 +1044,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "paginate" -version = "0.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, -] - [[package]] name = "pathspec" version = "1.0.4" @@ -1228,26 +1171,26 @@ wheels = [ [[package]] name = "prek" -version = "0.3.11" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/60/5b980c70525ca5f0d17942d8eae13b399051aa384413366fe5df229712ea/prek-0.3.11.tar.gz", hash = "sha256:c4cf77848009503c58d80ff216e32af45b63ea49652bb5546748c1ebfd4d9847", size = 433440, upload-time = "2026-04-27T04:22:59.923Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/01/1d2c238c6f226d75881cd7a5532e980f4d524babc3c034d16ad89e88b6e1/prek-0.4.1.tar.gz", hash = "sha256:622a8812bda87cf4ddcae2dab5ccecc55b88d70c677129dbe25e975d923179f0", size = 452606, upload-time = "2026-05-20T04:27:19.259Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/2a/3392fa7d1fd1ce538915baa7597e7203bbe888367a8b15bfd51ca74d4714/prek-0.3.11-py3-none-linux_armv6l.whl", hash = "sha256:787e605716cfdc86ec01e7c5cf62799f39c28d49de5e37d75595c8e6248cb0f3", size = 5423112, upload-time = "2026-04-27T04:22:52.659Z" }, - { url = "https://files.pythonhosted.org/packages/a9/b0/3fc653b30b70d6c2714fc56bcfe1c2439437fc38f60b72bc300603ace4cd/prek-0.3.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ef1f37187ca52d75ba9c46b53007476c4eab2c3f11bd23defd57a81c62d90442", size = 5801382, upload-time = "2026-04-27T04:23:04.464Z" }, - { url = "https://files.pythonhosted.org/packages/2e/46/39aedc7843c3703f1f43b686622e4f8cd123e03b87a163e5c8f2fbd56cda/prek-0.3.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d0828a1b50447502ea1be3f5a84da474fdca558cd5d76a1a5205169bb808c7", size = 5370817, upload-time = "2026-04-27T04:22:49.277Z" }, - { url = "https://files.pythonhosted.org/packages/15/83/df5f3aeacbdea96a88c4f06c98d3932469711fed4e3bf5b703dd6507abe7/prek-0.3.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bf49464b526ee36d2130baf60ab9580560bfaa60efd2997328e6d6671e209014", size = 5621405, upload-time = "2026-04-27T04:23:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f2/e32c9720747a327669863a4f92d05b9e6fadb851e903b0d7310a97c956a4/prek-0.3.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac344529f0d34757c7c95f65e66b9f6440a691f826eaf43f503247bd22023558", size = 5339780, upload-time = "2026-04-27T04:22:47.614Z" }, - { url = "https://files.pythonhosted.org/packages/29/2e/0e2f71b63bc2e5372575d5c1574b0666d2f90d30da51ed706a32cbf465a0/prek-0.3.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83672d963f249e2f246d3bec97f8fd2e8032e70da0a7d9acb2fa38af76dd82d2", size = 5735277, upload-time = "2026-04-27T04:23:12.437Z" }, - { url = "https://files.pythonhosted.org/packages/09/46/88abf51ac88eeff1ad2fe7d1797ca1fea43eb1ac1ddb8331463ac5b27ed2/prek-0.3.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef70957195d2896a30dd849e64f88344df7bb51af9c950cf16bd11519e7424b0", size = 6622420, upload-time = "2026-04-27T04:23:05.982Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b6/592028a45b084a68b76c7edef909c789d1c96b26761388f63659beef7166/prek-0.3.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4f0cc07d2cdb5fe2882015d5fdafc9af98b4c560d4caa1ae948caeab4341b79", size = 6020038, upload-time = "2026-04-27T04:22:54.367Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f7/e97f55a1645a2e9becffeee28892ad8bb66cd144dabfa4392ea8e2674bbe/prek-0.3.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d7554b436dae2ec97f4351a46817e3561657244307d1c0915f355b859f4fab71", size = 5622539, upload-time = "2026-04-27T04:23:01.314Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/f3119eef6b621782ad216a86d449609858ea34c57cf4a40fc6dc80556d7e/prek-0.3.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:caba5d635a5b64b7ac64d903f29b043ca5b0d9d9693543a0ef331ade89e6ad3f", size = 5440681, upload-time = "2026-04-27T04:23:07.422Z" }, - { url = "https://files.pythonhosted.org/packages/04/62/22dd4f59a47654faeebe74651182ecc48d436542646cc92723052dfd9a45/prek-0.3.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:c95a63f19dde48e84b70bd63a235670834af15fa4df8b85d8b7894dd5bc419a9", size = 5314773, upload-time = "2026-04-27T04:22:57.912Z" }, - { url = "https://files.pythonhosted.org/packages/bb/94/a8361462acb8d8f5b8505255b95ffbfc2ee0872a79b4e066eb330692f7be/prek-0.3.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7353b45f44a386c676fe96ba72a5ee326b676f789339f405cf6f1d69a1707194", size = 5596208, upload-time = "2026-04-27T04:23:09.08Z" }, - { url = "https://files.pythonhosted.org/packages/04/0c/5f065b86bbeb9977074a055d8a05e90c7201f6c4c7032dada61739b5f8cb/prek-0.3.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:39f4e86176ccbb70c098df6abbc8e36c1d86cb81281abe92fb79dcd572418214", size = 6132833, upload-time = "2026-04-27T04:22:56.054Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/8ab0ae140201dcee505f58b60abbe56bd05ac96b821a6866f6f90c4d971f/prek-0.3.11-py3-none-win32.whl", hash = "sha256:35d2361049653a3dcf27227b7f1b340c5c42a12c0e0361c4b785921bfd125839", size = 5120856, upload-time = "2026-04-27T04:23:02.752Z" }, - { url = "https://files.pythonhosted.org/packages/57/05/9844c1125d3714f6f6c7b475884128a4b0c6c3ee0cd208ead44ca8174687/prek-0.3.11-py3-none-win_amd64.whl", hash = "sha256:a387689cd2e182f92dbb681151ee5a04f494fe97e95d6d783875da90b950e6d5", size = 5510916, upload-time = "2026-04-27T04:22:45.704Z" }, - { url = "https://files.pythonhosted.org/packages/ff/13/24b0288c553dc8d61f44c4d0746fe9bb1e1bd29d1e70571658536e4c0f72/prek-0.3.11-py3-none-win_arm64.whl", hash = "sha256:e4a8f900378a6657c7eb2fc4b12fa5c934edf209d0a24544539842479ec16e0b", size = 5345988, upload-time = "2026-04-27T04:22:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ca/0274343faf2672d649b1e648053d3cb48fdfef7a390b43713d95880ebb67/prek-0.4.1-py3-none-linux_armv6l.whl", hash = "sha256:10e7e78ffe65dfba7d687a8c71b2f473554d1ba60f43c742105da4c0030feed9", size = 5515584, upload-time = "2026-05-20T04:27:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/37/4e/6a067f530194a6e4141c36463eece92356dfd7f924ffe0cbf456bdca723b/prek-0.4.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b25807e0aa57d2118747e127b58e7a1bf41d5d7b3323f5f3f1f3cb10031245cc", size = 5878925, upload-time = "2026-05-20T04:27:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3d/a334c0f5b88fadca888eadfc1fb3d7f1dc8358b1a534d0987339ecb8eb92/prek-0.4.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:efa95331c4c171a867c0064c19d8a4abc94a1c1c920c8b8092f2d7d87f4b90a8", size = 5440994, upload-time = "2026-05-20T04:27:40.578Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3b/fa6eb635495c3576e65d7f42a48b9fdf4926dd052010df506ed98e9f9680/prek-0.4.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d1805123ab5d730629de588bf319ea39e7078b589b3288c95740f1b4780a1d4", size = 5692369, upload-time = "2026-05-20T04:27:23.184Z" }, + { url = "https://files.pythonhosted.org/packages/70/cb/9d9078723b3facb40289444332ca82bf38c0e1db3b5a907af461aba12324/prek-0.4.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:051c442b570b53756225410240577bee1aeace6be52955dfacf45a9783223b36", size = 5430031, upload-time = "2026-05-20T04:27:27.475Z" }, + { url = "https://files.pythonhosted.org/packages/ff/96/2d8cc6b5425215cd0b610f1dcef3f6f0f23db2a2b85f1a6fca43b7e7fe24/prek-0.4.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76663998827a2cbc94f5e209319809655489b5bd1f8e70568a623372e80253f0", size = 5834244, upload-time = "2026-05-20T04:27:44.229Z" }, + { url = "https://files.pythonhosted.org/packages/59/e0/cce02f3ade48a6d4bffb25e5f0ac28d10928263b0a4f53ecc72954957f4e/prek-0.4.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ab3460641762edf128b1ec8e833ce7e9ae015d1268a894560cb90d3393a7527", size = 6711903, upload-time = "2026-05-20T04:27:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/ccd581b6222277a2aa095530844d5bb76db4547042f05a9cb649476bf904/prek-0.4.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e69a9c02ead38706a5d2a4ae209dccba08ccb5d0026e1d08e723c66ab964750", size = 6084138, upload-time = "2026-05-20T04:27:46.549Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b7/6164a7dc6bb4796cfc19445be798302cc7625b62e2bec89ffb4272d7f983/prek-0.4.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:dc744fedf98df8a00a9e3bcd629b163fee5e9f9e22bce66029d9945241586165", size = 5698950, upload-time = "2026-05-20T04:27:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/96/40/8151d6445a0f41ad60e979db39d8b0c6b074aad919cf5c73233281f0dff1/prek-0.4.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c0877e82c52359d655fe1072b3a5228639184d1d5f03c6803b6530cd6da1ef20", size = 5538662, upload-time = "2026-05-20T04:27:15.045Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/1f9892a45bb2dc8a3b4b89eb08f5de1cf745fcd7df9e535463ba4d41cebe/prek-0.4.1-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:60928d1dad45ff3e491d3083a50643cc213aa2d54f1dbd8d702d7193773c020e", size = 5406581, upload-time = "2026-05-20T04:27:21.101Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b8/94ddac155b502859e4dc7943db99fa7fffecfa3878a2ef11726a8e72fad0/prek-0.4.1-py3-none-musllinux_1_1_i686.whl", hash = "sha256:17ffa9d8dd40791b9b99cafe558c5cc28e78e5be57607b280b15f0dab90264e9", size = 5688880, upload-time = "2026-05-20T04:27:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/e93d3853d1bdc06b281fff2aaf4106e19610fe5187c67c9ff13195f2df59/prek-0.4.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:cdf4503a240369f66321213d9c4bc6f925014b64ff7121de9e9920c9b9838ce2", size = 6203536, upload-time = "2026-05-20T04:27:42.366Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/760969d6bfc77e3eba04f6c3801c81076e96a908a6c277c142a4b0f31f4e/prek-0.4.1-py3-none-win32.whl", hash = "sha256:7c515492ef3585e6bcd7b83f1bb1cb131038abc88ed2c843de1e4c3ceb865b19", size = 5208995, upload-time = "2026-05-20T04:27:38.331Z" }, + { url = "https://files.pythonhosted.org/packages/89/12/d43daf290a73dbc3e1a3eabb9077e45df661923949bee045de67cbe82524/prek-0.4.1-py3-none-win_amd64.whl", hash = "sha256:8fa707971465d8ad021c907e43691aad7bb98942943e61e294ece5f95d9fbc78", size = 5591734, upload-time = "2026-05-20T04:27:12.744Z" }, + { url = "https://files.pythonhosted.org/packages/ab/36/2ab7647fe1e84bba2baae7f04de241197eed62683fb3085e164de266d111/prek-0.4.1-py3-none-win_arm64.whl", hash = "sha256:5b4a348537924b20e208cbd87ef58e96ec37d691c5bec2969209c40de0ecf72e", size = 5423147, upload-time = "2026-05-20T04:27:17.023Z" }, ] [[package]] @@ -1261,7 +1204,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.3" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1269,139 +1212,139 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.3" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/98/b50eb9a411e87483b5c65dba4fa430a06bac4234d3403a40e5a9905ebcd0/pydantic_core-2.46.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1", size = 2108971, upload-time = "2026-04-20T14:43:51.945Z" }, - { url = "https://files.pythonhosted.org/packages/08/4b/f364b9d161718ff2217160a4b5d41ce38de60aed91c3689ebffa1c939d23/pydantic_core-2.46.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f", size = 1949588, upload-time = "2026-04-20T14:44:10.386Z" }, - { url = "https://files.pythonhosted.org/packages/8f/8b/30bd03ee83b2f5e29f5ba8e647ab3c456bf56f2ec72fdbcc0215484a0854/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3", size = 1975986, upload-time = "2026-04-20T14:43:57.106Z" }, - { url = "https://files.pythonhosted.org/packages/3c/54/13ccf954d84ec275d5d023d5786e4aa48840bc9f161f2838dc98e1153518/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a", size = 2055830, upload-time = "2026-04-20T14:44:15.499Z" }, - { url = "https://files.pythonhosted.org/packages/be/0e/65f38125e660fdbd72aa858e7dfae893645cfa0e7b13d333e174a367cd23/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807", size = 2222340, upload-time = "2026-04-20T14:41:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/d1/88/f3ab7739efe0e7e80777dbb84c59eb98518e3f57ea433206194c2e425272/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda", size = 2280727, upload-time = "2026-04-20T14:41:30.461Z" }, - { url = "https://files.pythonhosted.org/packages/2a/6d/c228219080817bec4982f9531cadb18da6aaa770fdeb114f49c237ac2c9f/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57", size = 2092158, upload-time = "2026-04-20T14:44:07.305Z" }, - { url = "https://files.pythonhosted.org/packages/0f/b1/525a16711e7c6d61635fac3b0bd54600b5c5d9f60c6fc5aaab26b64a2297/pydantic_core-2.46.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045", size = 2116626, upload-time = "2026-04-20T14:42:34.118Z" }, - { url = "https://files.pythonhosted.org/packages/ef/7c/17d30673351439a6951bf54f564cf2443ab00ae264ec9df00e2efd710eb5/pydantic_core-2.46.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943", size = 2160691, upload-time = "2026-04-20T14:41:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/86/66/af8adbcbc0886ead7f1a116606a534d75a307e71e6e08226000d51b880d2/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f", size = 2182543, upload-time = "2026-04-20T14:40:48.886Z" }, - { url = "https://files.pythonhosted.org/packages/b0/37/6de71e0f54c54a4190010f57deb749e1ddf75c568ada3b1320b70067f121/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4", size = 2324513, upload-time = "2026-04-20T14:42:36.121Z" }, - { url = "https://files.pythonhosted.org/packages/51/b1/9fc74ce94f603d5ef59ff258ca9c2c8fb902fb548d340a96f77f4d1c3b7f/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a", size = 2361853, upload-time = "2026-04-20T14:43:24.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/d0/4c652fc592db35f100279ee751d5a145aca1b9a7984b9684ba7c1b5b0535/pydantic_core-2.46.3-cp310-cp310-win32.whl", hash = "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7", size = 1980465, upload-time = "2026-04-20T14:44:46.239Z" }, - { url = "https://files.pythonhosted.org/packages/27/b8/a920453c38afbe1f355e1ea0b0d94a0a3e0b0879d32d793108755fa171d5/pydantic_core-2.46.3-cp310-cp310-win_amd64.whl", hash = "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6", size = 2073884, upload-time = "2026-04-20T14:43:01.201Z" }, - { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" }, - { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" }, - { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" }, - { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" }, - { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" }, - { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" }, - { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" }, - { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" }, - { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" }, - { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, - { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, - { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, - { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, - { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, - { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, - { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, - { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, - { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, - { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, - { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, - { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, - { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, - { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, - { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, - { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, - { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, - { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, - { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, - { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, - { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, - { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, - { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, - { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, - { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" }, - { url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" }, - { url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, - { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, - { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, - { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" }, - { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" }, - { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" }, - { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" }, - { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" }, - { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] name = "pydantic-settings" -version = "2.14.0" +version = "2.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] [[package]] @@ -1445,15 +1388,15 @@ crypto = [ [[package]] name = "pymdown-extensions" -version = "10.21.2" +version = "10.21.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, ] [[package]] @@ -1563,11 +1506,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -1685,27 +1628,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] [[package]] @@ -1739,18 +1682,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/10/0d23e4953eb7c1e1ad848084b3115f19234f34f907658ed11bed0d826aee/smokeshow-0.5.0-py3-none-any.whl", hash = "sha256:da12a960fc7cb525efc4035a0c3c9363b6217ea7e66bc39b9ed3cd8bed6eeedc", size = 8389, upload-time = "2025-01-07T19:41:49.194Z" }, ] -[[package]] -name = "super-collections" -version = "0.6.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "hjson" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e0/de/a0c3d1244912c260638f0f925e190e493ccea37ecaea9bbad7c14413b803/super_collections-0.6.2.tar.gz", hash = "sha256:0c8d8abacd9fad2c7c1c715f036c29f5db213f8cac65f24d45ecba12b4da187a", size = 31315, upload-time = "2025-09-30T00:37:08.067Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/43/47c7cf84b3bd74a8631b02d47db356656bb8dff6f2e61a4c749963814d0d/super_collections-0.6.2-py3-none-any.whl", hash = "sha256:291b74d26299e9051d69ad9d89e61b07b6646f86a57a2f5ab3063d206eee9c56", size = 16173, upload-time = "2025-09-30T00:37:07.104Z" }, -] - [[package]] name = "termcolor" version = "3.3.0" @@ -1774,75 +1705,81 @@ wheels = [ [[package]] name = "tomli" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "ty" -version = "0.0.32" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/7e/2aa791c9ae7b8cd5024cd4122e92267f664ca954cea3def3211919fa3c1f/ty-0.0.32.tar.gz", hash = "sha256:8743174c5f920f6700a4a0c9de140109189192ba16226884cd50095b43b8a45c", size = 5522294, upload-time = "2026-04-20T19:29:01.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/eb/1075dc6a49d7acbe2584ae4d5b410c41b1f177a5adcc567e09eca4c69000/ty-0.0.32-py3-none-linux_armv6l.whl", hash = "sha256:dacbc2f6cd698d488ae7436838ff929570455bf94bfa4d9fe57a630c552aff83", size = 10902959, upload-time = "2026-04-20T19:28:31.907Z" }, - { url = "https://files.pythonhosted.org/packages/33/d2/c35fc8bc66e98d1ee9b0f8ed319bf743e450e1f1e997574b178fab75670f/ty-0.0.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914bbc4f605ce2a9e2a78982e28fae1d3359a169d141f9dc3b4c7749cd5eca81", size = 10726172, upload-time = "2026-04-20T19:28:44.765Z" }, - { url = "https://files.pythonhosted.org/packages/96/32/c827da3ca480456fb02d8cea68a2609273b6c220fea0be9a4c8d8470b86e/ty-0.0.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4787ac9fe1f86b1f3133f5c6732adbe2df5668b50c679ac6e2d98cd284da812f", size = 10163701, upload-time = "2026-04-20T19:28:27.005Z" }, - { url = "https://files.pythonhosted.org/packages/ba/9e/2734478fbdb90c160cb2813a3916a16a2af5c1e231f87d635f6131d781fb/ty-0.0.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ea0a728af99fe40dd744cba6441a2404f80b7f4bde17aa6da393810af5ea57", size = 10656220, upload-time = "2026-04-20T19:29:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/44/9f/0007da2d35e424debe7e9f86ffbc1ab7f60983cfbc5f0411324ab2de5292/ty-0.0.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2850561f9b018ae33d7e5bbfa0ac414d3c518513edcffe43877dc9801446b9c5", size = 10696086, upload-time = "2026-04-20T19:28:46.829Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5e/ce5fd4ec803222ae3e69a76d2a2db2eed55e19f5b131702b9789ef45f93d/ty-0.0.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5fa2fb3c614349ee211d36476b49d88c5ef79a687cdb91b2872ad023b94d2f8", size = 11184800, upload-time = "2026-04-20T19:28:42.57Z" }, - { url = "https://files.pythonhosted.org/packages/6c/46/ebcf67a5999421331214aac51a7464db42de2be15bbe929c612a3ed0b039/ty-0.0.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b89969307ab2417d41c9be8059dd79feea577234e1e10d35132f5495e0d42c6", size = 11718718, upload-time = "2026-04-20T19:28:36.433Z" }, - { url = "https://files.pythonhosted.org/packages/18/2c/2141c86ed0ce0962b45cefb658a95e734f59759d47f20afdcd9c732910a1/ty-0.0.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b59868ede9b1d69a088f0d695df52a0061f95fa7baa1d5e0dc6fc9cf06e1334", size = 11346369, upload-time = "2026-04-20T19:28:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/7a/da/ed6f772339cf29bd9a46def9d6db5084689eb574ee4d150ff704224c1ed8/ty-0.0.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8300caf35345498e9b9b03e550bba03cee8f5f5f8ab4c83c3b1ff1b7403b7d3a", size = 11280714, upload-time = "2026-04-20T19:28:51.516Z" }, - { url = "https://files.pythonhosted.org/packages/da/9b/c6813987edf4816a40e0c8e408b555f97d3f267c7b3a1688c8bbdf65609c/ty-0.0.32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:583c7094f4574b02f724db924f98b804d1387a0bd9405ecb5e078cc0f47fbcfb", size = 10638806, upload-time = "2026-04-20T19:28:29.651Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d4/0cefcbd2ad0f3d51762ccf58e652ec7da146eb6ae34f87228f6254bbb8be/ty-0.0.32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e44ebe1bb4143a5628bc4db67ac0dfebe14594af671e4ee66f6f2e983da56501", size = 10726106, upload-time = "2026-04-20T19:29:06.3Z" }, - { url = "https://files.pythonhosted.org/packages/32/ad/2c8a97f91f06311f4367400f7d13534bbda2522c73c99a3e4c0757dff9b8/ty-0.0.32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:06f17ada3e069cba6148342ef88e9929156beca8473e8d4f101b68f66c75643e", size = 10872951, upload-time = "2026-04-20T19:28:34.077Z" }, - { url = "https://files.pythonhosted.org/packages/ba/68/42293f9248106dd51875120971a5cc6ea315c2c4dcfb8e59aa063aa0af26/ty-0.0.32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e96e60fa556cec04f15d7ea62d2ceee5982bd389233e961ab9fd42304e278175", size = 11363334, upload-time = "2026-04-20T19:28:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/df/92/be9abf4d3e589ad5023e2ea965b93e204ec856420d46adf73c5c36c04678/ty-0.0.32-py3-none-win32.whl", hash = "sha256:2ff2ebb4986b24aebcf1444db7db5ca41b36086040e95eea9f8fb851c11e805c", size = 10260689, upload-time = "2026-04-20T19:28:56.541Z" }, - { url = "https://files.pythonhosted.org/packages/14/61/dc86acea899349d2579cb8419aecedd83dc504d7d6a10df65eef546c8300/ty-0.0.32-py3-none-win_amd64.whl", hash = "sha256:ba7284a4a954b598c1b31500352b3ec1f89bff533825592b5958848226fdc7ee", size = 11255371, upload-time = "2026-04-20T19:28:39.917Z" }, - { url = "https://files.pythonhosted.org/packages/43/01/beffec56d71ca25b343ede63adb076456b5b3e211f1c066452a44cd120b3/ty-0.0.32-py3-none-win_arm64.whl", hash = "sha256:7e10aadbdbda989a7d567ee6a37f8b98d4d542e31e3b190a2879fd581f75d658", size = 10658087, upload-time = "2026-04-20T19:28:59.286Z" }, +version = "0.0.38" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/3b/45be6b37d5060d6917bf7f1f234c00d360fc5f8b7486f8a96af640e25661/ty-0.0.38.tar.gz", hash = "sha256:fbc8d47f7630457669ab41e333dc093897fdb7ead1ffc94dcf8f30b5d39aa56d", size = 5681218, upload-time = "2026-05-20T00:15:32.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/43/ea9b4e57d6a266670dbe34858e92f6093ca054ad1b48f1c82580a72340fb/ty-0.0.38-py3-none-linux_armv6l.whl", hash = "sha256:3501dcf44ca03f813f9cb4fabfdf601adc0ac1337c411405b470530679e37a45", size = 11289326, upload-time = "2026-05-20T00:14:52.371Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ff/24e2f623a1c6b5f5ccf8bf82fccd937033c6a7dba57a4028c7f41270fa4a/ty-0.0.38-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b34b4094b76252c3e8c90762cdd5e8a9f1101534484745ff4b480f71eb38ac2e", size = 11063047, upload-time = "2026-05-20T00:14:42.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/41/4f0d910f0acbd20b358eda80a5cd6a8361d27ff5b8e87ab559d3f69f125e/ty-0.0.38-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c518ad33a877677365baab2e21d82cf59ffee789203a15a143f5179ee5a1d3f8", size = 10494436, upload-time = "2026-05-20T00:15:24.425Z" }, + { url = "https://files.pythonhosted.org/packages/69/d8/da06833422082aa98b169a391f9197e2d73865e96c90b6979ac886b890a2/ty-0.0.38-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9238494722303eccddc6a27eb647948b694eecd6b974910d13b9e6cd46bbeb6a", size = 11000992, upload-time = "2026-05-20T00:14:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/16/f7/e1172197fb827e6410ca3eb0dc68ef2789f3c70683696f2a0ce5c90764fd/ty-0.0.38-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d91d7336c5d51bf822ac0df512f300584ca4dcca041fc6a6d7df03a8ddbb31", size = 11058583, upload-time = "2026-05-20T00:15:11.314Z" }, + { url = "https://files.pythonhosted.org/packages/5b/61/7fbaf0c05981e006a8804287819c574dff90a6bf8e96efad7226be0700aa/ty-0.0.38-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65165879814993450710b9349791e4898c65e36b1e14eec554884c06a2f20ff1", size = 11531036, upload-time = "2026-05-20T00:15:14.62Z" }, + { url = "https://files.pythonhosted.org/packages/49/e3/47c0c64e401d50f925df3e52479d4e7626754b2a9e38201d142fdacd6252/ty-0.0.38-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d61868b8d1c4033bf8088191de953fed245c2f9e1bb9d2d53e5699170b0924c", size = 12129991, upload-time = "2026-05-20T00:14:39.475Z" }, + { url = "https://files.pythonhosted.org/packages/90/99/2f452d02901bcd7f1b109cf5b848727ce37f372c3406143aa52d1305d40e/ty-0.0.38-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8f9a9175548c98dbff7707865738c07c2b1f8e07a09b8c68101baebb5dac59a4", size = 11756167, upload-time = "2026-05-20T00:15:27.526Z" }, + { url = "https://files.pythonhosted.org/packages/dd/0c/c7e14d111c813e1a20b82e944f1c997c4631a2bb710eaa64fb6b26835e13/ty-0.0.38-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375d3a964c6b4aea2e9237fdb5eb9ed03dc43088986a94209a28a4ea3b62001c", size = 11637099, upload-time = "2026-05-20T00:15:21.261Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/ab02659dd1ed62898db7db4d37f9937c80854dd45e95093fa0fe10328d82/ty-0.0.38-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:cdfd547782c45267aa0b52abad31bd406bf4768c264532ef9e2360cd3c6ce048", size = 11813583, upload-time = "2026-05-20T00:14:45.875Z" }, + { url = "https://files.pythonhosted.org/packages/7e/57/bd1b5ebf4e71a4295484afac0202df1740b0807762b86744b1bef4534984/ty-0.0.38-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:858bc675b75626470abe4e6c3b3934b853642b04f2ac4d7139fcefea3b48b213", size = 10975405, upload-time = "2026-05-20T00:15:30.354Z" }, + { url = "https://files.pythonhosted.org/packages/e7/55/0305c78711bbd23922cf291996a08ef9544f4179da98e9a75c14e608f379/ty-0.0.38-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:54be4f00432870da42cd74fe145a3362fd248e22d032c74bd807cb45bf068f94", size = 11097551, upload-time = "2026-05-20T00:14:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4f/7effe7f9a6ac9719eb7234172c01739c5f888bb47f9acc2ea8da1f4afed3/ty-0.0.38-py3-none-musllinux_1_2_i686.whl", hash = "sha256:494af66a76a86dbf16a3003d3b63b03484aa4c7489dfe11f3ee5413b98b22d60", size = 11214391, upload-time = "2026-05-20T00:15:18.094Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/d9fdfec3a74a6ad0209fa5e7113ae29d4f457d0651cfbb813b4c6563e0d4/ty-0.0.38-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3d92527c4be78a5ce6d32e8bb0aa2a6988d4076eddf1294e56fdaf06d1a98e7e", size = 11730871, upload-time = "2026-05-20T00:14:49.219Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4a/beefade12d109b4f7793d61b04b4478b1ad4d1465a719e7ff55b2d42461a/ty-0.0.38-py3-none-win32.whl", hash = "sha256:36fc5dd5dc09207ff3004b1560a79a3fb8d12456daeec914a7b802a918da654c", size = 10548583, upload-time = "2026-05-20T00:15:07.892Z" }, + { url = "https://files.pythonhosted.org/packages/15/64/941b205e2e46cc2297c245c64aa7691410b7454fa4d07a6cb3cf59487833/ty-0.0.38-py3-none-win_amd64.whl", hash = "sha256:eef0a8956ba14514076b1a963d13eb32986d9ebad7f0527b3cc01cb68bf35147", size = 11650542, upload-time = "2026-05-20T00:15:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/59/02/c1c4f9ec4b94d95190636fa13f79c32f65165fbe3a0503882d4df164d2ac/ty-0.0.38-py3-none-win_arm64.whl", hash = "sha256:79abfc8658a026c30b1c955613437dab3ef4b12feca56a3e6df50903cc39e07f", size = 11010307, upload-time = "2026-05-20T00:15:04.567Z" }, ] [[package]] @@ -1850,7 +1787,7 @@ name = "typer" source = { editable = "." } dependencies = [ { name = "annotated-doc" }, - { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "rich" }, { name = "shellingham" }, ] @@ -1863,9 +1800,6 @@ dev = [ { name = "griffe-warnings-deprecated" }, { name = "markdown-include-variants" }, { name = "mdx-include" }, - { name = "mkdocs-macros-plugin" }, - { name = "mkdocs-material" }, - { name = "mkdocs-redirects" }, { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, { name = "pillow" }, @@ -1879,6 +1813,8 @@ dev = [ { name = "ruff" }, { name = "shellingham" }, { name = "ty" }, + { name = "zensical" }, + { name = "zizmor" }, ] docs = [ { name = "cairosvg" }, @@ -1886,12 +1822,10 @@ docs = [ { name = "griffe-warnings-deprecated" }, { name = "markdown-include-variants" }, { name = "mdx-include" }, - { name = "mkdocs-macros-plugin" }, - { name = "mkdocs-material" }, - { name = "mkdocs-redirects" }, { name = "mkdocstrings", extra = ["python"] }, { name = "pillow" }, { name = "pyyaml" }, + { name = "zensical" }, ] github-actions = [ { name = "httpx" }, @@ -1916,7 +1850,7 @@ tests = [ [package.metadata] requires-dist = [ { name = "annotated-doc", specifier = ">=0.0.2" }, - { name = "click", specifier = ">=8.2.1" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "rich", specifier = ">=13.8.0" }, { name = "shellingham", specifier = ">=1.3.0" }, ] @@ -1929,10 +1863,7 @@ dev = [ { name = "griffe-warnings-deprecated", specifier = ">=1.1.0" }, { name = "markdown-include-variants", specifier = ">=0.0.8" }, { name = "mdx-include", specifier = ">=1.4.1" }, - { name = "mkdocs-macros-plugin", specifier = ">=1.5.0" }, - { name = "mkdocs-material", specifier = ">=9.7.1" }, - { name = "mkdocs-redirects", specifier = ">=1.2.1" }, - { name = "mkdocstrings", extras = ["python"], specifier = ">=0.30.1" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.3" }, { name = "mypy", specifier = ">=1.19.1" }, { name = "pillow", specifier = ">=11.3.0" }, { name = "prek", specifier = ">=0.3.2" }, @@ -1945,6 +1876,8 @@ dev = [ { name = "ruff", specifier = ">=0.15.0" }, { name = "shellingham", specifier = ">=1.3.0" }, { name = "ty", specifier = ">=0.0.25" }, + { name = "zensical", specifier = ">=0.0.42" }, + { name = "zizmor", specifier = ">=1.23.1" }, ] docs = [ { name = "cairosvg", specifier = ">=2.8.2" }, @@ -1952,12 +1885,10 @@ docs = [ { name = "griffe-warnings-deprecated", specifier = ">=1.1.0" }, { name = "markdown-include-variants", specifier = ">=0.0.8" }, { name = "mdx-include", specifier = ">=1.4.1" }, - { name = "mkdocs-macros-plugin", specifier = ">=1.5.0" }, - { name = "mkdocs-material", specifier = ">=9.7.1" }, - { name = "mkdocs-redirects", specifier = ">=1.2.1" }, - { name = "mkdocstrings", extras = ["python"], specifier = ">=0.30.1" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.3" }, { name = "pillow", specifier = ">=11.3.0" }, { name = "pyyaml", specifier = ">=5.3.1" }, + { name = "zensical", specifier = ">=0.0.42" }, ] github-actions = [ { name = "httpx", specifier = ">=0.27.0" }, @@ -2002,11 +1933,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] @@ -2049,3 +1980,51 @@ sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda5308 wheels = [ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, ] + +[[package]] +name = "zensical" +version = "0.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/85/ec45162e7824a8f879d887ef0774ee65926bf7d1064e2eebccc7eaee3378/zensical-0.0.43.tar.gz", hash = "sha256:dc2d3804ff562795c1024130e0c3ce79736467930729dda314f096d0e35b98c8", size = 3932396, upload-time = "2026-05-19T09:44:07.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/c2/55e0709607ae41c266987c3b91a1a9702b37fbbef0d07eddfe5e25c2d823/zensical-0.0.43-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:17c335362b6bac3a50178181694a964f6d9f0c516fc532129ba5a0a5c4103fb6", size = 12706531, upload-time = "2026-05-19T09:43:32.729Z" }, + { url = "https://files.pythonhosted.org/packages/2c/64/ce8627bc5ea30556162b29b041fe97d6a6aef2a87b51f12def628e4fa608/zensical-0.0.43-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8fe97f185194215f6193af45a17d2b30ebd72c8113e3650f2d7d6767b9c2206", size = 12563012, upload-time = "2026-05-19T09:43:35.962Z" }, + { url = "https://files.pythonhosted.org/packages/66/d1/533bc9454f0e06b3d9d8bd2e7ac405308c3d4dee6572acab98f0ed6d1c07/zensical-0.0.43-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c4c85978c765b3e7f347e8102dfe1373d4bbe4229d7008b6bdbf352f1fbcd7f", size = 12947599, upload-time = "2026-05-19T09:43:38.754Z" }, + { url = "https://files.pythonhosted.org/packages/75/a0/94f47d6fb592997be7ab9526938c929f0199adf2637c3c2b2b9b2101b28e/zensical-0.0.43-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90d7c06ffd07b2bdf78bef041d541baba8a3ea51fd2dd84dbdbc5b0229076524", size = 12904911, upload-time = "2026-05-19T09:43:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/96/fb/1db3ad9a86ff772f74a8bc60ad5b447aa02a158e70f94adacf50bdd5c40f/zensical-0.0.43-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60022f4a6b95e46ec0023f51052fcd491743b3ebd08c0066b22a5cf1e741fecd", size = 13269386, upload-time = "2026-05-19T09:43:45.387Z" }, + { url = "https://files.pythonhosted.org/packages/31/ee/b24fd0f94885519d851c35615b086d069a1077b0198021a56755395a4633/zensical-0.0.43-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e278eb948a0b7545d50609d713c7c27e366dade4523ff73a311a5d5f136518a", size = 12999364, upload-time = "2026-05-19T09:43:48.549Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/401ccd7afd9d2690f81b5319b7f1eed05108154ce20e4207053914518c1c/zensical-0.0.43-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b85e5ab99fbda13823e67c43a4be6e5ebda6600602969c6575e143f20ac203fd", size = 13124392, upload-time = "2026-05-19T09:43:50.965Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/9af6eba5826b0ef143fc8308bd1e219e221441e307a958e39f824ba9ab53/zensical-0.0.43-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:751385accc92cccfd4560dabed7c423870686ef6ede244a67e5c96286af25e8f", size = 13177538, upload-time = "2026-05-19T09:43:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/be/6b/cd090bd6659d32692487206469988ee84d41aa6de4cdf9e380f847da90e2/zensical-0.0.43-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:dd3ff5bfa6e65cf3d2550dc639c3da2a3bfa11087b83d57e06623c4c1607d583", size = 13327086, upload-time = "2026-05-19T09:43:56.8Z" }, + { url = "https://files.pythonhosted.org/packages/79/5b/ac2555354b5a53cb9c2c942811905c47be0b9f5603d3c1328ee8564333eb/zensical-0.0.43-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85055a115b12f49c6ab194dcf04f966fc06b690ed6a8ddddd819929fc5f340e6", size = 13284645, upload-time = "2026-05-19T09:43:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c6/1688ec6e5be15e3ab367d7804753291bfbdff3109b06e20c19ce30a7129c/zensical-0.0.43-cp310-abi3-win32.whl", hash = "sha256:8a75ddd4bb3cd3c4a8e71d2ebae44c5611fd636c1d355c6124dd96e2f9c52838", size = 12256740, upload-time = "2026-05-19T09:44:02.102Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a8/d967e70eac810a7e9eb8c5150d6d02848a1f42260f42977c71debed3cb02/zensical-0.0.43-cp310-abi3-win_amd64.whl", hash = "sha256:03a9d1744a6394ad66c355d6f1de04cfd92efa525b0b94bf6dbf6971c5cd2c6b", size = 12496166, upload-time = "2026-05-19T09:44:04.915Z" }, +] + +[[package]] +name = "zizmor" +version = "1.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/41/8987d546e3101cc76748b2f1b0ccda58e244773ef5124d39e7e749e3d6e4/zizmor-1.25.2.tar.gz", hash = "sha256:f26ffeb16659c8922c7b08203ca5a4f8bf5e1a7e8d190734961c40877cf778ea", size = 517794, upload-time = "2026-05-16T06:28:43.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/bd/84108a92ccbfda0d28efc11f382997c7a767b58863bf4a550634b8cf0211/zizmor-1.25.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:17cc8cfd9d472e8b11945a869c198d25cfdf4a33f36fa7a1f9674099f5fb509d", size = 9115548, upload-time = "2026-05-16T06:28:33.591Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c0/66453a2553a66286a96ca32d75e3e6bcc94ce7f907cd5f8c2c3fce55315e/zizmor-1.25.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3e301eb4465e2da77857cf01ab4ef0184cf3818e826800b270ab01ae7338977", size = 8665071, upload-time = "2026-05-16T06:28:30.861Z" }, + { url = "https://files.pythonhosted.org/packages/52/3e/d60939d1cc4907c0d021a7c46362aab5e8045550bb09157d56c070e43568/zizmor-1.25.2-py3-none-manylinux_2_24_aarch64.whl", hash = "sha256:cf64374149b567c9373228b76c8e77a389b4071899f84b82c36ee50fab894e79", size = 8842884, upload-time = "2026-05-16T06:28:26.041Z" }, + { url = "https://files.pythonhosted.org/packages/46/82/f3e8d9b6d941194f2558591b449c106d46a16ea566b95eccff3a83bf6acc/zizmor-1.25.2-py3-none-manylinux_2_28_armv7l.whl", hash = "sha256:0beba1601be08bd00c9277e6ed4b026e125b26b379d86d6d98eb708409b3050d", size = 8449741, upload-time = "2026-05-16T06:28:45.424Z" }, + { url = "https://files.pythonhosted.org/packages/4b/13/445bc98acc2c976d6b8f8ca59b9c09f055adb5ffb3445d99af8ff7efcb4f/zizmor-1.25.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:c4246f1344d8dbeffc044d7bb11b131773a7db7eb57d9073c45942dfd3543a1f", size = 9285184, upload-time = "2026-05-16T06:28:39.21Z" }, + { url = "https://files.pythonhosted.org/packages/cf/78/fc7717c706bde7531b2fde12003994fbc04c47ab4f91aa6ca9b3b24b30fd/zizmor-1.25.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dbb1b5c85b8de8eaa0227c6620f06c8e4fbd0a4da2086e218bc225c0bef0923d", size = 8886579, upload-time = "2026-05-16T06:28:51.384Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bc/a46f11377cdc145c625d62d88c30fead56f9d29bc31652069a1a0eaed6c2/zizmor-1.25.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d670a1e2f00b3cd56febd145bc1a0b2c4caf1cbe5dad8128721843fa877e2d2e", size = 8413576, upload-time = "2026-05-16T06:28:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/2b/3b/0fd93b77171c8f229e8e1304eecc9931bf3009f722c57967d545d9f151b6/zizmor-1.25.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b75c84d7387389f95edadbe859fb2aaf0a360c5b080932cc53e92ae1db6f09ef", size = 9378162, upload-time = "2026-05-16T06:28:41.999Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3f/dcb85fb9a0d87794847f9043f9db9bb4d274cf4b8077604bc13850c8fdb4/zizmor-1.25.2-py3-none-win32.whl", hash = "sha256:aa9f4c43b499c55339c3ef2e885133c5017cd9a18d76d9335541203cfa5ae1e7", size = 7548509, upload-time = "2026-05-16T06:28:28.828Z" }, + { url = "https://files.pythonhosted.org/packages/d2/81/1cb088098bd53f9b910098b0c19d06dc587acf328a170ef8afd1cd93b482/zizmor-1.25.2-py3-none-win_amd64.whl", hash = "sha256:af55bd9bd119ea8cbce2a7addc3922503019de32c1fe31106d70b3dc77d77908", size = 8609822, upload-time = "2026-05-16T06:28:48.078Z" }, +]