diff --git a/.github/CONTRIBUTING.rst b/.github/CONTRIBUTING.rst index b64a368ac43..db8a54bd371 100644 --- a/.github/CONTRIBUTING.rst +++ b/.github/CONTRIBUTING.rst @@ -22,12 +22,16 @@ Setting things up $ git remote add upstream https://github.com/python-telegram-bot/python-telegram-bot -4. Install dependencies: +4. Install the package in development mode as well as optional dependencies and development dependencies. + Note that the `--group` argument requires `pip` 25.1 or later. + + Alternatively, you can use your preferred package manager (such as uv, hatch, poetry, etc.) instead of pip. .. code-block:: bash - $ pip install -r requirements-dev-all.txt + $ pip install -e .[all] --group all + Installing the package itself is necessary because python-telegram-bot uses a src-based layout where the package code is located in the ``src/`` directory. 5. Install pre-commit hooks: @@ -83,7 +87,7 @@ Here's how to make a one-off code change. - Documenting types of global variables and complex types of class members can be done using the Sphinx docstring convention. - - In addition, PTB uses some formatting/styling and linting tools in the pre-commit setup. Some of those tools also have command line tools that can help to run these tools outside of the pre-commit step. If you'd like to leverage that, please have a look at the `pre-commit config file`_ for an overview of which tools (and which versions of them) are used. For example, we use `Black`_ for code formatting. Plugins for Black exist for some `popular editors`_. You can use those instead of manually formatting everything. + - In addition, PTB uses some formatting/styling and linting tools in the pre-commit setup. Some of those tools also have command line tools that can help to run these tools outside of the pre-commit step. If you'd like to leverage that, please have a look at the `pre-commit config file`_ for an overview of which tools (and which versions of them) are used. For example, we use `Ruff`_ for linting and formatting. - Please ensure that the code you write is well-tested and that all automated tests still pass. We have dedicated an `testing page`_ to help you with that. @@ -157,45 +161,47 @@ Check-list for PRs This checklist is a non-exhaustive reminder of things that should be done before a PR is merged, both for you as contributor and for the maintainers. Feel free to copy (parts of) the checklist to the PR description to remind you or the maintainers of open points or if you have questions on anything. -- Added ``.. versionadded:: NEXT.VERSION``, ``.. versionchanged:: NEXT.VERSION``, ``.. deprecated:: NEXT.VERSION`` or ``.. versionremoved:: NEXT.VERSION`` to the docstrings for user facing changes (for methods/class descriptions, arguments and attributes) -- Created new or adapted existing unit tests -- Documented code changes according to the `CSI standard `__ -- Added myself alphabetically to ``AUTHORS.rst`` (optional) -- Added new classes & modules to the docs and all suitable ``__all__`` s -- Checked the `Stability Policy `_ in case of deprecations or changes to documented behavior +.. code-block:: markdown -**If the PR contains API changes (otherwise, you can ignore this passage)** + ## Check-list for PRs -- Checked the Bot API specific sections of the `Stability Policy `_ -- Created a PR to remove functionality deprecated in the previous Bot API release (`see here `_) + - [ ] Added `.. versionadded:: NEXT.VERSION`, ``.. versionchanged:: NEXT.VERSION``, ``.. deprecated:: NEXT.VERSION`` or ``.. versionremoved:: NEXT.VERSION` to the docstrings for user facing changes (for methods/class descriptions, arguments and attributes) + - [ ] Created new or adapted existing unit tests + - [ ] Documented code changes according to the [CSI standard](https://standards.mousepawmedia.com/en/stable/csi.html) + - [ ] Added myself alphabetically to `AUTHORS.rst` (optional) + - [ ] Added new classes & modules to the docs and all suitable ``__all__`` s + - [ ] Checked the [Stability Policy](https://docs.python-telegram-bot.org/stability_policy.html) in case of deprecations or changes to documented behavior -- New classes: + **If the PR contains API changes (otherwise, you can ignore this passage)** - - Added ``self._id_attrs`` and corresponding documentation - - ``__init__`` accepts ``api_kwargs`` as kw-only + - [ ] Checked the Bot API specific sections of the [Stability Policy](https://docs.python-telegram-bot.org/stability_policy.html) + - [ ] Created a PR to remove functionality deprecated in the previous Bot API release ([see here](https://docs.python-telegram-bot.org/en/stable/stability_policy.html#case-2)) -- Added new shortcuts: + - New Classes - - In :class:`~telegram.Chat` & :class:`~telegram.User` for all methods that accept ``chat/user_id`` - - In :class:`~telegram.Message` for all methods that accept ``chat_id`` and ``message_id`` - - For new :class:`~telegram.Message` shortcuts: Added ``quote`` argument if methods accepts ``reply_to_message_id`` - - In :class:`~telegram.CallbackQuery` for all methods that accept either ``chat_id`` and ``message_id`` or ``inline_message_id`` + - [ ] Added `self._id_attrs` and corresponding documentation + - [ ] `__init__` accepts `api_kwargs` as keyword-only -- If relevant: + - Added New Shortcuts - - Added new constants at :mod:`telegram.constants` and shortcuts to them as class variables - - Link new and existing constants in docstrings instead of hard-coded numbers and strings - - Add new message types to :attr:`telegram.Message.effective_attachment` - - Added new handlers for new update types + - [ ] In [`telegram.Chat`](https://python-telegram-bot.readthedocs.io/en/stable/telegram.chat.html) \& [`telegram.User`](https://python-telegram-bot.readthedocs.io/en/stable/telegram.user.html) for all methods that accept `chat/user_id` + - [ ] In [`telegram.Message`](https://python-telegram-bot.readthedocs.io/en/stable/telegram.message.html) for all methods that accept `chat_id` and `message_id` + - [ ] For new `telegram.Message` shortcuts: Added `quote` argument if methods accept `reply_to_message_id` + - [ ] In [`telegram.CallbackQuery`](https://python-telegram-bot.readthedocs.io/en/stable/telegram.callbackquery.html) for all methods that accept either `chat_id` and `message_id` or `inline_message_id` - - Add the handlers to the warning loop in the :class:`~telegram.ext.ConversationHandler` + - If Relevant - - Added new filters for new message (sub)types - - Added or updated documentation for the changed class(es) and/or method(s) - - Added the new method(s) to ``_extbot.py`` - - Added or updated ``bot_methods.rst`` - - Updated the Bot API version number in all places: ``README.rst`` (including the badge) and ``telegram.constants.BOT_API_VERSION_INFO`` - - Added logic for arbitrary callback data in :class:`telegram.ext.ExtBot` for new methods that either accept a ``reply_markup`` in some form or have a return type that is/contains :class:`~telegram.Message` + - [ ] Added new constants at `telegram.constants` and shortcuts to them as class variables + - [ ] Linked new and existing constants in docstrings instead of hard-coded numbers and strings + - [ ] Added new message types to `telegram.Message.effective_attachment` + - [ ] Added new handlers for new update types + - [ ] Added the handlers to the warning loop in the [`telegram.ext.ConversationHandler`](https://python-telegram-bot.readthedocs.io/en/stable/telegram.ext.conversationhandler.html) + - [ ] Added new filters for new message (sub)types + - [ ] Added or updated documentation for the changed class(es) and/or method(s) + - [ ] Added the new method(s) to `_extbot.py` + - [ ] Added or updated `bot_methods.rst` + - [ ] Updated the Bot API version number in all places: `README.rst` (including the badge) and `telegram.constants.BOT_API_VERSION_INFO` + - [ ] Added logic for arbitrary callback data in `telegram.ext.ExtBot` for new methods that either accept a `reply_markup` in some form or have a return type that is/contains [`telegram.Message`](https://python-telegram-bot.readthedocs.io/en/stable/telegram.message.html) Documenting =========== @@ -282,8 +288,7 @@ to add new required arguments. It's also more explicit and easier to read. .. _`MyPy`: https://mypy.readthedocs.io/en/stable/index.html .. _`here`: https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html .. _`pre-commit config file`: https://github.com/python-telegram-bot/python-telegram-bot/blob/master/.pre-commit-config.yaml -.. _`Black`: https://black.readthedocs.io/en/stable/index.html -.. _`popular editors`: https://black.readthedocs.io/en/stable/integrations/editors.html +.. _`Ruff`: https://docs.astral.sh/ruff/ .. _`RTD`: https://docs.python-telegram-bot.org/ .. _`RTD build`: https://docs.python-telegram-bot.org/en/doc-fixes .. _`CSI`: https://standards.mousepawmedia.com/en/stable/csi.html diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..069c52f3afa --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,40 @@ +This is a python project which is a wrapper for the Telegram Bot API. Please read the contributing +guidelines mentioned in .github/CONTRIBUTING.rst to know how to contribute to this project. The +README.rst file lists the features and usage of the project. + +### Development Environment: + +Your development environment is set up using `uv`, a tool for managing Python environments and dependencies. +Your environment has all extra dependencies and groups installed, on Python 3.13. Please continue using `uv` for managing your development environment, +and for any scripts or tools you need to run. + +Some example commands on `uv`: +- `uv sync --all-extras --all-groups --locked` to install all dependencies and groups required by the project. +- `uv run -p 3.14 --all-groups --all-extras --locked tests/` to run tests on a specific Python version. Please use the `-p` flag often. +- `uv pip install ` to install a package in the current environment. + +If uv is somehow not available, you can install it using `pip install uv`. + +### Repository Structure: + +The repository follows a standard structure for Python projects. Here are some key directories and files: + +- `src/`: This directory contains the main source code for the project. +- `tests/`: This directory contains test cases for the project. +- `pyproject.toml`: This file contains the project metadata and dependencies. +- `.github/`: This directory contains GitHub-specific files, including workflows and issue templates. + + +### Things to keep in mind while coding: + +- Ensure that your code is properly and fully typed. All your code should be compatible from + Python 3.9 to 3.14. Don't use the `typing_extensions` module. +- Read the stability guide mentioned at docs/source/stability_policy.rst to understand if your changes + are breaking or incompatible. +- Try to make sure your code is asyncio-friendly and thread-safe. +- Run `uv run pre-commit` to run pre-commit hooks before committing your changes, but after `git add`ing them. +- Make sure you always test your changes. Either update or write new tests in the `tests/` directory. + +### Pull Requests: + +When you create a pull request, please also add the appropriate labels to it. diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index fb5b1d14166..00000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,20 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - day: "friday" - labels: - - "⚙️ dependencies" - - "🔗 python" - - # Updates the dependencies of the GitHub Actions workflows - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "monthly" - day: "friday" - labels: - - "⚙️ dependencies" - - "🔗 github-actions" diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 00000000000..c52377794c0 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,88 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ // See what config:best-practices does: https://docs.renovatebot.com/presets-config/#configbest-practices + "config:best-practices", + // Opt-in to updating the pre-commit-config.yaml file too: + ":enablePreCommit", + ":prConcurrentLimitNone" // No limits on the number of open PRs. + ], + + // Add pull request labels: + "labels": ["⚙️ ci-cd"], + + // Bump even patch versions: + "bumpVersion": "patch", + + // Let Renovate decide how to update. See docs: https://docs.renovatebot.com/configuration-options/#rangestrategy + "rangeStrategy": "auto", + + // Update the lock files: + "lockFileMaintenance": { + "enabled": true, + "schedule": ["* 0-3 1 * *"], // https://docs.renovatebot.com/presets-schedule/#schedulemonthly + "automerge": true + }, + + // Enable automerge globally: + "automerge": true, + + // Group package updates together: + "packageRules": [ + // Linting dependencies in pyproject.toml in sync with the pre-commit-config hooks: + // Unfortunately it seems we need to do this for every dependency group (https://github.com/python-telegram-bot/python-telegram-bot/pull/4887#discussion_r2272025832): + { + "description": "Group Ruff updates together", + "matchPackageNames": ["ruff", "astral-sh/ruff-pre-commit"], + "groupName": "Ruff" + }, + { + "description": "Group mypy updates together", + "matchPackageNames": ["mypy", "pre-commit/mirrors-mypy"], + "groupName": "Mypy" + }, + { + "description": "Group pylint updates together", + "matchPackageNames": ["pylint", "PyCQA/pylint"], + "groupName": "Pylint" + }, + { + "description": "Group chango updates together", + "matchPackageNames": ["chango", "Bibo-Joshi/chango"], + "groupName": "Chango" + }, + + // Don't automerge major updates for project dependencies: + { + "matchUpdateTypes": ["major"], + "matchDepTypes": ["project.dependencies", "project.optional-dependencies"], + "automerge": false + }, + + // Apply the "dependencies" label to all updates of optional/required dependencies: + { + "matchDepTypes": ["project.optional-dependencies", "project.dependencies"], + "labels": ["⚙️ dependencies"] + }, + + // Workflow and dev-dependencies update once a month + // https://docs.renovatebot.com/presets-schedule/#schedulemonthly + { + "matchFileNames": [".github/workflows/**"], + "schedule": ["* 0-3 1 * *"] + }, + { + "matchDepTypes": ["dependency-groups"], + "schedule": ["* 0-3 1 * *"] + } + ], + + // Increase the number of PR's Renovate can create in a hour. Default is 2. + "prHourlyLimit": 5, + + // Temporarily disabled: + "ignoreDeps": ["pytest-asyncio"], + + // schedule to allow PR's from Renovate: + "schedule": ["* * * * 0"] // Every Sunday + +} diff --git a/.github/workflows/assets/release_template.html b/.github/workflows/assets/release_template.html new file mode 100644 index 00000000000..2e672ca8482 --- /dev/null +++ b/.github/workflows/assets/release_template.html @@ -0,0 +1,5 @@ +We've just released {tag}. +Thank you to everyone who contributed to this release. +As usual, upgrade using pip install -U python-telegram-bot. + +The release notes can be found here. \ No newline at end of file diff --git a/.github/workflows/chango.yml b/.github/workflows/chango.yml new file mode 100644 index 00000000000..ef1dd9bc6ea --- /dev/null +++ b/.github/workflows/chango.yml @@ -0,0 +1,69 @@ +name: Chango +on: + pull_request: + types: + - opened + - reopened + - synchronize + +permissions: {} + +jobs: + create-chango-fragment: + permissions: + # Give the default GITHUB_TOKEN write permission to commit and push the + # added or changed files to the repository. + contents: write + name: Create chango Fragment + runs-on: ubuntu-latest + outputs: + IS_RELEASE_PR: ${{ steps.check_title.outputs.IS_RELEASE_PR }} + + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + # needed for commit and push step at the end + persist-credentials: true + - name: Check PR Title + id: check_title + run: | # zizmor: ignore[template-injection] + if [[ "$(echo "${{ github.event.pull_request.title }}" | tr '[:upper:]' '[:lower:]')" =~ ^bump\ version\ to\ .* ]]; then + echo "COMMIT_AND_PUSH=false" >> $GITHUB_OUTPUT + echo "IS_RELEASE_PR=true" >> $GITHUB_OUTPUT + else + echo "COMMIT_AND_PUSH=true" >> $GITHUB_OUTPUT + echo "IS_RELEASE_PR=false" >> $GITHUB_OUTPUT + fi + + # Create the new fragment + - uses: Bibo-Joshi/chango@bc58df46ef3ba8f15b8d744929998b7ae8a222d4 # 0.6.1 + with: + # passing this custom token has two purposes + # 1. it allows us to fetch info about issue types + # 2. it ensures that the push will also re-trigger workflows + github-token: ${{ secrets.CHANGO_PAT }} + query-issue-types: true + commit-and-push: ${{ steps.check_title.outputs.COMMIT_AND_PUSH }} + + # Run `chango release` if applicable - needs some additional setup. + - name: Set up Python + if: steps.check_title.outputs.IS_RELEASE_PR == 'true' + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.x" + + - name: Do Release + if: steps.check_title.outputs.IS_RELEASE_PR == 'true' + run: | + cd ./target-repo + git add changes/unreleased/* + pip install . --group docs + VERSION_TAG=$(python -c "from telegram import __version__; print(f'{__version__}')") + chango release --uid $VERSION_TAG + + - name: Commit & Push + if: steps.check_title.outputs.IS_RELEASE_PR == 'true' + uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0 + with: + commit_message: "Do chango Release" + repository: ./target-repo diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 00000000000..5d1eec79d59 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,42 @@ +# This file is for the copilot agent on Github. This helps to set up the development environment +# See the docs here: https://docs.github.com/en/enterprise-cloud@latest/copilot/how-tos/use-copilot-agents/coding-agent/customize-the-agent-environment#preinstalling-tools-or-dependencies-in-copilots-environment +name: "Copilot Setup Steps" + +# Automatically run the setup steps when they are changed to allow for easy validation, and +# allow manual testing through the repository's "Actions" tab +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + + # Set the permissions to the lowest permissions possible needed for your steps. + # Copilot will be given its own token for its operations. + permissions: + # If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete. + contents: read + pull-requests: write # So copilot can add labels to the PR + + # You can define any steps you want, and they will run before the agent starts. + # If you do not check out your code, Copilot will do this for you. + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 + with: + # Install a specific version of uv. + version: "0.9.28" + # Install 3.13: + python-version: 3.13 + + - name: Install the project + run: uv sync --all-extras --all-groups --locked diff --git a/.github/workflows/dependabot-prs.yml b/.github/workflows/dependabot-prs.yml deleted file mode 100644 index afb0fa6340e..00000000000 --- a/.github/workflows/dependabot-prs.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Process Dependabot PRs - -on: - pull_request: - types: [opened, reopened] - -jobs: - process-dependabot-prs: - permissions: - pull-requests: read - contents: write - - runs-on: ubuntu-latest - if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} - steps: - - - name: Fetch Dependabot metadata - id: dependabot-metadata - uses: dependabot/fetch-metadata@dbb049abf0d677abbd7f7eee0375145b417fdd34 # v2.2.0 - - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ github.event.pull_request.head.ref }} - persist-credentials: false - - - name: Update Version Number in Other Files - uses: jacobtomlinson/gha-find-replace@f1069b438f125e5395d84d1c6fd3b559a7880cb5 # v3 - with: - find: ${{ steps.dependabot-metadata.outputs.previous-version }} - replace: ${{ steps.dependabot-metadata.outputs.new-version }} - regex: false - exclude: CHANGES.rst - - - name: Commit & Push Changes to PR - uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 - with: - message: 'Update version number in other files' - committer_name: GitHub Actions - committer_email: 41898282+github-actions[bot]@users.noreply.github.com diff --git a/.github/workflows/docs-admonitions.yml b/.github/workflows/docs-admonitions.yml new file mode 100644 index 00000000000..581b174d3df --- /dev/null +++ b/.github/workflows/docs-admonitions.yml @@ -0,0 +1,41 @@ +name: Test Admonitions Generation +on: + pull_request: + paths: + - src/telegram/** + - docs/** + - .github/workflows/docs-admonitions.yml + push: + branches: + - master + +permissions: {} + +jobs: + test-admonitions: + name: Test Admonitions Generation + runs-on: ${{matrix.os}} + permissions: + # for uploading artifacts + actions: write + strategy: + matrix: + python-version: ['3.12'] + os: [ubuntu-latest] + fail-fast: False + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'pyproject.toml' + - name: Install dependencies + run: | + python -W ignore -m pip install --upgrade pip + python -W ignore -m pip install .[all] --group all + - name: Test autogeneration of admonitions + run: pytest -v --tb=short tests/docs/admonition_inserter.py \ No newline at end of file diff --git a/.github/workflows/docs-linkcheck.yml b/.github/workflows/docs-linkcheck.yml index cb1aa840daf..e73ad4a0b49 100644 --- a/.github/workflows/docs-linkcheck.yml +++ b/.github/workflows/docs-linkcheck.yml @@ -7,26 +7,35 @@ on: paths: - .github/workflows/docs-linkcheck.yml +permissions: {} + jobs: test-sphinx-build: name: test-sphinx-linkcheck runs-on: ${{matrix.os}} strategy: matrix: - python-version: ['3.10'] + python-version: ['3.12'] os: [ubuntu-latest] fail-fast: False steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -W ignore -m pip install --upgrade pip - python -W ignore -m pip install -r requirements-dev-all.txt + python -W ignore -m pip install .[all] --group all - name: Check Links - run: sphinx-build docs/source docs/build/html -W --keep-going -j auto -b linkcheck + run: sphinx-build docs/source docs/build/html --keep-going -j auto -b linkcheck + - name: Upload linkcheck output + # Run also if the previous steps failed + if: always() + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: linkcheck-output + path: docs/build/html/output.* diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index bf9d6a85631..00000000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Test Documentation Build -on: - pull_request: - paths: - - telegram/** - - docs/** - push: - branches: - - master - -jobs: - test-sphinx-build: - name: test-sphinx-build - runs-on: ${{matrix.os}} - strategy: - matrix: - python-version: ['3.10'] - os: [ubuntu-latest] - fail-fast: False - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - persist-credentials: false - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - cache-dependency-path: '**/requirements*.txt' - - name: Install dependencies - run: | - python -W ignore -m pip install --upgrade pip - python -W ignore -m pip install -r requirements-dev-all.txt - - name: Test autogeneration of admonitions - run: pytest -v --tb=short tests/docs/admonition_inserter.py - - name: Build docs - run: sphinx-build docs/source docs/build/html -W --keep-going -j auto - - name: Upload docs - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 - with: - name: HTML Docs - retention-days: 7 - path: | - # Exclude the .doctrees folder and .buildinfo file from the artifact - # since they are not needed and add to the size - docs/build/html/* - !docs/build/html/.doctrees - !docs/build/html/.buildinfo diff --git a/.github/workflows/gha_security.yml b/.github/workflows/gha_security.yml index 21d1edc326a..c7625a492b0 100644 --- a/.github/workflows/gha_security.yml +++ b/.github/workflows/gha_security.yml @@ -6,6 +6,8 @@ on: - master pull_request: +permissions: {} + jobs: zizmor: name: Security Analysis with zizmor @@ -15,17 +17,17 @@ jobs: security-events: write steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Install the latest version of uv - uses: astral-sh/setup-uv@887a942a15af3a7626099df99e897a18d9e5ab3a # v5.1.0 + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 - name: Run zizmor run: uvx zizmor --persona=pedantic --format sarif . > results.sarif env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 + uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 with: sarif_file: results.sarif - category: zizmor \ No newline at end of file + category: zizmor diff --git a/.github/workflows/labelling.yml b/.github/workflows/labelling.yml index 60e3744d2d5..21a4d6733ba 100644 --- a/.github/workflows/labelling.yml +++ b/.github/workflows/labelling.yml @@ -4,6 +4,8 @@ on: pull_request: types: [opened] +permissions: {} + jobs: pre-commit-ci: permissions: @@ -11,7 +13,7 @@ jobs: pull-requests: write # for srvaroa/labeler to add labels in PR runs-on: ubuntu-latest steps: - - uses: srvaroa/labeler@fe4b1c73bb8abf2f14a44a6912a8b4fee835d631 # v1.12.0 + - uses: srvaroa/labeler@0a20eccb8c94a1ee0bed5f16859aece1c45c3e55 # v1.13.0 # Config file at .github/labeler.yml env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 196b2c66704..e32ece0ff4e 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -4,9 +4,15 @@ on: schedule: - cron: '8 4 * * *' +permissions: {} + jobs: lock: runs-on: ubuntu-latest + permissions: + # For locking the threads + issues: write + pull-requests: write steps: - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 with: diff --git a/.github/workflows/release_pypi.yml b/.github/workflows/release_pypi.yml index 620291c8115..14616c9e5c1 100644 --- a/.github/workflows/release_pypi.yml +++ b/.github/workflows/release_pypi.yml @@ -4,19 +4,24 @@ on: # manually trigger the workflow workflow_dispatch: +permissions: {} + jobs: build: name: Build Distribution runs-on: ubuntu-latest outputs: TAG: ${{ steps.get_tag.outputs.TAG }} + permissions: + # for uploading artifacts + actions: write steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.x" - name: Install pypa/build @@ -25,7 +30,7 @@ jobs: - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: python-package-distributions path: dist/ @@ -46,15 +51,16 @@ jobs: url: https://pypi.org/p/python-telegram-bot permissions: id-token: write # IMPORTANT: mandatory for trusted publishing + actions: read # for downloading artifacts steps: - name: Download all the dists - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: python-package-distributions path: dist/ - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # v1.12.3 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 compute-signatures: name: Compute SHA1 Sums and Sign with Sigstore @@ -64,10 +70,11 @@ jobs: permissions: id-token: write # IMPORTANT: mandatory for sigstore + actions: write # for up/downloading artifacts steps: - name: Download all the dists - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: python-package-distributions path: dist/ @@ -79,13 +86,13 @@ jobs: sha1sum $file > $file.sha1 done - name: Sign the dists with Sigstore - uses: sigstore/gh-action-sigstore-python@f514d46b907ebcd5bedc05145c03b69c1edd8b46 # v3.0.0 + uses: sigstore/gh-action-sigstore-python@f832326173235dcb00dd5d92cd3f353de3188e6c # v3.1.0 with: inputs: >- ./dist/*.tar.gz ./dist/*.whl - name: Store the distribution packages and signatures - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: python-package-distributions-and-signatures path: dist/ @@ -100,10 +107,14 @@ jobs: permissions: contents: write # IMPORTANT: mandatory for making GitHub Releases + actions: read # for downloading artifacts steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Download all the dists - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: python-package-distributions-and-signatures path: dist/ @@ -111,13 +122,14 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} TAG: ${{ needs.build.outputs.TAG }} - # Create a tag and a GitHub Release. The description can be changed later, as for now - # we don't define it through this workflow. + # Create a tag and a GitHub Release. The description is filled by the static template, we + # just insert the correct tag in the template. run: >- + sed "s/{tag}/$TAG/g" .github/workflows/assets/release_template.html | gh release create "$TAG" --repo '${{ github.repository }}' - --generate-notes + --notes-file - - name: Upload artifact signatures to GitHub Release env: GITHUB_TOKEN: ${{ github.token }} @@ -129,3 +141,32 @@ jobs: gh release upload "$TAG" dist/** --repo '${{ github.repository }}' + + telegram-channel: + name: Publish to Telegram Channel + needs: + # required to have the output available for the env var + - build + - github-release + + runs-on: ubuntu-latest + environment: + name: release_pypi + permissions: {} + + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + - name: Publish to Telegram Channel + env: + TAG: ${{ needs.build.outputs.TAG }} + # This secret is configured only for the `pypi-release` branch + BOT_TOKEN: ${{ secrets.CHANNEL_BOT_TOKEN }} + run: >- + sed "s/{tag}/$TAG/g" .github/workflows/assets/release_template.html | + curl + -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" + -d "chat_id=@pythontelegrambotchannel" + -d "parse_mode=HTML" + --data-urlencode "text@-" diff --git a/.github/workflows/release_test_pypi.yml b/.github/workflows/release_test_pypi.yml index cccae31ed0a..cf38eb899f8 100644 --- a/.github/workflows/release_test_pypi.yml +++ b/.github/workflows/release_test_pypi.yml @@ -4,19 +4,24 @@ on: # manually trigger the workflow workflow_dispatch: +permissions: {} + jobs: build: name: Build Distribution runs-on: ubuntu-latest outputs: TAG: ${{ steps.get_tag.outputs.TAG }} + permissions: + # for uploading artifacts + actions: write steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.x" - name: Install pypa/build @@ -25,7 +30,7 @@ jobs: - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: python-package-distributions path: dist/ @@ -46,15 +51,16 @@ jobs: url: https://test.pypi.org/p/python-telegram-bot permissions: id-token: write # IMPORTANT: mandatory for trusted publishing + actions: read # for downloading artifacts steps: - name: Download all the dists - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: python-package-distributions path: dist/ - name: Publish to Test PyPI - uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # v1.12.3 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: repository-url: https://test.pypi.org/legacy/ @@ -66,10 +72,11 @@ jobs: permissions: id-token: write # IMPORTANT: mandatory for sigstore + actions: write # for up/downloading artifacts steps: - name: Download all the dists - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: python-package-distributions path: dist/ @@ -81,13 +88,13 @@ jobs: sha1sum $file > $file.sha1 done - name: Sign the dists with Sigstore - uses: sigstore/gh-action-sigstore-python@f514d46b907ebcd5bedc05145c03b69c1edd8b46 # v3.0.0 + uses: sigstore/gh-action-sigstore-python@f832326173235dcb00dd5d92cd3f353de3188e6c # v3.1.0 with: inputs: >- ./dist/*.tar.gz ./dist/*.whl - name: Store the distribution packages and signatures - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: python-package-distributions-and-signatures path: dist/ @@ -102,10 +109,14 @@ jobs: permissions: contents: write # IMPORTANT: mandatory for making GitHub Releases + actions: read # for downloading artifacts steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Download all the dists - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: python-package-distributions-and-signatures path: dist/ @@ -113,14 +124,15 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} TAG: ${{ needs.build.outputs.TAG }} - # Create a GitHub Release *draft*. The description can be changed later, as for now - # we don't define it through this workflow. + # Create a tag and a GitHub Release *draft*. The description is filled by the static + # template, we just insert the correct tag in the template. run: >- + sed "s/{tag}/$TAG/g" .github/workflows/assets/release_template.html | gh release create "$TAG" --repo '${{ github.repository }}' - --generate-notes --draft + --notes-file - - name: Upload artifact signatures to GitHub Release env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 6baacdedc1b..e925399cdc8 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -3,17 +3,22 @@ on: schedule: - cron: '42 2 * * *' +permissions: {} + jobs: stale: runs-on: ubuntu-latest + permissions: + # For adding labels and closing + issues: write steps: - - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 + - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 with: # PRs never get stale days-before-stale: 3 days-before-close: 2 days-before-pr-stale: -1 stale-issue-label: '📋 stale' - only-labels: 'question' + only-issue-types: '❔ question' stale-issue-message: '' close-issue-message: 'This issue has been automatically closed due to inactivity. Feel free to comment in order to reopen or ask again in our Telegram support group at https://t.me/pythontelegrambotgroup.' diff --git a/.github/workflows/test_official.yml b/.github/workflows/test_official.yml index cbf3b1b302f..3274a04b681 100644 --- a/.github/workflows/test_official.yml +++ b/.github/workflows/test_official.yml @@ -2,7 +2,7 @@ name: Bot API Tests on: pull_request: paths: - - telegram/** + - src/telegram/** - tests/** push: branches: @@ -11,6 +11,8 @@ on: # Run monday and friday morning at 03:07 - odd time to spread load on GitHub Actions - cron: '7 3 * * 1,5' +permissions: {} + jobs: check-conformity: name: check-conformity @@ -21,18 +23,17 @@ jobs: os: [ubuntu-latest] fail-fast: False steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -W ignore -m pip install --upgrade pip - python -W ignore -m pip install .[all] - python -W ignore -m pip install -r requirements-unit-tests.txt + python -W ignore -m pip install .[all] --group tests - name: Compare to official api run: | pytest -v tests/test_official/test_official.py --junit-xml=.test_report_official.xml diff --git a/.github/workflows/type_completeness.yml b/.github/workflows/type_completeness.yml index 66b2f4f3d47..947f931c2f8 100644 --- a/.github/workflows/type_completeness.yml +++ b/.github/workflows/type_completeness.yml @@ -2,19 +2,21 @@ name: Check Type Completeness on: pull_request: paths: - - telegram/** + - src/telegram/** - pyproject.toml - .github/workflows/type_completeness.yml push: branches: - master +permissions: {} + jobs: test-type-completeness: name: test-type-completeness runs-on: ubuntu-latest steps: - - uses: Bibo-Joshi/pyright-type-completeness@c85a67ff3c66f51dcbb2d06bfcf4fe83a57d69cc # v1.0.1 + - uses: Bibo-Joshi/pyright-type-completeness@c85a67ff3c66f51dcbb2d06bfcf4fe83a57d69cc # 1.0.1 with: package-name: telegram python-version: 3.12 diff --git a/.github/workflows/type_completeness_monthly.yml b/.github/workflows/type_completeness_monthly.yml index fecf73db948..30a8a1c8a3b 100644 --- a/.github/workflows/type_completeness_monthly.yml +++ b/.github/workflows/type_completeness_monthly.yml @@ -4,12 +4,14 @@ on: # Run first friday of the month at 03:17 - odd time to spread load on GitHub Actions - cron: '17 3 1-7 * 5' +permissions: {} + jobs: test-type-completeness: name: test-type-completeness runs-on: ubuntu-latest steps: - - uses: Bibo-Joshi/pyright-type-completeness@c85a67ff3c66f51dcbb2d06bfcf4fe83a57d69cc # v1.0.1 + - uses: Bibo-Joshi/pyright-type-completeness@c85a67ff3c66f51dcbb2d06bfcf4fe83a57d69cc # 1.0.1 id: pyright-type-completeness with: package-name: telegram diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index e6fd437c663..369aef1018a 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -2,17 +2,15 @@ name: Unit Tests on: pull_request: paths: - - telegram/** + - src/telegram/** - tests/** - .github/workflows/unit_tests.yml - pyproject.toml - - requirements-unit-tests.txt push: branches: - master - schedule: - # Run monday and friday morning at 03:07 - odd time to spread load on GitHub Actions - - cron: '7 3 * * 1,5' + +permissions: {} jobs: pytest: @@ -20,26 +18,25 @@ jobs: runs-on: ${{matrix.os}} strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] os: [ubuntu-latest, windows-latest, macos-latest] + include: + - python-version: '3.14t' + os: ubuntu-latest fail-fast: False steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} cache: 'pip' - cache-dependency-path: '**/requirements*.txt' - name: Install dependencies run: | python -W ignore -m pip install --upgrade pip - python -W ignore -m pip install -U pytest-cov - python -W ignore -m pip install . - python -W ignore -m pip install -r requirements-unit-tests.txt - python -W ignore -m pip install pytest-xdist + python -W ignore -m pip install . --group tests - name: Test with pytest # We run 4 different suites here @@ -61,11 +58,10 @@ jobs: TO_TEST="test_no_passport.py or test_datetime.py or test_defaults.py or test_jobqueue.py or test_applicationbuilder.py or test_ratelimiter.py or test_updater.py or test_callbackdatacache.py or test_request.py" pytest -v --cov -k "${TO_TEST}" --junit-xml=.test_report_no_optionals_junit.xml opt_dep_status=$? - + # Test the rest export TEST_WITH_OPT_DEPS='true' - # need to manually install pytz here, because it's no longer in the optional reqs - pip install .[all] pytz + pip install .[all] # `-n auto --dist worksteal` uses pytest-xdist to run tests on multiple CPU # workers. Increasing number of workers has little effect on test duration, but it seems # to increase flakyness. @@ -90,14 +86,14 @@ jobs: .test_report_optionals_junit.xml - name: Submit coverage - uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: env_vars: OS,PYTHON name: ${{ matrix.os }}-${{ matrix.python-version }} fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} - name: Upload test results to Codecov - uses: codecov/test-results-action@9739113ad922ea0a9abb4b2c0f8bf6a4aa8ef820 # v1.0.1 + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 if: ${{ !cancelled() }} with: files: .test_report_no_optionals_junit.xml,.test_report_optionals_junit.xml diff --git a/.gitignore b/.gitignore index 470d2a2aac1..01c2cfda73f 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ docs/_build/ # PyBuilder target/ .idea/ +.run/ # Sublime Text 2 *.sublime* @@ -92,6 +93,11 @@ telegram.jpg # virtual env venv* +pyvenv.cfg +Scripts/ # environment manager: -.mise.toml \ No newline at end of file +.mise.toml + +# Support for uv.lock will come in a future PR. See #4796 +uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e2cd308e78f..fb43282822e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,83 +1,60 @@ -# Make sure that the additional_dependencies here match pyproject.toml - ci: autofix_prs: false + # We use Renovate to update this file now, but we can't disable automatic pre-commit updates + # when using the `pre-commit` GitHub Action, so we set the schedule to quarterly to avoid + # frequent updates. autoupdate_schedule: quarterly autoupdate_commit_msg: 'Bump `pre-commit` Hooks to Latest Versions' repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.5.6' + rev: 'v0.15.2' hooks: - - id: ruff - name: ruff - additional_dependencies: - - httpx~=0.27 - - tornado~=6.4 - - APScheduler~=3.10.4 - - cachetools>=5.3.3,<5.5.0 - - aiolimiter~=1.1,<1.3 -- repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.4.2 - hooks: - - id: black - args: - - --diff - - --check -- repo: https://github.com/PyCQA/flake8 - rev: 7.1.0 - hooks: - - id: flake8 + # Run the linter: + - id: ruff-check + name: ruff check + # Run the formatter: + - id: ruff-format + name: ruff format - repo: https://github.com/PyCQA/pylint - rev: v3.3.2 + rev: v4.0.5 hooks: - id: pylint files: ^(?!(tests|docs)).*\.py$ + language: python additional_dependencies: - httpx~=0.27 - tornado~=6.4 - - APScheduler~=3.10.4 - - cachetools>=5.3.3,<5.5.0 + - APScheduler>=3.10.4,<3.12.0 + - cachetools>=7.0.0,<8.0.0 - aiolimiter~=1.1,<1.3 - . # this basically does `pip install -e .` - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.1 + rev: v1.19.1 hooks: - id: mypy name: mypy-ptb files: ^(?!(tests|examples|docs)).*\.py$ + language: python additional_dependencies: - types-pytz - types-cryptography - types-cachetools - httpx~=0.27 - tornado~=6.4 - - APScheduler~=3.10.4 - - cachetools>=5.3.3,<5.5.0 + - APScheduler>=3.10.4,<3.12.0 + - cachetools>=7.0.0,<8.0.0 - aiolimiter~=1.1,<1.3 - . # this basically does `pip install -e .` - id: mypy name: mypy-examples files: ^examples/.*\.py$ + language: python args: - --no-strict-optional - --follow-imports=silent additional_dependencies: - tornado~=6.4 - - APScheduler~=3.10.4 - - cachetools>=5.3.3,<5.5.0 + - APScheduler>=3.10.4,<3.12.0 + - cachetools>=7.0.0,<8.0.0 - . # this basically does `pip install -e .` -- repo: https://github.com/asottile/pyupgrade - rev: v3.16.0 - hooks: - - id: pyupgrade - args: - - --py39-plus -- repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort - name: isort - args: - - --diff - - --check diff --git a/.readthedocs.yml b/.readthedocs.yml index a23c582637d..5e14cbfe2a2 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -18,13 +18,16 @@ python: install: - method: pip path: . - - requirements: docs/requirements-docs.txt build: os: ubuntu-22.04 tools: python: "3" # latest stable cpython version jobs: + install: + - pip install -U pip + - pip install .[all] --group 'docs' --group 'tests' # install most dependency groups + post_build: # Based on https://github.com/readthedocs/readthedocs.org/issues/3242#issuecomment-1410321534 # This provides a HTML zip file for download, with the same structure as the hosted website diff --git a/AUTHORS.rst b/AUTHORS.rst index f6290a9294c..9ca986b53e5 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -7,10 +7,8 @@ The current development team includes - `Hinrich Mahler `_ (maintainer) - `Poolitzer `_ (community liaison) -- `Shivam `_ - `Harshil `_ -- `Dmitry Kolomatskiy `_ -- `Aditya `_ +- `Abdelrahman `_ Emeritus maintainers include `Jannes Höke `_ (`@jh0ker `_ on Telegram), @@ -21,7 +19,7 @@ Contributors The following wonderful people contributed directly or indirectly to this project: -- `Abdelrahman `_ +- `Aditya `_ - `Abshar `_ - `Abubakar Alaya `_ - `Alateas `_ @@ -42,6 +40,7 @@ The following wonderful people contributed directly or indirectly to this projec - `daimajia `_ - `Daniel Reed `_ - `D David Livingston `_ +- `Dmitry Kolomatskiy `_ - `DonalDuck004 `_ - `Eana Hufwe `_ - `Ehsan Online `_ @@ -81,6 +80,7 @@ The following wonderful people contributed directly or indirectly to this projec - `Kirill Vasin `_ - `Kjwon15 `_ - `Li-aung Yip `_ +- `locobott `_ - `Loo Zheng Yuan `_ - `LRezende `_ - `Luca Bellanti `_ @@ -110,6 +110,7 @@ The following wonderful people contributed directly or indirectly to this projec - `Patrick Hofmann `_ - `Paul Larsen `_ - `Pawan `_ +- `Philipp Isachenko `_ - `Pieter Schutz `_ - `Piraty `_ - `Poolitzer `_ @@ -117,11 +118,12 @@ The following wonderful people contributed directly or indirectly to this projec - `Rahiel Kasim `_ - `Riko Naka `_ - `Rizlas `_ -- `Snehashish Biswas `_ +- Snehashish Biswas - `Sahil Sharma `_ - `Sam Mosleh `_ - `Sascha `_ - `Shelomentsev D `_ +- `Shivam `_ - `Shivam Saini `_ - `Siloé Garcez `_ - `Simon Schürrle `_ diff --git a/README.rst b/README.rst index 41b38d84fe6..2a96b7112b9 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ :target: https://pypi.org/project/python-telegram-bot/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-8.2-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-9.3-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API version @@ -19,7 +19,7 @@ :target: https://pypistats.org/packages/python-telegram-bot :alt: PyPi Package Monthly Download -.. image:: https://readthedocs.org/projects/python-telegram-bot/badge/?version=stable +.. image:: https://app.readthedocs.org/projects/python-telegram-bot/badge/?version=stable :target: https://docs.python-telegram-bot.org/en/stable/ :alt: Documentation Status @@ -70,7 +70,7 @@ Introduction This library provides a pure Python, asynchronous interface for the `Telegram Bot API `_. -It's compatible with Python versions **3.9+**. +It's compatible with Python versions **3.10+**. In addition to the pure API implementation, this library features several convenience methods and shortcuts as well as a number of high-level classes to make the development of bots easy and straightforward. These classes are contained in the @@ -81,7 +81,7 @@ After installing_ the library, be sure to check out the section on `working with Telegram API support ~~~~~~~~~~~~~~~~~~~~ -All types and methods of the Telegram Bot API **8.2** are natively supported by this library. +All types and methods of the Telegram Bot API **9.3** are natively supported by this library. In addition, Bot API functionality not yet natively included can still be used as described `in our wiki `_. Notable Features @@ -114,6 +114,8 @@ You can also install ``python-telegram-bot`` from source, though this is usually $ pip install build $ python -m build +You can also use your favored package manager (such as ``uv``, ``hatch``, ``poetry``, etc.) instead of ``pip``. + Verifying Releases ~~~~~~~~~~~~~~~~~~ @@ -139,7 +141,7 @@ As these features are *optional*, the corresponding 3rd party dependencies are n Instead, they are listed as optional dependencies. This allows to avoid unnecessary dependency conflicts for users who don't need the optional features. -The only required dependency is `httpx ~= 0.27 `_ for +The only required dependency is `httpx >=0.27,<0.29 `_ for ``telegram.request.HTTPXRequest``, the default networking backend. ``python-telegram-bot`` is most useful when used along with additional libraries. @@ -157,7 +159,7 @@ PTB can be installed with optional dependencies: * ``pip install "python-telegram-bot[http2]"`` installs `httpx[http2] `_. Use this, if you want to use HTTP/2. * ``pip install "python-telegram-bot[rate-limiter]"`` installs `aiolimiter~=1.1,<1.3 `_. Use this, if you want to use ``telegram.ext.AIORateLimiter``. * ``pip install "python-telegram-bot[webhooks]"`` installs the `tornado~=6.4 `_ library. Use this, if you want to use ``telegram.ext.Updater.start_webhook``/``telegram.ext.Application.run_webhook``. -* ``pip install "python-telegram-bot[callback-data]"`` installs the `cachetools>=5.3.3,<5.6.0 `_ library. Use this, if you want to use `arbitrary callback_data `_. +* ``pip install "python-telegram-bot[callback-data]"`` installs the `cachetools>=5.3.3,<6.3.0 `_ library. Use this, if you want to use `arbitrary callback_data `_. * ``pip install "python-telegram-bot[job-queue]"`` installs the `APScheduler>=3.10.4,<3.12.0 `_ library. Use this, if you want to use the ``telegram.ext.JobQueue``. To install multiple optional dependencies, separate them by commas, e.g. ``pip install "python-telegram-bot[socks,webhooks]"``. @@ -204,7 +206,7 @@ Concurrency ~~~~~~~~~~~ Since v20.0, ``python-telegram-bot`` is built on top of Pythons ``asyncio`` module. -Because ``asyncio`` is in general single-threaded, ``python-telegram-bot`` does currently not aim to be thread-safe. +Because ``asyncio`` is in general single-threaded, ``python-telegram-bot`` currently does not aim to be thread-safe. Noteworthy parts of ``python-telegram-bots`` API that are likely to cause issues (e.g. race conditions) when used in a multi-threaded setting include: * ``telegram.ext.Application/Updater.update_queue`` @@ -213,6 +215,12 @@ Noteworthy parts of ``python-telegram-bots`` API that are likely to cause issues * ``telegram.ext.BasePersistence`` * all classes in the ``telegram.ext.filters`` module that allow to add/remove allowed users/chats at runtime +Free threading +~~~~~~~~~~~~~~ + +While ``python-telegram-bot`` is tested to work with Python 3.14 free threading, we do not guarantee that +PTB is thread-safe for all use cases. Please see issue `#4873 `_ for more information. + Contributing ------------ diff --git a/changes/22.0_2025-03-15/4671.7B3boYRvHdGzsrZgkpKp7B.toml b/changes/22.0_2025-03-15/4671.7B3boYRvHdGzsrZgkpKp7B.toml new file mode 100644 index 00000000000..002b70d4ad5 --- /dev/null +++ b/changes/22.0_2025-03-15/4671.7B3boYRvHdGzsrZgkpKp7B.toml @@ -0,0 +1,19 @@ +breaking = """This release removes all functionality that was deprecated in v20.x. This is in line with our :ref:`stability policy `. +This includes the following changes: + +- Removed ``filters.CHAT`` (all messages have an associated chat) and ``filters.StatusUpdate.USER_SHARED`` (use ``filters.StatusUpdate.USERS_SHARED`` instead). +- Removed ``Defaults.disable_web_page_preview`` and ``Defaults.quote``. Use ``Defaults.link_preview_options`` and ``Defaults.do_quote`` instead. +- Removed ``ApplicationBuilder.(get_updates_)proxy_url`` and ``HTTPXRequest.proxy_url``. Use ``ApplicationBuilder.(get_updates_)proxy`` and ``HTTPXRequest.proxy`` instead. +- Removed the ``*_timeout`` arguments of ``Application.run_polling`` and ``Updater.start_webhook``. Instead, specify the values via ``ApplicationBuilder.get_updates_*_timeout``. +- Removed ``constants.InlineQueryLimit.MIN_SWITCH_PM_TEXT_LENGTH``. Use ``constants.InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH`` instead. +- Removed the argument ``quote`` of ``Message.reply_*``. Use ``do_quote`` instead. +- Removed the superfluous ``EncryptedPassportElement.credentials`` without replacement. +- Changed attribute value of ``PassportFile.file_date`` from :obj:`int` to :class:`datetime.datetime`. Make sure to adjust your code accordingly. +- Changed the attribute value of ``PassportElementErrors.file_hashes`` from :obj:`list` to :obj:`tuple`. Make sure to adjust your code accordingly. +- Make ``BaseRequest.read_timeout`` an abstract property. If you subclass ``BaseRequest``, you need to implement this property. +- The default value for ``write_timeout`` now defaults to ``DEFAULT_NONE`` also for bot methods that send media. Previously, it was ``20``. If you subclass ``BaseRequest``, make sure to use your desired write timeout if ``RequestData.multipart_data`` is set. +""" +[[pull_requests]] +uid = "4671" +author_uid = "Bibo-Joshi" +closes_threads = ["4659"] diff --git a/changes/22.0_2025-03-15/4672.G9szuJ7pRafycByfem2DrT.toml b/changes/22.0_2025-03-15/4672.G9szuJ7pRafycByfem2DrT.toml new file mode 100644 index 00000000000..c13a2d145df --- /dev/null +++ b/changes/22.0_2025-03-15/4672.G9szuJ7pRafycByfem2DrT.toml @@ -0,0 +1,5 @@ +documentation = "Add `chango `_ As Changelog Management Tool" +[[pull_requests]] +uid = "4672" +author_uid = "Bibo-Joshi" +closes_threads = ["4321"] diff --git a/changes/22.0_2025-03-15/4697.GzyGCEgj74G6bTEDbuFjUU.toml b/changes/22.0_2025-03-15/4697.GzyGCEgj74G6bTEDbuFjUU.toml new file mode 100644 index 00000000000..abdb8f95575 --- /dev/null +++ b/changes/22.0_2025-03-15/4697.GzyGCEgj74G6bTEDbuFjUU.toml @@ -0,0 +1,5 @@ +internal = "Bump github/codeql-action from 3.28.8 to 3.28.10" +[[pull_requests]] +uid = "4697" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4698.Fkzr3oU2qcFmFX28xfoja5.toml b/changes/22.0_2025-03-15/4698.Fkzr3oU2qcFmFX28xfoja5.toml new file mode 100644 index 00000000000..93eb25aa5b5 --- /dev/null +++ b/changes/22.0_2025-03-15/4698.Fkzr3oU2qcFmFX28xfoja5.toml @@ -0,0 +1,5 @@ +internal = "Bump srvaroa/labeler from 1.12.0 to 1.13.0" +[[pull_requests]] +uid = "4698" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4699.LYAYfKXX7C7wqT54kRPnVy.toml b/changes/22.0_2025-03-15/4699.LYAYfKXX7C7wqT54kRPnVy.toml new file mode 100644 index 00000000000..6d028a80509 --- /dev/null +++ b/changes/22.0_2025-03-15/4699.LYAYfKXX7C7wqT54kRPnVy.toml @@ -0,0 +1,5 @@ +internal = "Bump astral-sh/setup-uv from 5.2.2 to 5.3.1" +[[pull_requests]] +uid = "4699" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4700.nm6R6YTnkmCo5evbykz4kz.toml b/changes/22.0_2025-03-15/4700.nm6R6YTnkmCo5evbykz4kz.toml new file mode 100644 index 00000000000..5f5355f6d2e --- /dev/null +++ b/changes/22.0_2025-03-15/4700.nm6R6YTnkmCo5evbykz4kz.toml @@ -0,0 +1,5 @@ +internal = "Bump Bibo-Joshi/chango from 0.3.1 to 0.3.2" +[[pull_requests]] +uid = "4700" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4701.ah8Wi4SWc22EbgBc4KQeqH.toml b/changes/22.0_2025-03-15/4701.ah8Wi4SWc22EbgBc4KQeqH.toml new file mode 100644 index 00000000000..ac941f29246 --- /dev/null +++ b/changes/22.0_2025-03-15/4701.ah8Wi4SWc22EbgBc4KQeqH.toml @@ -0,0 +1,5 @@ +internal = "Bump pypa/gh-action-pypi-publish from 1.12.3 to 1.12.4" +[[pull_requests]] +uid = "4701" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4709.dbwPVaU8vSacVkMLhiMjyJ.toml b/changes/22.0_2025-03-15/4709.dbwPVaU8vSacVkMLhiMjyJ.toml new file mode 100644 index 00000000000..5cc432cd401 --- /dev/null +++ b/changes/22.0_2025-03-15/4709.dbwPVaU8vSacVkMLhiMjyJ.toml @@ -0,0 +1,5 @@ +internal = "Bump pytest from 8.3.4 to 8.3.5" +[[pull_requests]] +uid = "4709" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4710.CSNixpvxJdLFaM6xSQ39Zf.toml b/changes/22.0_2025-03-15/4710.CSNixpvxJdLFaM6xSQ39Zf.toml new file mode 100644 index 00000000000..4219b05acba --- /dev/null +++ b/changes/22.0_2025-03-15/4710.CSNixpvxJdLFaM6xSQ39Zf.toml @@ -0,0 +1,5 @@ +internal = "Bump sphinx from 8.1.3 to 8.2.3" +[[pull_requests]] +uid = "4710" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4712.8ckkAPAXGzedityWEyv363.toml b/changes/22.0_2025-03-15/4712.8ckkAPAXGzedityWEyv363.toml new file mode 100644 index 00000000000..63f0ad0743e --- /dev/null +++ b/changes/22.0_2025-03-15/4712.8ckkAPAXGzedityWEyv363.toml @@ -0,0 +1,5 @@ +internal = "Bump Bibo-Joshi/chango from 0.3.2 to 0.4.0" +[[pull_requests]] +uid = "4712" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4719.d4xMztC8JjrbVgEscxvMWX.toml b/changes/22.0_2025-03-15/4719.d4xMztC8JjrbVgEscxvMWX.toml new file mode 100644 index 00000000000..c220a3eb6f3 --- /dev/null +++ b/changes/22.0_2025-03-15/4719.d4xMztC8JjrbVgEscxvMWX.toml @@ -0,0 +1,5 @@ +internal = "Bump Version to v22.0" +[[pull_requests]] +uid = "4719" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4692.dVZs28GuwTFnNJdWkvPbNv.toml b/changes/22.1_2025-05-15/4692.dVZs28GuwTFnNJdWkvPbNv.toml new file mode 100644 index 00000000000..a70d3418d7c --- /dev/null +++ b/changes/22.1_2025-05-15/4692.dVZs28GuwTFnNJdWkvPbNv.toml @@ -0,0 +1,5 @@ +breaking = "Drop backward compatibility for ``user_id`` in ``send_gift`` by updating the order of parameters. Please adapt your code accordingly or use keyword arguments." +[[pull_requests]] +uid = "4692" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4730.GsSUQSXzV8TF2Wav4CXRdd.toml b/changes/22.1_2025-05-15/4730.GsSUQSXzV8TF2Wav4CXRdd.toml new file mode 100644 index 00000000000..9a4d9369e2f --- /dev/null +++ b/changes/22.1_2025-05-15/4730.GsSUQSXzV8TF2Wav4CXRdd.toml @@ -0,0 +1,9 @@ +documentation = "Documentation Improvements. Among others, add missing ``Returns`` field in ``User.get_profile_photos``" +[[pull_requests]] +uid = "4730" +author_uid = "Bibo-Joshi" +closes_threads = [] +[[pull_requests]] +uid = "4740" +author_uid = "aelkheir" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4733.BRLwsEuh76974FPJRuiBjf.toml b/changes/22.1_2025-05-15/4733.BRLwsEuh76974FPJRuiBjf.toml new file mode 100644 index 00000000000..ebc3a8519f8 --- /dev/null +++ b/changes/22.1_2025-05-15/4733.BRLwsEuh76974FPJRuiBjf.toml @@ -0,0 +1,5 @@ +bugfixes = "Ensure execution of ``Bot.shutdown()`` even if ``Bot.get_me()`` fails in ``Bot.initialize()``" +[[pull_requests]] +uid = "4733" +author_uid = "Poolitzer" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4741.nVLBrFX4p8jTCBjMRqaYoQ.toml b/changes/22.1_2025-05-15/4741.nVLBrFX4p8jTCBjMRqaYoQ.toml new file mode 100644 index 00000000000..aacb5f2d501 --- /dev/null +++ b/changes/22.1_2025-05-15/4741.nVLBrFX4p8jTCBjMRqaYoQ.toml @@ -0,0 +1,5 @@ +internal = "Bump codecov/test-results-action from 1.0.2 to 1.1.0" +[[pull_requests]] +uid = "4741" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4742.oEA6MjYXMafdbu2akWT5tC.toml b/changes/22.1_2025-05-15/4742.oEA6MjYXMafdbu2akWT5tC.toml new file mode 100644 index 00000000000..97463ed483f --- /dev/null +++ b/changes/22.1_2025-05-15/4742.oEA6MjYXMafdbu2akWT5tC.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/setup-python from 5.4.0 to 5.5.0" +[[pull_requests]] +uid = "4742" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4743.SpMm4vvAMjEreykTcGwzcF.toml b/changes/22.1_2025-05-15/4743.SpMm4vvAMjEreykTcGwzcF.toml new file mode 100644 index 00000000000..b6724ab2917 --- /dev/null +++ b/changes/22.1_2025-05-15/4743.SpMm4vvAMjEreykTcGwzcF.toml @@ -0,0 +1,5 @@ +internal = "Bump github/codeql-action from 3.28.10 to 3.28.13" +[[pull_requests]] +uid = "4743" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4744.a4tsF64kZPA2noP7HtTzTX.toml b/changes/22.1_2025-05-15/4744.a4tsF64kZPA2noP7HtTzTX.toml new file mode 100644 index 00000000000..cb5f24ea554 --- /dev/null +++ b/changes/22.1_2025-05-15/4744.a4tsF64kZPA2noP7HtTzTX.toml @@ -0,0 +1,5 @@ +internal = "Bump astral-sh/setup-uv from 5.3.1 to 5.4.1" +[[pull_requests]] +uid = "4744" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4745.emNmhxtvtTP9uLNQxpcVSj.toml b/changes/22.1_2025-05-15/4745.emNmhxtvtTP9uLNQxpcVSj.toml new file mode 100644 index 00000000000..cae16287a79 --- /dev/null +++ b/changes/22.1_2025-05-15/4745.emNmhxtvtTP9uLNQxpcVSj.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/download-artifact from 4.1.8 to 4.2.1" +[[pull_requests]] +uid = "4745" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4746.gWnX3BCxbvujQ8B2QejtTK.toml b/changes/22.1_2025-05-15/4746.gWnX3BCxbvujQ8B2QejtTK.toml new file mode 100644 index 00000000000..c24204d45c4 --- /dev/null +++ b/changes/22.1_2025-05-15/4746.gWnX3BCxbvujQ8B2QejtTK.toml @@ -0,0 +1,5 @@ +internal = "Reenable ``test_official`` Blocked by Debug Remnant" +[[pull_requests]] +uid = "4746" +author_uid = "aelkheir" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4747.MLmApvpGdwN7J24j7fXsDU.toml b/changes/22.1_2025-05-15/4747.MLmApvpGdwN7J24j7fXsDU.toml new file mode 100644 index 00000000000..e6bb47332f9 --- /dev/null +++ b/changes/22.1_2025-05-15/4747.MLmApvpGdwN7J24j7fXsDU.toml @@ -0,0 +1,5 @@ +documentation = "Update ``AUTHORS.rst``, Adding `@aelkheir `_ to Active Development Team" +[[pull_requests]] +uid = "4747" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4748.j3cKusZZKqTLbc542K4sqJ.toml b/changes/22.1_2025-05-15/4748.j3cKusZZKqTLbc542K4sqJ.toml new file mode 100644 index 00000000000..a719b9ecd07 --- /dev/null +++ b/changes/22.1_2025-05-15/4748.j3cKusZZKqTLbc542K4sqJ.toml @@ -0,0 +1,5 @@ +internal = "Bump `pre-commit` Hooks to Latest Versions" +[[pull_requests]] +uid = "4748" +author_uid = "pre-commit-ci" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4756.JT5nmUmGRG6qDEh5ScMn5f.toml b/changes/22.1_2025-05-15/4756.JT5nmUmGRG6qDEh5ScMn5f.toml new file mode 100644 index 00000000000..a7a6b76c9a7 --- /dev/null +++ b/changes/22.1_2025-05-15/4756.JT5nmUmGRG6qDEh5ScMn5f.toml @@ -0,0 +1,51 @@ +features = "Full Support for Bot API 9.0" +deprecations = """This release comes with several deprecations, in line with our :ref:`stability policy `. +This includes the following: + +- Deprecated ``telegram.constants.StarTransactionsLimit.NANOSTAR_MIN_AMOUNT`` and ``telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT``. These members will be replaced by ``telegram.constants.NanostarLimit.MIN_AMOUNT`` and ``telegram.constants.NanostarLimit.MAX_AMOUNT``. +- Deprecated the class ``telegram.constants.StarTransactions``. Its only member ``telegram.constants.StarTransactions.NANOSTAR_VALUE`` will be replaced by ``telegram.constants.Nanostar.VALUE``. +- Bot API 9.0 deprecated ``BusinessConnection.can_reply`` in favor of ``BusinessConnection.rights`` +- Bot API 9.0 deprecated ``ChatFullInfo.can_send_gift`` in favor of ``ChatFullInfo.accepted_gift_types``. +- Bot API 9.0 introduced these new required fields to existing classes: + - ``TransactionPartnerUser.transaction_type`` + - ``ChatFullInfo.accepted_gift_types`` + + Passing these values as positional arguments is deprecated. We encourage you to use keyword arguments instead, as the the signature will be updated in a future release. + +These deprecations are backward compatible, but we strongly recommend to update your code to use the new members. +""" +[[pull_requests]] +uid = "4756" +author_uid = "Bibo-Joshi" +closes_threads = ["4754"] +[[pull_requests]] +uid = "4757" +author_uid = "Bibo-Joshi" +closes_threads = [] +[[pull_requests]] +uid = "4759" +author_uid = "Bibo-Joshi" +closes_threads = [] +[[pull_requests]] +uid = "4763" +author_uid = "aelkheir" +closes_threads = [] +[[pull_requests]] +uid = "4766" +author_uid = "Bibo-Joshi" +[[pull_requests]] +uid = "4769" +author_uid = "aelkheir" +closes_threads = [] +[[pull_requests]] +uid = "4773" +author_uid = "aelkheir" +closes_threads = [] +[[pull_requests]] +uid = "4781" +author_uid = "aelkheir" +closes_threads = [] +[[pull_requests]] +uid = "4782" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4758.dSyCdBJWEJroH2GynR2VaJ.toml b/changes/22.1_2025-05-15/4758.dSyCdBJWEJroH2GynR2VaJ.toml new file mode 100644 index 00000000000..c602a07af7c --- /dev/null +++ b/changes/22.1_2025-05-15/4758.dSyCdBJWEJroH2GynR2VaJ.toml @@ -0,0 +1,5 @@ +internal = "Fine-tune ``chango`` and release workflows" +[[pull_requests]] +uid = "4758" +author_uid = "Bibo-Joshi" +closes_threads = ["4720"] diff --git a/changes/22.1_2025-05-15/4761.mmsngFA6b4ccdEzEpFTZS3.toml b/changes/22.1_2025-05-15/4761.mmsngFA6b4ccdEzEpFTZS3.toml new file mode 100644 index 00000000000..a47dcdcc3b1 --- /dev/null +++ b/changes/22.1_2025-05-15/4761.mmsngFA6b4ccdEzEpFTZS3.toml @@ -0,0 +1,6 @@ +bugfixes = "Fix Handling of ``Defaults`` for ``InputPaidMedia``" + +[[pull_requests]] +uid = "4761" +author_uid = "ngrogolev" +closes_threads = ["4753"] diff --git a/changes/22.1_2025-05-15/4762.PbcJGM8KPBMbKri3fdHKjh.toml b/changes/22.1_2025-05-15/4762.PbcJGM8KPBMbKri3fdHKjh.toml new file mode 100644 index 00000000000..0aeebe750b6 --- /dev/null +++ b/changes/22.1_2025-05-15/4762.PbcJGM8KPBMbKri3fdHKjh.toml @@ -0,0 +1,5 @@ +documentation = "Clarify Documentation and Type Hints of ``InputMedia`` and ``InputPaidMedia``. Note that the ``media`` parameter accepts only objects of type ``str`` and ``InputFile``. The respective subclasses of ``Input(Paid)Media`` each accept a broader range of input type for the ``media`` parameter." +[[pull_requests]] +uid = "4762" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4775.kkLon84t7Vy5REKRe9LwPH.toml b/changes/22.1_2025-05-15/4775.kkLon84t7Vy5REKRe9LwPH.toml new file mode 100644 index 00000000000..b01e19eb5ec --- /dev/null +++ b/changes/22.1_2025-05-15/4775.kkLon84t7Vy5REKRe9LwPH.toml @@ -0,0 +1,5 @@ +internal = "Bump codecov/codecov-action from 5.1.2 to 5.4.2" +[[pull_requests]] +uid = "4775" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4776.g83DxRk4WVWCC8rCd6ocFC.toml b/changes/22.1_2025-05-15/4776.g83DxRk4WVWCC8rCd6ocFC.toml new file mode 100644 index 00000000000..2af8ebcd2d6 --- /dev/null +++ b/changes/22.1_2025-05-15/4776.g83DxRk4WVWCC8rCd6ocFC.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/upload-artifact from 4.5.0 to 4.6.2" +[[pull_requests]] +uid = "4776" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4777.Lhgz8xFtQjgrKM2KvNfxwD.toml b/changes/22.1_2025-05-15/4777.Lhgz8xFtQjgrKM2KvNfxwD.toml new file mode 100644 index 00000000000..4b5e40bad26 --- /dev/null +++ b/changes/22.1_2025-05-15/4777.Lhgz8xFtQjgrKM2KvNfxwD.toml @@ -0,0 +1,5 @@ +internal = "Bump stefanzweifel/git-auto-commit-action from 5.1.0 to 5.2.0" +[[pull_requests]] +uid = "4777" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4778.CeUSPNLbGGsqP2Vo4xKkdp.toml b/changes/22.1_2025-05-15/4778.CeUSPNLbGGsqP2Vo4xKkdp.toml new file mode 100644 index 00000000000..c14276f7821 --- /dev/null +++ b/changes/22.1_2025-05-15/4778.CeUSPNLbGGsqP2Vo4xKkdp.toml @@ -0,0 +1,5 @@ +internal = "Bump github/codeql-action from 3.28.13 to 3.28.16" +[[pull_requests]] +uid = "4778" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4779.UqcbJVKYxwTtrBEGDgb3VS.toml b/changes/22.1_2025-05-15/4779.UqcbJVKYxwTtrBEGDgb3VS.toml new file mode 100644 index 00000000000..b6917ffef1b --- /dev/null +++ b/changes/22.1_2025-05-15/4779.UqcbJVKYxwTtrBEGDgb3VS.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/download-artifact from 4.2.1 to 4.3.0" +[[pull_requests]] +uid = "4779" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4791.QycEvQbiaiJKraKDhAfFWM.toml b/changes/22.1_2025-05-15/4791.QycEvQbiaiJKraKDhAfFWM.toml new file mode 100644 index 00000000000..c5db706c800 --- /dev/null +++ b/changes/22.1_2025-05-15/4791.QycEvQbiaiJKraKDhAfFWM.toml @@ -0,0 +1,5 @@ +other = "Bump Version to v22.1" +[[pull_requests]] +uid = "4791" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4750.jJBu7iAgZa96hdqcpHK96W.toml b/changes/22.2_2025-06-29/4750.jJBu7iAgZa96hdqcpHK96W.toml new file mode 100644 index 00000000000..5d9d75d7ca9 --- /dev/null +++ b/changes/22.2_2025-06-29/4750.jJBu7iAgZa96hdqcpHK96W.toml @@ -0,0 +1,36 @@ +features = "Use `timedelta` to represent time periods in class arguments and attributes" +deprecations = """In this release, we're migrating attributes of Telegram objects that represent durations/time periods from having :obj:`int` type to Python's native :class:`datetime.timedelta`. This change is opt-in for now to allow for a smooth transition phase. It will become opt-out in future releases. + +Set ``PTB_TIMEDELTA=true`` or ``PTB_TIMEDELTA=1`` as an environment variable to make these attributes return :obj:`datetime.timedelta` objects instead of integers. Support for :obj:`int` values is deprecated and will be removed in a future major version. + +Affected Attributes: +- :attr:`telegram.ChatFullInfo.slow_mode_delay` and :attr:`telegram.ChatFullInfo.message_auto_delete_time` +- :attr:`telegram.Animation.duration` +- :attr:`telegram.Audio.duration` +- :attr:`telegram.Video.duration` and :attr:`telegram.Video.start_timestamp` +- :attr:`telegram.VideoNote.duration` +- :attr:`telegram.Voice.duration` +- :attr:`telegram.PaidMediaPreview.duration` +- :attr:`telegram.VideoChatEnded.duration` +- :attr:`telegram.InputMediaVideo.duration` +- :attr:`telegram.InputMediaAnimation.duration` +- :attr:`telegram.InputMediaAudio.duration` +- :attr:`telegram.InputPaidMediaVideo.duration` +- :attr:`telegram.InlineQueryResultGif.gif_duration` +- :attr:`telegram.InlineQueryResultMpeg4Gif.mpeg4_duration` +- :attr:`telegram.InlineQueryResultVideo.video_duration` +- :attr:`telegram.InlineQueryResultAudio.audio_duration` +- :attr:`telegram.InlineQueryResultVoice.voice_duration` +- :attr:`telegram.InlineQueryResultLocation.live_period` +- :attr:`telegram.Poll.open_period` +- :attr:`telegram.Location.live_period` +- :attr:`telegram.MessageAutoDeleteTimerChanged.message_auto_delete_time` +- :attr:`telegram.ChatInviteLink.subscription_period` +- :attr:`telegram.InputLocationMessageContent.live_period` +- :attr:`telegram.error.RetryAfter.retry_after` +""" +internal = "Modify `test_official` to handle time periods as timedelta automatically." +[[pull_requests]] +uid = "4750" +author_uid = "aelkheir" +closes_threads = ["4575"] diff --git a/changes/22.2_2025-06-29/4792.YsK6LmbEhZv6y3dvhHbXD7.toml b/changes/22.2_2025-06-29/4792.YsK6LmbEhZv6y3dvhHbXD7.toml new file mode 100644 index 00000000000..675c2904b4d --- /dev/null +++ b/changes/22.2_2025-06-29/4792.YsK6LmbEhZv6y3dvhHbXD7.toml @@ -0,0 +1,5 @@ +internal = "Fix Bug in Automated Channel Announcement" +[[pull_requests]] +uid = "4792" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4793.mDR5p3mrSmPFQkvWFGWBmD.toml b/changes/22.2_2025-06-29/4793.mDR5p3mrSmPFQkvWFGWBmD.toml new file mode 100644 index 00000000000..7a6ca4c3e95 --- /dev/null +++ b/changes/22.2_2025-06-29/4793.mDR5p3mrSmPFQkvWFGWBmD.toml @@ -0,0 +1,5 @@ +internal = "Fix a Failing Test Case" +[[pull_requests]] +uid = "4793" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4798.g7G3jRf2ns4ath9LRFEcit.toml b/changes/22.2_2025-06-29/4798.g7G3jRf2ns4ath9LRFEcit.toml new file mode 100644 index 00000000000..c238ebdfc67 --- /dev/null +++ b/changes/22.2_2025-06-29/4798.g7G3jRf2ns4ath9LRFEcit.toml @@ -0,0 +1,5 @@ +internal = "Rework Repository to `src` Layout" +[[pull_requests]] +uid = "4798" +author_uid = "Bibo-Joshi" +closes_threads = ["4797"] diff --git a/changes/22.2_2025-06-29/4800.2Z9Q8uvzdU2TqMJ9biBLam.toml b/changes/22.2_2025-06-29/4800.2Z9Q8uvzdU2TqMJ9biBLam.toml new file mode 100644 index 00000000000..4f670da8bd0 --- /dev/null +++ b/changes/22.2_2025-06-29/4800.2Z9Q8uvzdU2TqMJ9biBLam.toml @@ -0,0 +1,5 @@ +dependencies = "Implement PEP 735 Dependency Groups for Development Dependencies" +[[pull_requests]] +uid = "4800" +author_uid = "harshil21" +closes_threads = ["4795"] diff --git a/changes/22.2_2025-06-29/4801.feKaYKKZTZq2KBjhyxVVAM.toml b/changes/22.2_2025-06-29/4801.feKaYKKZTZq2KBjhyxVVAM.toml new file mode 100644 index 00000000000..3531270fc8d --- /dev/null +++ b/changes/22.2_2025-06-29/4801.feKaYKKZTZq2KBjhyxVVAM.toml @@ -0,0 +1,5 @@ +dependencies = "Update cachetools requirement from <5.6.0,>=5.3.3 to >=5.3.3,<6.1.0" +[[pull_requests]] +uid = "4801" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4802.RMLufX4UazobYg5aZojyoD.toml b/changes/22.2_2025-06-29/4802.RMLufX4UazobYg5aZojyoD.toml new file mode 100644 index 00000000000..8c558c7c1c8 --- /dev/null +++ b/changes/22.2_2025-06-29/4802.RMLufX4UazobYg5aZojyoD.toml @@ -0,0 +1,14 @@ +bugfixes = """ +Fixed a bug where calling ``Application.remove/add_handler`` during update handling can cause a ``RuntimeError`` in ``Application.process_update``. + +.. hint:: + Calling ``Application.add/remove_handler`` now has no influence on calls to ``process_update`` that are + already in progress. The same holds for ``Application.add/remove_error_handler`` and ``Application.process_error``, respectively. + + .. warning:: + This behavior should currently be considered an implementation detail and not as guaranteed behavior. +""" +[[pull_requests]] +uid = "4802" +author_uid = "Bibo-Joshi" +closes_threads = ["4803"] diff --git a/changes/22.2_2025-06-29/4810.KyRnffWk3ARyQFNcF88Uh3.toml b/changes/22.2_2025-06-29/4810.KyRnffWk3ARyQFNcF88Uh3.toml new file mode 100644 index 00000000000..dcb64b0d66b --- /dev/null +++ b/changes/22.2_2025-06-29/4810.KyRnffWk3ARyQFNcF88Uh3.toml @@ -0,0 +1,20 @@ +documentation = """Documentation Improvements. Among other things + +* mention alternative package managers in README and contribution guide +* remove ``furo-sphinx-search`` +""" + +[[pull_requests]] +uid = "4810" +author_uid = "Bibo-Joshi" +closes_threads = [] + +[[pull_requests]] +uid = "4824" +author_uid = "Aweryc" +closes_threads = ["4823"] + +[[pull_requests]] +uid = "4826" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4811.TQgVr5Sa6uDFtWXdTS5wfw.toml b/changes/22.2_2025-06-29/4811.TQgVr5Sa6uDFtWXdTS5wfw.toml new file mode 100644 index 00000000000..cefe0bb045c --- /dev/null +++ b/changes/22.2_2025-06-29/4811.TQgVr5Sa6uDFtWXdTS5wfw.toml @@ -0,0 +1,5 @@ +internal = "Bump github/codeql-action from 3.28.16 to 3.28.18" +[[pull_requests]] +uid = "4811" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4812.i4aqsTfCkdEs8uYNYS59si.toml b/changes/22.2_2025-06-29/4812.i4aqsTfCkdEs8uYNYS59si.toml new file mode 100644 index 00000000000..0382ab61f34 --- /dev/null +++ b/changes/22.2_2025-06-29/4812.i4aqsTfCkdEs8uYNYS59si.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/setup-python from 5.5.0 to 5.6.0" +[[pull_requests]] +uid = "4812" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4813.cnbzL2eSRzj3i9NcUMFyFo.toml b/changes/22.2_2025-06-29/4813.cnbzL2eSRzj3i9NcUMFyFo.toml new file mode 100644 index 00000000000..afd93290d34 --- /dev/null +++ b/changes/22.2_2025-06-29/4813.cnbzL2eSRzj3i9NcUMFyFo.toml @@ -0,0 +1,5 @@ +internal = "Bump dependabot/fetch-metadata from 2.3.0 to 2.4.0" +[[pull_requests]] +uid = "4813" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4814.RMQVXTNywcpBQ3HkX2TuyA.toml b/changes/22.2_2025-06-29/4814.RMQVXTNywcpBQ3HkX2TuyA.toml new file mode 100644 index 00000000000..789f6ebdc68 --- /dev/null +++ b/changes/22.2_2025-06-29/4814.RMQVXTNywcpBQ3HkX2TuyA.toml @@ -0,0 +1,5 @@ +internal = "Bump codecov/codecov-action from 5.4.2 to 5.4.3" +[[pull_requests]] +uid = "4814" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4815.9dLSFHFozQiAM7oCpX4NyL.toml b/changes/22.2_2025-06-29/4815.9dLSFHFozQiAM7oCpX4NyL.toml new file mode 100644 index 00000000000..e2559792f7b --- /dev/null +++ b/changes/22.2_2025-06-29/4815.9dLSFHFozQiAM7oCpX4NyL.toml @@ -0,0 +1,5 @@ +internal = "Bump codecov/test-results-action from 1.1.0 to 1.1.1" +[[pull_requests]] +uid = "4815" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4816.hhYVDfdzUgoQoMNRKkCDjb.toml b/changes/22.2_2025-06-29/4816.hhYVDfdzUgoQoMNRKkCDjb.toml new file mode 100644 index 00000000000..ade061585de --- /dev/null +++ b/changes/22.2_2025-06-29/4816.hhYVDfdzUgoQoMNRKkCDjb.toml @@ -0,0 +1,5 @@ +internal = "Fix Typo in `TelegramObject._get_attrs`" +[[pull_requests]] +uid = "4816" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4817.ZHx38jvj9zKRNZfQh9Xrpb.toml b/changes/22.2_2025-06-29/4817.ZHx38jvj9zKRNZfQh9Xrpb.toml new file mode 100644 index 00000000000..31e63a6a7ef --- /dev/null +++ b/changes/22.2_2025-06-29/4817.ZHx38jvj9zKRNZfQh9Xrpb.toml @@ -0,0 +1,5 @@ +bugfixes = "Allow for pattern matching empty inline queries" +[[pull_requests]] +uid = "4817" +author_uid = "locobott" +closes_threads = [] \ No newline at end of file diff --git a/changes/22.2_2025-06-29/4818.3VPDqqEWEhDCrTipFY8KKU.toml b/changes/22.2_2025-06-29/4818.3VPDqqEWEhDCrTipFY8KKU.toml new file mode 100644 index 00000000000..503e69ae82e --- /dev/null +++ b/changes/22.2_2025-06-29/4818.3VPDqqEWEhDCrTipFY8KKU.toml @@ -0,0 +1,12 @@ +bugfixes = """ +Correctly parse parameter ``allow_sending_without_reply`` in ``Message.reply_*`` when used in combination with ``do_quote=True``. + +.. hint:: + + Using ``dict`` valued input for ``do_quote`` along with passing ``allow_sending_without_reply`` is not supported and will raise an error. +""" + +[[pull_requests]] +uid = "4818" +author_uid = "Bibo-Joshi" +closes_threads = ["4807"] diff --git a/changes/22.2_2025-06-29/4820.7bFkjLSeWKdNVhThPpVMAT.toml b/changes/22.2_2025-06-29/4820.7bFkjLSeWKdNVhThPpVMAT.toml new file mode 100644 index 00000000000..f0b2f0f9ff0 --- /dev/null +++ b/changes/22.2_2025-06-29/4820.7bFkjLSeWKdNVhThPpVMAT.toml @@ -0,0 +1,5 @@ +dependencies = "Bump ``httpx`` from ~=0.27 to >=0.27,<0.29" +[[pull_requests]] +uid = "4820" +author_uid = "Bibo-Joshi" +closes_threads = ["4819"] diff --git a/changes/22.2_2025-06-29/4822.DrW3tJ3KoB8kTmHtNnNEpQ.toml b/changes/22.2_2025-06-29/4822.DrW3tJ3KoB8kTmHtNnNEpQ.toml new file mode 100644 index 00000000000..060021dc6af --- /dev/null +++ b/changes/22.2_2025-06-29/4822.DrW3tJ3KoB8kTmHtNnNEpQ.toml @@ -0,0 +1,5 @@ +other = "Improve Informativeness of Network Errors Raised by ``BaseRequest.post/retrieve``" + +[[pull_requests]] +uid = "4822" +author_uid = "Bibo-Joshi" diff --git a/changes/22.2_2025-06-29/4825.R7wiTzvN37KAV656s9kfnC.toml b/changes/22.2_2025-06-29/4825.R7wiTzvN37KAV656s9kfnC.toml new file mode 100644 index 00000000000..5f932e8254d --- /dev/null +++ b/changes/22.2_2025-06-29/4825.R7wiTzvN37KAV656s9kfnC.toml @@ -0,0 +1,5 @@ +other = "Add Python 3.14 Beta To Test Matrix. *Python 3.14 is not officially supported by PTB yet!*" +[[pull_requests]] +uid = "4825" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4830.EZzGEAk7DiFuedKPoQMMvd.toml b/changes/22.2_2025-06-29/4830.EZzGEAk7DiFuedKPoQMMvd.toml new file mode 100644 index 00000000000..40e5f0fb805 --- /dev/null +++ b/changes/22.2_2025-06-29/4830.EZzGEAk7DiFuedKPoQMMvd.toml @@ -0,0 +1,5 @@ +dependencies = "Update ``cachetools`` requirement from <6.1.0,>=5.3.3 to >=5.3.3,<6.2.0" + +[[pull_requests]] +uid = "4830" +author_uid = "dependabot" diff --git a/changes/22.2_2025-06-29/4834.oBWsiGWNMuoSXvJNom6N6A.toml b/changes/22.2_2025-06-29/4834.oBWsiGWNMuoSXvJNom6N6A.toml new file mode 100644 index 00000000000..db25128ceaa --- /dev/null +++ b/changes/22.2_2025-06-29/4834.oBWsiGWNMuoSXvJNom6N6A.toml @@ -0,0 +1,5 @@ +other = "Bump Version to v22.2" +[[pull_requests]] +uid = "4834" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4837.cVTsY7pxfQqgVXfFW9hgZK.toml b/changes/22.3_2025-07-20/4837.cVTsY7pxfQqgVXfFW9hgZK.toml new file mode 100644 index 00000000000..799b32d75fe --- /dev/null +++ b/changes/22.3_2025-07-20/4837.cVTsY7pxfQqgVXfFW9hgZK.toml @@ -0,0 +1,6 @@ +internal = "Update API Token for Local Testing Bot" + +[[pull_requests]] +uid = "4837" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4839.gExNhF7C6kR5VrBg6cBaar.toml b/changes/22.3_2025-07-20/4839.gExNhF7C6kR5VrBg6cBaar.toml new file mode 100644 index 00000000000..30824ba789f --- /dev/null +++ b/changes/22.3_2025-07-20/4839.gExNhF7C6kR5VrBg6cBaar.toml @@ -0,0 +1,5 @@ +documentation = "Documentation Improvements. Among others, fix links to source code." +[[pull_requests]] +uid = "4839" +author_uid = "aelkheir" +closes_threads = ["4838"] diff --git a/changes/22.3_2025-07-20/4840.jz9uGugc5DUd8x8pQHPyzg.toml b/changes/22.3_2025-07-20/4840.jz9uGugc5DUd8x8pQHPyzg.toml new file mode 100644 index 00000000000..bf51748b985 --- /dev/null +++ b/changes/22.3_2025-07-20/4840.jz9uGugc5DUd8x8pQHPyzg.toml @@ -0,0 +1,5 @@ +internal = "Bump stefanzweifel/git-auto-commit-action from 5.2.0 to 6.0.1" +[[pull_requests]] +uid = "4840" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4841.HHVCCXXZbYAaaQVmKHCJm2.toml b/changes/22.3_2025-07-20/4841.HHVCCXXZbYAaaQVmKHCJm2.toml new file mode 100644 index 00000000000..5959a63505f --- /dev/null +++ b/changes/22.3_2025-07-20/4841.HHVCCXXZbYAaaQVmKHCJm2.toml @@ -0,0 +1,5 @@ +internal = "Bump github/codeql-action from 3.28.18 to 3.29.2" +[[pull_requests]] +uid = "4841" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4842.PSW9ZbxENhwfRhbSfemCwE.toml b/changes/22.3_2025-07-20/4842.PSW9ZbxENhwfRhbSfemCwE.toml new file mode 100644 index 00000000000..25aab233487 --- /dev/null +++ b/changes/22.3_2025-07-20/4842.PSW9ZbxENhwfRhbSfemCwE.toml @@ -0,0 +1,5 @@ +internal = "Bump astral-sh/setup-uv from 5.4.1 to 6.3.1" +[[pull_requests]] +uid = "4842" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4843.f5aZ5Vpxevw8fEEudawU5u.toml b/changes/22.3_2025-07-20/4843.f5aZ5Vpxevw8fEEudawU5u.toml new file mode 100644 index 00000000000..6a2593d4fc3 --- /dev/null +++ b/changes/22.3_2025-07-20/4843.f5aZ5Vpxevw8fEEudawU5u.toml @@ -0,0 +1,5 @@ +internal = "Bump sigstore/gh-action-sigstore-python from 3.0.0 to 3.0.1" +[[pull_requests]] +uid = "4843" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml b/changes/22.3_2025-07-20/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml new file mode 100644 index 00000000000..82d84835c8e --- /dev/null +++ b/changes/22.3_2025-07-20/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml @@ -0,0 +1,18 @@ +highlights = "Full Support for Bot API 9.1" + +features = """ +New filters based on Bot API 9.1: + +* ``filters.StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED`` for ``Message.direct_message_price_changed`` +* ``filters.StatusUpdate.CHECKLIST_TASKS_ADDED`` for ``Message.checklist_tasks_added`` +* ``filters.StatusUpdate.CHECKLIST_TASKS_DONE`` for ``Message.checklist_tasks_done`` +* ``filters.CHECKLIST`` for ``Message.checklist`` +""" + +pull_requests = [ + { uid = "4847", author_uid = "Bibo-Joshi", closes_threads = ["4845"] }, + { uid = "4848", author_uid = "Bibo-Joshi" }, + { uid = "4849", author_uid = "harshil21" }, + { uid = "4851", author_uid = "harshil21" }, + { uid = "4857", author_uid = "aelkheir" }, +] diff --git a/changes/22.3_2025-07-20/4852.mz3RDjX636ZdGoR456C6v9.toml b/changes/22.3_2025-07-20/4852.mz3RDjX636ZdGoR456C6v9.toml new file mode 100644 index 00000000000..73640caded1 --- /dev/null +++ b/changes/22.3_2025-07-20/4852.mz3RDjX636ZdGoR456C6v9.toml @@ -0,0 +1,11 @@ +breaking = """Remove Functionality Deprecated in API 9.0 + +* Remove deprecated argument and attribute ``BusinessConnection.can_reply``. +* Remove deprecated argument and attribute ``ChatFullInfo.can_send_gift`` +* Remove deprecated class ``constants.StarTransactions``. Please instead use :attr:`telegram.constants.Nanostar.VALUE`. +* Remove deprecated attributes ``constants.StarTransactionsLimit.NANOSTAR_MIN_AMOUNT`` and ``constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT``. Please instead use :attr:`telegram.constants.NanostarLimit.MIN_AMOUNT` and :attr:`telegram.constants.NanostarLimit.MAX_AMOUNT`. +""" +[[pull_requests]] +uid = "4852" +author_uid = "aelkheir" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4855.8hCFRFMeMaRWpBEYaxrTMq.toml b/changes/22.3_2025-07-20/4855.8hCFRFMeMaRWpBEYaxrTMq.toml new file mode 100644 index 00000000000..cbbda59a270 --- /dev/null +++ b/changes/22.3_2025-07-20/4855.8hCFRFMeMaRWpBEYaxrTMq.toml @@ -0,0 +1,5 @@ +other= "Make Gender Input Case-Insensitive in ``conversationbot.py``" +[[pull_requests]] +uid = "4855" +author_uid = "fengxiaohu" +closes_threads = ["4846"] diff --git a/changes/22.3_2025-07-20/4858.ajt46xDsbfzFqcghJ2rP6g.toml b/changes/22.3_2025-07-20/4858.ajt46xDsbfzFqcghJ2rP6g.toml new file mode 100644 index 00000000000..54620eebed2 --- /dev/null +++ b/changes/22.3_2025-07-20/4858.ajt46xDsbfzFqcghJ2rP6g.toml @@ -0,0 +1,5 @@ +internal = "Bump `pre-commit` Hooks to Latest Versions" +[[pull_requests]] +uid = "4858" +author_uid = "pre-commit-ci" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4870.QeVo4BE5TNouLRJjCFbRMo.toml b/changes/22.3_2025-07-20/4870.QeVo4BE5TNouLRJjCFbRMo.toml new file mode 100644 index 00000000000..fe24e83ca2d --- /dev/null +++ b/changes/22.3_2025-07-20/4870.QeVo4BE5TNouLRJjCFbRMo.toml @@ -0,0 +1,5 @@ +other = "Bump Version to v22.3" +[[pull_requests]] +uid = "4870" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4869.7GK56NC89XswBFUCiWXtqc.toml b/changes/22.4_2025-09-13/4869.7GK56NC89XswBFUCiWXtqc.toml new file mode 100644 index 00000000000..c02dd235421 --- /dev/null +++ b/changes/22.4_2025-09-13/4869.7GK56NC89XswBFUCiWXtqc.toml @@ -0,0 +1,5 @@ +features = "Extend :meth:`telegram.Message.delete` shortcut to support business message deletion" +[[pull_requests]] +uid = "4869" +author_uid = "jainamoswal" +closes_threads = ["4867"] diff --git a/changes/22.4_2025-09-13/4875.29srP6fENz7FrGGoWSkPNa.toml b/changes/22.4_2025-09-13/4875.29srP6fENz7FrGGoWSkPNa.toml new file mode 100644 index 00000000000..ed9df36be08 --- /dev/null +++ b/changes/22.4_2025-09-13/4875.29srP6fENz7FrGGoWSkPNa.toml @@ -0,0 +1,5 @@ +bugfixes = "Adapt logic on getting the event loop in ``Application.run_polling/webhook`` to Python 3.14" +[[pull_requests]] +uid = "4875" +author_uid = "harshil21" +closes_threads = ["4874"] diff --git a/changes/22.4_2025-09-13/4878.AbGo6aozTWquUjnzzpSdyX.toml b/changes/22.4_2025-09-13/4878.AbGo6aozTWquUjnzzpSdyX.toml new file mode 100644 index 00000000000..81e41dc7b98 --- /dev/null +++ b/changes/22.4_2025-09-13/4878.AbGo6aozTWquUjnzzpSdyX.toml @@ -0,0 +1,9 @@ +documentation = "Documentation Improvements" + +[[pull_requests]] +uid = "4878" +author_uid = "Bibo-Joshi" + +[[pull_requests]] +uid = "4872" +author_uid = "Ca5parAD" diff --git a/changes/22.4_2025-09-13/4879.kABAi45KpR2H6jqJu6NtDS.toml b/changes/22.4_2025-09-13/4879.kABAi45KpR2H6jqJu6NtDS.toml new file mode 100644 index 00000000000..f1fcabc5453 --- /dev/null +++ b/changes/22.4_2025-09-13/4879.kABAi45KpR2H6jqJu6NtDS.toml @@ -0,0 +1,5 @@ +internal = "Address Failing Unit Test for ``send_paid_media``" + +[[pull_requests]] +uid = "4879" +author_uid = "Bibo-Joshi" diff --git a/changes/22.4_2025-09-13/4880.42PW26hGpLypMJMxTeiQZ6.toml b/changes/22.4_2025-09-13/4880.42PW26hGpLypMJMxTeiQZ6.toml new file mode 100644 index 00000000000..fdcc0952a37 --- /dev/null +++ b/changes/22.4_2025-09-13/4880.42PW26hGpLypMJMxTeiQZ6.toml @@ -0,0 +1,6 @@ +internal = "Improve Internal Logic for Network Retries" + +[[pull_requests]] +uid = "4880" +author_uid = "Bibo-Joshi" +closes_threads = ["4871"] diff --git a/changes/22.4_2025-09-13/4881.dJNT656MVxJk9DgcoRePip.toml b/changes/22.4_2025-09-13/4881.dJNT656MVxJk9DgcoRePip.toml new file mode 100644 index 00000000000..6f331b1e4fe --- /dev/null +++ b/changes/22.4_2025-09-13/4881.dJNT656MVxJk9DgcoRePip.toml @@ -0,0 +1,6 @@ +features = "Add convenience properties for ``firstname``, ``lastname``, and ``username`` to ``SharedUser`` and ``ChatShared``" +internal = "Introduce utility module ``_utils.usernames`` refactoring convenience properties around Telegram Objects' ``firstname``, ``lastname``, and ``username``" +pull_requests = [ + { uid = "4881", author_uid = "aelkheir" }, + { uid = "4713", author_uid = "david-shiko" }, +] diff --git a/changes/22.4_2025-09-13/4882.JpgQAHHnCponMKStbC4JsG.toml b/changes/22.4_2025-09-13/4882.JpgQAHHnCponMKStbC4JsG.toml new file mode 100644 index 00000000000..f47875d9dc3 --- /dev/null +++ b/changes/22.4_2025-09-13/4882.JpgQAHHnCponMKStbC4JsG.toml @@ -0,0 +1,10 @@ +other = """ +Set the default connection pool size for ``HTTPXRequest`` to 256 to allow more concurrent requests by default. Drop the ``httpx`` parameter ``max_keepalive_connections``. This way, the ``httpx`` default of 20 is used, leading to a smaller number of idle connections in large connection pools. + +.. hint:: + If you manually build the ``HTTPXRequest`` objects, please be aware that these changes also applies to you. Kindly double check your settings. To specify custom limits, you can set them via the parameter ``httpx_kwargs`` of ``HTTPXRequest``. See also `the httpx documentation `__ for more details on these settings." +""" +[[pull_requests]] +uid = "4882" +author_uid = "Poolitzer" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4884.ADcdCj6GrMaKffYSLzWdsL.toml b/changes/22.4_2025-09-13/4884.ADcdCj6GrMaKffYSLzWdsL.toml new file mode 100644 index 00000000000..fa0d9fe3ecb --- /dev/null +++ b/changes/22.4_2025-09-13/4884.ADcdCj6GrMaKffYSLzWdsL.toml @@ -0,0 +1,5 @@ +internal = "Add Copilot Instructions and Setup Steps" +[[pull_requests]] +uid = "4884" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4886.XSHPk83uhDQdYzurbb4xx9.toml b/changes/22.4_2025-09-13/4886.XSHPk83uhDQdYzurbb4xx9.toml new file mode 100644 index 00000000000..25cf57c755c --- /dev/null +++ b/changes/22.4_2025-09-13/4886.XSHPk83uhDQdYzurbb4xx9.toml @@ -0,0 +1,5 @@ +internal = "Remove ``black``, ``isort``, ``flake8``, and ``pyupgrade`` in favor of ``ruff``" +[[pull_requests]] +uid = "4886" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4887.5yypYZV7Sq5PN4ihmf2NUr.toml b/changes/22.4_2025-09-13/4887.5yypYZV7Sq5PN4ihmf2NUr.toml new file mode 100644 index 00000000000..335d6755af8 --- /dev/null +++ b/changes/22.4_2025-09-13/4887.5yypYZV7Sq5PN4ihmf2NUr.toml @@ -0,0 +1,5 @@ +internal = "Use Renovate to Keep Dependencies Up-To-Date" +[[pull_requests]] +uid = "4887" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4890.oCzELerGo8dqPygShWMV9S.toml b/changes/22.4_2025-09-13/4890.oCzELerGo8dqPygShWMV9S.toml new file mode 100644 index 00000000000..6048f32bb9b --- /dev/null +++ b/changes/22.4_2025-09-13/4890.oCzELerGo8dqPygShWMV9S.toml @@ -0,0 +1,5 @@ +internal = "Add and use a ``uv.lock`` lockfile when setting up the development environment using ``uv``." +[[pull_requests]] +uid = "4890" +author_uid = "harshil21" +closes_threads = ["4796"] diff --git a/changes/22.4_2025-09-13/4892.iRGvURLUGiMSMEDfdpP5jB.toml b/changes/22.4_2025-09-13/4892.iRGvURLUGiMSMEDfdpP5jB.toml new file mode 100644 index 00000000000..a7b25052e53 --- /dev/null +++ b/changes/22.4_2025-09-13/4892.iRGvURLUGiMSMEDfdpP5jB.toml @@ -0,0 +1,5 @@ +internal = "Bump ``pytest`` from 8.4.0 to 8.4.1" +[[pull_requests]] +uid = "4892" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4893.Fbkdqj4bmTTH6e54gRPwvc.toml b/changes/22.4_2025-09-13/4893.Fbkdqj4bmTTH6e54gRPwvc.toml new file mode 100644 index 00000000000..af83a93db66 --- /dev/null +++ b/changes/22.4_2025-09-13/4893.Fbkdqj4bmTTH6e54gRPwvc.toml @@ -0,0 +1,5 @@ +internal = "Bump ``pytest-xdist`` from 3.6.1 to 3.8.0" +[[pull_requests]] +uid = "4893" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4894.PLQSCgnKXygrGdkekTbx8q.toml b/changes/22.4_2025-09-13/4894.PLQSCgnKXygrGdkekTbx8q.toml new file mode 100644 index 00000000000..7a12066430d --- /dev/null +++ b/changes/22.4_2025-09-13/4894.PLQSCgnKXygrGdkekTbx8q.toml @@ -0,0 +1,5 @@ +documentation = "Bump ``furo`` from 2024.8.6 to 2025.7.19" +[[pull_requests]] +uid = "4894" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4895.UECAN369MCuQaLnTmtYFGr.toml b/changes/22.4_2025-09-13/4895.UECAN369MCuQaLnTmtYFGr.toml new file mode 100644 index 00000000000..13b8b8da8ac --- /dev/null +++ b/changes/22.4_2025-09-13/4895.UECAN369MCuQaLnTmtYFGr.toml @@ -0,0 +1,5 @@ +internal = "Bump ``astral-sh/setup-uv`` from 6.3.1 to 6.4.3" +[[pull_requests]] +uid = "4895" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4896.R92Xq7W6PDbabuk6aZ5FZt.toml b/changes/22.4_2025-09-13/4896.R92Xq7W6PDbabuk6aZ5FZt.toml new file mode 100644 index 00000000000..0536ace20a8 --- /dev/null +++ b/changes/22.4_2025-09-13/4896.R92Xq7W6PDbabuk6aZ5FZt.toml @@ -0,0 +1,5 @@ +internal = "Bump ``github/codeql-action`` from 3.29.2 to 3.29.5" +[[pull_requests]] +uid = "4896" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4906.Fju7LDJgwCz2sxgQy9muBG.toml b/changes/22.4_2025-09-13/4906.Fju7LDJgwCz2sxgQy9muBG.toml new file mode 100644 index 00000000000..584d132930b --- /dev/null +++ b/changes/22.4_2025-09-13/4906.Fju7LDJgwCz2sxgQy9muBG.toml @@ -0,0 +1,5 @@ +features = "Add ``filters.FORUM`` to filter messages from forum topic chats" +[[pull_requests]] +uid = "4906" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4908.KnwujH2JruNRZSrTTvvc9B.toml b/changes/22.4_2025-09-13/4908.KnwujH2JruNRZSrTTvvc9B.toml new file mode 100644 index 00000000000..a8359bfc2e4 --- /dev/null +++ b/changes/22.4_2025-09-13/4908.KnwujH2JruNRZSrTTvvc9B.toml @@ -0,0 +1,5 @@ +bugfixes = "Fix ``ResourceWarning`` when passing ``pathlib.Path`` objects to methods which accept file input" +[[pull_requests]] +uid = "4908" +author_uid = "harshil21" +closes_threads = ["4907"] diff --git a/changes/22.4_2025-09-13/4911.kiF45Y4cfPGMq5cuLpa5da.toml b/changes/22.4_2025-09-13/4911.kiF45Y4cfPGMq5cuLpa5da.toml new file mode 100644 index 00000000000..9b7a7cc4080 --- /dev/null +++ b/changes/22.4_2025-09-13/4911.kiF45Y4cfPGMq5cuLpa5da.toml @@ -0,0 +1,14 @@ +features = "Full Support for Bot API 9.2" + +pull_requests = [ + { uid = "4911", author_uid = "aelkheir", closes_threads = ["4910"] }, + { uid = "4918", author_uid = "Poolitzer" }, + { uid = "4917", author_uid = "Poolitzer" }, + { uid = "4914", author_uid = "harshil21"}, + { uid = "4916", author_uid = "harshil21"}, + { uid = "4912", author_uid = "aelkheir" }, + { uid = "4921", author_uid = "aelkheir" }, + { uid = "4936", author_uid = "Bibo-Joshi" }, + { uid = "4935", author_uid = "Bibo-Joshi" }, + { uid = "4931", author_uid = "aelkheir" }, +] diff --git a/changes/22.4_2025-09-13/4915.D3x6MoyYYDaUcB7jmLUyeY.toml b/changes/22.4_2025-09-13/4915.D3x6MoyYYDaUcB7jmLUyeY.toml new file mode 100644 index 00000000000..a38d8d3facd --- /dev/null +++ b/changes/22.4_2025-09-13/4915.D3x6MoyYYDaUcB7jmLUyeY.toml @@ -0,0 +1,5 @@ +internal = "Don't update ``uv.lock`` in copilot runtime environment" +[[pull_requests]] +uid = "4915" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4923.AEztvspxU4Ajz7N3ms56Wq.toml b/changes/22.4_2025-09-13/4923.AEztvspxU4Ajz7N3ms56Wq.toml new file mode 100644 index 00000000000..6c7db1a97af --- /dev/null +++ b/changes/22.4_2025-09-13/4923.AEztvspxU4Ajz7N3ms56Wq.toml @@ -0,0 +1,5 @@ +dependencies = "Update cachetools requirement from <6.2.0,>=5.3.3 to >=5.3.3,<6.3.0" +[[pull_requests]] +uid = "4923" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4925.FCwe9rrjgv5GmfvuhHXjHg.toml b/changes/22.4_2025-09-13/4925.FCwe9rrjgv5GmfvuhHXjHg.toml new file mode 100644 index 00000000000..90a0991a591 --- /dev/null +++ b/changes/22.4_2025-09-13/4925.FCwe9rrjgv5GmfvuhHXjHg.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/checkout from 4.2.2 to 5.0.0" +[[pull_requests]] +uid = "4925" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4926.GMcTfT2hSAdmdT7wi73mzZ.toml b/changes/22.4_2025-09-13/4926.GMcTfT2hSAdmdT7wi73mzZ.toml new file mode 100644 index 00000000000..36b95d96367 --- /dev/null +++ b/changes/22.4_2025-09-13/4926.GMcTfT2hSAdmdT7wi73mzZ.toml @@ -0,0 +1,5 @@ +internal = "Bump codecov/codecov-action from 5.4.3 to 5.5.0" +[[pull_requests]] +uid = "4926" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4927.mHtL79KfGaRw3LEMdHN6Ry.toml b/changes/22.4_2025-09-13/4927.mHtL79KfGaRw3LEMdHN6Ry.toml new file mode 100644 index 00000000000..2489cd6e31b --- /dev/null +++ b/changes/22.4_2025-09-13/4927.mHtL79KfGaRw3LEMdHN6Ry.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/download-artifact from 4.3.0 to 5.0.0" +[[pull_requests]] +uid = "4927" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4928.ej4TvW3TjhqhpUHNyne2r4.toml b/changes/22.4_2025-09-13/4928.ej4TvW3TjhqhpUHNyne2r4.toml new file mode 100644 index 00000000000..44e3dea0b67 --- /dev/null +++ b/changes/22.4_2025-09-13/4928.ej4TvW3TjhqhpUHNyne2r4.toml @@ -0,0 +1,5 @@ +internal = "Bump astral-sh/setup-uv from 6.4.3 to 6.6.1" +[[pull_requests]] +uid = "4928" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4929.P2dSYTjRtQtXPdKyaDHPJA.toml b/changes/22.4_2025-09-13/4929.P2dSYTjRtQtXPdKyaDHPJA.toml new file mode 100644 index 00000000000..28cec83f04c --- /dev/null +++ b/changes/22.4_2025-09-13/4929.P2dSYTjRtQtXPdKyaDHPJA.toml @@ -0,0 +1,5 @@ +internal = "Bump github/codeql-action from 3.29.7 to 3.30.0" +[[pull_requests]] +uid = "4929" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4933.CPGgVg7gvHC3dqmhzAVDVo.toml b/changes/22.4_2025-09-13/4933.CPGgVg7gvHC3dqmhzAVDVo.toml new file mode 100644 index 00000000000..ce9877c8e2c --- /dev/null +++ b/changes/22.4_2025-09-13/4933.CPGgVg7gvHC3dqmhzAVDVo.toml @@ -0,0 +1,5 @@ +internal = "Bump pytest from 8.4.1 to 8.4.2" +[[pull_requests]] +uid = "4933" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4934.W4nUZkUHd9Hw4mGaLA6Ayf.toml b/changes/22.4_2025-09-13/4934.W4nUZkUHd9Hw4mGaLA6Ayf.toml new file mode 100644 index 00000000000..49fb8d8afd8 --- /dev/null +++ b/changes/22.4_2025-09-13/4934.W4nUZkUHd9Hw4mGaLA6Ayf.toml @@ -0,0 +1,5 @@ +internal = "Use Tagged Release of `pydantic` in Development Dependencies" +[[pull_requests]] +uid = "4934" +author_uid = "harshil21" +closes_threads = ["4932"] diff --git a/changes/22.4_2025-09-13/4939.YDNzLiFegKD2difT6yr6P7.toml b/changes/22.4_2025-09-13/4939.YDNzLiFegKD2difT6yr6P7.toml new file mode 100644 index 00000000000..1bb6b630184 --- /dev/null +++ b/changes/22.4_2025-09-13/4939.YDNzLiFegKD2difT6yr6P7.toml @@ -0,0 +1,5 @@ +other = "Bump Version to v22.4" +[[pull_requests]] +uid = "4939" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4861.HEoGVs2mYXWzqMahi6SEhV.toml b/changes/22.5_2025-09-27/4861.HEoGVs2mYXWzqMahi6SEhV.toml new file mode 100644 index 00000000000..5d1f2a0833f --- /dev/null +++ b/changes/22.5_2025-09-27/4861.HEoGVs2mYXWzqMahi6SEhV.toml @@ -0,0 +1,10 @@ +features = """ +Add convenience methods for ``BusinessOpeningHours``: + +* ``get_opening_hours_for_day``: returns the opening hours applicable for a specific date +* ``is_open``: indicates whether the business is open at the specified date and time. +""" +[[pull_requests]] +uid = "4861" +author_uid = "Aweryc" +closes_threads = ["4194"] diff --git a/changes/22.5_2025-09-27/4938.U2x6X7ZGikYv5eo9aUSNGc.toml b/changes/22.5_2025-09-27/4938.U2x6X7ZGikYv5eo9aUSNGc.toml new file mode 100644 index 00000000000..1f16b7393bc --- /dev/null +++ b/changes/22.5_2025-09-27/4938.U2x6X7ZGikYv5eo9aUSNGc.toml @@ -0,0 +1,5 @@ +internal = "Lock file maintenance" +[[pull_requests]] +uid = "4938" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4943.DdNTkLjQbNGeG3nEBGHjBo.toml b/changes/22.5_2025-09-27/4943.DdNTkLjQbNGeG3nEBGHjBo.toml new file mode 100644 index 00000000000..aad26c94270 --- /dev/null +++ b/changes/22.5_2025-09-27/4943.DdNTkLjQbNGeG3nEBGHjBo.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv digest to b75a909" +[[pull_requests]] +uid = "4943" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4944.mYorcTojrvwihYQ5NPwNcE.toml b/changes/22.5_2025-09-27/4944.mYorcTojrvwihYQ5NPwNcE.toml new file mode 100644 index 00000000000..e215535b055 --- /dev/null +++ b/changes/22.5_2025-09-27/4944.mYorcTojrvwihYQ5NPwNcE.toml @@ -0,0 +1,5 @@ +internal = "Update codecov/codecov-action action to v5.5.1" +[[pull_requests]] +uid = "4944" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4945.LDiTvYjbXuQBp7kLyQMYa5.toml b/changes/22.5_2025-09-27/4945.LDiTvYjbXuQBp7kLyQMYa5.toml new file mode 100644 index 00000000000..5e6ac6c4a0a --- /dev/null +++ b/changes/22.5_2025-09-27/4945.LDiTvYjbXuQBp7kLyQMYa5.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.8.17" +[[pull_requests]] +uid = "4945" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4946.KujJLUNcND72Y8aKs3bzSx.toml b/changes/22.5_2025-09-27/4946.KujJLUNcND72Y8aKs3bzSx.toml new file mode 100644 index 00000000000..cf4fd1b38b3 --- /dev/null +++ b/changes/22.5_2025-09-27/4946.KujJLUNcND72Y8aKs3bzSx.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v3.30.3" +[[pull_requests]] +uid = "4946" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4947.CcLd8YTb8XaANn8gQXruNb.toml b/changes/22.5_2025-09-27/4947.CcLd8YTb8XaANn8gQXruNb.toml new file mode 100644 index 00000000000..165ab8886df --- /dev/null +++ b/changes/22.5_2025-09-27/4947.CcLd8YTb8XaANn8gQXruNb.toml @@ -0,0 +1,5 @@ +internal = "Update Pylint to v3.3.8" +[[pull_requests]] +uid = "4947" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4948.ernZikrNEarFtTZgLN7huS.toml b/changes/22.5_2025-09-27/4948.ernZikrNEarFtTZgLN7huS.toml new file mode 100644 index 00000000000..80611fd62c2 --- /dev/null +++ b/changes/22.5_2025-09-27/4948.ernZikrNEarFtTZgLN7huS.toml @@ -0,0 +1,5 @@ +internal = "Update Chango to v0.5.0" +[[pull_requests]] +uid = "4948" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4949.YhMnQj57Qpm9HD5zkkhfZC.toml b/changes/22.5_2025-09-27/4949.YhMnQj57Qpm9HD5zkkhfZC.toml new file mode 100644 index 00000000000..4fb4495e923 --- /dev/null +++ b/changes/22.5_2025-09-27/4949.YhMnQj57Qpm9HD5zkkhfZC.toml @@ -0,0 +1,5 @@ +internal = "Update Mypy to v1.18.1" +[[pull_requests]] +uid = "4949" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4950.VA9pgTVrLPABkUrxifLZnh.toml b/changes/22.5_2025-09-27/4950.VA9pgTVrLPABkUrxifLZnh.toml new file mode 100644 index 00000000000..74a5ce57e9f --- /dev/null +++ b/changes/22.5_2025-09-27/4950.VA9pgTVrLPABkUrxifLZnh.toml @@ -0,0 +1,5 @@ +internal = "Align pre-commit hook APScheduler to with ``pyproject.toml``" +[[pull_requests]] +uid = "4950" +author_uids = ["renovatebot", "Bibo-Joshi"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4951.7ZExRZuEwEoGXX5FMPWctm.toml b/changes/22.5_2025-09-27/4951.7ZExRZuEwEoGXX5FMPWctm.toml new file mode 100644 index 00000000000..f94dc7df9bf --- /dev/null +++ b/changes/22.5_2025-09-27/4951.7ZExRZuEwEoGXX5FMPWctm.toml @@ -0,0 +1,5 @@ +internal = "Align pre-commit hook cachetools to with ``pyproject.toml``" +[[pull_requests]] +uid = "4951" +author_uids = ["renovatebot", "Bibo-Joshi"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4952.bVCE8YehtKGcwLYdiXkmQb.toml b/changes/22.5_2025-09-27/4952.bVCE8YehtKGcwLYdiXkmQb.toml new file mode 100644 index 00000000000..668a0c24cff --- /dev/null +++ b/changes/22.5_2025-09-27/4952.bVCE8YehtKGcwLYdiXkmQb.toml @@ -0,0 +1,5 @@ +internal = "Update pypa/gh-action-pypi-publish action to v1.13.0" +[[pull_requests]] +uid = "4952" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4953.8aPTEQhSPSgTrguzvsw4tq.toml b/changes/22.5_2025-09-27/4953.8aPTEQhSPSgTrguzvsw4tq.toml new file mode 100644 index 00000000000..a06988837a4 --- /dev/null +++ b/changes/22.5_2025-09-27/4953.8aPTEQhSPSgTrguzvsw4tq.toml @@ -0,0 +1,5 @@ +internal = "Renovate: No README updates, label behaviour change, automerge lockfiles" +[[pull_requests]] +uid = "4953" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4954.XrYn8gzjeQF9LoPQomutuW.toml b/changes/22.5_2025-09-27/4954.XrYn8gzjeQF9LoPQomutuW.toml new file mode 100644 index 00000000000..8b0fe1e271f --- /dev/null +++ b/changes/22.5_2025-09-27/4954.XrYn8gzjeQF9LoPQomutuW.toml @@ -0,0 +1,5 @@ +internal = "Lock file maintenance" +[[pull_requests]] +uid = "4954" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4955.2BXmasa9Ms9vqXngSMKSVX.toml b/changes/22.5_2025-09-27/4955.2BXmasa9Ms9vqXngSMKSVX.toml new file mode 100644 index 00000000000..649c61623af --- /dev/null +++ b/changes/22.5_2025-09-27/4955.2BXmasa9Ms9vqXngSMKSVX.toml @@ -0,0 +1,5 @@ +internal = "Lock file maintenance" +[[pull_requests]] +uid = "4955" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4958.ktVtuEvzAxxFxSwk7KXQ66.toml b/changes/22.5_2025-09-27/4958.ktVtuEvzAxxFxSwk7KXQ66.toml new file mode 100644 index 00000000000..9ab6aa44b95 --- /dev/null +++ b/changes/22.5_2025-09-27/4958.ktVtuEvzAxxFxSwk7KXQ66.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv digest to 208b0c0" +[[pull_requests]] +uid = "4958" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4959.H2BCKBh9WXaoFKmwiMX4m7.toml b/changes/22.5_2025-09-27/4959.H2BCKBh9WXaoFKmwiMX4m7.toml new file mode 100644 index 00000000000..dfdfd84c0d2 --- /dev/null +++ b/changes/22.5_2025-09-27/4959.H2BCKBh9WXaoFKmwiMX4m7.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.8.19" +[[pull_requests]] +uid = "4959" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4960.KyngeakT6uHLQXYftm2Cpe.toml b/changes/22.5_2025-09-27/4960.KyngeakT6uHLQXYftm2Cpe.toml new file mode 100644 index 00000000000..14b8add118b --- /dev/null +++ b/changes/22.5_2025-09-27/4960.KyngeakT6uHLQXYftm2Cpe.toml @@ -0,0 +1,5 @@ +internal = "Update Mypy to v1.18.2" +[[pull_requests]] +uid = "4960" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4961.nd2KdJpSCyLC3K5xninxZL.toml b/changes/22.5_2025-09-27/4961.nd2KdJpSCyLC3K5xninxZL.toml new file mode 100644 index 00000000000..d404ca538f9 --- /dev/null +++ b/changes/22.5_2025-09-27/4961.nd2KdJpSCyLC3K5xninxZL.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v6.7.0" +[[pull_requests]] +uid = "4961" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4962.mZAGRv4sXnpirJCkrKniVJ.toml b/changes/22.5_2025-09-27/4962.mZAGRv4sXnpirJCkrKniVJ.toml new file mode 100644 index 00000000000..6f26eb9a9ce --- /dev/null +++ b/changes/22.5_2025-09-27/4962.mZAGRv4sXnpirJCkrKniVJ.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.13.1" +[[pull_requests]] +uid = "4962" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4964.KbKLtQ5MYHdt49gpBxCaja.toml b/changes/22.5_2025-09-27/4964.KbKLtQ5MYHdt49gpBxCaja.toml new file mode 100644 index 00000000000..bd0179d21a1 --- /dev/null +++ b/changes/22.5_2025-09-27/4964.KbKLtQ5MYHdt49gpBxCaja.toml @@ -0,0 +1,5 @@ +internal = "Update actions/stale action to v10" +[[pull_requests]] +uid = "4964" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4965.488hEhfeZfPvfZszvRKdkJ.toml b/changes/22.5_2025-09-27/4965.488hEhfeZfPvfZszvRKdkJ.toml new file mode 100644 index 00000000000..e94bb08a3d9 --- /dev/null +++ b/changes/22.5_2025-09-27/4965.488hEhfeZfPvfZszvRKdkJ.toml @@ -0,0 +1,5 @@ +internal = "Properly Pin Dependency to ``astral/setup-uv`` in Copilot Setup Steps" +[[pull_requests]] +uid = "4965" +author_uids = ["Bibo-Joshi"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4967.a9vCqXXpyAfzTb8TD7ouqB.toml b/changes/22.5_2025-09-27/4967.a9vCqXXpyAfzTb8TD7ouqB.toml new file mode 100644 index 00000000000..7c2046ffe37 --- /dev/null +++ b/changes/22.5_2025-09-27/4967.a9vCqXXpyAfzTb8TD7ouqB.toml @@ -0,0 +1,5 @@ +internal = "Lock file maintenance" +[[pull_requests]] +uid = "4967" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4968.ecxiPBroDpMUkab6uBCUde.toml b/changes/22.5_2025-09-27/4968.ecxiPBroDpMUkab6uBCUde.toml new file mode 100644 index 00000000000..6b961885c0d --- /dev/null +++ b/changes/22.5_2025-09-27/4968.ecxiPBroDpMUkab6uBCUde.toml @@ -0,0 +1,5 @@ +internal = "Tune Renovate Configuration" +[[pull_requests]] +uid = "4968" +author_uids = ["harshil21"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4972.RX4QftpcFPCv2wXbTAhDAh.toml b/changes/22.5_2025-09-27/4972.RX4QftpcFPCv2wXbTAhDAh.toml new file mode 100644 index 00000000000..4fca643fbd5 --- /dev/null +++ b/changes/22.5_2025-09-27/4972.RX4QftpcFPCv2wXbTAhDAh.toml @@ -0,0 +1,10 @@ +breaking = """Move param ``ReplyParameters.checklist_task_id`` to last position. + +.. hint:: + This change addresses a breaking change accidentally introduced in version 22.4 where the introduction of the new parameter was not done in a backward compatible way. + Existing code using keyword arguments is unaffected. Only code using positional arguments (and based on version 22.4) may need updates. +""" +[[pull_requests]] +uid = "4972" +author_uids = ["aelkheir"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4973.PtSpAPsm7wh4PWc4p3uajX.toml b/changes/22.5_2025-09-27/4973.PtSpAPsm7wh4PWc4p3uajX.toml new file mode 100644 index 00000000000..4c518bff806 --- /dev/null +++ b/changes/22.5_2025-09-27/4973.PtSpAPsm7wh4PWc4p3uajX.toml @@ -0,0 +1,5 @@ +bugfixes = "Fix Handling of Infinite Bootstrap Retries in ``Application.run_*`` and ``Updater.start_*``" +[[pull_requests]] +uid = "4973" +author_uids = ["Bibo-Joshi"] +closes_threads = ["4966"] diff --git a/changes/22.5_2025-09-27/4974.c4UDqA4q7eBr9mYGmJL4CQ.toml b/changes/22.5_2025-09-27/4974.c4UDqA4q7eBr9mYGmJL4CQ.toml new file mode 100644 index 00000000000..11e5e4980ee --- /dev/null +++ b/changes/22.5_2025-09-27/4974.c4UDqA4q7eBr9mYGmJL4CQ.toml @@ -0,0 +1,4 @@ +documentation = "Documentation Improvemennts" +[[pull_requests]] +uid = "4974" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.5_2025-09-27/4975.cxvpDwNRvRzK9aZxRbav6b.toml b/changes/22.5_2025-09-27/4975.cxvpDwNRvRzK9aZxRbav6b.toml new file mode 100644 index 00000000000..3bd6d50e5cc --- /dev/null +++ b/changes/22.5_2025-09-27/4975.cxvpDwNRvRzK9aZxRbav6b.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.8.22" +[[pull_requests]] +uid = "4975" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4976.GVUSXmvHEopzdff7bVCNF4.toml b/changes/22.5_2025-09-27/4976.GVUSXmvHEopzdff7bVCNF4.toml new file mode 100644 index 00000000000..d02dddb97e7 --- /dev/null +++ b/changes/22.5_2025-09-27/4976.GVUSXmvHEopzdff7bVCNF4.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v3.30.5" +[[pull_requests]] +uid = "4976" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4977.YYYUcDY7oSNQ9FXUGZq7sp.toml b/changes/22.5_2025-09-27/4977.YYYUcDY7oSNQ9FXUGZq7sp.toml new file mode 100644 index 00000000000..a99f05f73bb --- /dev/null +++ b/changes/22.5_2025-09-27/4977.YYYUcDY7oSNQ9FXUGZq7sp.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.13.2" +[[pull_requests]] +uid = "4977" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4978.9hhbtgMWDjm7BUNaNozjoV.toml b/changes/22.5_2025-09-27/4978.9hhbtgMWDjm7BUNaNozjoV.toml new file mode 100644 index 00000000000..0f52602a8cb --- /dev/null +++ b/changes/22.5_2025-09-27/4978.9hhbtgMWDjm7BUNaNozjoV.toml @@ -0,0 +1,5 @@ +internal = "Update dependency furo to v2025.9.25" +[[pull_requests]] +uid = "4978" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4979.gRpn8vd66DtorTtkMN4AsU.toml b/changes/22.5_2025-09-27/4979.gRpn8vd66DtorTtkMN4AsU.toml new file mode 100644 index 00000000000..ead5a1dbbd3 --- /dev/null +++ b/changes/22.5_2025-09-27/4979.gRpn8vd66DtorTtkMN4AsU.toml @@ -0,0 +1,5 @@ +other = "Bump Version to v22.5" +[[pull_requests]] +uid = "4979" +author_uids = ["Bibo-Joshi"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4827.PBXyEEjvXz5sJbiDWkeGFX.toml b/changes/22.6_2026-01-24/4827.PBXyEEjvXz5sJbiDWkeGFX.toml new file mode 100644 index 00000000000..89da3b9b9b1 --- /dev/null +++ b/changes/22.6_2026-01-24/4827.PBXyEEjvXz5sJbiDWkeGFX.toml @@ -0,0 +1,5 @@ +other = "Remove Support for Python 3.9" +[[pull_requests]] +uid = "4827" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4980.86EojeXUgUSi87JKMZojGD.toml b/changes/22.6_2026-01-24/4980.86EojeXUgUSi87JKMZojGD.toml new file mode 100644 index 00000000000..92f328ae495 --- /dev/null +++ b/changes/22.6_2026-01-24/4980.86EojeXUgUSi87JKMZojGD.toml @@ -0,0 +1,5 @@ +internal = "Lock file maintenance" +[[pull_requests]] +uid = "4980" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4984.XKkS99HJWztHscs6N6wpEh.toml b/changes/22.6_2026-01-24/4984.XKkS99HJWztHscs6N6wpEh.toml new file mode 100644 index 00000000000..d2733305d2f --- /dev/null +++ b/changes/22.6_2026-01-24/4984.XKkS99HJWztHscs6N6wpEh.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v3.30.6" +[[pull_requests]] +uid = "4984" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4985.LrPNHbexoHAmim6zXB9nMp.toml b/changes/22.6_2026-01-24/4985.LrPNHbexoHAmim6zXB9nMp.toml new file mode 100644 index 00000000000..c8fa893b9b2 --- /dev/null +++ b/changes/22.6_2026-01-24/4985.LrPNHbexoHAmim6zXB9nMp.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.13.3" +[[pull_requests]] +uid = "4985" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4986.fwbZif2YKWgeugPof8Fikj.toml b/changes/22.6_2026-01-24/4986.fwbZif2YKWgeugPof8Fikj.toml new file mode 100644 index 00000000000..517db07fe1d --- /dev/null +++ b/changes/22.6_2026-01-24/4986.fwbZif2YKWgeugPof8Fikj.toml @@ -0,0 +1,5 @@ +internal = "Update actions/stale action to v10.1.0" +[[pull_requests]] +uid = "4986" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4987.PLoqzvYm725uVfKcWhwuCb.toml b/changes/22.6_2026-01-24/4987.PLoqzvYm725uVfKcWhwuCb.toml new file mode 100644 index 00000000000..a628db0ae0a --- /dev/null +++ b/changes/22.6_2026-01-24/4987.PLoqzvYm725uVfKcWhwuCb.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v6.8.0" +[[pull_requests]] +uid = "4987" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4988.Lncn8TNDcQF4hLQZJvyzQY.toml b/changes/22.6_2026-01-24/4988.Lncn8TNDcQF4hLQZJvyzQY.toml new file mode 100644 index 00000000000..0e56db89db9 --- /dev/null +++ b/changes/22.6_2026-01-24/4988.Lncn8TNDcQF4hLQZJvyzQY.toml @@ -0,0 +1,5 @@ +documentation = "Documentation Improvements. Among others, fix dead links." + +[[pull_requests]] +uid = "4988" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/4989.TjTuqb5LqcEXDLvArDzcEY.toml b/changes/22.6_2026-01-24/4989.TjTuqb5LqcEXDLvArDzcEY.toml new file mode 100644 index 00000000000..b75b4f63bf1 --- /dev/null +++ b/changes/22.6_2026-01-24/4989.TjTuqb5LqcEXDLvArDzcEY.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.8.23" +[[pull_requests]] +uid = "4989" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4990.HxHP2zqbTAcGzjapkgX3E5.toml b/changes/22.6_2026-01-24/4990.HxHP2zqbTAcGzjapkgX3E5.toml new file mode 100644 index 00000000000..99b4a0fecbb --- /dev/null +++ b/changes/22.6_2026-01-24/4990.HxHP2zqbTAcGzjapkgX3E5.toml @@ -0,0 +1,5 @@ +internal = "Update configuration of actions/stale to use issue types instead of labels" + +[[pull_requests]] +uid = "4990" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/4994.K8XVzJe4kuoHmc2yRsrYTb.toml b/changes/22.6_2026-01-24/4994.K8XVzJe4kuoHmc2yRsrYTb.toml new file mode 100644 index 00000000000..fccd71fdc7d --- /dev/null +++ b/changes/22.6_2026-01-24/4994.K8XVzJe4kuoHmc2yRsrYTb.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v3.30.8" +[[pull_requests]] +uid = "4994" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4995.TGRtuG8BUqB6S9JxpPxiNR.toml b/changes/22.6_2026-01-24/4995.TGRtuG8BUqB6S9JxpPxiNR.toml new file mode 100644 index 00000000000..6201c93f14d --- /dev/null +++ b/changes/22.6_2026-01-24/4995.TGRtuG8BUqB6S9JxpPxiNR.toml @@ -0,0 +1,5 @@ +internal = "Update Pylint to v3.3.9" +[[pull_requests]] +uid = "4995" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4996.bRG2iuMsWDR47wDt556q57.toml b/changes/22.6_2026-01-24/4996.bRG2iuMsWDR47wDt556q57.toml new file mode 100644 index 00000000000..0db147afd39 --- /dev/null +++ b/changes/22.6_2026-01-24/4996.bRG2iuMsWDR47wDt556q57.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.2" +[[pull_requests]] +uid = "4996" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4997.hJRqz3id5FnzSzPeMhY3k6.toml b/changes/22.6_2026-01-24/4997.hJRqz3id5FnzSzPeMhY3k6.toml new file mode 100644 index 00000000000..6798caa22df --- /dev/null +++ b/changes/22.6_2026-01-24/4997.hJRqz3id5FnzSzPeMhY3k6.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.0" +[[pull_requests]] +uid = "4997" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4998.jvTA7u2hEA7xwa87XoUqec.toml b/changes/22.6_2026-01-24/4998.jvTA7u2hEA7xwa87XoUqec.toml new file mode 100644 index 00000000000..ac897e504fc --- /dev/null +++ b/changes/22.6_2026-01-24/4998.jvTA7u2hEA7xwa87XoUqec.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v7" +[[pull_requests]] +uid = "4998" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4999.NGTC66pJdAhjtoLykBsxQc.toml b/changes/22.6_2026-01-24/4999.NGTC66pJdAhjtoLykBsxQc.toml new file mode 100644 index 00000000000..b2a5c245a32 --- /dev/null +++ b/changes/22.6_2026-01-24/4999.NGTC66pJdAhjtoLykBsxQc.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v4" +[[pull_requests]] +uid = "4999" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5000.A5P7pNDw2Bc5Uaq3psVEWZ.toml b/changes/22.6_2026-01-24/5000.A5P7pNDw2Bc5Uaq3psVEWZ.toml new file mode 100644 index 00000000000..650d5627bba --- /dev/null +++ b/changes/22.6_2026-01-24/5000.A5P7pNDw2Bc5Uaq3psVEWZ.toml @@ -0,0 +1,5 @@ +internal = "Update Pylint to v4 (major)" +[[pull_requests]] +uid = "5000" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5001.DXjSVgk4h3o8WoPTy8Jpng.toml b/changes/22.6_2026-01-24/5001.DXjSVgk4h3o8WoPTy8Jpng.toml new file mode 100644 index 00000000000..6099fcbd870 --- /dev/null +++ b/changes/22.6_2026-01-24/5001.DXjSVgk4h3o8WoPTy8Jpng.toml @@ -0,0 +1,5 @@ +internal = "Update stefanzweifel/git-auto-commit-action action to v7" +[[pull_requests]] +uid = "5001" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5002.cube9Ku57EajUDF8xoSdfV.toml b/changes/22.6_2026-01-24/5002.cube9Ku57EajUDF8xoSdfV.toml new file mode 100644 index 00000000000..5508daf4ef6 --- /dev/null +++ b/changes/22.6_2026-01-24/5002.cube9Ku57EajUDF8xoSdfV.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v7.1.0" +[[pull_requests]] +uid = "5002" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5004.k7NzG5uTwQxcHP6shbogHL.toml b/changes/22.6_2026-01-24/5004.k7NzG5uTwQxcHP6shbogHL.toml new file mode 100644 index 00000000000..cbe56a74ee6 --- /dev/null +++ b/changes/22.6_2026-01-24/5004.k7NzG5uTwQxcHP6shbogHL.toml @@ -0,0 +1,5 @@ +internal = "Use Python 3.14 Final in the Test Suite" +[[pull_requests]] +uid = "5004" +author_uids = ["harshil21"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5006.hRJtYPL4nBeZ43DWqii68V.toml b/changes/22.6_2026-01-24/5006.hRJtYPL4nBeZ43DWqii68V.toml new file mode 100644 index 00000000000..8bf90cfcb20 --- /dev/null +++ b/changes/22.6_2026-01-24/5006.hRJtYPL4nBeZ43DWqii68V.toml @@ -0,0 +1,5 @@ +internal = "Add Freethreaded Python 3.14 to the Test Suite" +[[pull_requests]] +uid = "5006" +author_uids = ["harshil21"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5007.ANT5x4yXn667UnjSHsCSUb.toml b/changes/22.6_2026-01-24/5007.ANT5x4yXn667UnjSHsCSUb.toml new file mode 100644 index 00000000000..90cec876e36 --- /dev/null +++ b/changes/22.6_2026-01-24/5007.ANT5x4yXn667UnjSHsCSUb.toml @@ -0,0 +1,5 @@ +internal = "Make ``chango`` Commit Re-Trigger Workflows" +[[pull_requests]] +uid = "5007" +author_uids = ["Bibo-Joshi"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5008.4XBBoZAXkrtsuHRxPqAW9V.toml b/changes/22.6_2026-01-24/5008.4XBBoZAXkrtsuHRxPqAW9V.toml new file mode 100644 index 00000000000..16bfab5b754 --- /dev/null +++ b/changes/22.6_2026-01-24/5008.4XBBoZAXkrtsuHRxPqAW9V.toml @@ -0,0 +1,4 @@ +internal = "Temporarily disable ``actions/stale`` due to a bug with ``only-issue-types``." +[[pull_requests]] +uid = "5008" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/5009.QuigkwxAs8bemB9gnKbXhz.toml b/changes/22.6_2026-01-24/5009.QuigkwxAs8bemB9gnKbXhz.toml new file mode 100644 index 00000000000..ec095e6e6ff --- /dev/null +++ b/changes/22.6_2026-01-24/5009.QuigkwxAs8bemB9gnKbXhz.toml @@ -0,0 +1,4 @@ +internal = "Re-Enable ``actions/stale`` Workflow" +[[pull_requests]] +uid = "5009" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/5011.PSJLmKo5LhUXjPbnGbZ3bn.toml b/changes/22.6_2026-01-24/5011.PSJLmKo5LhUXjPbnGbZ3bn.toml new file mode 100644 index 00000000000..f2f254b2029 --- /dev/null +++ b/changes/22.6_2026-01-24/5011.PSJLmKo5LhUXjPbnGbZ3bn.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.3" +[[pull_requests]] +uid = "5011" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5012.CBCzSf2FaoALedVnSyExqD.toml b/changes/22.6_2026-01-24/5012.CBCzSf2FaoALedVnSyExqD.toml new file mode 100644 index 00000000000..935e16c628f --- /dev/null +++ b/changes/22.6_2026-01-24/5012.CBCzSf2FaoALedVnSyExqD.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v4.30.9" +[[pull_requests]] +uid = "5012" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5013.FrUE8G2c4xicUxiTj4Drfn.toml b/changes/22.6_2026-01-24/5013.FrUE8G2c4xicUxiTj4Drfn.toml new file mode 100644 index 00000000000..8869abbf953 --- /dev/null +++ b/changes/22.6_2026-01-24/5013.FrUE8G2c4xicUxiTj4Drfn.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.1" +[[pull_requests]] +uid = "5013" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5014.MzMmSLFoUDeCV6JeJNuL2H.toml b/changes/22.6_2026-01-24/5014.MzMmSLFoUDeCV6JeJNuL2H.toml new file mode 100644 index 00000000000..6b53759235d --- /dev/null +++ b/changes/22.6_2026-01-24/5014.MzMmSLFoUDeCV6JeJNuL2H.toml @@ -0,0 +1,5 @@ +internal = "Update dependency chango to ~=0.6.0" +[[pull_requests]] +uid = "5014" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5015.6PQeFxBMxfEdw82aAqoRSy.toml b/changes/22.6_2026-01-24/5015.6PQeFxBMxfEdw82aAqoRSy.toml new file mode 100644 index 00000000000..93deeec0825 --- /dev/null +++ b/changes/22.6_2026-01-24/5015.6PQeFxBMxfEdw82aAqoRSy.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.4" +[[pull_requests]] +uid = "5015" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5016.bmfLnKrJ932h5QGKWs9kke.toml b/changes/22.6_2026-01-24/5016.bmfLnKrJ932h5QGKWs9kke.toml new file mode 100644 index 00000000000..598a48a0b9d --- /dev/null +++ b/changes/22.6_2026-01-24/5016.bmfLnKrJ932h5QGKWs9kke.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v7.1.1" +[[pull_requests]] +uid = "5016" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5022.Kz9z6826xjnfjXhXTaJhGE.toml b/changes/22.6_2026-01-24/5022.Kz9z6826xjnfjXhXTaJhGE.toml new file mode 100644 index 00000000000..eddecbfce4a --- /dev/null +++ b/changes/22.6_2026-01-24/5022.Kz9z6826xjnfjXhXTaJhGE.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.5" +[[pull_requests]] +uid = "5022" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5023.Zeup3tNhhr2F6M5KHxcj5f.toml b/changes/22.6_2026-01-24/5023.Zeup3tNhhr2F6M5KHxcj5f.toml new file mode 100644 index 00000000000..0ff91ad48c8 --- /dev/null +++ b/changes/22.6_2026-01-24/5023.Zeup3tNhhr2F6M5KHxcj5f.toml @@ -0,0 +1,5 @@ +internal = "Update Pylint to v4.0.2" +[[pull_requests]] +uid = "5023" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5024.8UrFV8RpA74s8VaD4FFwaZ.toml b/changes/22.6_2026-01-24/5024.8UrFV8RpA74s8VaD4FFwaZ.toml new file mode 100644 index 00000000000..349cd8b7f8d --- /dev/null +++ b/changes/22.6_2026-01-24/5024.8UrFV8RpA74s8VaD4FFwaZ.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.2" +[[pull_requests]] +uid = "5024" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5025.Zzz5pN2Rmu8DZGtKFzi7wx.toml b/changes/22.6_2026-01-24/5025.Zzz5pN2Rmu8DZGtKFzi7wx.toml new file mode 100644 index 00000000000..88743c7eba7 --- /dev/null +++ b/changes/22.6_2026-01-24/5025.Zzz5pN2Rmu8DZGtKFzi7wx.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v4.31.0" +[[pull_requests]] +uid = "5025" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5026.UgTNGQ3nckNfhFJrRYYznN.toml b/changes/22.6_2026-01-24/5026.UgTNGQ3nckNfhFJrRYYznN.toml new file mode 100644 index 00000000000..513808c72f8 --- /dev/null +++ b/changes/22.6_2026-01-24/5026.UgTNGQ3nckNfhFJrRYYznN.toml @@ -0,0 +1,5 @@ +internal = "Update sigstore/gh-action-sigstore-python action to v3.1.0" +[[pull_requests]] +uid = "5026" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5027.2ostaGWzx3TtWpQACbgDLs.toml b/changes/22.6_2026-01-24/5027.2ostaGWzx3TtWpQACbgDLs.toml new file mode 100644 index 00000000000..4b67d6032d0 --- /dev/null +++ b/changes/22.6_2026-01-24/5027.2ostaGWzx3TtWpQACbgDLs.toml @@ -0,0 +1,5 @@ +internal = "Update GitHub Artifact Actions (major)" +[[pull_requests]] +uid = "5027" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5029.8qJNkyaEhhMckJjNZSyUMT.toml b/changes/22.6_2026-01-24/5029.8qJNkyaEhhMckJjNZSyUMT.toml new file mode 100644 index 00000000000..0784d21c71f --- /dev/null +++ b/changes/22.6_2026-01-24/5029.8qJNkyaEhhMckJjNZSyUMT.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v7.1.2" +[[pull_requests]] +uid = "5029" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5030.bBRC3598kUYHL2Qpo4gjMb.toml b/changes/22.6_2026-01-24/5030.bBRC3598kUYHL2Qpo4gjMb.toml new file mode 100644 index 00000000000..f68fec4d04f --- /dev/null +++ b/changes/22.6_2026-01-24/5030.bBRC3598kUYHL2Qpo4gjMb.toml @@ -0,0 +1,5 @@ +bugfixes = "Fix a Bug in Initialization Logic of ``Bot``" +[[pull_requests]] +uid = "5030" +author_uids = ["codomposer"] +closes_threads = ["5021"] \ No newline at end of file diff --git a/changes/22.6_2026-01-24/5032.gJe5x9EV6iFcEFDy5ov3Qf.toml b/changes/22.6_2026-01-24/5032.gJe5x9EV6iFcEFDy5ov3Qf.toml new file mode 100644 index 00000000000..f5fde2fdcaf --- /dev/null +++ b/changes/22.6_2026-01-24/5032.gJe5x9EV6iFcEFDy5ov3Qf.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.7" +[[pull_requests]] +uid = "5032" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5033.JX2YjsVeznxTBqZF5yPyHw.toml b/changes/22.6_2026-01-24/5033.JX2YjsVeznxTBqZF5yPyHw.toml new file mode 100644 index 00000000000..ef44d75c370 --- /dev/null +++ b/changes/22.6_2026-01-24/5033.JX2YjsVeznxTBqZF5yPyHw.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v4.31.2" +[[pull_requests]] +uid = "5033" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5034.TboLf7ySS68mHXaoDkQLJa.toml b/changes/22.6_2026-01-24/5034.TboLf7ySS68mHXaoDkQLJa.toml new file mode 100644 index 00000000000..f5704ed428b --- /dev/null +++ b/changes/22.6_2026-01-24/5034.TboLf7ySS68mHXaoDkQLJa.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.3" +[[pull_requests]] +uid = "5034" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5035.LvduDeof5HBLViwSE3D7Tj.toml b/changes/22.6_2026-01-24/5035.LvduDeof5HBLViwSE3D7Tj.toml new file mode 100644 index 00000000000..eb17b8cfc40 --- /dev/null +++ b/changes/22.6_2026-01-24/5035.LvduDeof5HBLViwSE3D7Tj.toml @@ -0,0 +1,5 @@ +internal = "Lock file maintenance" +[[pull_requests]] +uid = "5035" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5040.fdwnytzmSECx9qsps5YQ8p.toml b/changes/22.6_2026-01-24/5040.fdwnytzmSECx9qsps5YQ8p.toml new file mode 100644 index 00000000000..bcd8853abf2 --- /dev/null +++ b/changes/22.6_2026-01-24/5040.fdwnytzmSECx9qsps5YQ8p.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.8" +[[pull_requests]] +uid = "5040" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5041.KvQgtUQd4LsSVxifJurtCa.toml b/changes/22.6_2026-01-24/5041.KvQgtUQd4LsSVxifJurtCa.toml new file mode 100644 index 00000000000..76867b04eb2 --- /dev/null +++ b/changes/22.6_2026-01-24/5041.KvQgtUQd4LsSVxifJurtCa.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.4" +[[pull_requests]] +uid = "5041" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5042.EsubhGfoUGEXXD3iYhpscb.toml b/changes/22.6_2026-01-24/5042.EsubhGfoUGEXXD3iYhpscb.toml new file mode 100644 index 00000000000..6cea2946657 --- /dev/null +++ b/changes/22.6_2026-01-24/5042.EsubhGfoUGEXXD3iYhpscb.toml @@ -0,0 +1,5 @@ +internal = "Update dependency pytest to v9" +[[pull_requests]] +uid = "5042" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5044.QdtCmSghVutBX7hdFxy39Y.toml b/changes/22.6_2026-01-24/5044.QdtCmSghVutBX7hdFxy39Y.toml new file mode 100644 index 00000000000..58eac75ac7b --- /dev/null +++ b/changes/22.6_2026-01-24/5044.QdtCmSghVutBX7hdFxy39Y.toml @@ -0,0 +1,4 @@ +internal = "Reduce Frequence of Renovate Updates for Development Dependencies" +[[pull_requests]] +uid = "5044" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/5048.GCKPwc255mjcVTXBemz9PS.toml b/changes/22.6_2026-01-24/5048.GCKPwc255mjcVTXBemz9PS.toml new file mode 100644 index 00000000000..f143cd5f468 --- /dev/null +++ b/changes/22.6_2026-01-24/5048.GCKPwc255mjcVTXBemz9PS.toml @@ -0,0 +1,5 @@ +internal = "Update Pylint to v4.0.3" +[[pull_requests]] +uid = "5048" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5049.S5gFYpudshPwYzoLYVJQM4.toml b/changes/22.6_2026-01-24/5049.S5gFYpudshPwYzoLYVJQM4.toml new file mode 100644 index 00000000000..2e3e40e5532 --- /dev/null +++ b/changes/22.6_2026-01-24/5049.S5gFYpudshPwYzoLYVJQM4.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.5" +[[pull_requests]] +uid = "5049" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5050.V7Svc2KJrD2Rpn3YnDuYoi.toml b/changes/22.6_2026-01-24/5050.V7Svc2KJrD2Rpn3YnDuYoi.toml new file mode 100644 index 00000000000..a6dffd3174f --- /dev/null +++ b/changes/22.6_2026-01-24/5050.V7Svc2KJrD2Rpn3YnDuYoi.toml @@ -0,0 +1,4 @@ +internal = "Stabilize some unit tests" +[[pull_requests]] +uid = "5050" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/5055.B8q4kJCbfLU8km5JAGSFZj.toml b/changes/22.6_2026-01-24/5055.B8q4kJCbfLU8km5JAGSFZj.toml new file mode 100644 index 00000000000..67f0e3bf8cb --- /dev/null +++ b/changes/22.6_2026-01-24/5055.B8q4kJCbfLU8km5JAGSFZj.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.6" +[[pull_requests]] +uid = "5055" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5056.XnhzpGSAsyoXMGS5mV47Ec.toml b/changes/22.6_2026-01-24/5056.XnhzpGSAsyoXMGS5mV47Ec.toml new file mode 100644 index 00000000000..6f956c20d44 --- /dev/null +++ b/changes/22.6_2026-01-24/5056.XnhzpGSAsyoXMGS5mV47Ec.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.7" +[[pull_requests]] +uid = "5056" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5057.F3hY56ZMSYAUszphSqQHiK.toml b/changes/22.6_2026-01-24/5057.F3hY56ZMSYAUszphSqQHiK.toml new file mode 100644 index 00000000000..1b29b4c38d0 --- /dev/null +++ b/changes/22.6_2026-01-24/5057.F3hY56ZMSYAUszphSqQHiK.toml @@ -0,0 +1,5 @@ +internal = "Update Pylint to v4.0.4" +[[pull_requests]] +uid = "5057" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5058.28oHRt4BwX6yeg2LRMaCXQ.toml b/changes/22.6_2026-01-24/5058.28oHRt4BwX6yeg2LRMaCXQ.toml new file mode 100644 index 00000000000..54281e298f0 --- /dev/null +++ b/changes/22.6_2026-01-24/5058.28oHRt4BwX6yeg2LRMaCXQ.toml @@ -0,0 +1,5 @@ +internal = "Update actions/checkout action to v5.0.1" +[[pull_requests]] +uid = "5058" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5059.4hDeRNcVVVgJkN8fV2ahxJ.toml b/changes/22.6_2026-01-24/5059.4hDeRNcVVVgJkN8fV2ahxJ.toml new file mode 100644 index 00000000000..6ed692df4e2 --- /dev/null +++ b/changes/22.6_2026-01-24/5059.4hDeRNcVVVgJkN8fV2ahxJ.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v7.1.4" +[[pull_requests]] +uid = "5059" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5060.3thn8ynSueq4pZEDBTTs52.toml b/changes/22.6_2026-01-24/5060.3thn8ynSueq4pZEDBTTs52.toml new file mode 100644 index 00000000000..cf28803797c --- /dev/null +++ b/changes/22.6_2026-01-24/5060.3thn8ynSueq4pZEDBTTs52.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.13" +[[pull_requests]] +uid = "5060" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5061.FmvsKgVUc4Rgb9avL4Wmws.toml b/changes/22.6_2026-01-24/5061.FmvsKgVUc4Rgb9avL4Wmws.toml new file mode 100644 index 00000000000..b73558049e5 --- /dev/null +++ b/changes/22.6_2026-01-24/5061.FmvsKgVUc4Rgb9avL4Wmws.toml @@ -0,0 +1,5 @@ +internal = "Update dependency pytest to v9.0.1" +[[pull_requests]] +uid = "5061" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5062.T5v63ioaphzU2yjAkvQnz2.toml b/changes/22.6_2026-01-24/5062.T5v63ioaphzU2yjAkvQnz2.toml new file mode 100644 index 00000000000..81e3103ef1c --- /dev/null +++ b/changes/22.6_2026-01-24/5062.T5v63ioaphzU2yjAkvQnz2.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v4.31.5" +[[pull_requests]] +uid = "5062" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5067.V47rAvnekJ8nizGaGfXKbb.toml b/changes/22.6_2026-01-24/5067.V47rAvnekJ8nizGaGfXKbb.toml new file mode 100644 index 00000000000..38b7361238e --- /dev/null +++ b/changes/22.6_2026-01-24/5067.V47rAvnekJ8nizGaGfXKbb.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.8" +[[pull_requests]] +uid = "5067" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5068.3Z2v29SeC8j3oB7kDNcpEd.toml b/changes/22.6_2026-01-24/5068.3Z2v29SeC8j3oB7kDNcpEd.toml new file mode 100644 index 00000000000..903c934d1c0 --- /dev/null +++ b/changes/22.6_2026-01-24/5068.3Z2v29SeC8j3oB7kDNcpEd.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.9" +[[pull_requests]] +uid = "5068" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5070.guYXqNjxEqyktC3ndMsdqc.toml b/changes/22.6_2026-01-24/5070.guYXqNjxEqyktC3ndMsdqc.toml new file mode 100644 index 00000000000..bee861e3e40 --- /dev/null +++ b/changes/22.6_2026-01-24/5070.guYXqNjxEqyktC3ndMsdqc.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.10" +[[pull_requests]] +uid = "5070" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5075.YxKmAd2uSaR5xJZG6Zf6h9.toml b/changes/22.6_2026-01-24/5075.YxKmAd2uSaR5xJZG6Zf6h9.toml new file mode 100644 index 00000000000..9b291ca9076 --- /dev/null +++ b/changes/22.6_2026-01-24/5075.YxKmAd2uSaR5xJZG6Zf6h9.toml @@ -0,0 +1,5 @@ +documentation = "Update Copyright to 2026" + +[[pull_requests]] +uid = "5075" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/5078.FoNwUYLbXQFRebTFhR6UPn.toml b/changes/22.6_2026-01-24/5078.FoNwUYLbXQFRebTFhR6UPn.toml new file mode 100644 index 00000000000..7cc4071b32f --- /dev/null +++ b/changes/22.6_2026-01-24/5078.FoNwUYLbXQFRebTFhR6UPn.toml @@ -0,0 +1,28 @@ +features = """ +Full Support for Bot API 9.3 + +.. warning:: + + - Bot API 9.3 deprecates the field ``last_resale_star_count`` of ``UniqueGiftInfo`` in favor of the new fields ``last_resale_currency`` and ``last_resale_amount``. The field ``last_resale_star_count`` is still present in PTB for backward compatibility, but it will be removed in future releases. Please update your code accordingly. + + - Bot API 9.3 deprecates the argument ``exclude_limited`` of ``Bot.get_business_account_gifts`` in favor of the new arguments ``exclude_limited_upgradable`` and ``exclude_limited_non_upgradable``. The argument ``exclude_limited`` is still present in PTB for backward compatibility, but it will be removed in future releases. Please update your code accordingly. + + - Bot API 9.3 introduces a now required argument ``gift_id`` to ``UniqueGift``. For backward compatibility, the argument is currently still marked as optional in the signature and it's presence is enforced through a runtime check. In future versions, this argument will be made required in the signature as well. + Please make sure to update your code accordingly to avoid potential issues in the future. We recommend using keyword arguments to ensure compatibility with future updates. +""" + +pull_requests = [ + { uid = "5086", author_uids = ["Bibo-Joshi"] }, + { uid = "5078", author_uid = "aelkheir", closes_threads = ["5077"] }, + { uid = "5079", author_uid = "aelkheir" }, + { uid = "5084", author_uids = ["Bibo-Joshi"] }, + { uid = "5085", author_uids = ["Bibo-Joshi"] }, + { uid = "5087", author_uids = ["Bibo-Joshi"] }, + { uid = "5091", author_uids = ["Bibo-Joshi"] }, + { uid = "5090", author_uids = ["Bibo-Joshi"] }, + { uid = "5089", author_uids = ["Bibo-Joshi"] }, + { uid = "5092", author_uids = ["Bibo-Joshi"] }, + { uid = "5095", author_uids = ["Bibo-Joshi"] }, + { uid = "5094", author_uids = ["Bibo-Joshi"] }, + { uid = "5106", author_uid = ["aelkheir"] }, +] diff --git a/changes/22.6_2026-01-24/5080.BYez3ivHiHpRDEjnsCStKM.toml b/changes/22.6_2026-01-24/5080.BYez3ivHiHpRDEjnsCStKM.toml new file mode 100644 index 00000000000..cc7fa90a9e0 --- /dev/null +++ b/changes/22.6_2026-01-24/5080.BYez3ivHiHpRDEjnsCStKM.toml @@ -0,0 +1,5 @@ +documentation = "Fix Broken Links in Documentation" +[[pull_requests]] +uid = "5080" +author_uids = ["calm329"] +closes_threads = ["5074"] \ No newline at end of file diff --git a/changes/22.6_2026-01-24/5083.MebfmAm8GVX9To4ZWxeU4h.toml b/changes/22.6_2026-01-24/5083.MebfmAm8GVX9To4ZWxeU4h.toml new file mode 100644 index 00000000000..40e5ec22381 --- /dev/null +++ b/changes/22.6_2026-01-24/5083.MebfmAm8GVX9To4ZWxeU4h.toml @@ -0,0 +1,4 @@ +internal = "Bump ``pre-commit`` Hooks to Latest VErsion" +[[pull_requests]] +uid = "5083" +author_uids = ["pre-commit-ci", "Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/5088.dbco4MBy5FHuS4qawHww3M.toml b/changes/22.6_2026-01-24/5088.dbco4MBy5FHuS4qawHww3M.toml new file mode 100644 index 00000000000..011a85bb884 --- /dev/null +++ b/changes/22.6_2026-01-24/5088.dbco4MBy5FHuS4qawHww3M.toml @@ -0,0 +1,6 @@ +features = "Add Fallback Name Support for Job Callbacks without ``__name__``" +[[pull_requests]] +uid = "5088" +author_uids = ["SmartDever02"] +closes_threads = ["4992"] + diff --git a/changes/22.6_2026-01-24/5100.NnTUgrxZibXT6N3SAw28aH.toml b/changes/22.6_2026-01-24/5100.NnTUgrxZibXT6N3SAw28aH.toml new file mode 100644 index 00000000000..1fc7a5a8d3d --- /dev/null +++ b/changes/22.6_2026-01-24/5100.NnTUgrxZibXT6N3SAw28aH.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.11" +[[pull_requests]] +uid = "5100" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5101.SUFGhSSTbqovDSZpVF2LYi.toml b/changes/22.6_2026-01-24/5101.SUFGhSSTbqovDSZpVF2LYi.toml new file mode 100644 index 00000000000..72fc4fbb979 --- /dev/null +++ b/changes/22.6_2026-01-24/5101.SUFGhSSTbqovDSZpVF2LYi.toml @@ -0,0 +1,5 @@ +internal = "Bump ``chango`` to 0.6.1" +[[pull_requests]] +uid = "5101" +author_uids = ["Bibo-Joshi"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5107.dkBZXpsYuApxXCiLSPkeZR.toml b/changes/22.6_2026-01-24/5107.dkBZXpsYuApxXCiLSPkeZR.toml new file mode 100644 index 00000000000..2a3c9d780a9 --- /dev/null +++ b/changes/22.6_2026-01-24/5107.dkBZXpsYuApxXCiLSPkeZR.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.13" +[[pull_requests]] +uid = "5107" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5108.CUt49oxmPxDZ3BPhDDCAm5.toml b/changes/22.6_2026-01-24/5108.CUt49oxmPxDZ3BPhDDCAm5.toml new file mode 100644 index 00000000000..fb69e538612 --- /dev/null +++ b/changes/22.6_2026-01-24/5108.CUt49oxmPxDZ3BPhDDCAm5.toml @@ -0,0 +1,5 @@ +other = "Bump Version to v22.6" +[[pull_requests]] +uid = "5108" +author_uids = ["Bibo-Joshi"] +closes_threads = [] diff --git a/CHANGES.rst b/changes/LEGACY.rst similarity index 97% rename from CHANGES.rst rename to changes/LEGACY.rst index c7b1ffc881e..81c4205cc29 100644 --- a/CHANGES.rst +++ b/changes/LEGACY.rst @@ -1,8 +1,62 @@ -.. _ptb-changelog: +Version 21.11.1 +=============== + +*Released 2025-03-01* + +This is the technical changelog for version 21.11. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Documentation Improvements +-------------------------- + +- Fix ReadTheDocs Build (:pr:`4695`) + +Version 21.11 +============= + +*Released 2025-03-01* + +This is the technical changelog for version 21.11. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes and New Features +------------------------------ + +- Full Support for Bot API 8.3 (:pr:`4676` closes :issue:`4677`, :pr:`4682` by `aelkheir `_, :pr:`4690` by `aelkheir `_, :pr:`4691` by `aelkheir `_) +- Make ``provider_token`` Argument Optional (:pr:`4689`) +- Remove Deprecated ``InlineQueryResultArticle.hide_url`` (:pr:`4640` closes :issue:`4638`) +- Accept ``datetime.timedelta`` Input in ``Bot`` Method Parameters (:pr:`4651`) +- Extend Customization Support for ``Bot.base_(file_)url`` (:pr:`4632` closes :issue:`3355`) +- Support ``allow_paid_broadcast`` in ``AIORateLimiter`` (:pr:`4627` closes :issue:`4578`) +- Add ``BaseUpdateProcessor.current_concurrent_updates`` (:pr:`4626` closes :issue:`3984`) + +Minor Changes and Bug Fixes +--------------------------- + +- Add Bootstrapping Logic to ``Application.run_*`` (:pr:`4673` closes :issue:`4657`) +- Fix a Bug in ``edit_user_star_subscription`` (:pr:`4681` by `vavasik800 `_) +- Simplify Handling of Empty Data in ``TelegramObject.de_json`` and Friends (:pr:`4617` closes :issue:`4614`) + +Documentation Improvements +-------------------------- + +- Documentation Improvements (:pr:`4641`) +- Overhaul Admonition Insertion in Documentation (:pr:`4462` closes :issue:`4414`) + +Internal Changes +---------------- + +- Stabilize Linkcheck Test (:pr:`4693`) +- Bump ``pre-commit`` Hooks to Latest Versions (:pr:`4643`) +- Refactor Tests for ``TelegramObject`` Classes with Subclasses (:pr:`4654` closes :issue:`4652`) +- Use Fine Grained Permissions for GitHub Actions Workflows (:pr:`4668`) + +Dependency Updates +------------------ -========= -Changelog -========= +- Bump ``actions/setup-python`` from 5.3.0 to 5.4.0 (:pr:`4665`) +- Bump ``dependabot/fetch-metadata`` from 2.2.0 to 2.3.0 (:pr:`4666`) +- Bump ``actions/stale`` from 9.0.0 to 9.1.0 (:pr:`4667`) +- Bump ``astral-sh/setup-uv`` from 5.1.0 to 5.2.2 (:pr:`4664`) +- Bump ``codecov/test-results-action`` from 1.0.1 to 1.0.2 (:pr:`4663`) Version 21.10 ============= @@ -86,7 +140,7 @@ Major Changes Documentation Improvements -------------------------- -- Documentation Improvements (:pr:`4565` by `Snehashish06 `_, :pr:`4573`) +- Documentation Improvements (:pr:`4565` by Snehashish06, :pr:`4573`) Version 21.7 ============ diff --git a/changes/config.py b/changes/config.py new file mode 100644 index 00000000000..aab47484def --- /dev/null +++ b/changes/config.py @@ -0,0 +1,104 @@ +# noqa: INP001 +# pylint: disable=import-error +"""Configuration for the chango changelog tool""" + +import re +from collections.abc import Collection +from pathlib import Path + +from chango import Version +from chango.concrete import DirectoryChanGo, DirectoryVersionScanner, HeaderVersionHistory +from chango.concrete.sections import GitHubSectionChangeNote, Section, SectionVersionNote + +version_scanner = DirectoryVersionScanner(base_directory=".", unreleased_directory="unreleased") + + +class ChangoSectionChangeNote( + GitHubSectionChangeNote.with_sections( # type: ignore[misc] + [ + Section(uid="highlights", title="Highlights", sort_order=0), + Section(uid="breaking", title="Breaking Changes", sort_order=1), + Section(uid="security", title="Security Changes", sort_order=2), + Section(uid="deprecations", title="Deprecations", sort_order=3), + Section(uid="features", title="New Features", sort_order=4), + Section(uid="bugfixes", title="Bug Fixes", sort_order=5), + Section(uid="dependencies", title="Dependencies", sort_order=6), + Section(uid="other", title="Other Changes", sort_order=7), + Section(uid="documentation", title="Documentation", sort_order=8), + Section(uid="internal", title="Internal Changes", sort_order=9), + ] + ) +): + """Custom change note type for PTB. Mainly overrides get_sections to map labels to sections""" + + OWNER = "python-telegram-bot" + REPOSITORY = "python-telegram-bot" + + @classmethod + def get_sections( + cls, + labels: Collection[str], + issue_types: Collection[str] | None, + ) -> set[str]: + """Override get_sections to have customized auto-detection of relevant sections based on + the pull request and linked issues. Certainly not perfect in all cases, but should be a + good start for most PRs. + """ + combined_labels = set(labels) | (set(issue_types or [])) + + mapping = { + "🐛 bug": "bugfixes", + "💡 feature": "features", + "🧹 chore": "internal", + "⚙️ bot-api": "features", + "⚙️ documentation": "documentation", + "⚙️ tests": "internal", + "⚙️ ci-cd": "internal", + "⚙️ security": "security", + "⚙️ examples": "documentation", + "⚙️ type-hinting": "other", + "🛠 refactor": "internal", + "🛠 breaking": "breaking", + "⚙️ dependencies": "dependencies", + "🔗 github-actions": "internal", + "🛠 code-quality": "internal", + } + + # we want to return *all* from the mapping that are in the combined_labels + # removing superfluous sections from the fragment is a tad easier than adding them + found = {section for label, section in mapping.items() if label in combined_labels} + + # if we have not found any sections, we default to "other" + return found or {"other"} + + +class CustomChango(DirectoryChanGo): + """Custom ChanGo class for overriding release""" + + def release(self, version: Version) -> bool: + """replace "14.5" with version.uid except in the contrib guide + then call super + """ + root = Path(__file__).parent.parent / "src" + python_files = root.rglob("*.py") + pattern = re.compile(r"NEXT\.VERSION") + excluded_paths = {root / "docs/source/contribute.rst"} + for file_path in python_files: + if str(file_path) in excluded_paths: + continue + + content = file_path.read_text(encoding="utf-8") + modified = pattern.sub(version.uid, content) + + if content != modified: + file_path.write_text(modified, encoding="utf-8") + + return super().release(version) + + +chango_instance = CustomChango( + change_note_type=ChangoSectionChangeNote, + version_note_type=SectionVersionNote, + version_history_type=HeaderVersionHistory, + scanner=version_scanner, +) diff --git a/telegram/_files/__init__.py b/changes/unreleased/.gitkeep similarity index 100% rename from telegram/_files/__init__.py rename to changes/unreleased/.gitkeep diff --git a/changes/unreleased/5110.NfCAazzRFNXvNn9CNmMZpZ.toml b/changes/unreleased/5110.NfCAazzRFNXvNn9CNmMZpZ.toml new file mode 100644 index 00000000000..c9fbd9ec0c2 --- /dev/null +++ b/changes/unreleased/5110.NfCAazzRFNXvNn9CNmMZpZ.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.14" +[[pull_requests]] +uid = "5110" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5112.Na2dk8jzBTtTg3EvNSo6kh.toml b/changes/unreleased/5112.Na2dk8jzBTtTg3EvNSo6kh.toml new file mode 100644 index 00000000000..e3a74dc00b9 --- /dev/null +++ b/changes/unreleased/5112.Na2dk8jzBTtTg3EvNSo6kh.toml @@ -0,0 +1,5 @@ +internal = "Update codecov/codecov-action action to v5.5.2" +[[pull_requests]] +uid = "5112" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5113.YfEbfC8WGAxMFb8rKBCUej.toml b/changes/unreleased/5113.YfEbfC8WGAxMFb8rKBCUej.toml new file mode 100644 index 00000000000..331e991fe41 --- /dev/null +++ b/changes/unreleased/5113.YfEbfC8WGAxMFb8rKBCUej.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.28" +[[pull_requests]] +uid = "5113" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5114.LGfvVo5cdK74VyVWHFJDW3.toml b/changes/unreleased/5114.LGfvVo5cdK74VyVWHFJDW3.toml new file mode 100644 index 00000000000..fe90c627e2a --- /dev/null +++ b/changes/unreleased/5114.LGfvVo5cdK74VyVWHFJDW3.toml @@ -0,0 +1,5 @@ +internal = "Update dependency pytest to v9.0.2" +[[pull_requests]] +uid = "5114" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5115.CgTBPjBCWJ7reRZG4vu8vJ.toml b/changes/unreleased/5115.CgTBPjBCWJ7reRZG4vu8vJ.toml new file mode 100644 index 00000000000..b28bba852e6 --- /dev/null +++ b/changes/unreleased/5115.CgTBPjBCWJ7reRZG4vu8vJ.toml @@ -0,0 +1,5 @@ +internal = "Update actions/setup-python action to v6.2.0" +[[pull_requests]] +uid = "5115" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5116.9YKyNfUgP73QatTcuD5qNm.toml b/changes/unreleased/5116.9YKyNfUgP73QatTcuD5qNm.toml new file mode 100644 index 00000000000..b3191a9243e --- /dev/null +++ b/changes/unreleased/5116.9YKyNfUgP73QatTcuD5qNm.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v7.2.1" +[[pull_requests]] +uid = "5116" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5117.ZoYfowizxLvCbfWcABcezC.toml b/changes/unreleased/5117.ZoYfowizxLvCbfWcABcezC.toml new file mode 100644 index 00000000000..eb1e712f0a8 --- /dev/null +++ b/changes/unreleased/5117.ZoYfowizxLvCbfWcABcezC.toml @@ -0,0 +1,5 @@ +internal = "Update pre-commit hook cachetools to v7" +[[pull_requests]] +uid = "5117" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5122.8DXxjQycntr4ecdcrcWZ7f.toml b/changes/unreleased/5122.8DXxjQycntr4ecdcrcWZ7f.toml new file mode 100644 index 00000000000..307e0ebf302 --- /dev/null +++ b/changes/unreleased/5122.8DXxjQycntr4ecdcrcWZ7f.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.15.0" +[[pull_requests]] +uid = "5122" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5122.EZ5hbq74CEoTbixKNcSmQz.toml b/changes/unreleased/5122.EZ5hbq74CEoTbixKNcSmQz.toml new file mode 100644 index 00000000000..307e0ebf302 --- /dev/null +++ b/changes/unreleased/5122.EZ5hbq74CEoTbixKNcSmQz.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.15.0" +[[pull_requests]] +uid = "5122" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5124.ZMLaT6JfSLD4orqRAqrZXj.toml b/changes/unreleased/5124.ZMLaT6JfSLD4orqRAqrZXj.toml new file mode 100644 index 00000000000..399927a4119 --- /dev/null +++ b/changes/unreleased/5124.ZMLaT6JfSLD4orqRAqrZXj.toml @@ -0,0 +1,5 @@ +other = "Fixing failing sphinx builds" +[[pull_requests]] +uid = "5124" +author_uids = ["Poolitzer"] +closes_threads = [] diff --git a/changes/unreleased/5125.Shtxta26FemMvrpd3bWFeF.toml b/changes/unreleased/5125.Shtxta26FemMvrpd3bWFeF.toml new file mode 100644 index 00000000000..42ba56708c0 --- /dev/null +++ b/changes/unreleased/5125.Shtxta26FemMvrpd3bWFeF.toml @@ -0,0 +1,5 @@ +dependencies = "Update dependency cryptography to v46.0.5 [SECURITY]" +[[pull_requests]] +uid = "5125" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5135.7NGhdLCnjTdBHFpwbxdLQt.toml b/changes/unreleased/5135.7NGhdLCnjTdBHFpwbxdLQt.toml new file mode 100644 index 00000000000..90800f28279 --- /dev/null +++ b/changes/unreleased/5135.7NGhdLCnjTdBHFpwbxdLQt.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.15.1" +[[pull_requests]] +uid = "5135" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5140.jcXPuwWiM4zqtxGPWgYSoo.toml b/changes/unreleased/5140.jcXPuwWiM4zqtxGPWgYSoo.toml new file mode 100644 index 00000000000..deb35d65d78 --- /dev/null +++ b/changes/unreleased/5140.jcXPuwWiM4zqtxGPWgYSoo.toml @@ -0,0 +1,5 @@ +other = "Fix: Moved inline test file to _inline folder" +[[pull_requests]] +uid = "5140" +author_uids = ["Poolitzer"] +closes_threads = [] diff --git a/changes/unreleased/5143.HYNP5H8WQXGydLcnAa2pkf.toml b/changes/unreleased/5143.HYNP5H8WQXGydLcnAa2pkf.toml new file mode 100644 index 00000000000..516f53f6aee --- /dev/null +++ b/changes/unreleased/5143.HYNP5H8WQXGydLcnAa2pkf.toml @@ -0,0 +1,10 @@ +breaking = """Remove Functionality Deprecated in Bot API 9.3 + +* Remove deprecated argument and attribute ``UniqueGiftInfo.last_resale_star_count``. +* Remove deprecated argument and attribute ``Bot.get_business_account_gifts.exclude_limited``. +* :attr:`telegram.UniqueGift.gift_id` is now a positional argument. +""" +[[pull_requests]] +uid = "5143" +author_uids = ["harshil21"] +closes_threads = ["5093"] diff --git a/changes/unreleased/5145.3uQNphqhEArdGYLTFDSShP.toml b/changes/unreleased/5145.3uQNphqhEArdGYLTFDSShP.toml new file mode 100644 index 00000000000..620617f4a7e --- /dev/null +++ b/changes/unreleased/5145.3uQNphqhEArdGYLTFDSShP.toml @@ -0,0 +1,5 @@ +internal = "Update Pylint to v4.0.5" +[[pull_requests]] +uid = "5145" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5146.YwQ5dRVNyvkZ3mtmFQWtzJ.toml b/changes/unreleased/5146.YwQ5dRVNyvkZ3mtmFQWtzJ.toml new file mode 100644 index 00000000000..9342e4d8032 --- /dev/null +++ b/changes/unreleased/5146.YwQ5dRVNyvkZ3mtmFQWtzJ.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.15.2" +[[pull_requests]] +uid = "5146" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/docs/auxil/admonition_inserter.py b/docs/auxil/admonition_inserter.py index f423eae5e44..945492dd7a3 100644 --- a/docs/auxil/admonition_inserter.py +++ b/docs/auxil/admonition_inserter.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,18 +16,55 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import collections.abc +import contextlib import inspect import re +import types import typing from collections import defaultdict from collections.abc import Iterator -from typing import Any, Union +from socket import socket +from types import FunctionType + +from apscheduler.job import Job as APSJob import telegram +import telegram._utils.defaultvalue +import telegram._utils.types import telegram.ext +import telegram.ext._utils.types +from tests.auxil.slots import mro_slots + +# Define the namespace for type resolution. This helps dealing with the internal imports that +# we do in many places +# The .copy() is important to avoid modifying the original namespace +TG_NAMESPACE = vars(telegram).copy() +TG_NAMESPACE.update(vars(telegram._utils.types)) +TG_NAMESPACE.update(vars(telegram._utils.defaultvalue)) +TG_NAMESPACE.update(vars(telegram.ext)) +TG_NAMESPACE.update(vars(telegram.ext._utils.types)) +TG_NAMESPACE.update(vars(telegram.ext._applicationbuilder)) +TG_NAMESPACE.update({"socket": socket, "APSJob": APSJob}) + + +class PublicMethod(typing.NamedTuple): + name: str + method: FunctionType + + +def _is_inherited_method(cls: type, method_name: str) -> bool: + """Checks if a method is inherited from a parent class. + Inheritance is not considered if the parent class is private. + Recurses through all direcot or indirect parent classes. + """ + # The [1:] slice is used to exclude the class itself from the MRO. + for base in cls.__mro__[1:]: + if method_name in base.__dict__ and not base.__name__.startswith("_"): + return True + return False -def _iter_own_public_methods(cls: type) -> Iterator[tuple[str, type]]: +def _iter_own_public_methods(cls: type) -> Iterator[PublicMethod]: """Iterates over methods of a class that are not protected/private, not camelCase and not inherited from the parent class. @@ -35,13 +72,15 @@ def _iter_own_public_methods(cls: type) -> Iterator[tuple[str, type]]: This function is defined outside the class because it is used to create class constants. """ - return ( - m - for m in inspect.getmembers(cls, predicate=inspect.isfunction) # not .ismethod - if not m[0].startswith("_") - and m[0].islower() # to avoid camelCase methods - and m[0] in cls.__dict__ # method is not inherited from parent class - ) + + # Use .isfunction() instead of .ismethod() because we want to include static methods. + for m in inspect.getmembers(cls, predicate=inspect.isfunction): + if ( + not m[0].startswith("_") + and m[0].islower() # to avoid camelCase methods + and not _is_inherited_method(cls, m[0]) + ): + yield PublicMethod(m[0], m[1]) class AdmonitionInserter: @@ -58,24 +97,18 @@ class AdmonitionInserter: start and end markers. """ - FORWARD_REF_SKIP_PATTERN = re.compile(r"^ForwardRef\('DefaultValue\[\w+]'\)$") - """A pattern that will be used to skip known ForwardRef's that need not be resolved - to a Telegram class, e.g.: - ForwardRef('DefaultValue[None]') - ForwardRef('DefaultValue[DVValueType]') - """ - - METHOD_NAMES_FOR_BOT_AND_APPBUILDER: typing.ClassVar[dict[type, str]] = { - cls: tuple(m[0] for m in _iter_own_public_methods(cls)) # m[0] means we take only names - for cls in (telegram.Bot, telegram.ext.ApplicationBuilder) + METHOD_NAMES_FOR_BOT_APP_APPBUILDER: typing.ClassVar[dict[type, str]] = { + cls: tuple(m.name for m in _iter_own_public_methods(cls)) + for cls in (telegram.Bot, telegram.ext.ApplicationBuilder, telegram.ext.Application) } - """A dictionary mapping Bot and ApplicationBuilder classes to their relevant methods that will + """A dictionary mapping Bot, Application & ApplicationBuilder classes to their relevant methods + that will be mentioned in 'Returned in' and 'Use in' admonitions in other classes' docstrings. Methods must be public, not aliases, not inherited from TelegramObject. """ def __init__(self): - self.admonitions: dict[str, dict[Union[type, collections.abc.Callable], str]] = { + self.admonitions: dict[str, dict[type | collections.abc.Callable, str]] = { # dynamically determine which method to use to create a sub-dictionary admonition_type: getattr(self, f"_create_{admonition_type}")() for admonition_type in self.ALL_ADMONITION_TYPES @@ -83,20 +116,27 @@ def __init__(self): """Dictionary with admonitions. Contains sub-dictionaries, one per admonition type. Each sub-dictionary matches bot methods (for "Shortcuts") or telegram classes (for other admonition types) to texts of admonitions, e.g.: + ``` { - "use_in": {: - <"Use in" admonition for ChatInviteLink>, ...}, - "available_in": {: - <"Available in" admonition">, ...}, - "returned_in": {...} + "use_in": { + : + <"Use in" admonition for ChatInviteLink>, + ... + }, + "available_in": { + : + <"Available in" admonition">, + ... + }, + "returned_in": {...} } ``` """ def insert_admonitions( self, - obj: Union[type, collections.abc.Callable], + obj: type | collections.abc.Callable, docstring_lines: list[str], ): """Inserts admonitions into docstring lines for a given class or method. @@ -128,34 +168,6 @@ def _create_available_in(self) -> dict[type, str]: # i.e. {telegram._files.sticker.Sticker: {":attr:`telegram.Message.sticker`", ...}} attrs_for_class = defaultdict(set) - # The following regex is supposed to capture a class name in a line like this: - # media (:obj:`str` | :class:`telegram.InputFile`): Audio file to send. - # - # Note that even if such typing description spans over multiple lines but each line ends - # with a backslash (otherwise Sphinx will throw an error) - # (e.g. EncryptedPassportElement.data), then Sphinx will combine these lines into a single - # line automatically, and it will contain no backslash (only some extra many whitespaces - # from the indentation). - - attr_docstr_pattern = re.compile( - r"^\s*(?P[a-z_]+)" # Any number of spaces, named group for attribute - r"\s?\(" # Optional whitespace, opening parenthesis - r".*" # Any number of characters (that could denote a built-in type) - r":(class|obj):`.+`" # Marker of a classref, class name in backticks - r".*\):" # Any number of characters, closing parenthesis, colon. - # The ^ colon above along with parenthesis is important because it makes sure that - # the class is mentioned in the attribute description, not in free text. - r".*$", # Any number of characters, end of string (end of line) - re.VERBOSE, - ) - - # for properties: there is no attr name in docstring. Just check if there's a class name. - prop_docstring_pattern = re.compile(r":(class|obj):`.+`.*:") - - # pattern for iterating over potentially many class names in docstring for one attribute. - # Tilde is optional (sometimes it is in the docstring, sometimes not). - single_class_name_pattern = re.compile(r":(class|obj):`~?(?P[\w.]*)`") - classes_to_inspect = inspect.getmembers(telegram, inspect.isclass) + inspect.getmembers( telegram.ext, inspect.isclass ) @@ -166,40 +178,31 @@ def _create_available_in(self) -> dict[type, str]: # docstrings. name_of_inspected_class_in_docstr = self._generate_class_name_for_link(inspected_class) - # Parsing part of the docstring with attributes (parsing of properties follows later) - docstring_lines = inspect.getdoc(inspected_class).splitlines() - lines_with_attrs = [] - for idx, line in enumerate(docstring_lines): - if line.strip() == "Attributes:": - lines_with_attrs = docstring_lines[idx + 1 :] - break - - for line in lines_with_attrs: - if not (line_match := attr_docstr_pattern.match(line)): - continue - - target_attr = line_match.group("attr_name") - # a typing description of one attribute can contain multiple classes - for match in single_class_name_pattern.finditer(line): - name_of_class_in_attr = match.group("class_name") - - # Writing to dictionary: matching the class found in the docstring - # and its subclasses to the attribute of the class being inspected. - # The class in the attribute docstring (or its subclass) is the key, - # ReST link to attribute of the class currently being inspected is the value. - try: - self._resolve_arg_and_add_link( - arg=name_of_class_in_attr, - dict_of_methods_for_class=attrs_for_class, - link=f":attr:`{name_of_inspected_class_in_docstr}.{target_attr}`", - ) - except NotImplementedError as e: - raise NotImplementedError( - "Error generating Sphinx 'Available in' admonition " - f"(admonition_inserter.py). Class {name_of_class_in_attr} present in " - f"attribute {target_attr} of class {name_of_inspected_class_in_docstr}" - f" could not be resolved. {e!s}" - ) from e + # Writing to dictionary: matching the class found in the type hint + # and its subclasses to the attribute of the class being inspected. + # The class in the attribute typehint (or its subclass) is the key, + # ReST link to attribute of the class currently being inspected is the value. + + # best effort - args of __init__ means not all attributes are covered, but there is no + # other way to get type hints of all attributes, other than doing ast parsing maybe. + # (Docstring parsing was discontinued with the closing of #4414) + type_hints = typing.get_type_hints(inspected_class.__init__, localns=TG_NAMESPACE) + class_attrs = [slot for slot in mro_slots(inspected_class) if not slot.startswith("_")] + for target_attr in class_attrs: + try: + self._resolve_arg_and_add_link( + dict_of_methods_for_class=attrs_for_class, + link=f":attr:`{name_of_inspected_class_in_docstr}.{target_attr}`", + type_hints={target_attr: type_hints.get(target_attr)}, + resolve_nested_type_vars=False, + ) + except NotImplementedError as e: + raise NotImplementedError( + "Error generating Sphinx 'Available in' admonition " + f"(admonition_inserter.py). Class {inspected_class} present in " + f"attribute {target_attr} of class {name_of_inspected_class_in_docstr}" + f" could not be resolved. {e!s}" + ) from e # Properties need to be parsed separately because they act like attributes but not # listed as attributes. @@ -210,39 +213,29 @@ def _create_available_in(self) -> dict[type, str]: if prop_name not in inspected_class.__dict__: continue - # 1. Can't use typing.get_type_hints because double-quoted type hints - # (like "Application") will throw a NameError - # 2. Can't use inspect.signature because return annotations of properties can be - # hard to parse (like "(self) -> BD"). - # 3. fget is used to access the actual function under the property wrapper - docstring = inspect.getdoc(getattr(inspected_class, prop_name).fget) - if docstring is None: - continue - - first_line = docstring.splitlines()[0] - if not prop_docstring_pattern.match(first_line): - continue + # fget is used to access the actual function under the property wrapper + type_hints = typing.get_type_hints( + getattr(inspected_class, prop_name).fget, localns=TG_NAMESPACE + ) - for match in single_class_name_pattern.finditer(first_line): - name_of_class_in_prop = match.group("class_name") - - # Writing to dictionary: matching the class found in the docstring and its - # subclasses to the property of the class being inspected. - # The class in the property docstring (or its subclass) is the key, - # ReST link to property of the class currently being inspected is the value. - try: - self._resolve_arg_and_add_link( - arg=name_of_class_in_prop, - dict_of_methods_for_class=attrs_for_class, - link=f":attr:`{name_of_inspected_class_in_docstr}.{prop_name}`", - ) - except NotImplementedError as e: - raise NotImplementedError( - "Error generating Sphinx 'Available in' admonition " - f"(admonition_inserter.py). Class {name_of_class_in_prop} present in " - f"property {prop_name} of class {name_of_inspected_class_in_docstr}" - f" could not be resolved. {e!s}" - ) from e + # Writing to dictionary: matching the class found in the docstring and its + # subclasses to the property of the class being inspected. + # The class in the property docstring (or its subclass) is the key, + # ReST link to property of the class currently being inspected is the value. + try: + self._resolve_arg_and_add_link( + dict_of_methods_for_class=attrs_for_class, + link=f":attr:`{name_of_inspected_class_in_docstr}.{prop_name}`", + type_hints={prop_name: type_hints.get("return")}, + resolve_nested_type_vars=False, + ) + except NotImplementedError as e: + raise NotImplementedError( + "Error generating Sphinx 'Available in' admonition " + f"(admonition_inserter.py). Class {inspected_class} present in " + f"property {prop_name} of class {name_of_inspected_class_in_docstr}" + f" could not be resolved. {e!s}" + ) from e return self._generate_admonitions(attrs_for_class, admonition_type="available_in") @@ -250,29 +243,28 @@ def _create_returned_in(self) -> dict[type, str]: """Creates a dictionary with 'Returned in' admonitions for classes that are returned in Bot's and ApplicationBuilder's methods. """ - # Generate a mapping of classes to ReST links to Bot methods which return it, # i.e. {: {:meth:`telegram.Bot.send_message`, ...}} methods_for_class = defaultdict(set) - for cls, method_names in self.METHOD_NAMES_FOR_BOT_AND_APPBUILDER.items(): + for cls, method_names in self.METHOD_NAMES_FOR_BOT_APP_APPBUILDER.items(): for method_name in method_names: - sig = inspect.signature(getattr(cls, method_name)) - ret_annot = sig.return_annotation - method_link = self._generate_link_to_method(method_name, cls) + arg = getattr(cls, method_name) + ret_type_hint = typing.get_type_hints(arg, localns=TG_NAMESPACE) try: self._resolve_arg_and_add_link( - arg=ret_annot, dict_of_methods_for_class=methods_for_class, link=method_link, + type_hints={"return": ret_type_hint.get("return")}, + resolve_nested_type_vars=False, ) except NotImplementedError as e: raise NotImplementedError( "Error generating Sphinx 'Returned in' admonition " f"(admonition_inserter.py). {cls}, method {method_name}. " - f"Couldn't resolve type hint in return annotation {ret_annot}. {e!s}" + f"Couldn't resolve type hint in return annotation {ret_type_hint}. {e!s}" ) from e return self._generate_admonitions(methods_for_class, admonition_type="returned_in") @@ -299,8 +291,13 @@ def _create_shortcuts(self) -> dict[collections.abc.Callable, str]: # inspect methods of all telegram classes for return statements that indicate # that this given method is a shortcut for a Bot method for _class_name, cls in inspect.getmembers(telegram, predicate=inspect.isclass): - # no need to inspect Bot's own methods, as Bot can't have shortcuts in Bot + if not cls.__module__.startswith("telegram"): + # For some reason inspect.getmembers() also yields some classes that are + # imported in the namespace but not part of the telegram module. + continue + if cls is telegram.Bot: + # no need to inspect Bot's own methods, as Bot can't have shortcuts in Bot continue for method_name, method in _iter_own_public_methods(cls): @@ -310,9 +307,7 @@ def _create_shortcuts(self) -> dict[collections.abc.Callable, str]: continue bot_method = getattr(telegram.Bot, bot_method_match.group()) - link_to_shortcut_method = self._generate_link_to_method(method_name, cls) - shortcuts_for_bot_method[bot_method].add(link_to_shortcut_method) return self._generate_admonitions(shortcuts_for_bot_method, admonition_type="shortcuts") @@ -327,26 +322,26 @@ def _create_use_in(self) -> dict[type, str]: # {:meth:`telegram.Bot.answer_inline_query`, ...}} methods_for_class = defaultdict(set) - for cls, method_names in self.METHOD_NAMES_FOR_BOT_AND_APPBUILDER.items(): + for cls, method_names in self.METHOD_NAMES_FOR_BOT_APP_APPBUILDER.items(): for method_name in method_names: + if method_name == "get_file": + pass method_link = self._generate_link_to_method(method_name, cls) - sig = inspect.signature(getattr(cls, method_name)) - parameters = sig.parameters - - for param in parameters.values(): - try: - self._resolve_arg_and_add_link( - arg=param.annotation, - dict_of_methods_for_class=methods_for_class, - link=method_link, - ) - except NotImplementedError as e: - raise NotImplementedError( - "Error generating Sphinx 'Use in' admonition " - f"(admonition_inserter.py). {cls}, method {method_name}, parameter " - f"{param}: Couldn't resolve type hint {param.annotation}. {e!s}" - ) from e + arg = getattr(cls, method_name) + param_type_hints = typing.get_type_hints(arg, localns=TG_NAMESPACE) + param_type_hints.pop("return", None) + try: + self._resolve_arg_and_add_link( + dict_of_methods_for_class=methods_for_class, + link=method_link, + type_hints=param_type_hints, + ) + except NotImplementedError as e: + raise NotImplementedError( + "Error generating Sphinx 'Use in' admonition " + f"(admonition_inserter.py). {cls}, method {method_name}, parameter " + ) from e return self._generate_admonitions(methods_for_class, admonition_type="use_in") @@ -362,7 +357,7 @@ def _find_insert_pos_for_admonition(lines: list[str]) -> int: for idx, value in list(enumerate(lines)): if value.startswith( ( - ".. seealso:", + # ".. seealso:", # The docstring contains heading "Examples:", but Sphinx will have it converted # to ".. admonition: Examples": ".. admonition:: Examples", @@ -435,12 +430,12 @@ def _generate_admonitions( return admonition_for_class @staticmethod - def _generate_class_name_for_link(cls: type) -> str: + def _generate_class_name_for_link(cls_: type) -> str: """Generates class name that can be used in a ReST link.""" # Check for potential presence of ".ext.", we will need to keep it. - ext = ".ext" if ".ext." in str(cls) else "" - return f"telegram{ext}.{cls.__name__}" + ext = ".ext" if ".ext." in str(cls_) else "" + return f"telegram{ext}.{cls_.__name__}" def _generate_link_to_method(self, method_name: str, cls: type) -> str: """Generates a ReST link to a method of a telegram class.""" @@ -448,19 +443,22 @@ def _generate_link_to_method(self, method_name: str, cls: type) -> str: return f":meth:`{self._generate_class_name_for_link(cls)}.{method_name}`" @staticmethod - def _iter_subclasses(cls: type) -> Iterator: + def _iter_subclasses(cls_: type) -> Iterator: + if not hasattr(cls_, "__subclasses__") or cls_ is telegram.TelegramObject: + return iter([]) return ( # exclude private classes c - for c in cls.__subclasses__() + for c in cls_.__subclasses__() if not str(c).split(".")[-1].startswith("_") ) def _resolve_arg_and_add_link( self, - arg: Any, dict_of_methods_for_class: defaultdict, link: str, + type_hints: dict[str, type], + resolve_nested_type_vars: bool = True, ) -> None: """A helper method. Tries to resolve the arg into a valid class. In case of success, adds the link (to a method, attribute, or property) for that class' and its subclasses' @@ -468,7 +466,9 @@ def _resolve_arg_and_add_link( **Modifies dictionary in place.** """ - for cls in self._resolve_arg(arg): + type_hints.pop("self", None) + + for cls in self._resolve_arg(type_hints, resolve_nested_type_vars): # When trying to resolve an argument from args or return annotation, # the method _resolve_arg returns None if nothing could be resolved. # Also, if class was resolved correctly, "telegram" will definitely be in its str(). @@ -480,91 +480,72 @@ def _resolve_arg_and_add_link( for subclass in self._iter_subclasses(cls): dict_of_methods_for_class[subclass].add(link) - def _resolve_arg(self, arg: Any) -> Iterator[Union[type, None]]: + def _resolve_arg( + self, + type_hints: dict[str, type], + resolve_nested_type_vars: bool, + ) -> list[type]: """Analyzes an argument of a method and recursively yields classes that the argument or its sub-arguments (in cases like Union[...]) belong to, if they can be resolved to telegram or telegram.ext classes. + Args: + type_hints: A dictionary of argument names and their types. + resolve_nested_type_vars: If True, nested type variables (like Application[BT, …]) + will be resolved to their actual classes. If False, only the outermost type + variable will be resolved. *Only* affects ptb classes, not built-in types. + Useful for checking the return type of methods, where nested type variables + are not really useful. + Raises `NotImplementedError`. """ - origin = typing.get_origin(arg) - - if ( - origin in (collections.abc.Callable, typing.IO) - or arg is None - # no other check available (by type or origin) for these: - or str(type(arg)) in ("", "") - ): - pass - - # RECURSIVE CALLS - # for cases like Union[Sequence.... - elif origin in ( - Union, - collections.abc.Coroutine, - collections.abc.Sequence, - ): - for sub_arg in typing.get_args(arg): - yield from self._resolve_arg(sub_arg) - - elif isinstance(arg, typing.TypeVar): - # gets access to the "bound=..." parameter - yield from self._resolve_arg(arg.__bound__) - # END RECURSIVE CALLS - - elif isinstance(arg, typing.ForwardRef): - m = self.FORWARD_REF_PATTERN.match(str(arg)) - # We're sure it's a ForwardRef, so, unless it belongs to known exceptions, - # the class must be resolved. - # If it isn't resolved, we'll have the program throw an exception to be sure. - try: - cls = self._resolve_class(m.group("class_name")) - except AttributeError as exc: - # skip known ForwardRef's that need not be resolved to a Telegram class - if self.FORWARD_REF_SKIP_PATTERN.match(str(arg)): - pass - else: - raise NotImplementedError(f"Could not process ForwardRef: {arg}") from exc - else: - yield cls - - # For custom generics like telegram.ext._application.Application[~BT, ~CCT, ~UD...]. - # This must come before the check for isinstance(type) because GenericAlias can also be - # recognized as type if it belongs to . - elif str(type(arg)) in ( - "", - "", - "", - ): - if "telegram" in str(arg): - # get_origin() of telegram.ext._application.Application[~BT, ~CCT, ~UD...] - # will produce - yield origin - - elif isinstance(arg, type): - if "telegram" in str(arg): - yield arg - - # For some reason "InlineQueryResult", "InputMedia" & some others are currently not - # recognized as ForwardRefs and are identified as plain strings. - elif isinstance(arg, str): - # args like "ApplicationBuilder[BT, CCT, UD, CD, BD, JQ]" can be recognized as strings. - # Remove whatever is in the square brackets because it doesn't need to be parsed. - arg = re.sub(r"\[.+]", "", arg) - - cls = self._resolve_class(arg) - # Here we don't want an exception to be thrown since we're not sure it's ForwardRef - if cls is not None: - yield cls - - else: - raise NotImplementedError( - f"Cannot process argument {arg} of type {type(arg)} (origin {origin})" - ) + def _is_ptb_class(cls: type) -> bool: + if not hasattr(cls, "__module__"): + return False + return cls.__module__.startswith("telegram") + + # will be edited in place + telegram_classes = set() + + def recurse_type(type_, is_recursed_from_ptb_class: bool): + next_is_recursed_from_ptb_class = is_recursed_from_ptb_class or _is_ptb_class(type_) + + if hasattr(type_, "__origin__") or isinstance( + type_, types.UnionType + ): # For generic types like Union, List, etc. + # Make sure it's not a telegram.ext generic type (e.g. ContextTypes[...]) + org = typing.get_origin(type_) + if "telegram.ext" in str(org): + telegram_classes.add(org) + + args = typing.get_args(type_) + for arg in args: + recurse_type(arg, next_is_recursed_from_ptb_class) + elif isinstance(type_, typing.TypeVar) and ( + resolve_nested_type_vars or not is_recursed_from_ptb_class + ): + # gets access to the "bound=..." parameter + recurse_type(type_.__bound__, next_is_recursed_from_ptb_class) + elif inspect.isclass(type_) and "telegram" in inspect.getmodule(type_).__name__: + telegram_classes.add(type_) + elif isinstance(type_, typing.ForwardRef): + # Resolving ForwardRef is not easy. https://peps.python.org/pep-0749/ will + # hopefully make it better by introducing typing.resolve_forward_ref() in py3.14 + # but that's not there yet + # So for now we fall back to a best effort approach of guessing if the class is + # available in tg or tg.ext + with contextlib.suppress(AttributeError): + telegram_classes.add(self._resolve_class(type_.__forward_arg__)) + + for type_hint in type_hints.values(): + if type_hint is not None: + recurse_type(type_hint, False) + + return list(telegram_classes) @staticmethod - def _resolve_class(name: str) -> Union[type, None]: + def _resolve_class(name: str) -> type | None: """The keys in the admonitions dictionary are not strings like "telegram.StickerSet" but classes like . @@ -581,16 +562,14 @@ def _resolve_class(name: str) -> Union[type, None]: f"telegram.ext.{name}", f"telegram.ext.filters.{name}", ): - try: - return eval(option) # NameError will be raised if trying to eval just name and it doesn't work, e.g. # "Name 'ApplicationBuilder' is not defined". # AttributeError will be raised if trying to e.g. eval f"telegram.{name}" when the # class denoted by `name` actually belongs to `telegram.ext`: # "module 'telegram' has no attribute 'ApplicationBuilder'". # If neither option works, this is not a PTB class. - except (NameError, AttributeError): - continue + with contextlib.suppress(NameError, AttributeError): + return eval(option) return None diff --git a/docs/auxil/kwargs_insertion.py b/docs/auxil/kwargs_insertion.py index 02fc39c040a..b2f05f8741b 100644 --- a/docs/auxil/kwargs_insertion.py +++ b/docs/auxil/kwargs_insertion.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -47,29 +47,29 @@ "", ] -media_write_timeout_deprecation_methods = [ - "send_photo", +media_write_timeout_change_methods = [ + "add_sticker_to_set", + "create_new_sticker_set", + "send_animation", "send_audio", "send_document", + "send_media_group", + "send_photo", "send_sticker", "send_video", "send_video_note", - "send_animation", "send_voice", - "send_media_group", "set_chat_photo", "upload_sticker_file", - "add_sticker_to_set", - "create_new_sticker_set", ] -media_write_timeout_deprecation = [ +media_write_timeout_change = [ " write_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to " " :paramref:`telegram.request.BaseRequest.post.write_timeout`. By default, ``20`` " " seconds are used as write timeout." "", "", - " .. deprecated:: 20.7", - " In future versions, the default value will be changed to " + " .. versionchanged:: 22.0", + " The default value changed to " " :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.", "", "", diff --git a/docs/auxil/link_code.py b/docs/auxil/link_code.py index 63dd3fad86a..a6705082bc2 100644 --- a/docs/auxil/link_code.py +++ b/docs/auxil/link_code.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,6 +19,7 @@ to link to the correct files & lines on github. Can be simplified once https://github.com/sphinx-doc/sphinx/issues/1556 is closed """ + import subprocess from pathlib import Path diff --git a/docs/auxil/sphinx_hooks.py b/docs/auxil/sphinx_hooks.py index cdd48e42d38..7183a0dbaf7 100644 --- a/docs/auxil/sphinx_hooks.py +++ b/docs/auxil/sphinx_hooks.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -15,12 +15,12 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -import collections.abc import contextlib import inspect import re import typing from pathlib import Path +from typing import TYPE_CHECKING from sphinx.application import Sphinx @@ -32,11 +32,15 @@ find_insert_pos_for_kwargs, get_updates_read_timeout_addition, keyword_args, - media_write_timeout_deprecation, - media_write_timeout_deprecation_methods, + media_write_timeout_change, + media_write_timeout_change_methods, ) from docs.auxil.link_code import LINE_NUMBERS +if TYPE_CHECKING: + import collections.abc + + ADMONITION_INSERTER = AdmonitionInserter() # Some base classes are implementation detail @@ -51,6 +55,8 @@ } +# Resolves to the parent directory of `telegram/`, depending on installation setup, +# could either be `/src` or `/site-packages` FILE_ROOT = Path(inspect.getsourcefile(telegram)).parent.parent.resolve() @@ -95,7 +101,7 @@ def autodoc_process_docstring( """ # 1) Insert the Keyword Args and "Shortcuts" admonitions for the Bot methods - method_name = name.split(".")[-1] + method_name = name.rsplit(".", maxsplit=1)[0] if ( name.startswith("telegram.Bot.") and what == "method" @@ -116,9 +122,9 @@ def autodoc_process_docstring( if ( "post.write_timeout`. Defaults to" in to_insert - and method_name in media_write_timeout_deprecation_methods + and method_name in media_write_timeout_change_methods ): - effective_insert: list[str] = media_write_timeout_deprecation + effective_insert: list[str] = media_write_timeout_change elif get_updates and to_insert.lstrip().startswith("read_timeout"): effective_insert = [to_insert, *get_updates_read_timeout_addition] else: @@ -128,7 +134,7 @@ def autodoc_process_docstring( insert_idx += len(effective_insert) ADMONITION_INSERTER.insert_admonitions( - obj=typing.cast(collections.abc.Callable, obj), + obj=typing.cast("collections.abc.Callable", obj), docstring_lines=lines, ) @@ -136,7 +142,7 @@ def autodoc_process_docstring( # (where applicable) if what == "class": ADMONITION_INSERTER.insert_admonitions( - obj=typing.cast(type, obj), # since "what" == class, we know it's not just object + obj=typing.cast("type", obj), # since "what" == class, we know it's not just object docstring_lines=lines, ) @@ -157,7 +163,7 @@ def autodoc_process_docstring( with contextlib.suppress(Exception): source_lines, start_line = inspect.getsourcelines(obj) end_line = start_line + len(source_lines) - file = Path(inspect.getsourcefile(obj)).relative_to(FILE_ROOT) + file = Path("src") / Path(inspect.getsourcefile(obj)).relative_to(FILE_ROOT) LINE_NUMBERS[name] = (file, start_line, end_line) # Since we don't document the `__init__`, we call this manually to have it available for diff --git a/docs/auxil/tg_const_role.py b/docs/auxil/tg_const_role.py index a46ebcb48f0..00ba68e0f95 100644 --- a/docs/auxil/tg_const_role.py +++ b/docs/auxil/tg_const_role.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -77,7 +77,7 @@ def process_link( if ( isinstance(value, dtm.datetime) and value == telegram.constants.ZERO_DATE - and target in ("telegram.constants.ZERO_DATE",) + and target == "telegram.constants.ZERO_DATE" ): return repr(value), target sphinx_logger.warning( diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt deleted file mode 100644 index ab62b887813..00000000000 --- a/docs/requirements-docs.txt +++ /dev/null @@ -1,9 +0,0 @@ -sphinx==8.1.3 -furo==2024.8.6 -furo-sphinx-search @ git+https://github.com/harshil21/furo-sphinx-search@v0.2.0.1 -sphinx-paramlinks==0.6.0 -sphinxcontrib-mermaid==1.0.0 -sphinx-copybutton==0.5.2 -sphinx-inline-tabs==2023.4.21 -# Temporary. See #4387 -sphinx-build-compatibility @ git+https://github.com/readthedocs/sphinx-build-compatibility.git@58aabc5f207c6c2421f23d3578adc0b14af57047 diff --git a/docs/source/_static/style_admonitions.css b/docs/source/_static/style_admonitions.css index 89c0d4b9e5e..4d86486afe9 100644 --- a/docs/source/_static/style_admonitions.css +++ b/docs/source/_static/style_admonitions.css @@ -61,5 +61,5 @@ } .admonition.returned-in > ul, .admonition.available-in > ul, .admonition.use-in > ul, .admonition.shortcuts > ul { max-height: 200px; - overflow-y: scroll; + overflow-y: auto; } diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 1cb32f6be91..c5be34d2b04 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -1 +1,9 @@ -.. include:: ../../CHANGES.rst \ No newline at end of file +.. _ptb-changelog: + +========= +Changelog +========= + +.. chango:: + +.. include:: ../../changes/LEGACY.rst \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index 09c258c9e92..fc50def6dd4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -8,12 +8,17 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. from sphinx.application import Sphinx +if sys.version_info < (3, 12): + # Due to dependency on chango + raise RuntimeError("This documentation needs at least Python 3.12") + + sys.path.insert(0, str(Path("../..").resolve().absolute())) # -- General configuration ------------------------------------------------ # General information about the project. project = "python-telegram-bot" -copyright = "2015-2025, Leandro Toledo" +copyright = "2015-2026, Leandro Toledo" author = "Leandro Toledo" # The version info for the project you're documenting, acts as replacement for @@ -36,6 +41,7 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ + "chango.sphinx_ext", "sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx.ext.intersphinx", @@ -45,13 +51,15 @@ "sphinx_copybutton", "sphinx_inline_tabs", "sphinxcontrib.mermaid", - "sphinx_search.extension", ] # Temporary. See #4387 if os.environ.get("READTHEDOCS", "") == "True": extensions.append("sphinx_build_compatibility.extension") +# Configuration for the chango sphinx directive +chango_pyproject_toml_path = Path(__file__).parent.parent.parent + # For shorter links to Wiki in docstrings extlinks = { "wiki": ("https://github.com/python-telegram-bot/python-telegram-bot/wiki/%s", "%s"), @@ -111,6 +119,13 @@ # Anchors are apparently inserted by GitHub dynamically, so let's skip checking them "https://github.com/python-telegram-bot/python-telegram-bot/tree/master/examples#", r"https://github\.com/python-telegram-bot/python-telegram-bot/wiki/[\w\-_,]+\#", + # The LGPL license link regularly causes network errors for some reason + re.escape("https://www.gnu.org/licenses/lgpl-3.0.html"), + # The doc-fixes branch may not always exist - doesn't matter, we only link to it from the + # contributing guide + re.escape("https://docs.python-telegram-bot.org/en/doc-fixes"), + # Apparently has some human-verification check and gives 403 in the sphinx build + re.escape("https://stackoverflow.com/questions/tagged/python-telegram-bot"), ] linkcheck_allowed_redirects = { # Redirects to the default version are okay diff --git a/docs/source/examples.rst b/docs/source/examples.rst index 53815770685..ce87c73450e 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -61,7 +61,7 @@ for this one, too! :any:`examples.nestedconversationbot` ------------------------------------- -A even more complex example of a bot that uses the nested +An even more complex example of a bot that uses the nested ``ConversationHandler``\ s. While it’s certainly not that complex that you couldn’t built it without nested ``ConversationHanldler``\ s, it gives a good impression on how to work with them. Of course, there is a diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index 240c258f68f..7bb89f23dac 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -35,6 +35,8 @@ - Used for sending media grouped together * - :meth:`~telegram.Bot.send_message` - Used for sending text messages + * - :meth:`~telegram.Bot.send_message_draft` + - Used for streaming partial text messages * - :meth:`~telegram.Bot.send_paid_media` - Used for sending paid media to channels * - :meth:`~telegram.Bot.send_photo` @@ -121,6 +123,10 @@ - Used for approving a chat join request * - :meth:`~telegram.Bot.decline_chat_join_request` - Used for declining a chat join request + * - :meth:`~telegram.Bot.approve_suggested_post` + - Used for approving a suggested post + * - :meth:`~telegram.Bot.decline_suggested_post` + - Used for declining a suggested post * - :meth:`~telegram.Bot.ban_chat_member` - Used for banning a member from the chat * - :meth:`~telegram.Bot.unban_chat_member` @@ -161,8 +167,6 @@ - Used for unpinning a message * - :meth:`~telegram.Bot.unpin_all_chat_messages` - Used for unpinning all pinned chat messages - * - :meth:`~telegram.Bot.get_business_connection` - - Used for getting information about the business account. * - :meth:`~telegram.Bot.get_user_profile_photos` - Used for obtaining user's profile pictures * - :meth:`~telegram.Bot.get_chat` @@ -392,10 +396,72 @@ - Used to generate an HTTP link for an invoice * - :meth:`~telegram.Bot.edit_user_star_subscription` - Used for editing a user's star subscription + * - :meth:`~telegram.Bot.get_my_star_balance` + - Used for obtaining the bot's Telegram Stars balance * - :meth:`~telegram.Bot.get_star_transactions` - Used for obtaining the bot's Telegram Stars transactions * - :meth:`~telegram.Bot.refund_star_payment` - Used for refunding a payment in Telegram Stars + * - :meth:`~telegram.Bot.gift_premium_subscription` + - Used for gifting Telegram Premium to another user. + +.. raw:: html + + +
+ +.. raw:: html + +
+ Business Related Methods + +.. list-table:: + :align: left + :widths: 1 4 + + * - :meth:`~telegram.Bot.get_business_connection` + - Used for getting information about the business account. + * - :meth:`~telegram.Bot.get_business_account_gifts` + - Used for getting gifts owned by the business account. + * - :meth:`~telegram.Bot.get_business_account_star_balance` + - Used for getting the amount of Stars owned by the business account. + * - :meth:`~telegram.Bot.read_business_message` + - Used for marking a message as read. + * - :meth:`~telegram.Bot.delete_story` + - Used for deleting business stories posted by the bot. + * - :meth:`~telegram.Bot.delete_business_messages` + - Used for deleting business messages. + * - :meth:`~telegram.Bot.remove_business_account_profile_photo` + - Used for removing the business accounts profile photo + * - :meth:`~telegram.Bot.set_business_account_name` + - Used for setting the business account name. + * - :meth:`~telegram.Bot.set_business_account_username` + - Used for setting the business account username. + * - :meth:`~telegram.Bot.set_business_account_bio` + - Used for setting the business account bio. + * - :meth:`~telegram.Bot.set_business_account_gift_settings` + - Used for setting the business account gift settings. + * - :meth:`~telegram.Bot.set_business_account_profile_photo` + - Used for setting the business accounts profile photo + * - :meth:`~telegram.Bot.post_story` + - Used for posting a story on behalf of business account. + * - :meth:`~telegram.Bot.repost_story` + - Used for reposting an existing story on behalf of business account. + * - :meth:`~telegram.Bot.edit_story` + - Used for editing business stories posted by the bot. + * - :meth:`~telegram.Bot.convert_gift_to_stars` + - Used for converting owned reqular gifts to stars. + * - :meth:`~telegram.Bot.upgrade_gift` + - Used for upgrading owned regular gifts to unique ones. + * - :meth:`~telegram.Bot.transfer_gift` + - Used for transferring owned unique gifts to another user. + * - :meth:`~telegram.Bot.transfer_business_account_stars` + - Used for transfering Stars from the business account balance to the bot's balance. + * - :meth:`~telegram.Bot.send_checklist` + - Used for sending a checklist on behalf of the business account. + * - :meth:`~telegram.Bot.edit_message_checklist` + - Used for editing a checklist on behalf of the business account. + .. raw:: html @@ -419,8 +485,12 @@ - Used for getting basic info about a file * - :meth:`~telegram.Bot.get_available_gifts` - Used for getting information about gifts available for sending + * - :meth:`~telegram.Bot.get_chat_gifts` + - Used for getting information about gifts owned and hosted by a chat * - :meth:`~telegram.Bot.get_me` - Used for getting basic information about the bot + * - :meth:`~telegram.Bot.get_user_gifts` + - Used for getting information about gifts owned and hosted by a user * - :meth:`~telegram.Bot.save_prepared_inline_message` - Used for storing a message to be sent by a user of a Mini App diff --git a/docs/source/stability_policy.rst b/docs/source/stability_policy.rst index 972892185fc..621b99b540e 100644 --- a/docs/source/stability_policy.rst +++ b/docs/source/stability_policy.rst @@ -1,3 +1,5 @@ +.. _stability-policy: + Stability Policy ================ diff --git a/docs/source/telegram.acceptedgifttypes.rst b/docs/source/telegram.acceptedgifttypes.rst new file mode 100644 index 00000000000..2926dffd338 --- /dev/null +++ b/docs/source/telegram.acceptedgifttypes.rst @@ -0,0 +1,6 @@ +AcceptedGiftTypes +================= + +.. autoclass:: telegram.AcceptedGiftTypes + :members: + :show-inheritance: diff --git a/docs/source/telegram.animation.rst b/docs/source/telegram.animation.rst index 94b5f818721..4e654fad49c 100644 --- a/docs/source/telegram.animation.rst +++ b/docs/source/telegram.animation.rst @@ -6,4 +6,4 @@ Animation .. autoclass:: telegram.Animation :members: :show-inheritance: - :inherited-members: TelegramObject + :inherited-members: TelegramObject, object diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 22abbfb3867..de76000019d 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -4,6 +4,7 @@ Available Types .. toctree:: :titlesonly: + telegram.acceptedgifttypes telegram.animation telegram.audio telegram.birthdate @@ -19,6 +20,7 @@ Available Types telegram.botdescription telegram.botname telegram.botshortdescription + telegram.businessbotrights telegram.businessconnection telegram.businessintro telegram.businesslocation @@ -29,6 +31,10 @@ Available Types telegram.chat telegram.chatadministratorrights telegram.chatbackground + telegram.checklist + telegram.checklisttask + telegram.checklisttasksadded + telegram.checklisttasksdone telegram.copytextbutton telegram.backgroundtype telegram.backgroundtypefill @@ -64,6 +70,8 @@ Available Types telegram.chatshared telegram.contact telegram.dice + telegram.directmessagepricechanged + telegram.directmessagestopic telegram.document telegram.externalreplyinfo telegram.file @@ -75,6 +83,8 @@ Available Types telegram.forumtopicreopened telegram.generalforumtopichidden telegram.generalforumtopicunhidden + telegram.giftbackground + telegram.giftinfo telegram.giveaway telegram.giveawaycompleted telegram.giveawaycreated @@ -82,6 +92,8 @@ Available Types telegram.inaccessiblemessage telegram.inlinekeyboardbutton telegram.inlinekeyboardmarkup + telegram.inputchecklist + telegram.inputchecklisttask telegram.inputfile telegram.inputmedia telegram.inputmediaanimation @@ -92,13 +104,20 @@ Available Types telegram.inputpaidmedia telegram.inputpaidmediaphoto telegram.inputpaidmediavideo + telegram.inputprofilephoto + telegram.inputprofilephotoanimated + telegram.inputprofilephotostatic telegram.inputpolloption + telegram.inputstorycontent + telegram.inputstorycontentphoto + telegram.inputstorycontentvideo telegram.keyboardbutton telegram.keyboardbuttonpolltype telegram.keyboardbuttonrequestchat telegram.keyboardbuttonrequestusers telegram.linkpreviewoptions telegram.location + telegram.locationaddress telegram.loginurl telegram.maybeinaccessiblemessage telegram.menubutton @@ -116,12 +135,17 @@ Available Types telegram.messageoriginuser telegram.messagereactioncountupdated telegram.messagereactionupdated + telegram.ownedgift + telegram.ownedgiftregular + telegram.ownedgifts + telegram.ownedgiftunique telegram.paidmedia telegram.paidmediainfo telegram.paidmediaphoto telegram.paidmediapreview telegram.paidmediapurchased telegram.paidmediavideo + telegram.paidmessagepricechanged telegram.photosize telegram.poll telegram.pollanswer @@ -138,13 +162,37 @@ Available Types telegram.sentwebappmessage telegram.shareduser telegram.story + telegram.storyarea + telegram.storyareaposition + telegram.storyareatype + telegram.storyareatypelink + telegram.storyareatypelocation + telegram.storyareatypesuggestedreaction + telegram.storyareatypeuniquegift + telegram.storyareatypeweather + telegram.suggestedpostapprovalfailed + telegram.suggestedpostapproved + telegram.suggestedpostdeclined + telegram.suggestedpostinfo + telegram.suggestedpostpaid + telegram.suggestedpostparameters + telegram.suggestedpostprice + telegram.suggestedpostrefunded telegram.switchinlinequerychosenchat telegram.telegramobject telegram.textquote + telegram.uniquegift + telegram.uniquegiftcolors + telegram.uniquegiftbackdrop + telegram.uniquegiftbackdropcolors + telegram.uniquegiftinfo + telegram.uniquegiftmodel + telegram.uniquegiftsymbol telegram.update telegram.user telegram.userchatboosts telegram.userprofilephotos + telegram.userrating telegram.usersshared telegram.venue telegram.video diff --git a/docs/source/telegram.audio.rst b/docs/source/telegram.audio.rst index 9e501f70141..563de6c0289 100644 --- a/docs/source/telegram.audio.rst +++ b/docs/source/telegram.audio.rst @@ -6,4 +6,4 @@ Audio .. autoclass:: telegram.Audio :members: :show-inheritance: - :inherited-members: TelegramObject + :inherited-members: TelegramObject, object diff --git a/docs/source/telegram.businessbotrights.rst b/docs/source/telegram.businessbotrights.rst new file mode 100644 index 00000000000..d6bdab1a809 --- /dev/null +++ b/docs/source/telegram.businessbotrights.rst @@ -0,0 +1,6 @@ +BusinessBotRights +================= + +.. autoclass:: telegram.BusinessBotRights + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chat.rst b/docs/source/telegram.chat.rst index d69b08b60e8..df53940c4a7 100644 --- a/docs/source/telegram.chat.rst +++ b/docs/source/telegram.chat.rst @@ -5,4 +5,4 @@ Chat .. autoclass:: telegram.Chat :members: :show-inheritance: - :inherited-members: TelegramObject + :inherited-members: TelegramObject, object diff --git a/docs/source/telegram.chatfullinfo.rst b/docs/source/telegram.chatfullinfo.rst index 3bbc9fa9e18..7ba8f3d3828 100644 --- a/docs/source/telegram.chatfullinfo.rst +++ b/docs/source/telegram.chatfullinfo.rst @@ -5,4 +5,4 @@ ChatFullInfo .. autoclass:: telegram.ChatFullInfo :members: :show-inheritance: - :inherited-members: TelegramObject \ No newline at end of file + :inherited-members: TelegramObject, object \ No newline at end of file diff --git a/docs/source/telegram.checklist.rst b/docs/source/telegram.checklist.rst new file mode 100644 index 00000000000..a01dac43aad --- /dev/null +++ b/docs/source/telegram.checklist.rst @@ -0,0 +1,6 @@ +Checklist +========= + +.. autoclass:: telegram.Checklist + :members: + :show-inheritance: diff --git a/docs/source/telegram.checklisttask.rst b/docs/source/telegram.checklisttask.rst new file mode 100644 index 00000000000..27f44d629de --- /dev/null +++ b/docs/source/telegram.checklisttask.rst @@ -0,0 +1,6 @@ +ChecklistTask +============= + +.. autoclass:: telegram.ChecklistTask + :members: + :show-inheritance: diff --git a/docs/source/telegram.checklisttasksadded.rst b/docs/source/telegram.checklisttasksadded.rst new file mode 100644 index 00000000000..d3c33c02300 --- /dev/null +++ b/docs/source/telegram.checklisttasksadded.rst @@ -0,0 +1,6 @@ +ChecklistTasksAdded +=================== + +.. autoclass:: telegram.ChecklistTasksAdded + :members: + :show-inheritance: diff --git a/docs/source/telegram.checklisttasksdone.rst b/docs/source/telegram.checklisttasksdone.rst new file mode 100644 index 00000000000..aa1e0b83f84 --- /dev/null +++ b/docs/source/telegram.checklisttasksdone.rst @@ -0,0 +1,6 @@ +ChecklistTasksDone +================== + +.. autoclass:: telegram.ChecklistTasksDone + :members: + :show-inheritance: diff --git a/docs/source/telegram.constants.rst b/docs/source/telegram.constants.rst index ef1e6720107..618b35246f1 100644 --- a/docs/source/telegram.constants.rst +++ b/docs/source/telegram.constants.rst @@ -5,5 +5,4 @@ telegram.constants Module :members: :show-inheritance: :no-undoc-members: - :inherited-members: Enum, EnumMeta, str, int, float :exclude-members: __format__, __new__, __repr__, __str__ diff --git a/docs/source/telegram.directmessagepricechanged.rst b/docs/source/telegram.directmessagepricechanged.rst new file mode 100644 index 00000000000..64356e1a689 --- /dev/null +++ b/docs/source/telegram.directmessagepricechanged.rst @@ -0,0 +1,6 @@ +DirectMessagePriceChanged +========================= + +.. autoclass:: telegram.DirectMessagePriceChanged + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.directmessagestopic.rst b/docs/source/telegram.directmessagestopic.rst new file mode 100644 index 00000000000..9779a021c91 --- /dev/null +++ b/docs/source/telegram.directmessagestopic.rst @@ -0,0 +1,6 @@ +DirectMessagesTopic +=================== + +.. autoclass:: telegram.DirectMessagesTopic + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.document.rst b/docs/source/telegram.document.rst index e59a84ba674..1a337077069 100644 --- a/docs/source/telegram.document.rst +++ b/docs/source/telegram.document.rst @@ -5,4 +5,4 @@ Document .. autoclass:: telegram.Document :members: :show-inheritance: - :inherited-members: TelegramObject + :inherited-members: TelegramObject, object diff --git a/docs/source/telegram.giftbackground.rst b/docs/source/telegram.giftbackground.rst new file mode 100644 index 00000000000..c2785ff67b0 --- /dev/null +++ b/docs/source/telegram.giftbackground.rst @@ -0,0 +1,7 @@ +GiftBackground +============== + +.. autoclass:: telegram.GiftBackground + :members: + :show-inheritance: + diff --git a/docs/source/telegram.giftinfo.rst b/docs/source/telegram.giftinfo.rst new file mode 100644 index 00000000000..ff5ab6ad352 --- /dev/null +++ b/docs/source/telegram.giftinfo.rst @@ -0,0 +1,7 @@ +GiftInfo +======== + +.. autoclass:: telegram.GiftInfo + :members: + :show-inheritance: + diff --git a/docs/source/telegram.inputchecklist.rst b/docs/source/telegram.inputchecklist.rst new file mode 100644 index 00000000000..f83345884a0 --- /dev/null +++ b/docs/source/telegram.inputchecklist.rst @@ -0,0 +1,6 @@ +InputChecklist +============== + +.. autoclass:: telegram.InputChecklist + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputchecklisttask.rst b/docs/source/telegram.inputchecklisttask.rst new file mode 100644 index 00000000000..1cc14095b0c --- /dev/null +++ b/docs/source/telegram.inputchecklisttask.rst @@ -0,0 +1,6 @@ +InputChecklistTask +================== + +.. autoclass:: telegram.InputChecklistTask + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputprofilephoto.rst b/docs/source/telegram.inputprofilephoto.rst new file mode 100644 index 00000000000..723f3c92389 --- /dev/null +++ b/docs/source/telegram.inputprofilephoto.rst @@ -0,0 +1,6 @@ +InputProfilePhoto +================= + +.. autoclass:: telegram.InputProfilePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputprofilephotoanimated.rst b/docs/source/telegram.inputprofilephotoanimated.rst new file mode 100644 index 00000000000..c192d0d8e58 --- /dev/null +++ b/docs/source/telegram.inputprofilephotoanimated.rst @@ -0,0 +1,6 @@ +InputProfilePhotoAnimated +========================= + +.. autoclass:: telegram.InputProfilePhotoAnimated + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputprofilephotostatic.rst b/docs/source/telegram.inputprofilephotostatic.rst new file mode 100644 index 00000000000..49b498c13ba --- /dev/null +++ b/docs/source/telegram.inputprofilephotostatic.rst @@ -0,0 +1,6 @@ +InputProfilePhotoStatic +======================= + +.. autoclass:: telegram.InputProfilePhotoStatic + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputstorycontent.rst b/docs/source/telegram.inputstorycontent.rst new file mode 100644 index 00000000000..3406e8cf253 --- /dev/null +++ b/docs/source/telegram.inputstorycontent.rst @@ -0,0 +1,6 @@ +InputStoryContent +================= + +.. autoclass:: telegram.InputStoryContent + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputstorycontentphoto.rst b/docs/source/telegram.inputstorycontentphoto.rst new file mode 100644 index 00000000000..1adacb2322c --- /dev/null +++ b/docs/source/telegram.inputstorycontentphoto.rst @@ -0,0 +1,6 @@ +InputStoryContentPhoto +====================== + +.. autoclass:: telegram.InputStoryContentPhoto + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputstorycontentvideo.rst b/docs/source/telegram.inputstorycontentvideo.rst new file mode 100644 index 00000000000..27550468e3b --- /dev/null +++ b/docs/source/telegram.inputstorycontentvideo.rst @@ -0,0 +1,6 @@ +InputStoryContentVideo +====================== + +.. autoclass:: telegram.InputStoryContentVideo + :members: + :show-inheritance: diff --git a/docs/source/telegram.locationaddress.rst b/docs/source/telegram.locationaddress.rst new file mode 100644 index 00000000000..f6e3874de9d --- /dev/null +++ b/docs/source/telegram.locationaddress.rst @@ -0,0 +1,6 @@ +LocationAddress +=============== + +.. autoclass:: telegram.LocationAddress + :members: + :show-inheritance: diff --git a/docs/source/telegram.ownedgift.rst b/docs/source/telegram.ownedgift.rst new file mode 100644 index 00000000000..0c726895c07 --- /dev/null +++ b/docs/source/telegram.ownedgift.rst @@ -0,0 +1,6 @@ +OwnedGift +========= + +.. autoclass:: telegram.OwnedGift + :members: + :show-inheritance: diff --git a/docs/source/telegram.ownedgiftregular.rst b/docs/source/telegram.ownedgiftregular.rst new file mode 100644 index 00000000000..eb4f3641ed6 --- /dev/null +++ b/docs/source/telegram.ownedgiftregular.rst @@ -0,0 +1,6 @@ +OwnedGiftRegular +================ + +.. autoclass:: telegram.OwnedGiftRegular + :members: + :show-inheritance: diff --git a/docs/source/telegram.ownedgifts.rst b/docs/source/telegram.ownedgifts.rst new file mode 100644 index 00000000000..71a1c51b86f --- /dev/null +++ b/docs/source/telegram.ownedgifts.rst @@ -0,0 +1,6 @@ +OwnedGifts +========== + +.. autoclass:: telegram.OwnedGifts + :members: + :show-inheritance: diff --git a/docs/source/telegram.ownedgiftunique.rst b/docs/source/telegram.ownedgiftunique.rst new file mode 100644 index 00000000000..cc114fecc49 --- /dev/null +++ b/docs/source/telegram.ownedgiftunique.rst @@ -0,0 +1,6 @@ +OwnedGiftUnique +=============== + +.. autoclass:: telegram.OwnedGiftUnique + :members: + :show-inheritance: diff --git a/docs/source/telegram.paidmessagepricechanged.rst b/docs/source/telegram.paidmessagepricechanged.rst new file mode 100644 index 00000000000..3d0e739c456 --- /dev/null +++ b/docs/source/telegram.paidmessagepricechanged.rst @@ -0,0 +1,6 @@ +PaidMessagePriceChanged +======================= + +.. autoclass:: telegram.PaidMessagePriceChanged + :members: + :show-inheritance: diff --git a/docs/source/telegram.payments-tree.rst b/docs/source/telegram.payments-tree.rst index e8ec7bd3e3b..94e4fec3c99 100644 --- a/docs/source/telegram.payments-tree.rst +++ b/docs/source/telegram.payments-tree.rst @@ -22,11 +22,13 @@ Your bot can accept payments from Telegram users. Please see the `introduction t telegram.shippingaddress telegram.shippingoption telegram.shippingquery + telegram.staramount telegram.startransaction telegram.startransactions telegram.successfulpayment telegram.transactionpartner telegram.transactionpartneraffiliateprogram + telegram.transactionpartnerchat telegram.transactionpartnerfragment telegram.transactionpartnerother telegram.transactionpartnertelegramads diff --git a/docs/source/telegram.photosize.rst b/docs/source/telegram.photosize.rst index be044f1164b..53632ac9bd4 100644 --- a/docs/source/telegram.photosize.rst +++ b/docs/source/telegram.photosize.rst @@ -5,4 +5,4 @@ PhotoSize .. autoclass:: telegram.PhotoSize :members: :show-inheritance: - :inherited-members: TelegramObject + :inherited-members: TelegramObject, object diff --git a/docs/source/telegram.revenuewithdrawalstate.rst b/docs/source/telegram.revenuewithdrawalstate.rst index d3f7eef81cc..a8b0d9ef0ef 100644 --- a/docs/source/telegram.revenuewithdrawalstate.rst +++ b/docs/source/telegram.revenuewithdrawalstate.rst @@ -4,4 +4,3 @@ RevenueWithdrawalState .. autoclass:: telegram.RevenueWithdrawalState :members: :show-inheritance: - :inherited-members: TelegramObject diff --git a/docs/source/telegram.revenuewithdrawalstatefailed.rst b/docs/source/telegram.revenuewithdrawalstatefailed.rst index ac319c6a67a..63379122869 100644 --- a/docs/source/telegram.revenuewithdrawalstatefailed.rst +++ b/docs/source/telegram.revenuewithdrawalstatefailed.rst @@ -4,4 +4,3 @@ RevenueWithdrawalStateFailed .. autoclass:: telegram.RevenueWithdrawalStateFailed :members: :show-inheritance: - :inherited-members: TelegramObject diff --git a/docs/source/telegram.revenuewithdrawalstatepending.rst b/docs/source/telegram.revenuewithdrawalstatepending.rst index 19a74e5f28c..3c2110271c0 100644 --- a/docs/source/telegram.revenuewithdrawalstatepending.rst +++ b/docs/source/telegram.revenuewithdrawalstatepending.rst @@ -4,4 +4,3 @@ RevenueWithdrawalStatePending .. autoclass:: telegram.RevenueWithdrawalStatePending :members: :show-inheritance: - :inherited-members: TelegramObject diff --git a/docs/source/telegram.revenuewithdrawalstatesucceeded.rst b/docs/source/telegram.revenuewithdrawalstatesucceeded.rst index 7f7980e799f..40bd6fdb5c7 100644 --- a/docs/source/telegram.revenuewithdrawalstatesucceeded.rst +++ b/docs/source/telegram.revenuewithdrawalstatesucceeded.rst @@ -4,4 +4,3 @@ RevenueWithdrawalStateSucceeded .. autoclass:: telegram.RevenueWithdrawalStateSucceeded :members: :show-inheritance: - :inherited-members: TelegramObject diff --git a/docs/source/telegram.staramount.rst b/docs/source/telegram.staramount.rst new file mode 100644 index 00000000000..9d5a6e24572 --- /dev/null +++ b/docs/source/telegram.staramount.rst @@ -0,0 +1,6 @@ +StarAmount +========== + +.. autoclass:: telegram.StarAmount + :members: + :show-inheritance: diff --git a/docs/source/telegram.startransaction.rst b/docs/source/telegram.startransaction.rst index 42f84e39b67..b8a68c8e99e 100644 --- a/docs/source/telegram.startransaction.rst +++ b/docs/source/telegram.startransaction.rst @@ -4,4 +4,3 @@ StarTransaction .. autoclass:: telegram.StarTransaction :members: :show-inheritance: - :inherited-members: TelegramObject diff --git a/docs/source/telegram.startransactions.rst b/docs/source/telegram.startransactions.rst index 1f1860920b5..e71439c8c87 100644 --- a/docs/source/telegram.startransactions.rst +++ b/docs/source/telegram.startransactions.rst @@ -4,5 +4,4 @@ StarTransactions .. autoclass:: telegram.StarTransactions :members: :show-inheritance: - :inherited-members: TelegramObject diff --git a/docs/source/telegram.sticker.rst b/docs/source/telegram.sticker.rst index 65b4a0f23c6..459629b7ecc 100644 --- a/docs/source/telegram.sticker.rst +++ b/docs/source/telegram.sticker.rst @@ -6,4 +6,4 @@ Sticker .. autoclass:: telegram.Sticker :members: :show-inheritance: - :inherited-members: TelegramObject + :inherited-members: TelegramObject, object diff --git a/docs/source/telegram.storyarea.rst b/docs/source/telegram.storyarea.rst new file mode 100644 index 00000000000..88c028535d6 --- /dev/null +++ b/docs/source/telegram.storyarea.rst @@ -0,0 +1,6 @@ +StoryArea +========= + +.. autoclass:: telegram.StoryArea + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareaposition.rst b/docs/source/telegram.storyareaposition.rst new file mode 100644 index 00000000000..d14aa66cb2a --- /dev/null +++ b/docs/source/telegram.storyareaposition.rst @@ -0,0 +1,6 @@ +StoryAreaPosition +================= + +.. autoclass:: telegram.StoryAreaPosition + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareatype.rst b/docs/source/telegram.storyareatype.rst new file mode 100644 index 00000000000..aa4ad3312aa --- /dev/null +++ b/docs/source/telegram.storyareatype.rst @@ -0,0 +1,6 @@ +StoryAreaType +============= + +.. autoclass:: telegram.StoryAreaType + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareatypelink.rst b/docs/source/telegram.storyareatypelink.rst new file mode 100644 index 00000000000..493eeef5da2 --- /dev/null +++ b/docs/source/telegram.storyareatypelink.rst @@ -0,0 +1,6 @@ +StoryAreaTypeLink +================= + +.. autoclass:: telegram.StoryAreaTypeLink + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareatypelocation.rst b/docs/source/telegram.storyareatypelocation.rst new file mode 100644 index 00000000000..8f09ee9bf40 --- /dev/null +++ b/docs/source/telegram.storyareatypelocation.rst @@ -0,0 +1,6 @@ +StoryAreaTypeLocation +===================== + +.. autoclass:: telegram.StoryAreaTypeLocation + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareatypesuggestedreaction.rst b/docs/source/telegram.storyareatypesuggestedreaction.rst new file mode 100644 index 00000000000..e099e992d61 --- /dev/null +++ b/docs/source/telegram.storyareatypesuggestedreaction.rst @@ -0,0 +1,6 @@ +StoryAreaTypeSuggestedReaction +============================== + +.. autoclass:: telegram.StoryAreaTypeSuggestedReaction + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareatypeuniquegift.rst b/docs/source/telegram.storyareatypeuniquegift.rst new file mode 100644 index 00000000000..c6e7fd9a119 --- /dev/null +++ b/docs/source/telegram.storyareatypeuniquegift.rst @@ -0,0 +1,6 @@ +StoryAreaTypeUniqueGift +======================= + +.. autoclass:: telegram.StoryAreaTypeUniqueGift + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareatypeweather.rst b/docs/source/telegram.storyareatypeweather.rst new file mode 100644 index 00000000000..a704e7eecfd --- /dev/null +++ b/docs/source/telegram.storyareatypeweather.rst @@ -0,0 +1,6 @@ +StoryAreaTypeWeather +==================== + +.. autoclass:: telegram.StoryAreaTypeWeather + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostapprovalfailed.rst b/docs/source/telegram.suggestedpostapprovalfailed.rst new file mode 100644 index 00000000000..5b730f18583 --- /dev/null +++ b/docs/source/telegram.suggestedpostapprovalfailed.rst @@ -0,0 +1,6 @@ +SuggestedPostApprovalFailed +=========================== + +.. autoclass:: telegram.SuggestedPostApprovalFailed + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostapproved.rst b/docs/source/telegram.suggestedpostapproved.rst new file mode 100644 index 00000000000..c9e74a94652 --- /dev/null +++ b/docs/source/telegram.suggestedpostapproved.rst @@ -0,0 +1,6 @@ +SuggestedPostApproved +===================== + +.. autoclass:: telegram.SuggestedPostApproved + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostdeclined.rst b/docs/source/telegram.suggestedpostdeclined.rst new file mode 100644 index 00000000000..bf9194d074b --- /dev/null +++ b/docs/source/telegram.suggestedpostdeclined.rst @@ -0,0 +1,6 @@ +SuggestedPostDeclined +===================== + +.. autoclass:: telegram.SuggestedPostDeclined + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostinfo.rst b/docs/source/telegram.suggestedpostinfo.rst new file mode 100644 index 00000000000..a974dda9887 --- /dev/null +++ b/docs/source/telegram.suggestedpostinfo.rst @@ -0,0 +1,6 @@ +SuggestedPostInfo +================= + +.. autoclass:: telegram.SuggestedPostInfo + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostpaid.rst b/docs/source/telegram.suggestedpostpaid.rst new file mode 100644 index 00000000000..6eb4a57bbda --- /dev/null +++ b/docs/source/telegram.suggestedpostpaid.rst @@ -0,0 +1,6 @@ +SuggestedPostPaid +================= + +.. autoclass:: telegram.SuggestedPostPaid + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostparameters.rst b/docs/source/telegram.suggestedpostparameters.rst new file mode 100644 index 00000000000..5111d8fdd48 --- /dev/null +++ b/docs/source/telegram.suggestedpostparameters.rst @@ -0,0 +1,6 @@ +SuggestedPostParameters +======================= + +.. autoclass:: telegram.SuggestedPostParameters + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostprice.rst b/docs/source/telegram.suggestedpostprice.rst new file mode 100644 index 00000000000..f5034e8f047 --- /dev/null +++ b/docs/source/telegram.suggestedpostprice.rst @@ -0,0 +1,6 @@ +SuggestedPostPrice +================== + +.. autoclass:: telegram.SuggestedPostPrice + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostrefunded.rst b/docs/source/telegram.suggestedpostrefunded.rst new file mode 100644 index 00000000000..2fb5ad1beff --- /dev/null +++ b/docs/source/telegram.suggestedpostrefunded.rst @@ -0,0 +1,6 @@ +SuggestedPostRefunded +===================== + +.. autoclass:: telegram.SuggestedPostRefunded + :members: + :show-inheritance: diff --git a/docs/source/telegram.transactionpartner.rst b/docs/source/telegram.transactionpartner.rst index 9ccca02cec0..1970cfb3f94 100644 --- a/docs/source/telegram.transactionpartner.rst +++ b/docs/source/telegram.transactionpartner.rst @@ -4,4 +4,3 @@ TransactionPartner .. autoclass:: telegram.TransactionPartner :members: :show-inheritance: - :inherited-members: TelegramObject diff --git a/docs/source/telegram.transactionpartnerchat.rst b/docs/source/telegram.transactionpartnerchat.rst new file mode 100644 index 00000000000..3f278f05d80 --- /dev/null +++ b/docs/source/telegram.transactionpartnerchat.rst @@ -0,0 +1,6 @@ +TransactionPartnerChat +====================== + +.. autoclass:: telegram.TransactionPartnerChat + :members: + :show-inheritance: diff --git a/docs/source/telegram.transactionpartnerfragment.rst b/docs/source/telegram.transactionpartnerfragment.rst index c65f9262f81..dbdad66f2df 100644 --- a/docs/source/telegram.transactionpartnerfragment.rst +++ b/docs/source/telegram.transactionpartnerfragment.rst @@ -4,4 +4,3 @@ TransactionPartnerFragment .. autoclass:: telegram.TransactionPartnerFragment :members: :show-inheritance: - :inherited-members: TransactionPartner diff --git a/docs/source/telegram.transactionpartnerother.rst b/docs/source/telegram.transactionpartnerother.rst index b0c14f0713c..cbc4c41be52 100644 --- a/docs/source/telegram.transactionpartnerother.rst +++ b/docs/source/telegram.transactionpartnerother.rst @@ -4,4 +4,3 @@ TransactionPartnerOther .. autoclass:: telegram.TransactionPartnerOther :members: :show-inheritance: - :inherited-members: TransactionPartner diff --git a/docs/source/telegram.transactionpartnertelegramads.rst b/docs/source/telegram.transactionpartnertelegramads.rst index ce9a52a117f..8304bc84a06 100644 --- a/docs/source/telegram.transactionpartnertelegramads.rst +++ b/docs/source/telegram.transactionpartnertelegramads.rst @@ -4,4 +4,3 @@ TransactionPartnerTelegramAds .. autoclass:: telegram.TransactionPartnerTelegramAds :members: :show-inheritance: - :inherited-members: TransactionPartner diff --git a/docs/source/telegram.transactionpartnertelegramapi.rst b/docs/source/telegram.transactionpartnertelegramapi.rst index 9aeba6b94b8..619b4a0c89f 100644 --- a/docs/source/telegram.transactionpartnertelegramapi.rst +++ b/docs/source/telegram.transactionpartnertelegramapi.rst @@ -4,4 +4,3 @@ TransactionPartnerTelegramApi .. autoclass:: telegram.TransactionPartnerTelegramApi :members: :show-inheritance: - :inherited-members: TransactionPartner diff --git a/docs/source/telegram.transactionpartneruser.rst b/docs/source/telegram.transactionpartneruser.rst index def37495344..7709bd668c4 100644 --- a/docs/source/telegram.transactionpartneruser.rst +++ b/docs/source/telegram.transactionpartneruser.rst @@ -4,4 +4,3 @@ TransactionPartnerUser .. autoclass:: telegram.TransactionPartnerUser :members: :show-inheritance: - :inherited-members: TransactionPartner diff --git a/docs/source/telegram.uniquegift.rst b/docs/source/telegram.uniquegift.rst new file mode 100644 index 00000000000..0d9d1a12d32 --- /dev/null +++ b/docs/source/telegram.uniquegift.rst @@ -0,0 +1,7 @@ +UniqueGift +========== + +.. autoclass:: telegram.UniqueGift + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftbackdrop.rst b/docs/source/telegram.uniquegiftbackdrop.rst new file mode 100644 index 00000000000..52264731b22 --- /dev/null +++ b/docs/source/telegram.uniquegiftbackdrop.rst @@ -0,0 +1,7 @@ +UniqueGiftBackdrop +================== + +.. autoclass:: telegram.UniqueGiftBackdrop + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftbackdropcolors.rst b/docs/source/telegram.uniquegiftbackdropcolors.rst new file mode 100644 index 00000000000..40fbf609a37 --- /dev/null +++ b/docs/source/telegram.uniquegiftbackdropcolors.rst @@ -0,0 +1,7 @@ +UniqueGiftBackdropColors +======================== + +.. autoclass:: telegram.UniqueGiftBackdropColors + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftcolors.rst b/docs/source/telegram.uniquegiftcolors.rst new file mode 100644 index 00000000000..3e554abd8de --- /dev/null +++ b/docs/source/telegram.uniquegiftcolors.rst @@ -0,0 +1,7 @@ +UniqueGiftColors +================ + +.. autoclass:: telegram.UniqueGiftColors + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftinfo.rst b/docs/source/telegram.uniquegiftinfo.rst new file mode 100644 index 00000000000..5d8ef6402cf --- /dev/null +++ b/docs/source/telegram.uniquegiftinfo.rst @@ -0,0 +1,7 @@ +UniqueGiftInfo +============== + +.. autoclass:: telegram.UniqueGiftInfo + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftmodel.rst b/docs/source/telegram.uniquegiftmodel.rst new file mode 100644 index 00000000000..a0a95a04307 --- /dev/null +++ b/docs/source/telegram.uniquegiftmodel.rst @@ -0,0 +1,7 @@ +UniqueGiftModel +=============== + +.. autoclass:: telegram.UniqueGiftModel + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftsymbol.rst b/docs/source/telegram.uniquegiftsymbol.rst new file mode 100644 index 00000000000..8246da5cf17 --- /dev/null +++ b/docs/source/telegram.uniquegiftsymbol.rst @@ -0,0 +1,7 @@ +UniqueGiftSymbol +================ + +.. autoclass:: telegram.UniqueGiftSymbol + :members: + :show-inheritance: + diff --git a/docs/source/telegram.userrating.rst b/docs/source/telegram.userrating.rst new file mode 100644 index 00000000000..f4a8db4a068 --- /dev/null +++ b/docs/source/telegram.userrating.rst @@ -0,0 +1,6 @@ +UserRating +========== + +.. autoclass:: telegram.UserRating + :members: + :show-inheritance: diff --git a/docs/source/telegram.video.rst b/docs/source/telegram.video.rst index 34c81eb242a..bcda1026431 100644 --- a/docs/source/telegram.video.rst +++ b/docs/source/telegram.video.rst @@ -6,4 +6,4 @@ Video .. autoclass:: telegram.Video :members: :show-inheritance: - :inherited-members: TelegramObject \ No newline at end of file + :inherited-members: TelegramObject, object \ No newline at end of file diff --git a/docs/source/telegram.videonote.rst b/docs/source/telegram.videonote.rst index 5217acb0479..e7b504fb515 100644 --- a/docs/source/telegram.videonote.rst +++ b/docs/source/telegram.videonote.rst @@ -6,4 +6,4 @@ VideoNote .. autoclass:: telegram.VideoNote :members: :show-inheritance: - :inherited-members: TelegramObject \ No newline at end of file + :inherited-members: TelegramObject, object \ No newline at end of file diff --git a/docs/source/telegram.voice.rst b/docs/source/telegram.voice.rst index b3667b6edcb..b1530967bd2 100644 --- a/docs/source/telegram.voice.rst +++ b/docs/source/telegram.voice.rst @@ -6,4 +6,4 @@ Voice .. autoclass:: telegram.Voice :members: :show-inheritance: - :inherited-members: TelegramObject + :inherited-members: TelegramObject, object diff --git a/docs/substitutions/global.rst b/docs/substitutions/global.rst index b71ac1f0eac..f1bad363716 100644 --- a/docs/substitutions/global.rst +++ b/docs/substitutions/global.rst @@ -30,7 +30,7 @@ .. |message_thread_id| replace:: Unique identifier for the target message thread of the forum topic. -.. |message_thread_id_arg| replace:: Unique identifier for the target message thread (topic) of the forum; for forum supergroups only. +.. |message_thread_id_arg| replace:: Unique identifier for the target message thread (topic) of a forum; for forum supergroups and private chats of bots with forum topic mode enabled only. .. |parse_mode| replace:: Mode for parsing entities. See :class:`telegram.constants.ParseMode` and `formatting options `__ for more details. @@ -80,7 +80,7 @@ .. |reply_quote| replace:: If set to :obj:`True`, the reply is sent as an actual reply to this message. If ``reply_to_message_id`` is passed, this parameter will be ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. -.. |do_quote| replace:: If set to :obj:`True`, the replied message is quoted. For a dict, it must be the output of :meth:`~telegram.Message.build_reply_arguments` to specify exact ``reply_parameters``. If ``reply_to_message_id`` or ``reply_parameters`` are passed, this parameter will be ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. +.. |do_quote| replace:: If set to :obj:`True`, the replied message is quoted. For a dict, it must be the output of :meth:`~telegram.Message.build_reply_arguments` to specify exact ``reply_parameters``. If ``reply_to_message_id`` or ``reply_parameters`` are passed, this parameter will be ignored. When passing dict-valued input, ``do_quote`` is mutually exclusive with ``allow_sending_without_reply``. Default: :obj:`True` in group chats and :obj:`False` in private chats. .. |non_optional_story_argument| replace:: As of this version, this argument is now required. In accordance with our `stability policy `__, the signature will be kept as optional for now, though they are mandatory and an error will be raised if you don't pass it. @@ -96,4 +96,14 @@ .. |allow_paid_broadcast| replace:: Pass True to allow up to :tg-const:`telegram.constants.FloodLimit.PAID_MESSAGES_PER_SECOND` messages per second, ignoring `broadcasting limits `__ for a fee of 0.1 Telegram Stars per message. The relevant Stars will be withdrawn from the bot's balance. -.. |tz-naive-dtms| replace:: For timezone naive :obj:`datetime.datetime` objects, the default timezone of the bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is used. \ No newline at end of file +.. |direct_messages_topic_id| replace:: Identifier of the direct messages topic to which the message will be sent; required if the message is sent to a direct messages chat. + +.. |suggested_post_parameters| replace:: An object containing the parameters of the suggested post to send; for direct messages chats only. If the message is sent as a reply to another suggested post, then that suggested post is automatically declined. + +.. |tz-naive-dtms| replace:: For timezone naive :obj:`datetime.datetime` objects, the default timezone of the bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is used. + +.. |org-verify| replace:: `on behalf of the organization `__ + +.. |time-period-input| replace:: :class:`datetime.timedelta` objects are accepted in addition to plain :obj:`int` values. + +.. |time-period-int-deprecated| replace:: In a future major version this attribute will be of type :obj:`datetime.timedelta`. You can opt-in early by setting `PTB_TIMEDELTA=true` or ``PTB_TIMEDELTA=1`` as an environment variable. diff --git a/examples/arbitrarycallbackdatabot.py b/examples/arbitrarycallbackdatabot.py index e11620c1670..4e5fbd877c1 100644 --- a/examples/arbitrarycallbackdatabot.py +++ b/examples/arbitrarycallbackdatabot.py @@ -11,6 +11,7 @@ To use arbitrary callback data, you must install PTB via `pip install "python-telegram-bot[callback-data]"` """ + import logging from typing import cast @@ -69,7 +70,7 @@ async def list_button(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non # Get the data from the callback_data. # If you're using a type checker like MyPy, you'll have to use typing.cast # to make the checker get the expected type of the callback_data - number, number_list = cast(tuple[int, list[int]], query.data) + number, number_list = cast("tuple[int, list[int]]", query.data) # append the number to the list number_list.append(number) diff --git a/examples/chatmemberbot.py b/examples/chatmemberbot.py index 34dad2a8385..342385c71cf 100644 --- a/examples/chatmemberbot.py +++ b/examples/chatmemberbot.py @@ -12,7 +12,6 @@ """ import logging -from typing import Optional from telegram import Chat, ChatMember, ChatMemberUpdated, Update from telegram.constants import ParseMode @@ -37,7 +36,7 @@ logger = logging.getLogger(__name__) -def extract_status_change(chat_member_update: ChatMemberUpdated) -> Optional[tuple[bool, bool]]: +def extract_status_change(chat_member_update: ChatMemberUpdated) -> tuple[bool, bool] | None: """Takes a ChatMemberUpdated instance and extracts whether the 'old_chat_member' was a member of the chat and whether the 'new_chat_member' is a member of the chat. Returns None, if the status didn't change. diff --git a/examples/contexttypesbot.py b/examples/contexttypesbot.py index b89d8ffc7d7..cd8df888f8e 100644 --- a/examples/contexttypesbot.py +++ b/examples/contexttypesbot.py @@ -12,7 +12,6 @@ import logging from collections import defaultdict -from typing import Optional from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.constants import ParseMode @@ -50,11 +49,11 @@ class CustomContext(CallbackContext[ExtBot, dict, ChatData, dict]): def __init__( self, application: Application, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, + chat_id: int | None = None, + user_id: int | None = None, ): super().__init__(application=application, chat_id=chat_id, user_id=user_id) - self._message_id: Optional[int] = None + self._message_id: int | None = None @property def bot_user_ids(self) -> set[int]: @@ -62,7 +61,7 @@ def bot_user_ids(self) -> set[int]: return self.bot_data.setdefault("user_ids", set()) @property - def message_clicks(self) -> Optional[int]: + def message_clicks(self) -> int | None: """Access the number of clicks for the message this context object was built for.""" if self._message_id: return self.chat_data.clicks_per_message[self._message_id] diff --git a/examples/conversationbot.py b/examples/conversationbot.py index 751f48f74e0..dbe637c1203 100644 --- a/examples/conversationbot.py +++ b/examples/conversationbot.py @@ -145,7 +145,9 @@ def main() -> None: conv_handler = ConversationHandler( entry_points=[CommandHandler("start", start)], states={ - GENDER: [MessageHandler(filters.Regex("^(Boy|Girl|Other)$"), gender)], + # Use case-insensitive regex to accept gender input regardless of letter casing, + # e.g., "boy", "BOY", "Girl", etc., will all be matched + GENDER: [MessageHandler(filters.Regex("(?i)^(Boy|Girl|Other)$"), gender)], PHOTO: [MessageHandler(filters.PHOTO, photo), CommandHandler("skip", skip_photo)], LOCATION: [ MessageHandler(filters.LOCATION, location), diff --git a/examples/customwebhookbot/djangobot.py b/examples/customwebhookbot/djangobot.py index 0e4c9c12ad1..3bbd3dd1640 100644 --- a/examples/customwebhookbot/djangobot.py +++ b/examples/customwebhookbot/djangobot.py @@ -13,6 +13,7 @@ You may also need to change the `listen` value in the uvicorn configuration to match your setup. Press Ctrl-C on the command line or send a signal to the process to stop the bot. """ + import asyncio import html import json diff --git a/examples/customwebhookbot/flaskbot.py b/examples/customwebhookbot/flaskbot.py index 66c9da6661a..70a98d6b2f4 100644 --- a/examples/customwebhookbot/flaskbot.py +++ b/examples/customwebhookbot/flaskbot.py @@ -13,6 +13,7 @@ You may also need to change the `listen` value in the uvicorn configuration to match your setup. Press Ctrl-C on the command line or send a signal to the process to stop the bot. """ + import asyncio import html import logging @@ -117,13 +118,13 @@ async def main() -> None: # Set up webserver flask_app = Flask(__name__) - @flask_app.post("/telegram") # type: ignore[misc] + @flask_app.post("/telegram") # type: ignore[untyped-decorator] async def telegram() -> Response: """Handle incoming Telegram updates by putting them into the `update_queue`""" await application.update_queue.put(Update.de_json(data=request.json, bot=application.bot)) return Response(status=HTTPStatus.OK) - @flask_app.route("/submitpayload", methods=["GET", "POST"]) # type: ignore[misc] + @flask_app.route("/submitpayload", methods=["GET", "POST"]) # type: ignore[untyped-decorator] async def custom_updates() -> Response: """ Handle incoming webhook updates by also putting them into the `update_queue` if @@ -143,7 +144,7 @@ async def custom_updates() -> Response: await application.update_queue.put(WebhookUpdate(user_id=user_id, payload=payload)) return Response(status=HTTPStatus.OK) - @flask_app.get("/healthcheck") # type: ignore[misc] + @flask_app.get("/healthcheck") # type: ignore[untyped-decorator] async def health() -> Response: """For the health endpoint, reply with a simple plain text message.""" response = make_response("The bot is still running fine :)", HTTPStatus.OK) diff --git a/examples/customwebhookbot/quartbot.py b/examples/customwebhookbot/quartbot.py index 71ab83f5c8c..e65d5cc9ff6 100644 --- a/examples/customwebhookbot/quartbot.py +++ b/examples/customwebhookbot/quartbot.py @@ -13,6 +13,7 @@ You may also need to change the `listen` value in the uvicorn configuration to match your setup. Press Ctrl-C on the command line or send a signal to the process to stop the bot. """ + import asyncio import html import logging @@ -116,7 +117,7 @@ async def main() -> None: # Set up webserver quart_app = Quart(__name__) - @quart_app.post("/telegram") # type: ignore[misc] + @quart_app.post("/telegram") # type: ignore[untyped-decorator] async def telegram() -> Response: """Handle incoming Telegram updates by putting them into the `update_queue`""" await application.update_queue.put( @@ -124,7 +125,7 @@ async def telegram() -> Response: ) return Response(status=HTTPStatus.OK) - @quart_app.route("/submitpayload", methods=["GET", "POST"]) # type: ignore[misc] + @quart_app.route("/submitpayload", methods=["GET", "POST"]) # type: ignore[untyped-decorator] async def custom_updates() -> Response: """ Handle incoming webhook updates by also putting them into the `update_queue` if @@ -144,7 +145,7 @@ async def custom_updates() -> Response: await application.update_queue.put(WebhookUpdate(user_id=user_id, payload=payload)) return Response(status=HTTPStatus.OK) - @quart_app.get("/healthcheck") # type: ignore[misc] + @quart_app.get("/healthcheck") # type: ignore[untyped-decorator] async def health() -> Response: """For the health endpoint, reply with a simple plain text message.""" response = await make_response("The bot is still running fine :)", HTTPStatus.OK) diff --git a/examples/customwebhookbot/starlettebot.py b/examples/customwebhookbot/starlettebot.py index 08397ebd729..26ee12fe2ad 100644 --- a/examples/customwebhookbot/starlettebot.py +++ b/examples/customwebhookbot/starlettebot.py @@ -13,6 +13,7 @@ You may also need to change the `listen` value in the uvicorn configuration to match your setup. Press Ctrl-C on the command line or send a signal to the process to stop the bot. """ + import asyncio import html import logging diff --git a/examples/errorhandlerbot.py b/examples/errorhandlerbot.py index 25885bd6fd3..450b18eb284 100644 --- a/examples/errorhandlerbot.py +++ b/examples/errorhandlerbot.py @@ -3,6 +3,7 @@ # This program is dedicated to the public domain under the CC0 license. """This is a very simple example on how one could implement a custom error handler.""" + import html import json import logging diff --git a/examples/inlinebot.py b/examples/inlinebot.py index 566cfe14bea..1805e3b4dd7 100644 --- a/examples/inlinebot.py +++ b/examples/inlinebot.py @@ -14,6 +14,7 @@ Press Ctrl-C on the command line or send a signal to the process to stop the bot. """ + import logging from html import escape from uuid import uuid4 diff --git a/examples/inlinekeyboard.py b/examples/inlinekeyboard.py index ee82c4e81f6..8fcc53af8a3 100644 --- a/examples/inlinekeyboard.py +++ b/examples/inlinekeyboard.py @@ -6,6 +6,7 @@ Basic example for a bot that uses inline keyboards. For an in-depth explanation, check out https://github.com/python-telegram-bot/python-telegram-bot/wiki/InlineKeyboard-Example. """ + import logging from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update diff --git a/examples/inlinekeyboard2.py b/examples/inlinekeyboard2.py index 639fde4dd1c..2a766be70e8 100644 --- a/examples/inlinekeyboard2.py +++ b/examples/inlinekeyboard2.py @@ -14,6 +14,7 @@ Send /start to initiate the conversation. Press Ctrl-C on the command line to stop the bot. """ + import logging from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update diff --git a/examples/passportbot.py b/examples/passportbot.py index c883e0f76fe..6b012b583c9 100644 --- a/examples/passportbot.py +++ b/examples/passportbot.py @@ -14,6 +14,7 @@ To use Telegram Passport, you must install PTB via `pip install "python-telegram-bot[passport]"` """ + import logging from pathlib import Path diff --git a/examples/paymentbot.py b/examples/paymentbot.py index 90daa44ce2a..dfa641cccc8 100644 --- a/examples/paymentbot.py +++ b/examples/paymentbot.py @@ -61,9 +61,9 @@ async def start_with_shipping_callback(update: Update, context: ContextTypes.DEF title, description, payload, - PAYMENT_PROVIDER_TOKEN, currency, prices, + provider_token=PAYMENT_PROVIDER_TOKEN, need_name=True, need_phone_number=True, need_email=True, @@ -90,7 +90,13 @@ async def start_without_shipping_callback( # optionally pass need_name=True, need_phone_number=True, # need_email=True, need_shipping_address=True, is_flexible=True await context.bot.send_invoice( - chat_id, title, description, payload, PAYMENT_PROVIDER_TOKEN, currency, prices + chat_id, + title, + description, + payload, + currency, + prices, + provider_token=PAYMENT_PROVIDER_TOKEN, ) diff --git a/examples/pollbot.py b/examples/pollbot.py index 71446e9a81d..c4e296e1c1f 100644 --- a/examples/pollbot.py +++ b/examples/pollbot.py @@ -7,6 +7,7 @@ poll/quiz the bot generates. The preview command generates a closed poll/quiz, exactly like the one the user sends the bot """ + import logging from telegram import ( diff --git a/examples/rawapibot.py b/examples/rawapibot.py index b6a70fc3de0..b668046f835 100644 --- a/examples/rawapibot.py +++ b/examples/rawapibot.py @@ -5,8 +5,10 @@ on the telegram.ext bot framework. This program is dedicated to the public domain under the CC0 license. """ + import asyncio import contextlib +import datetime as dtm import logging from typing import NoReturn @@ -47,7 +49,9 @@ async def main() -> NoReturn: async def echo(bot: Bot, update_id: int) -> int: """Echo the message the user sent.""" # Request updates after the last update_id - updates = await bot.get_updates(offset=update_id, timeout=10, allowed_updates=Update.ALL_TYPES) + updates = await bot.get_updates( + offset=update_id, timeout=dtm.timedelta(seconds=10), allowed_updates=Update.ALL_TYPES + ) for update in updates: next_update_id = update.update_id + 1 diff --git a/examples/webappbot.py b/examples/webappbot.py index f9bb30eb0ad..6b095d00726 100644 --- a/examples/webappbot.py +++ b/examples/webappbot.py @@ -8,6 +8,7 @@ Currently only showcases starting the WebApp via a KeyboardButton, as all other methods would require a bot token. """ + import json import logging diff --git a/pyproject.toml b/pyproject.toml index 01406663f55..f713385d64e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,9 @@ dynamic = ["version"] name = "python-telegram-bot" description = "We have made you a wrapper you can't refuse" readme = "README.rst" -requires-python = ">=3.9" +requires-python = ">=3.10" license = "LGPL-3.0-only" -license-files = { paths = ["LICENSE", "LICENSE.dual", "LICENSE.lesser"] } +license-files = ["LICENSE", "LICENSE.dual", "LICENSE.lesser"] authors = [ { name = "Leandro Toledo", email = "devs@python-telegram-bot.org" } ] @@ -31,14 +31,15 @@ classifiers = [ "Topic :: Internet", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = [ - "httpx ~= 0.27", + "httpx >=0.27,<0.29", + "httpcore >=1.0.9; python_version >= '3.14'" # httpx doesn't pin this as of 0.28.1 ] [project.urls] @@ -51,14 +52,12 @@ dependencies = [ "Support" = "https://t.me/pythontelegrambotgroup" [project.optional-dependencies] -# Make sure to install those as additional_dependencies in the -# pre-commit hooks for pylint & mypy -# Also update the readme accordingly +# Make sure to install those as additional_dependencies in the pre-commit hooks # # When dependencies release new versions and tests succeed, we should try to expand the allowed # versions and only increase the lower bound if necessary # -# When adding new groups, make sure to update `ext` and `all` accordingly +# When adding new extras, make sure to update `ext` and `all` accordingly # Optional dependencies for production all = [ @@ -66,7 +65,7 @@ all = [ ] callback-data = [ # Cachetools doesn't have a strict stability policy. Let's be cautious for now. - "cachetools>=5.3.3,<5.6.0", + "cachetools>=7.0.0,<8.0.0", ] ext = [ "python-telegram-bot[callback-data,job-queue,rate-limiter,webhooks]", @@ -91,28 +90,73 @@ socks = [ ] webhooks = [ # tornado is rather stable, but let's not allow the next major release without prior testing - "tornado~=6.4", + "tornado~=6.5", ] +[dependency-groups] +tests = [ + # required for building the wheels for releases + "build", + # For the test suite + "pytest==9.0.2", + # needed because pytest doesn't come with native support for coroutines as tests + "pytest-asyncio==0.21.2", + # xdist runs tests in parallel + "pytest-xdist==3.8.0", + # Used for flaky tests (flaky decorator) + "flaky>=3.8.1", + # used in test_official for parsing tg docs + "beautifulsoup4", + # For testing with timezones. Might not be needed on all systems, but to ensure that unit tests + # run correctly on all systems, we include it here. + "tzdata", + # We've deprecated support pytz, but we still need it for testing that it works with the library. + "pytz", + # Install coverage: + "pytest-cov" +] +docs = [ + "chango~=0.6.0; python_version >= '3.12'", + "sphinx==8.2.3; python_version >= '3.11'", + "furo==2025.9.25", + "sphinx-paramlinks==0.6.0", + "sphinxcontrib-mermaid==1.0.0", + "sphinx-copybutton==0.5.2", + "sphinx-inline-tabs==2023.4.21", + # Temporary. See #4387 + "sphinx-build-compatibility @ git+https://github.com/readthedocs/sphinx-build-compatibility.git@58aabc5f207c6c2421f23d3578adc0b14af57047", + # For python 3.14 support, we need a version of pydantic-core >= 2.35.0, since it upgrades the + # rust toolchain, required for building the project. + # This should ideally be done in `chango`'s dependencies. We can remove this once a new + # stable pydantic version is released. + "pydantic >= 2.12.0a1 ; python_version >= '3.14'" +] +linting = [ + "pre-commit", + "ruff==0.15.2", + "mypy==1.18.2", + "pylint==4.0.5" +] +all = [{ include-group = "tests" }, { include-group = "docs" }, { include-group = "linting"}] # HATCH [tool.hatch.version] # dynamically evaluates the `__version__` variable in that file source = "code" -path = "telegram/_version.py" -search-paths = ["telegram"] +path = "src/telegram/_version.py" -[tool.hatch.build] -packages = ["telegram"] - -# BLACK: -[tool.black] -line-length = 99 +# See also https://github.com/pypa/hatch/issues/1230 for discussion +# the source distribution will include most of the files in the root directory +[tool.hatch.build.targets.sdist] +exclude = [".venv*", "venv*", ".github", "uv.lock"] +# the wheel will only include the src/telegram package +[tool.hatch.build.targets.wheel] +packages = ["src/telegram"] -# ISORT: -[tool.isort] # black config -profile = "black" -line_length = 99 +# CHANGO +[tool.chango] +sys_path = "changes" +chango_instance = { name= "chango_instance", module = "config" } # RUFF: [tool.ruff] @@ -120,9 +164,8 @@ line-length = 99 show-fixes = true [tool.ruff.lint] -preview = true -explicit-preview-rules = true # TODO: Drop this when RUF022 and RUF023 are out of preview -ignore = ["PLR2004", "PLR0911", "PLR0912", "PLR0913", "PLR0915", "PERF203"] +typing-extensions = false +ignore = ["PLR2004", "PLR0911", "PLR0912", "PLR0913", "PLR0915", "PERF203", "ASYNC240"] select = ["E", "F", "I", "PL", "UP", "RUF", "PTH", "C4", "B", "PIE", "SIM", "RET", "RSE", "G", "ISC", "PT", "ASYNC", "TCH", "SLOT", "PERF", "PYI", "FLY", "AIR", "RUF022", "RUF023", "Q", "INP", "W", "YTT", "DTZ", "ARG", "T20", "FURB", "DOC", "TRY", @@ -132,9 +175,9 @@ select = ["E", "F", "I", "PL", "UP", "RUF", "PTH", "C4", "B", "PIE", "SIM", "RET [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["B018"] "tests/**.py" = ["RUF012", "ASYNC230", "DTZ", "ARG", "T201", "ASYNC109", "D", "S", "TRY"] -"telegram/**.py" = ["TRY003"] -"telegram/ext/_applicationbuilder.py" = ["TRY004"] -"telegram/ext/filters.py" = ["D102"] +"src/telegram/**.py" = ["TRY003"] +"src/telegram/ext/_applicationbuilder.py" = ["TRY004"] +"src/telegram/ext/filters.py" = ["D102"] "docs/**.py" = ["INP001", "ARG", "D", "TRY003", "S"] "examples/**.py" = ["ARG", "D", "S105", "TRY003"] @@ -155,6 +198,7 @@ disable = ["duplicate-code", "too-many-arguments", "too-many-public-methods", # run pylint across multiple cpu cores to speed it up- # https://pylint.pycqa.org/en/latest/user_guide/run.html?#parallel-execution to know more jobs = 0 +py-version = "3.10" [tool.pylint.classes] exclude-protected = ["_unfrozen"] @@ -162,6 +206,7 @@ exclude-protected = ["_unfrozen"] # PYTEST: [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["src"] addopts = "--no-success-flaky-report -rX" filterwarnings = [ "error", @@ -184,13 +229,14 @@ log_cli_format = "%(funcName)s - Line %(lineno)d - %(message)s" # MYPY: [tool.mypy] +mypy_path = "src" warn_unused_ignores = true warn_unused_configs = true disallow_untyped_defs = true disallow_incomplete_defs = true disallow_untyped_decorators = true show_error_codes = true -python_version = "3.9" +python_version = "3.10" # For some files, it's easier to just disable strict-optional all together instead of # cluttering the code with `# type: ignore`s or stuff like @@ -226,12 +272,12 @@ ignore_missing_imports = true # COVERAGE: [tool.coverage.run] branch = true -source = ["telegram"] +source = ["src/telegram"] parallel = true concurrency = ["thread", "multiprocessing"] omit = [ "tests/", - "telegram/__main__.py" + "src/telegram/__main__.py" ] [tool.coverage.report] diff --git a/requirements-dev-all.txt b/requirements-dev-all.txt deleted file mode 100644 index 995e067c420..00000000000 --- a/requirements-dev-all.txt +++ /dev/null @@ -1,5 +0,0 @@ --e .[all] -# needed for pre-commit hooks in the git commit command -pre-commit --r requirements-unit-tests.txt --r docs/requirements-docs.txt diff --git a/requirements-unit-tests.txt b/requirements-unit-tests.txt deleted file mode 100644 index a9c4ba3c2c9..00000000000 --- a/requirements-unit-tests.txt +++ /dev/null @@ -1,23 +0,0 @@ --e . - -# required for building the wheels for releases -build - -# For the test suite -pytest==8.3.4 - -# needed because pytest doesn't come with native support for coroutines as tests -pytest-asyncio==0.21.2 - -# xdist runs tests in parallel -pytest-xdist==3.6.1 - -# Used for flaky tests (flaky decorator) -flaky>=3.8.1 - -# used in test_official for parsing tg docs -beautifulsoup4 - -# For testing with timezones. Might not be needed on all systems, but to ensure that unit tests -# run correctly on all systems, we include it here. -tzdata \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index c24e78bc4e1..00000000000 --- a/setup.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[flake8] -max-line-length = 99 -ignore = W503, W605 -extend-ignore = E203, E704 -exclude = docs/source/conf.py diff --git a/telegram/__init__.py b/src/telegram/__init__.py similarity index 86% rename from telegram/__init__.py rename to src/telegram/__init__.py index a895e60dd1f..f4c9a605f17 100644 --- a/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,6 +20,7 @@ __author__ = "devs@python-telegram-bot.org" __all__ = ( + "AcceptedGiftTypes", "AffiliateInfo", "Animation", "Audio", @@ -46,6 +47,7 @@ "BotDescription", "BotName", "BotShortDescription", + "BusinessBotRights", "BusinessConnection", "BusinessIntro", "BusinessLocation", @@ -80,12 +82,18 @@ "ChatPermissions", "ChatPhoto", "ChatShared", + "Checklist", + "ChecklistTask", + "ChecklistTasksAdded", + "ChecklistTasksDone", "ChosenInlineResult", "Contact", "CopyTextButton", "Credentials", "DataCredentials", "Dice", + "DirectMessagePriceChanged", + "DirectMessagesTopic", "Document", "EncryptedCredentials", "EncryptedPassportElement", @@ -103,6 +111,8 @@ "GeneralForumTopicHidden", "GeneralForumTopicUnhidden", "Gift", + "GiftBackground", + "GiftInfo", "Gifts", "Giveaway", "GiveawayCompleted", @@ -135,6 +145,8 @@ "InlineQueryResultVideo", "InlineQueryResultVoice", "InlineQueryResultsButton", + "InputChecklist", + "InputChecklistTask", "InputContactMessageContent", "InputFile", "InputInvoiceMessageContent", @@ -150,7 +162,13 @@ "InputPaidMediaPhoto", "InputPaidMediaVideo", "InputPollOption", + "InputProfilePhoto", + "InputProfilePhotoAnimated", + "InputProfilePhotoStatic", "InputSticker", + "InputStoryContent", + "InputStoryContentPhoto", + "InputStoryContentVideo", "InputTextMessageContent", "InputVenueMessageContent", "Invoice", @@ -161,6 +179,7 @@ "LabeledPrice", "LinkPreviewOptions", "Location", + "LocationAddress", "LoginUrl", "MaskPosition", "MaybeInaccessibleMessage", @@ -180,12 +199,17 @@ "MessageReactionCountUpdated", "MessageReactionUpdated", "OrderInfo", + "OwnedGift", + "OwnedGiftRegular", + "OwnedGiftUnique", + "OwnedGifts", "PaidMedia", "PaidMediaInfo", "PaidMediaPhoto", "PaidMediaPreview", "PaidMediaPurchased", "PaidMediaVideo", + "PaidMessagePriceChanged", "PassportData", "PassportElementError", "PassportElementErrorDataField", @@ -227,26 +251,52 @@ "ShippingAddress", "ShippingOption", "ShippingQuery", + "StarAmount", "StarTransaction", "StarTransactions", "Sticker", "StickerSet", "Story", + "StoryArea", + "StoryAreaPosition", + "StoryAreaType", + "StoryAreaTypeLink", + "StoryAreaTypeLocation", + "StoryAreaTypeSuggestedReaction", + "StoryAreaTypeUniqueGift", + "StoryAreaTypeWeather", "SuccessfulPayment", + "SuggestedPostApprovalFailed", + "SuggestedPostApproved", + "SuggestedPostDeclined", + "SuggestedPostInfo", + "SuggestedPostPaid", + "SuggestedPostParameters", + "SuggestedPostPrice", + "SuggestedPostRefunded", "SwitchInlineQueryChosenChat", "TelegramObject", "TextQuote", "TransactionPartner", "TransactionPartnerAffiliateProgram", + "TransactionPartnerChat", "TransactionPartnerFragment", "TransactionPartnerOther", "TransactionPartnerTelegramAds", "TransactionPartnerTelegramApi", "TransactionPartnerUser", + "UniqueGift", + "UniqueGiftBackdrop", + "UniqueGiftBackdropColors", + "UniqueGiftColors", + "UniqueGiftInfo", + "UniqueGiftModel", + "UniqueGiftSymbol", "Update", "User", "UserChatBoosts", "UserProfilePhotos", + "UserRating", "UsersShared", "Venue", "Video", @@ -271,10 +321,13 @@ "warnings", ) +from telegram._inputchecklist import InputChecklist, InputChecklistTask +from telegram._payment.stars.staramount import StarAmount from telegram._payment.stars.startransactions import StarTransaction, StarTransactions from telegram._payment.stars.transactionpartner import ( TransactionPartner, TransactionPartnerAffiliateProgram, + TransactionPartnerChat, TransactionPartnerFragment, TransactionPartnerOther, TransactionPartnerTelegramAds, @@ -299,6 +352,7 @@ from ._botdescription import BotDescription, BotShortDescription from ._botname import BotName from ._business import ( + BusinessBotRights, BusinessConnection, BusinessIntro, BusinessLocation, @@ -347,9 +401,17 @@ ) from ._chatmemberupdated import ChatMemberUpdated from ._chatpermissions import ChatPermissions +from ._checklists import Checklist, ChecklistTask, ChecklistTasksAdded, ChecklistTasksDone from ._choseninlineresult import ChosenInlineResult from ._copytextbutton import CopyTextButton from ._dice import Dice +from ._directmessagepricechanged import DirectMessagePriceChanged +from ._directmessagestopic import DirectMessagesTopic +from ._files._inputstorycontent import ( + InputStoryContent, + InputStoryContentPhoto, + InputStoryContentVideo, +) from ._files.animation import Animation from ._files.audio import Audio from ._files.chatphoto import ChatPhoto @@ -368,6 +430,11 @@ InputPaidMediaPhoto, InputPaidMediaVideo, ) +from ._files.inputprofilephoto import ( + InputProfilePhoto, + InputProfilePhotoAnimated, + InputProfilePhotoStatic, +) from ._files.inputsticker import InputSticker from ._files.location import Location from ._files.photosize import PhotoSize @@ -389,7 +456,7 @@ from ._games.callbackgame import CallbackGame from ._games.game import Game from ._games.gamehighscore import GameHighScore -from ._gifts import Gift, Gifts +from ._gifts import AcceptedGiftTypes, Gift, GiftBackground, GiftInfo, Gifts from ._giveaway import Giveaway, GiveawayCompleted, GiveawayCreated, GiveawayWinners from ._inline.inlinekeyboardbutton import InlineKeyboardButton from ._inline.inlinekeyboardmarkup import InlineKeyboardMarkup @@ -441,6 +508,7 @@ MessageOriginUser, ) from ._messagereactionupdated import MessageReactionCountUpdated, MessageReactionUpdated +from ._ownedgift import OwnedGift, OwnedGiftRegular, OwnedGifts, OwnedGiftUnique from ._paidmedia import ( PaidMedia, PaidMediaInfo, @@ -449,6 +517,7 @@ PaidMediaPurchased, PaidMediaVideo, ) +from ._paidmessagepricechanged import PaidMessagePriceChanged from ._passport.credentials import ( Credentials, DataCredentials, @@ -504,11 +573,42 @@ from ._sentwebappmessage import SentWebAppMessage from ._shared import ChatShared, SharedUser, UsersShared from ._story import Story +from ._storyarea import ( + LocationAddress, + StoryArea, + StoryAreaPosition, + StoryAreaType, + StoryAreaTypeLink, + StoryAreaTypeLocation, + StoryAreaTypeSuggestedReaction, + StoryAreaTypeUniqueGift, + StoryAreaTypeWeather, +) +from ._suggestedpost import ( + SuggestedPostApprovalFailed, + SuggestedPostApproved, + SuggestedPostDeclined, + SuggestedPostInfo, + SuggestedPostPaid, + SuggestedPostParameters, + SuggestedPostPrice, + SuggestedPostRefunded, +) from ._switchinlinequerychosenchat import SwitchInlineQueryChosenChat from ._telegramobject import TelegramObject +from ._uniquegift import ( + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftColors, + UniqueGiftInfo, + UniqueGiftModel, + UniqueGiftSymbol, +) from ._update import Update from ._user import User from ._userprofilephotos import UserProfilePhotos +from ._userrating import UserRating from ._videochat import ( VideoChatEnded, VideoChatParticipantsInvited, diff --git a/telegram/__main__.py b/src/telegram/__main__.py similarity index 93% rename from telegram/__main__.py rename to src/telegram/__main__.py index 7d291b2ae1e..2b324fe8a2c 100644 --- a/telegram/__main__.py +++ b/src/telegram/__main__.py @@ -1,7 +1,7 @@ # !/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,16 +17,15 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. # pylint: disable=missing-module-docstring -# ruff: noqa: T201, D100, S603, S607 +# ruff: noqa: T201, D100, S607 import subprocess import sys -from typing import Optional from . import __version__ as telegram_ver from .constants import BOT_API_VERSION -def _git_revision() -> Optional[str]: +def _git_revision() -> str | None: try: output = subprocess.check_output( ["git", "describe", "--long", "--tags"], stderr=subprocess.STDOUT diff --git a/telegram/_birthdate.py b/src/telegram/_birthdate.py similarity index 92% rename from telegram/_birthdate.py rename to src/telegram/_birthdate.py index 643af05fc7d..73e6c3c2d32 100644 --- a/telegram/_birthdate.py +++ b/src/telegram/_birthdate.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Birthday.""" + import datetime as dtm -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -51,9 +51,9 @@ def __init__( self, day: int, month: int, - year: Optional[int] = None, + year: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -61,7 +61,7 @@ def __init__( self.day: int = day self.month: int = month # Optional - self.year: Optional[int] = year + self.year: int | None = year self._id_attrs = ( self.day, @@ -70,7 +70,7 @@ def __init__( self._freeze() - def to_date(self, year: Optional[int] = None) -> dtm.date: + def to_date(self, year: int | None = None) -> dtm.date: """Return the birthdate as a date object. .. versionchanged:: 21.2 diff --git a/telegram/_bot.py b/src/telegram/_bot.py similarity index 75% rename from telegram/_bot.py rename to src/telegram/_bot.py index 1d684a77eb5..9b014570cee 100644 --- a/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -2,7 +2,7 @@ # pylint: disable=too-many-arguments # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -24,16 +24,13 @@ import copy import datetime as dtm import pickle -from collections.abc import Sequence +from collections.abc import Callable, Sequence from types import TracebackType from typing import ( TYPE_CHECKING, Any, - Callable, NoReturn, - Optional, TypeVar, - Union, cast, no_type_check, ) @@ -75,17 +72,21 @@ from telegram._files.voice import Voice from telegram._forumtopic import ForumTopic from telegram._games.gamehighscore import GameHighScore -from telegram._gifts import Gift, Gifts +from telegram._gifts import AcceptedGiftTypes, Gift, Gifts from telegram._inline.inlinequeryresultsbutton import InlineQueryResultsButton from telegram._inline.preparedinlinemessage import PreparedInlineMessage +from telegram._inputchecklist import InputChecklist from telegram._menubutton import MenuButton from telegram._message import Message from telegram._messageid import MessageId +from telegram._ownedgift import OwnedGifts +from telegram._payment.stars.staramount import StarAmount from telegram._payment.stars.startransactions import StarTransactions from telegram._poll import InputPollOption, Poll from telegram._reaction import ReactionType, ReactionTypeCustomEmoji, ReactionTypeEmoji from telegram._reply import ReplyParameters from telegram._sentwebappmessage import SentWebAppMessage +from telegram._story import Story from telegram._telegramobject import TelegramObject from telegram._update import Update from telegram._user import User @@ -96,7 +97,14 @@ from telegram._utils.logging import get_logger from telegram._utils.repr import build_repr_with_selected_attrs from telegram._utils.strings import to_camel_case -from telegram._utils.types import CorrectOptionID, FileInput, JSONDict, ODVInput, ReplyMarkup +from telegram._utils.types import ( + BaseUrl, + CorrectOptionID, + FileInput, + JSONDict, + ODVInput, + TimePeriod, +) from telegram._utils.warnings import warn from telegram._webhookinfo import WebhookInfo from telegram.constants import InlineQueryLimit, ReactionEmoji @@ -104,7 +112,7 @@ from telegram.request import BaseRequest, RequestData from telegram.request._httpxrequest import HTTPXRequest from telegram.request._requestparameter import RequestParameter -from telegram.warnings import PTBDeprecationWarning, PTBUserWarning +from telegram.warnings import PTBUserWarning if TYPE_CHECKING: from telegram import ( @@ -115,17 +123,51 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputProfilePhoto, InputSticker, + InputStoryContent, LabeledPrice, LinkPreviewOptions, MessageEntity, PassportElementError, ShippingOption, + StoryArea, + SuggestedPostParameters, ) + from telegram._utils.types import ReplyMarkup BT = TypeVar("BT", bound="Bot") +# Even though we document only {token} as supported insertion, we are a bit more flexible +# internally and support additional variants. At the very least, we don't want the insertion +# to be case sensitive. +_SUPPORTED_INSERTIONS = {"token", "TOKEN", "bot_token", "BOT_TOKEN", "bot-token", "BOT-TOKEN"} +_INSERTION_STRINGS = {f"{{{insertion}}}" for insertion in _SUPPORTED_INSERTIONS} + + +class _TokenDict(dict): + __slots__ = ("token",) + + # small helper to make .format_map work without knowing which exact insertion name is used + def __init__(self, token: str): + self.token = token + super().__init__() + + def __missing__(self, key: str) -> str: + if key in _SUPPORTED_INSERTIONS: + return self.token + raise KeyError(f"Base URL string contains unsupported insertion: {key}") + + +def _parse_base_url(value: BaseUrl, token: str) -> str: + if callable(value): + return value(token) + if any(insertion in value for insertion in _INSERTION_STRINGS): + return value.format_map(_TokenDict(token)) + return value + token + + class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): """This object represents a Telegram Bot. @@ -193,8 +235,40 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): Args: token (:obj:`str`): Bot's unique authentication token. - base_url (:obj:`str`, optional): Telegram Bot API service URL. + base_url (:obj:`str` | Callable[[:obj:`str`], :obj:`str`], optional): Telegram Bot API + service URL. If the string contains ``{token}``, it will be replaced with the bot's + token. If a callable is passed, it will be called with the bot's token as the only + argument and must return the base URL. Otherwise, the token will be appended to the + string. Defaults to ``"https://api.telegram.org/bot"``. + + Tip: + Customizing the base URL can be used to run a bot against + :wiki:`Local Bot API Server ` or using Telegrams + `test environment \ + `_. + + Example: + ``"https://api.telegram.org/bot{token}/test"`` + + .. versionchanged:: 21.11 + Supports callable input and string formatting. base_file_url (:obj:`str`, optional): Telegram Bot API file URL. + If the string contains ``{token}``, it will be replaced with the bot's + token. If a callable is passed, it will be called with the bot's token as the only + argument and must return the base URL. Otherwise, the token will be appended to the + string. Defaults to ``"https://api.telegram.org/bot"``. + + Tip: + Customizing the base URL can be used to run a bot against + :wiki:`Local Bot API Server ` or using Telegrams + `test environment \ + `_. + + Example: + ``"https://api.telegram.org/file/bot{token}/test"`` + + .. versionchanged:: 21.11 + Supports callable input and string formatting. request (:class:`telegram.request.BaseRequest`, optional): Pre initialized :class:`telegram.request.BaseRequest` instances. Will be used for all bot methods *except* for :meth:`get_updates`. If not passed, an instance of @@ -228,23 +302,24 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): __slots__ = ( "_base_file_url", "_base_url", + "_bot_initialized", "_bot_user", - "_initialized", "_local_mode", "_private_key", "_request", + "_requests_initialized", "_token", ) def __init__( self, token: str, - base_url: str = "https://api.telegram.org/bot", - base_file_url: str = "https://api.telegram.org/file/bot", - request: Optional[BaseRequest] = None, - get_updates_request: Optional[BaseRequest] = None, - private_key: Optional[bytes] = None, - private_key_password: Optional[bytes] = None, + base_url: BaseUrl = "https://api.telegram.org/bot", + base_file_url: BaseUrl = "https://api.telegram.org/file/bot", + request: BaseRequest | None = None, + get_updates_request: BaseRequest | None = None, + private_key: bytes | None = None, + private_key_password: bytes | None = None, local_mode: bool = False, ): super().__init__(api_kwargs=None) @@ -252,19 +327,27 @@ def __init__( raise InvalidToken("You must pass the token you received from https://t.me/Botfather!") self._token: str = token - self._base_url: str = base_url + self._token - self._base_file_url: str = base_file_url + self._token + self._base_url: str = _parse_base_url(base_url, self._token) + self._base_file_url: str = _parse_base_url(base_file_url, self._token) + self._LOGGER.debug("Set Bot API URL: %s", self._base_url) + self._LOGGER.debug("Set Bot API File URL: %s", self._base_file_url) + self._local_mode: bool = local_mode - self._bot_user: Optional[User] = None - self._private_key: Optional[bytes] = None - self._initialized: bool = False + self._bot_user: User | None = None + self._private_key: bytes | None = None + self._requests_initialized: bool = False + self._bot_initialized: bool = False self._request: tuple[BaseRequest, BaseRequest] = ( - HTTPXRequest() if get_updates_request is None else get_updates_request, + ( + HTTPXRequest(connection_pool_size=1) + if get_updates_request is None + else get_updates_request + ), HTTPXRequest() if request is None else request, ) - # this section is about issuing a warning when using HTTP/2 and connect to a self hosted + # this section is about issuing a warning when using HTTP/2 and connect to a self-hosted # bot api instance, which currently only supports HTTP/1.1. Checking if a custom base url # is set is the best way to do that. @@ -273,14 +356,14 @@ def __init__( if ( isinstance(self._request[0], HTTPXRequest) and self._request[0].http_version == "2" - and not base_url.startswith("https://api.telegram.org/bot") + and not self.base_url.startswith("https://api.telegram.org/bot") ): warning_string = "get_updates_request" if ( isinstance(self._request[1], HTTPXRequest) and self._request[1].http_version == "2" - and not base_url.startswith("https://api.telegram.org/bot") + and not self.base_url.startswith("https://api.telegram.org/bot") ): if warning_string: warning_string += " and request" @@ -328,9 +411,9 @@ async def __aenter__(self: BT) -> BT: async def __aexit__( self, - exc_type: Optional[type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> None: """|async_context_manager| :meth:`shuts down ` the Bot.""" # Make sure not to return `True` so that exceptions are not suppressed @@ -426,7 +509,7 @@ def local_mode(self) -> bool: # 1. cryptography doesn't have a nice base class, so it would get lengthy # 2. we can't import cryptography if it's not installed @property - def private_key(self) -> Optional[Any]: + def private_key(self) -> Any | None: """Deserialized private key for decryption of telegram passport data. .. versionadded:: 20.0 @@ -523,7 +606,7 @@ def name(self) -> str: @classmethod def _warn( cls, - message: Union[str, PTBUserWarning], + message: str | PTBUserWarning, category: type[Warning] = PTBUserWarning, stacklevel: int = 0, ) -> None: @@ -534,11 +617,11 @@ def _warn( def _parse_file_input( self, - file_input: Union[FileInput, "TelegramObject"], - tg_type: Optional[type["TelegramObject"]] = None, - filename: Optional[str] = None, + file_input: "FileInput| TelegramObject", + tg_type: type["TelegramObject"] | None = None, + filename: str | None = None, attach: bool = False, - ) -> Union[str, "InputFile", Any]: + ) -> "str| InputFile| Any": return parse_file_input( file_input=file_input, tg_type=tg_type, @@ -593,13 +676,13 @@ def _insert_defaults(self, data: dict[str, object]) -> None: async def _post( self, endpoint: str, - data: Optional[JSONDict] = None, + data: JSONDict | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Any: # We know that the return type is Union[bool, JSONDict, list[JSONDict]], but it's hard # to tell mypy which methods expects which of these return values and `Any` saves us a @@ -634,7 +717,7 @@ async def _do_post( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - ) -> Union[bool, JSONDict, list[JSONDict]]: + ) -> bool | JSONDict | list[JSONDict]: # This also converts datetimes into timestamps. # We don't do this earlier so that _insert_defaults (see above) has a chance to convert # to the default timezone in case this is called by ExtBot @@ -664,25 +747,27 @@ async def _send_message( endpoint: str, data: JSONDict, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - caption: Optional[str] = None, + message_thread_id: int | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, link_preview_options: ODVInput["LinkPreviewOptions"] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Any: """Protected method to send or edit messages of any type. @@ -717,6 +802,7 @@ async def _send_message( "business_connection_id": business_connection_id, "caption": caption, "caption_entities": caption_entities, + "direct_messages_topic_id": direct_messages_topic_id, "disable_notification": disable_notification, "link_preview_options": link_preview_options, "message_thread_id": message_thread_id, @@ -725,6 +811,7 @@ async def _send_message( "protect_content": protect_content, "reply_markup": reply_markup, "reply_parameters": reply_parameters, + "suggested_post_parameters": suggested_post_parameters, } ) @@ -752,18 +839,23 @@ async def initialize(self) -> None: .. versionadded:: 20.0 """ - if self._initialized: + if self._requests_initialized and self._bot_initialized: self._LOGGER.debug("This Bot is already initialized.") return - await asyncio.gather(self._request[0].initialize(), self._request[1].initialize()) + # Initialize request objects if not already done + if not self._requests_initialized: + await asyncio.gather(self._request[0].initialize(), self._request[1].initialize()) + self._requests_initialized = True + + # Initialize bot user # Since the bot is to be initialized only once, we can also use it for # verifying the token passed and raising an exception if it's invalid. try: await self.get_me() + self._bot_initialized = True except InvalidToken as exc: raise InvalidToken(f"The token `{self._token}` was rejected by the server.") from exc - self._initialized = True async def shutdown(self) -> None: """Stop & clear resources used by this class. Currently just calls @@ -773,18 +865,19 @@ async def shutdown(self) -> None: .. versionadded:: 20.0 """ - if not self._initialized: + if not self._requests_initialized: self._LOGGER.debug("This Bot is already shut down. Returning.") return await asyncio.gather(self._request[0].shutdown(), self._request[1].shutdown()) - self._initialized = False + self._requests_initialized = False + self._bot_initialized = False async def do_api_request( self, endpoint: str, - api_kwargs: Optional[JSONDict] = None, - return_type: Optional[type[TelegramObject]] = None, + api_kwargs: JSONDict | None = None, + return_type: type[TelegramObject] | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -856,7 +949,7 @@ async def do_api_request( # 2) correct tokens but non-existing method, e.g. api.tg.org/botTOKEN/unkonwnMethod # 2) is relevant only for Bot.do_api_request, that's why we have special handling for # that here rather than in BaseRequest._request_wrapper - if self._initialized: + if self._bot_initialized: raise EndPointNotFound( f"Endpoint '{camel_case_endpoint}' not found in Bot API" ) from exc @@ -880,7 +973,7 @@ async def get_me( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> User: """A simple method for testing your bot's auth token. Requires no parameters. @@ -901,32 +994,34 @@ async def get_me( api_kwargs=api_kwargs, ) self._bot_user = User.de_json(result, self) - return self._bot_user # type: ignore[return-value] + return self._bot_user async def send_message( self, - chat_id: Union[int, str], + chat_id: int | str, text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, - entities: Optional[Sequence["MessageEntity"]] = None, + entities: Sequence["MessageEntity"] | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - message_thread_id: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + message_thread_id: int | None = None, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - disable_web_page_preview: Optional[bool] = None, + reply_to_message_id: int | None = None, + disable_web_page_preview: bool | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send text messages. @@ -972,6 +1067,13 @@ async def send_message( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -1031,6 +1133,8 @@ async def send_message( reply_parameters=reply_parameters, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1040,14 +1144,14 @@ async def send_message( async def delete_message( self, - chat_id: Union[str, int], + chat_id: str | int, message_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to delete a message, including service messages, with the following @@ -1096,16 +1200,79 @@ async def delete_message( api_kwargs=api_kwargs, ) + async def send_message_draft( + self, + chat_id: int, + draft_id: int, + text: str, + message_thread_id: int | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Sequence["MessageEntity"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Use this method to stream a partial message to a user while the message is being + generated; supported only for bots with forum topic mode enabled. + + .. versionadded:: 22.6 + + Args: + chat_id (:obj:`int`): Unique identifier for the target private chat. + draft_id (:obj:`int`): Unique identifier of the message draft; must be non-zero. + Changes of drafts with the same identifier are animated. + text (:obj:`str`): Text of the message to be sent, + :tg-const:`telegram.constants.MessageLimit.MIN_TEXT_LENGTH`- + :tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`): |parse_mode| + entities (Sequence[:class:`telegram.MessageEntity`], optional): Sequence of special + entities that appear in message text, which can be specified instead of + :paramref:`parse_mode`. + + |sequenceargs| + message_thread_id (:obj:`int`, optional): Unique identifier for the target + message thread. + + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "chat_id": chat_id, + "draft_id": draft_id, + "text": text, + "entities": entities, + } + return await self._send_message( + "sendMessageDraft", + data, + message_thread_id=message_thread_id, + parse_mode=parse_mode, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def delete_messages( self, - chat_id: Union[int, str], + chat_id: int | str, message_ids: Sequence[int], *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to delete multiple messages simultaneously. If some of the specified @@ -1140,18 +1307,22 @@ async def delete_messages( async def forward_message( self, - chat_id: Union[int, str], - from_chat_id: Union[str, int], + chat_id: int | str, + from_chat_id: str | int, message_id: int, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to forward messages of any kind. Service messages can't be forwarded. @@ -1170,6 +1341,10 @@ async def forward_message( original message was sent (or channel username in the format ``@channelusername``). message_id (:obj:`int`): Message identifier in the chat specified in :paramref:`from_chat_id`. + video_start_timestamp (:obj:`int`, optional): New start timestamp for the + forwarded video in the message + + .. versionadded:: 21.11 disable_notification (:obj:`bool`, optional): |disable_notification| protect_content (:obj:`bool`, optional): |protect_content| @@ -1177,6 +1352,20 @@ async def forward_message( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): An + object containing the parameters of the suggested post to send; for direct messages + chats only. + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): Identifier of the direct messages + topic to which the message will be forwarded; required if the message is + forwarded to a direct messages chat. + + .. versionadded:: 22.4 + message_effect_id (:obj:`str`, optional): Unique identifier of the message effect to be + added to the message; only available when forwarding to private chats + + .. versionadded:: 22.6 Returns: :class:`telegram.Message`: On success, the sent Message is returned. @@ -1188,6 +1377,7 @@ async def forward_message( "chat_id": chat_id, "from_chat_id": from_chat_id, "message_id": message_id, + "video_start_timestamp": video_start_timestamp, } return await self._send_message( @@ -1196,27 +1386,31 @@ async def forward_message( disable_notification=disable_notification, protect_content=protect_content, message_thread_id=message_thread_id, + suggested_post_parameters=suggested_post_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, + message_effect_id=message_effect_id, ) async def forward_messages( self, - chat_id: Union[int, str], - from_chat_id: Union[str, int], + chat_id: int | str, + from_chat_id: str | int, message_ids: Sequence[int], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple[MessageId, ...]: """ Use this method to forward messages of any kind. If some of the specified messages can't be @@ -1237,6 +1431,11 @@ async def forward_messages( disable_notification (:obj:`bool`, optional): |disable_notification| protect_content (:obj:`bool`, optional): |protect_content| message_thread_id (:obj:`int`, optional): |message_thread_id_arg| + direct_messages_topic_id (:obj:`int`, optional): Identifier of the direct messages + topic to which the messages will be forwarded; required if the messages are + forwarded to a direct messages chat. + + .. versionadded:: 22.4 Returns: tuple[:class:`telegram.Message`]: On success, a tuple of ``MessageId`` of sent messages @@ -1252,6 +1451,7 @@ async def forward_messages( "disable_notification": disable_notification, "protect_content": protect_content, "message_thread_id": message_thread_id, + "direct_messages_topic_id": direct_messages_topic_id, } result = await self._post( @@ -1267,30 +1467,32 @@ async def forward_messages( async def send_photo( self, - chat_id: Union[int, str], - photo: Union[FileInput, "PhotoSize"], - caption: Optional[str] = None, + chat_id: int | str, + photo: "FileInput | PhotoSize", + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send photos. @@ -1354,6 +1556,13 @@ async def send_photo( show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| .. versionadded:: 21.3 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -1415,36 +1624,40 @@ async def send_photo( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_audio( self, - chat_id: Union[int, str], - audio: Union[FileInput, "Audio"], - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - caption: Optional[str] = None, + chat_id: int | str, + audio: "FileInput | Audio", + duration: TimePeriod | None = None, + performer: str | None = None, + title: str | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send audio files, if you want Telegram clients to display them in the @@ -1483,7 +1696,11 @@ async def send_audio( .. versionchanged:: 20.0 |sequenceargs| - duration (:obj:`int`, optional): Duration of sent audio in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of sent audio + in seconds. + + .. versionchanged:: 21.11 + |time-period-input| performer (:obj:`str`, optional): Performer. title (:obj:`str`, optional): Track name. disable_notification (:obj:`bool`, optional): |disable_notification| @@ -1514,6 +1731,13 @@ async def send_audio( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -1577,34 +1801,38 @@ async def send_audio( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_document( self, - chat_id: Union[int, str], - document: Union[FileInput, "Document"], - caption: Optional[str] = None, + chat_id: int | str, + document: "FileInput | Document", + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_content_type_detection: Optional[bool] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + disable_content_type_detection: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send general files. @@ -1673,6 +1901,13 @@ async def send_document( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -1732,29 +1967,33 @@ async def send_document( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_sticker( self, - chat_id: Union[int, str], - sticker: Union[FileInput, "Sticker"], + chat_id: int | str, + sticker: "FileInput | Sticker", disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - emoji: Optional[str] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + emoji: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send static ``.WEBP``, animated ``.TGS``, or video ``.WEBM`` stickers. @@ -1804,6 +2043,13 @@ async def send_sticker( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -1855,39 +2101,45 @@ async def send_sticker( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_video( self, - chat_id: Union[int, str], - video: Union[FileInput, "Video"], - duration: Optional[int] = None, - caption: Optional[str] = None, + chat_id: int | str, + video: "FileInput | Video", + duration: TimePeriod | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - width: Optional[int] = None, - height: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + width: int | None = None, + height: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - supports_streaming: Optional[bool] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + supports_streaming: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send video files, Telegram clients support mp4 videos (other formats may be sent as Document). @@ -1919,9 +2171,20 @@ async def send_video( .. versionchanged:: 20.0 File paths as input is also accepted for bots *not* running in :paramref:`~telegram.Bot.local_mode`. - duration (:obj:`int`, optional): Duration of sent video in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of sent video + in seconds. + + .. versionchanged:: 21.11 + |time-period-input| width (:obj:`int`, optional): Video width. height (:obj:`int`, optional): Video height. + cover (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + optional): Cover for the video in the message. |fileinputnopath| + + .. versionadded:: 21.11 + start_timestamp (:obj:`int`, optional): Start timestamp for the video in the message. + + .. versionadded:: 21.11 caption (:obj:`str`, optional): Video caption (may also be used when resending videos by file_id), 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -1968,6 +2231,13 @@ async def send_video( show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| .. versionadded:: 21.3 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -2008,6 +2278,8 @@ async def send_video( "width": width, "height": height, "supports_streaming": supports_streaming, + "cover": self._parse_file_input(cover, attach=True) if cover else None, + "start_timestamp": start_timestamp, "thumbnail": self._parse_file_input(thumbnail, attach=True) if thumbnail else None, "has_spoiler": has_spoiler, "show_caption_above_media": show_caption_above_media, @@ -2034,32 +2306,36 @@ async def send_video( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_video_note( self, - chat_id: Union[int, str], - video_note: Union[FileInput, "VideoNote"], - duration: Optional[int] = None, - length: Optional[int] = None, + chat_id: int | str, + video_note: "FileInput | VideoNote", + duration: TimePeriod | None = None, + length: int | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ As of v.4.0, Telegram clients support rounded square mp4 videos of up to 1 minute long. @@ -2092,7 +2368,11 @@ async def send_video_note( .. versionchanged:: 20.0 File paths as input is also accepted for bots *not* running in :paramref:`~telegram.Bot.local_mode`. - duration (:obj:`int`, optional): Duration of sent video in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of sent video + in seconds. + + .. versionchanged:: 21.11 + |time-period-input| length (:obj:`int`, optional): Video width and height, i.e. diameter of the video message. disable_notification (:obj:`bool`, optional): |disable_notification| @@ -2123,6 +2403,13 @@ async def send_video_note( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -2182,38 +2469,42 @@ async def send_video_note( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_animation( self, - chat_id: Union[int, str], - animation: Union[FileInput, "Animation"], - duration: Optional[int] = None, - width: Optional[int] = None, - height: Optional[int] = None, - caption: Optional[str] = None, + chat_id: int | str, + animation: "FileInput | Animation", + duration: TimePeriod | None = None, + width: int | None = None, + height: int | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "ReplyMarkup | None" = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send animation files (GIF or H.264/MPEG-4 AVC video without sound). @@ -2240,7 +2531,11 @@ async def send_animation( .. versionchanged:: 13.2 Accept :obj:`bytes` as input. - duration (:obj:`int`, optional): Duration of sent animation in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of sent + animation in seconds. + + .. versionchanged:: 21.11 + |time-period-input| width (:obj:`int`, optional): Animation width. height (:obj:`int`, optional): Animation height. caption (:obj:`str`, optional): Animation caption (may also be used when resending @@ -2288,6 +2583,13 @@ async def send_animation( show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| .. versionadded:: 21.3 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -2353,33 +2655,37 @@ async def send_animation( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_voice( self, - chat_id: Union[int, str], - voice: Union[FileInput, "Voice"], - duration: Optional[int] = None, - caption: Optional[str] = None, + chat_id: int | str, + voice: "FileInput | Voice", + duration: TimePeriod | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send audio files, if you want Telegram clients to display the file @@ -2420,7 +2726,11 @@ async def send_voice( .. versionchanged:: 20.0 |sequenceargs| - duration (:obj:`int`, optional): Duration of the voice message in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of the voice + message in seconds. + + .. versionchanged:: 21.11 + |time-period-input| disable_notification (:obj:`bool`, optional): |disable_notification| protect_content (:obj:`bool`, optional): |protect_content| @@ -2445,6 +2755,13 @@ async def send_voice( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -2505,32 +2822,35 @@ async def send_voice( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_media_group( self, - chat_id: Union[int, str], + chat_id: int | str, media: Sequence[ - Union["InputMediaAudio", "InputMediaDocument", "InputMediaPhoto", "InputMediaVideo"] + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - caption: Optional[str] = None, + api_kwargs: JSONDict | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, ) -> tuple[Message, ...]: """Use this method to send a group of photos, videos, documents or audios as an album. Documents and audio files can be only grouped in an album with messages of the same type. @@ -2576,6 +2896,12 @@ async def send_media_group( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + direct_messages_topic_id (:obj:`int`, optional): Identifier of the direct messages + topic to which the messages will be sent; required if the messages are sent to a + direct messages chat. + + .. versionadded:: 22.4 + Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -2671,6 +2997,7 @@ async def send_media_group( "business_connection_id": business_connection_id, "message_effect_id": message_effect_id, "allow_paid_broadcast": allow_paid_broadcast, + "direct_messages_topic_id": direct_messages_topic_id, } result = await self._post( @@ -2687,30 +3014,32 @@ async def send_media_group( async def send_location( self, - chat_id: Union[int, str], - latitude: Optional[float] = None, - longitude: Optional[float] = None, + chat_id: int | str, + latitude: float | None = None, + longitude: float | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - live_period: Optional[int] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + live_period: TimePeriod | None = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - location: Optional[Location] = None, + reply_to_message_id: int | None = None, + location: "Location | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send point on the map. @@ -2725,12 +3054,16 @@ async def send_location( horizontal_accuracy (:obj:`int`, optional): The radius of uncertainty for the location, measured in meters; 0-:tg-const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`, optional): Period in seconds for which the location will be + live_period (:obj:`int` | :class:`datetime.timedelta`, optional): Period in seconds for + which the location will be updated, should be between :tg-const:`telegram.constants.LocationLimit.MIN_LIVE_PERIOD` and :tg-const:`telegram.constants.LocationLimit.MAX_LIVE_PERIOD`, or :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` for live locations that can be edited indefinitely. + + .. versionchanged:: 21.11 + |time-period-input| heading (:obj:`int`, optional): For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.constants.LocationLimit.MIN_HEADING` and @@ -2764,6 +3097,13 @@ async def send_location( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -2835,29 +3175,31 @@ async def send_location( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def edit_message_live_location( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, - live_period: Optional[int] = None, - business_connection_id: Optional[str] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + latitude: float | None = None, + longitude: float | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, + live_period: TimePeriod | None = None, + business_connection_id: str | None = None, *, - location: Optional[Location] = None, + location: "Location | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Use this method to edit live location messages sent by the bot or via the bot (for inline bots). A location can be edited until its :attr:`telegram.Location.live_period` expires or editing is explicitly disabled by a call to :meth:`stop_message_live_location`. @@ -2888,7 +3230,8 @@ async def edit_message_live_location( if specified. reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for a new inline keyboard. - live_period (:obj:`int`, optional): New period in seconds during which the location + live_period (:obj:`int` | :class:`datetime.timedelta`, optional): New period in seconds + during which the location can be updated, starting from the message send date. If :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` is specified, then the location can be updated forever. Otherwise, the new value must not exceed @@ -2897,6 +3240,9 @@ async def edit_message_live_location( remains unchanged .. versionadded:: 21.2. + + .. versionchanged:: 21.11 + |time-period-input| business_connection_id (:obj:`str`, optional): |business_id_str_edit| .. versionadded:: 21.4 @@ -2949,18 +3295,18 @@ async def edit_message_live_location( async def stop_message_live_location( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - business_connection_id: Optional[str] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Use this method to stop updating a live location message sent by the bot or via the bot (for inline bots) before :paramref:`~telegram.Location.live_period` expires. @@ -3001,32 +3347,34 @@ async def stop_message_live_location( async def send_venue( self, - chat_id: Union[int, str], - latitude: Optional[float] = None, - longitude: Optional[float] = None, - title: Optional[str] = None, - address: Optional[str] = None, - foursquare_id: Optional[str] = None, + chat_id: int | str, + latitude: float | None = None, + longitude: float | None = None, + title: str | None = None, + address: str | None = None, + foursquare_id: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - venue: Optional[Venue] = None, + reply_to_message_id: int | None = None, + venue: "Venue | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send information about a venue. @@ -3051,7 +3399,7 @@ async def send_venue( google_place_id (:obj:`str`, optional): Google Places identifier of the venue. google_place_type (:obj:`str`, optional): Google Places type of the venue. (See `supported types \ - `_.) + `_.) disable_notification (:obj:`bool`, optional): |disable_notification| protect_content (:obj:`bool`, optional): |protect_content| @@ -3076,6 +3424,13 @@ async def send_venue( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -3158,32 +3513,36 @@ async def send_venue( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_contact( self, - chat_id: Union[int, str], - phone_number: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, + chat_id: int | str, + phone_number: str | None = None, + first_name: str | None = None, + last_name: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - vcard: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + vcard: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - contact: Optional[Contact] = None, + reply_to_message_id: int | None = None, + contact: "Contact | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send phone contacts. @@ -3223,6 +3582,13 @@ async def send_contact( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -3296,6 +3662,8 @@ async def send_contact( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_game( @@ -3303,21 +3671,21 @@ async def send_game( chat_id: int, game_short_name: str, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send a game. @@ -3400,16 +3768,16 @@ async def send_game( async def send_chat_action( self, - chat_id: Union[str, int], + chat_id: str | int, action: str, - message_thread_id: Optional[int] = None, - business_connection_id: Optional[str] = None, + message_thread_id: int | None = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method when you need to tell the user that something is happening on the bot's @@ -3454,12 +3822,11 @@ async def send_chat_action( def _effective_inline_results( self, - results: Union[ - Sequence["InlineQueryResult"], Callable[[int], Optional[Sequence["InlineQueryResult"]]] - ], - next_offset: Optional[str] = None, - current_offset: Optional[str] = None, - ) -> tuple[Sequence["InlineQueryResult"], Optional[str]]: + results: Sequence["InlineQueryResult"] + | (Callable[[int], Sequence["InlineQueryResult"] | None]), + next_offset: str | None = None, + current_offset: str | None = None, + ) -> tuple[Sequence["InlineQueryResult"], str | None]: """ Builds the effective results from the results input. We make this a stand-alone method so tg.ext.ExtBot can wrap it. @@ -3494,8 +3861,7 @@ def _effective_inline_results( next_offset_int = current_offset_int + 1 next_offset = str(next_offset_int) effective_results = results[ - current_offset_int - * InlineQueryLimit.RESULTS : next_offset_int + current_offset_int * InlineQueryLimit.RESULTS : next_offset_int * InlineQueryLimit.RESULTS ] else: @@ -3549,20 +3915,19 @@ def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> "InlineQ async def answer_inline_query( self, inline_query_id: str, - results: Union[ - Sequence["InlineQueryResult"], Callable[[int], Optional[Sequence["InlineQueryResult"]]] - ], - cache_time: Optional[int] = None, - is_personal: Optional[bool] = None, - next_offset: Optional[str] = None, - button: Optional[InlineQueryResultsButton] = None, + results: Sequence["InlineQueryResult"] + | (Callable[[int], Sequence["InlineQueryResult"] | None]), + cache_time: TimePeriod | None = None, + is_personal: bool | None = None, + next_offset: str | None = None, + button: InlineQueryResultsButton | None = None, *, - current_offset: Optional[str] = None, + current_offset: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to send answers to an inline query. No more than @@ -3588,8 +3953,12 @@ async def answer_inline_query( a callable that accepts the current page index starting from 0. It must return either a list of :class:`telegram.InlineQueryResult` instances or :obj:`None` if there are no more results. - cache_time (:obj:`int`, optional): The maximum amount of time in seconds that the + cache_time (:obj:`int` | :class:`datetime.timedelta`, optional): The maximum amount of + time in seconds that the result of the inline query may be cached on the server. Defaults to ``300``. + + .. versionchanged:: 21.11 + |time-period-input| is_personal (:obj:`bool`, optional): Pass :obj:`True`, if results may be cached on the server side only for the user that sent the query. By default, results may be returned to any user who sends the same query. @@ -3647,16 +4016,16 @@ async def save_prepared_inline_message( self, user_id: int, result: "InlineQueryResult", - allow_user_chats: Optional[bool] = None, - allow_bot_chats: Optional[bool] = None, - allow_group_chats: Optional[bool] = None, - allow_channel_chats: Optional[bool] = None, + allow_user_chats: bool | None = None, + allow_bot_chats: bool | None = None, + allow_group_chats: bool | None = None, + allow_channel_chats: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> PreparedInlineMessage: """Stores a message that can be sent by a user of a Mini App. @@ -3689,7 +4058,7 @@ async def save_prepared_inline_message( "allow_group_chats": allow_group_chats, "allow_channel_chats": allow_channel_chats, } - return PreparedInlineMessage.de_json( # type: ignore[return-value] + return PreparedInlineMessage.de_json( await self._post( "savePreparedInlineMessage", data, @@ -3705,15 +4074,15 @@ async def save_prepared_inline_message( async def get_user_profile_photos( self, user_id: int, - offset: Optional[int] = None, - limit: Optional[int] = None, + offset: int | None = None, + limit: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> UserProfilePhotos: + api_kwargs: JSONDict | None = None, + ) -> "UserProfilePhotos": """Use this method to get a list of profile pictures for a user. Args: @@ -3744,19 +4113,28 @@ async def get_user_profile_photos( api_kwargs=api_kwargs, ) - return UserProfilePhotos.de_json(result, self) # type: ignore[return-value] + return UserProfilePhotos.de_json(result, self) async def get_file( self, - file_id: Union[ - str, Animation, Audio, ChatPhoto, Document, PhotoSize, Sticker, Video, VideoNote, Voice - ], + file_id: ( + str + | Animation + | Audio + | ChatPhoto + | Document + | PhotoSize + | Sticker + | Video + | VideoNote + | Voice + ), *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> File: """ Use this method to get basic info about a file and prepare it for downloading. For the @@ -3805,24 +4183,24 @@ async def get_file( api_kwargs=api_kwargs, ) - file_path = cast(dict, result).get("file_path") + file_path = cast("dict", result).get("file_path") if file_path and not is_local_file(file_path): result["file_path"] = f"{self._base_file_url}/{file_path}" - return File.de_json(result, self) # type: ignore[return-value] + return File.de_json(result, self) async def ban_chat_member( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, - until_date: Optional[Union[int, dtm.datetime]] = None, - revoke_messages: Optional[bool] = None, + until_date: int | dtm.datetime | None = None, + revoke_messages: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to ban a user from a group, supergroup or a channel. In the case of @@ -3874,14 +4252,14 @@ async def ban_chat_member( async def ban_chat_sender_chat( self, - chat_id: Union[str, int], + chat_id: str | int, sender_chat_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to ban a channel chat in a supergroup or a channel. Until the chat is @@ -3917,15 +4295,15 @@ async def ban_chat_sender_chat( async def unban_chat_member( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, - only_if_banned: Optional[bool] = None, + only_if_banned: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to unban a previously kicked user in a supergroup or channel. @@ -3961,14 +4339,14 @@ async def unban_chat_member( async def unban_chat_sender_chat( self, - chat_id: Union[str, int], + chat_id: str | int, sender_chat_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to unban a previously banned channel in a supergroup or channel. The bot must be an administrator for this to work and must have the @@ -4002,16 +4380,16 @@ async def unban_chat_sender_chat( async def answer_callback_query( self, callback_query_id: str, - text: Optional[str] = None, - show_alert: Optional[bool] = None, - url: Optional[str] = None, - cache_time: Optional[int] = None, + text: str | None = None, + show_alert: bool | None = None, + url: str | None = None, + cache_time: TimePeriod | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to send answers to callback queries sent from inline keyboards. The answer @@ -4036,9 +4414,13 @@ async def answer_callback_query( opens your game - note that this will only work if the query comes from a callback game button. Otherwise, you may use links like t.me/your_bot?start=XXXX that open your bot with a parameter. - cache_time (:obj:`int`, optional): The maximum amount of time in seconds that the + cache_time (:obj:`int` | :class:`datetime.timedelta`, optional): The maximum amount of + time in seconds that the result of the callback query may be cached client-side. Defaults to 0. + .. versionchanged:: 21.11 + |time-period-input| + Returns: :obj:`bool` On success, :obj:`True` is returned. @@ -4067,22 +4449,22 @@ async def answer_callback_query( async def edit_message_text( self, text: str, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, - business_connection_id: Optional[str] = None, + business_connection_id: str | None = None, *, - disable_web_page_preview: Optional[bool] = None, + disable_web_page_preview: bool | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """ Use this method to edit text and game messages. @@ -4173,22 +4555,22 @@ async def edit_message_text( async def edit_message_caption( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + caption: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - show_caption_above_media: Optional[bool] = None, - business_connection_id: Optional[str] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """ Use this method to edit captions of messages. @@ -4254,18 +4636,18 @@ async def edit_message_caption( async def edit_message_media( self, media: "InputMedia", - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - business_connection_id: Optional[str] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """ Use this method to edit animation, audio, document, photo, or video messages, or to add media to text messages. If a message @@ -4323,18 +4705,18 @@ async def edit_message_media( async def edit_message_reply_markup( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - business_connection_id: Optional[str] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """ Use this method to edit only the reply markup of messages sent by the bot or via the bot (for inline bots). @@ -4384,16 +4766,16 @@ async def edit_message_reply_markup( async def get_updates( self, - offset: Optional[int] = None, - limit: Optional[int] = None, - timeout: Optional[int] = None, # noqa: ASYNC109 - allowed_updates: Optional[Sequence[str]] = None, + offset: int | None = None, + limit: int | None = None, + timeout: TimePeriod | None = None, + allowed_updates: Sequence[str] | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple[Update, ...]: """Use this method to receive incoming updates using long polling. @@ -4421,9 +4803,12 @@ async def get_updates( between :tg-const:`telegram.constants.PollingLimit.MIN_LIMIT`- :tg-const:`telegram.constants.PollingLimit.MAX_LIMIT` are accepted. Defaults to ``100``. - timeout (:obj:`int`, optional): Timeout in seconds for long polling. Defaults to ``0``, - i.e. usual short polling. Should be positive, short polling should be used for - testing purposes only. + timeout (:obj:`int` | :class:`datetime.timedelta`, optional): Timeout in seconds for + long polling. Defaults to ``0``, i.e. usual short polling. Should be positive, + short polling should be used for testing purposes only. + + .. versionchanged:: v22.2 + |time-period-input| allowed_updates (Sequence[:obj:`str`]), optional): A sequence the types of updates you want your bot to receive. For example, specify ["message", "edited_channel_post", "callback_query"] to only receive updates of these types. @@ -4456,19 +4841,13 @@ async def get_updates( if not isinstance(read_timeout, DefaultValue): arg_read_timeout: float = read_timeout or 0 else: - try: - arg_read_timeout = self._request[0].read_timeout or 0 - except NotImplementedError: - arg_read_timeout = 2 - self._warn( - PTBDeprecationWarning( - "20.7", - f"The class {self._request[0].__class__.__name__} does not override " - "the property `read_timeout`. Overriding this property will be mandatory " - "in future versions. Using 2 seconds as fallback.", - ), - stacklevel=2, - ) + arg_read_timeout = self._request[0].read_timeout or 0 + + read_timeout = ( + (arg_read_timeout + timeout.total_seconds()) + if isinstance(timeout, dtm.timedelta) + else (arg_read_timeout + timeout if timeout else arg_read_timeout) + ) # Ideally we'd use an aggressive read timeout for the polling. However, # * Short polling should return within 2 seconds. @@ -4476,11 +4855,11 @@ async def get_updates( # waiting for the server to return and there's no way of knowing the connection had been # dropped in real time. result = cast( - list[JSONDict], + "list[JSONDict]", await self._post( "getUpdates", data, - read_timeout=arg_read_timeout + timeout if timeout else arg_read_timeout, + read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, @@ -4507,24 +4886,27 @@ async def get_updates( async def set_webhook( self, url: str, - certificate: Optional[FileInput] = None, - max_connections: Optional[int] = None, - allowed_updates: Optional[Sequence[str]] = None, - ip_address: Optional[str] = None, - drop_pending_updates: Optional[bool] = None, - secret_token: Optional[str] = None, + certificate: "FileInput | None" = None, + max_connections: int | None = None, + allowed_updates: Sequence[str] | None = None, + ip_address: str | None = None, + drop_pending_updates: bool | None = None, + secret_token: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to specify a url and receive incoming updates via an outgoing webhook. Whenever there is an update for the bot, Telegram will send an HTTPS POST request to the - specified url, containing An Update. In case of an unsuccessful request, - Telegram will give up after a reasonable amount of attempts. + specified url, containing An Update. In case of an unsuccessful request + (a request with response + `HTTP status code `_different + from ``2XY``), + Telegram will repeat the request and give up after a reasonable amount of attempts. If you'd like to make sure that the Webhook was set by you, you can specify secret data in the parameter :paramref:`secret_token`. If specified, the request will contain a header @@ -4621,13 +5003,13 @@ async def set_webhook( async def delete_webhook( self, - drop_pending_updates: Optional[bool] = None, + drop_pending_updates: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to remove webhook integration if you decide to switch back to @@ -4658,13 +5040,13 @@ async def delete_webhook( async def leave_chat( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method for your bot to leave a group, supergroup or channel. @@ -4692,13 +5074,13 @@ async def leave_chat( async def get_chat( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> ChatFullInfo: """ Use this method to get up to date information about the chat (current name of the user for @@ -4729,17 +5111,17 @@ async def get_chat( api_kwargs=api_kwargs, ) - return ChatFullInfo.de_json(result, self) # type: ignore[return-value] + return ChatFullInfo.de_json(result, self) async def get_chat_administrators( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple[ChatMember, ...]: """ Use this method to get a list of administrators in a chat. @@ -4774,13 +5156,13 @@ async def get_chat_administrators( async def get_chat_member_count( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> int: """Use this method to get the number of members in a chat. @@ -4809,14 +5191,14 @@ async def get_chat_member_count( async def get_chat_member( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> ChatMember: """Use this method to get information about a member of a chat. The method is only guaranteed to work for other users if the bot is an administrator in the chat. @@ -4842,18 +5224,18 @@ async def get_chat_member( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return ChatMember.de_json(result, self) # type: ignore[return-value] + return ChatMember.de_json(result, self) async def set_chat_sticker_set( self, - chat_id: Union[str, int], + chat_id: str | int, sticker_set_name: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to set a new group sticker set for a supergroup. The bot must be an administrator in the chat for this to work and must have the appropriate @@ -4881,13 +5263,13 @@ async def set_chat_sticker_set( async def delete_chat_sticker_set( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to delete a group sticker set from a supergroup. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. @@ -4918,7 +5300,7 @@ async def get_webhook_info( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> WebhookInfo: """Use this method to get current webhook status. Requires no parameters. @@ -4937,24 +5319,24 @@ async def get_webhook_info( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return WebhookInfo.de_json(result, self) # type: ignore[return-value] + return WebhookInfo.de_json(result, self) async def set_game_score( self, user_id: int, score: int, - chat_id: Optional[int] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - force: Optional[bool] = None, - disable_edit_message: Optional[bool] = None, + chat_id: int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + force: bool | None = None, + disable_edit_message: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """ Use this method to set the score of the specified user in a game message. @@ -5006,15 +5388,15 @@ async def set_game_score( async def get_game_high_scores( self, user_id: int, - chat_id: Optional[int] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, + chat_id: int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple[GameHighScore, ...]: """ Use this method to get data for high score tables. Will return the score of the specified @@ -5065,43 +5447,45 @@ async def get_game_high_scores( async def send_invoice( self, - chat_id: Union[int, str], + chat_id: int | str, title: str, description: str, payload: str, - provider_token: Optional[str], # This arg is now optional as of Bot API 7.4 currency: str, prices: Sequence["LabeledPrice"], - start_parameter: Optional[str] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - is_flexible: Optional[bool] = None, + provider_token: str | None = None, + start_parameter: str | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + is_flexible: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - provider_data: Optional[Union[str, object]] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + provider_data: str | object | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send invoices. @@ -5124,13 +5508,13 @@ async def send_invoice( :tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`- :tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be displayed to the user, use it for your internal processes. - provider_token (:obj:`str`): Payments provider token, obtained via + provider_token (:obj:`str`, optional): Payments provider token, obtained via `@BotFather `_. Pass an empty string for payments in |tg_stars|. - .. deprecated:: 21.3 - As of Bot API 7.4, this parameter is now optional and future versions of the - library will make it optional as well. + .. versionchanged:: 21.11 + Bot API 7.4 made this parameter is optional and this is now reflected in the + function signature. currency (:obj:`str`): Three-letter ISO 4217 currency code, see `more on currencies `_. Pass ``XTR`` for @@ -5214,6 +5598,13 @@ async def send_invoice( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -5284,20 +5675,22 @@ async def send_invoice( api_kwargs=api_kwargs, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def answer_shipping_query( self, shipping_query_id: str, ok: bool, - shipping_options: Optional[Sequence["ShippingOption"]] = None, - error_message: Optional[str] = None, + shipping_options: Sequence["ShippingOption"] | None = None, + error_message: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ If you sent an invoice requesting a shipping address and the parameter @@ -5348,13 +5741,13 @@ async def answer_pre_checkout_query( self, pre_checkout_query_id: str, ok: bool, - error_message: Optional[str] = None, + error_message: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Once the user has confirmed their payment and shipping details, the Bot API sends the final @@ -5409,7 +5802,7 @@ async def answer_web_app_query( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> SentWebAppMessage: """Use this method to set the result of an interaction with a Web App and send a corresponding message on behalf of the user to the chat from which the query originated. @@ -5444,21 +5837,21 @@ async def answer_web_app_query( api_kwargs=api_kwargs, ) - return SentWebAppMessage.de_json(api_result, self) # type: ignore[return-value] + return SentWebAppMessage.de_json(api_result, self) async def restrict_chat_member( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, permissions: ChatPermissions, - until_date: Optional[Union[int, dtm.datetime]] = None, - use_independent_chat_permissions: Optional[bool] = None, + until_date: int | dtm.datetime | None = None, + use_independent_chat_permissions: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to restrict a user in a supergroup. The bot must be an administrator in @@ -5519,29 +5912,30 @@ async def restrict_chat_member( async def promote_chat_member( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, - can_change_info: Optional[bool] = None, - can_post_messages: Optional[bool] = None, - can_edit_messages: Optional[bool] = None, - can_delete_messages: Optional[bool] = None, - can_invite_users: Optional[bool] = None, - can_restrict_members: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - can_promote_members: Optional[bool] = None, - is_anonymous: Optional[bool] = None, - can_manage_chat: Optional[bool] = None, - can_manage_video_chats: Optional[bool] = None, - can_manage_topics: Optional[bool] = None, - can_post_stories: Optional[bool] = None, - can_edit_stories: Optional[bool] = None, - can_delete_stories: Optional[bool] = None, + can_change_info: bool | None = None, + can_post_messages: bool | None = None, + can_edit_messages: bool | None = None, + can_delete_messages: bool | None = None, + can_invite_users: bool | None = None, + can_restrict_members: bool | None = None, + can_pin_messages: bool | None = None, + can_promote_members: bool | None = None, + is_anonymous: bool | None = None, + can_manage_chat: bool | None = None, + can_manage_video_chats: bool | None = None, + can_manage_topics: bool | None = None, + can_post_stories: bool | None = None, + can_edit_stories: bool | None = None, + can_delete_stories: bool | None = None, + can_manage_direct_messages: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to promote or demote a user in a supergroup or a channel. The bot must be @@ -5580,7 +5974,9 @@ async def promote_chat_member( can_invite_users (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can invite new users to the chat. can_restrict_members (:obj:`bool`, optional): Pass :obj:`True`, if the administrator - can restrict, ban or unban chat members, or access supergroup statistics. + can restrict, ban or unban chat members, or access supergroup statistics. For + backward compatibility, defaults to :obj:`True` for promotions of channel + administrators. can_pin_messages (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can pin messages, for supergroups only. can_promote_members (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can @@ -5604,6 +6000,11 @@ async def promote_chat_member( delete stories posted by other users. .. versionadded:: 20.6 + can_manage_direct_messages (:obj:`bool`, optional): Pass :obj:`True`, if the + administrator can manage direct messages within the channel and decline suggested + posts; for channels only + + .. versionadded:: 22.4 Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -5630,6 +6031,7 @@ async def promote_chat_member( "can_post_stories": can_post_stories, "can_edit_stories": can_edit_stories, "can_delete_stories": can_delete_stories, + "can_manage_direct_messages": can_manage_direct_messages, } return await self._post( @@ -5644,15 +6046,15 @@ async def promote_chat_member( async def set_chat_permissions( self, - chat_id: Union[str, int], + chat_id: str | int, permissions: ChatPermissions, - use_independent_chat_permissions: Optional[bool] = None, + use_independent_chat_permissions: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to set default chat permissions for all members. The bot must be an @@ -5702,7 +6104,7 @@ async def set_chat_permissions( async def set_chat_administrator_custom_title( self, - chat_id: Union[int, str], + chat_id: int | str, user_id: int, custom_title: str, *, @@ -5710,7 +6112,7 @@ async def set_chat_administrator_custom_title( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to set a custom title for administrators promoted by the bot in a @@ -5744,13 +6146,13 @@ async def set_chat_administrator_custom_title( async def export_chat_invite_link( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> str: """ Use this method to generate a new primary invite link for a chat; any previously generated @@ -5787,17 +6189,17 @@ async def export_chat_invite_link( async def create_chat_invite_link( self, - chat_id: Union[str, int], - expire_date: Optional[Union[int, dtm.datetime]] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - creates_join_request: Optional[bool] = None, + chat_id: str | int, + expire_date: int | dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + creates_join_request: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> ChatInviteLink: """ Use this method to create an additional invite link for a chat. The bot must be an @@ -5858,22 +6260,22 @@ async def create_chat_invite_link( api_kwargs=api_kwargs, ) - return ChatInviteLink.de_json(result, self) # type: ignore[return-value] + return ChatInviteLink.de_json(result, self) async def edit_chat_invite_link( self, - chat_id: Union[str, int], - invite_link: Union[str, "ChatInviteLink"], - expire_date: Optional[Union[int, dtm.datetime]] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - creates_join_request: Optional[bool] = None, + chat_id: str | int, + invite_link: "str | ChatInviteLink", + expire_date: int | dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + creates_join_request: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> ChatInviteLink: """ Use this method to edit a non-primary invite link created by the bot. The bot must be an @@ -5937,18 +6339,18 @@ async def edit_chat_invite_link( api_kwargs=api_kwargs, ) - return ChatInviteLink.de_json(result, self) # type: ignore[return-value] + return ChatInviteLink.de_json(result, self) async def revoke_chat_invite_link( self, - chat_id: Union[str, int], - invite_link: Union[str, "ChatInviteLink"], + chat_id: str | int, + invite_link: "str | ChatInviteLink", *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> ChatInviteLink: """ Use this method to revoke an invite link created by the bot. If the primary link is @@ -5984,18 +6386,18 @@ async def revoke_chat_invite_link( api_kwargs=api_kwargs, ) - return ChatInviteLink.de_json(result, self) # type: ignore[return-value] + return ChatInviteLink.de_json(result, self) async def approve_chat_join_request( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to approve a chat join request. @@ -6028,14 +6430,14 @@ async def approve_chat_join_request( async def decline_chat_join_request( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to decline a chat join request. @@ -6068,14 +6470,14 @@ async def decline_chat_join_request( async def set_chat_photo( self, - chat_id: Union[str, int], + chat_id: str | int, photo: FileInput, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to set a new profile photo for the chat. @@ -6114,13 +6516,13 @@ async def set_chat_photo( async def delete_chat_photo( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to delete a chat photo. Photos can't be changed for private chats. The bot @@ -6150,14 +6552,14 @@ async def delete_chat_photo( async def set_chat_title( self, - chat_id: Union[str, int], + chat_id: str | int, title: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the title of a chat. Titles can't be changed for private chats. @@ -6190,14 +6592,14 @@ async def set_chat_title( async def set_chat_description( self, - chat_id: Union[str, int], - description: Optional[str] = None, + chat_id: str | int, + description: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the description of a group, a supergroup or a channel. The bot @@ -6232,14 +6634,14 @@ async def set_chat_description( async def set_user_emoji_status( self, user_id: int, - emoji_status_custom_emoji_id: Optional[str] = None, - emoji_status_expiration_date: Optional[Union[int, dtm.datetime]] = None, + emoji_status_custom_emoji_id: str | None = None, + emoji_status_expiration_date: int | dtm.datetime | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Changes the emoji status for a given user that previously allowed the bot to manage their emoji status via the Mini App method @@ -6252,7 +6654,7 @@ async def set_user_emoji_status( user_id (:obj:`int`): Unique identifier of the target user emoji_status_custom_emoji_id (:obj:`str`, optional): Custom emoji identifier of the emoji status to set. Pass an empty string to remove the status. - emoji_status_expiration_date (Union[:obj:`int`, :obj:`datetime.datetime`], optional): + emoji_status_expiration_date (:obj:`int` | :obj:`datetime.datetime`, optional): Expiration date of the emoji status, if any, as unix timestamp or :class:`datetime.datetime` object. |tz-naive-dtms| @@ -6281,16 +6683,16 @@ async def set_user_emoji_status( async def pin_chat_message( self, - chat_id: Union[str, int], + chat_id: str | int, message_id: int, disable_notification: ODVInput[bool] = DEFAULT_NONE, - business_connection_id: Optional[str] = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to add a message to the list of pinned messages in a chat. If the @@ -6336,15 +6738,15 @@ async def pin_chat_message( async def unpin_chat_message( self, - chat_id: Union[str, int], - message_id: Optional[int] = None, - business_connection_id: Optional[str] = None, + chat_id: str | int, + message_id: int | None = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to remove a message from the list of pinned messages in a chat. If the @@ -6388,13 +6790,13 @@ async def unpin_chat_message( async def unpin_all_chat_messages( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to clear the list of pinned messages in a chat. If the @@ -6432,7 +6834,7 @@ async def get_sticker_set( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> StickerSet: """Use this method to get a sticker set. @@ -6456,7 +6858,7 @@ async def get_sticker_set( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return StickerSet.de_json(result, self) # type: ignore[return-value] + return StickerSet.de_json(result, self) async def get_custom_emoji_stickers( self, @@ -6466,7 +6868,7 @@ async def get_custom_emoji_stickers( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple[Sticker, ...]: """ Use this method to get information about emoji stickers by their identifiers. @@ -6511,7 +6913,7 @@ async def upload_sticker_file( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> File: """ Use this method to upload a file with a sticker for later use in the @@ -6559,7 +6961,7 @@ async def upload_sticker_file( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return File.de_json(result, self) # type: ignore[return-value] + return File.de_json(result, self) async def add_sticker_to_set( self, @@ -6571,7 +6973,7 @@ async def add_sticker_to_set( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to add a new sticker to a set created by the bot. The format of the added @@ -6622,14 +7024,14 @@ async def add_sticker_to_set( async def set_sticker_position_in_set( self, - sticker: Union[str, "Sticker"], + sticker: "str | Sticker", position: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to move a sticker in a set created by the bot to a specific position. @@ -6668,14 +7070,14 @@ async def create_new_sticker_set( name: str, title: str, stickers: Sequence["InputSticker"], - sticker_type: Optional[str] = None, - needs_repainting: Optional[bool] = None, + sticker_type: str | None = None, + needs_repainting: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to create new sticker set owned by a user. @@ -6756,13 +7158,13 @@ async def create_new_sticker_set( async def delete_sticker_from_set( self, - sticker: Union[str, "Sticker"], + sticker: "str | Sticker", *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to delete a sticker from a set created by the bot. @@ -6799,7 +7201,7 @@ async def delete_sticker_set( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to delete a sticker set that was created by the bot. @@ -6832,13 +7234,13 @@ async def set_sticker_set_thumbnail( name: str, user_id: int, format: str, # pylint: disable=redefined-builtin - thumbnail: Optional[FileInput] = None, + thumbnail: "FileInput | None" = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to set the thumbnail of a regular or mask sticker set. The format of the thumbnail file must match the format of the stickers in the set. @@ -6856,7 +7258,7 @@ async def set_sticker_set_thumbnail( :tg-const:`telegram.constants.StickerFormat.STATIC` for a ``.WEBP`` or ``.PNG`` image, :tg-const:`telegram.constants.StickerFormat.ANIMATED` for a ``.TGS`` animation, :tg-const:`telegram.constants.StickerFormat.VIDEO` for a - WEBM video. + ``.WEBM`` video. .. versionadded:: 21.1 @@ -6869,12 +7271,12 @@ async def set_sticker_set_thumbnail( **.TGS** animation with the thumbnail up to :tg-const:`telegram.constants.StickerSetLimit.MAX_ANIMATED_THUMBNAIL_SIZE` kilobytes in size; see - `the docs `_ for - animated sticker technical requirements, or a **.WEBM** video with the thumbnail up + `the docs `_ for + animated sticker technical requirements, or a ``.WEBM`` video with the thumbnail up to :tg-const:`telegram.constants.StickerSetLimit.MAX_ANIMATED_THUMBNAIL_SIZE` kilobytes in size; see - `this `_ for video sticker - technical requirements. + `this `_ for video + sticker technical requirements. |fileinput| @@ -6915,7 +7317,7 @@ async def set_sticker_set_title( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to set the title of a created sticker set. @@ -6948,14 +7350,14 @@ async def set_sticker_set_title( async def set_sticker_emoji_list( self, - sticker: Union[str, "Sticker"], + sticker: "str | Sticker", emoji_list: Sequence[str], *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the list of emoji assigned to a regular or custom emoji sticker. @@ -6996,14 +7398,14 @@ async def set_sticker_emoji_list( async def set_sticker_keywords( self, - sticker: Union[str, "Sticker"], - keywords: Optional[Sequence[str]] = None, + sticker: "str | Sticker", + keywords: Sequence[str] | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change search keywords assigned to a regular or custom emoji sticker. @@ -7044,14 +7446,14 @@ async def set_sticker_keywords( async def set_sticker_mask_position( self, - sticker: Union[str, "Sticker"], - mask_position: Optional[MaskPosition] = None, + sticker: "str | Sticker", + mask_position: MaskPosition | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the mask position of a mask sticker. @@ -7092,13 +7494,13 @@ async def set_sticker_mask_position( async def set_custom_emoji_sticker_set_thumbnail( self, name: str, - custom_emoji_id: Optional[str] = None, + custom_emoji_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to set the thumbnail of a custom emoji sticker set. @@ -7139,7 +7541,7 @@ async def set_passport_data_errors( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Informs a user that some of the Telegram Passport elements they provided contains errors. @@ -7178,37 +7580,37 @@ async def set_passport_data_errors( async def send_poll( self, - chat_id: Union[int, str], + chat_id: int | str, question: str, - options: Sequence[Union[str, "InputPollOption"]], - is_anonymous: Optional[bool] = None, - type: Optional[str] = None, # pylint: disable=redefined-builtin - allows_multiple_answers: Optional[bool] = None, - correct_option_id: Optional[CorrectOptionID] = None, - is_closed: Optional[bool] = None, + options: Sequence["str | InputPollOption"], + is_anonymous: bool | None = None, + type: str | None = None, # pylint: disable=redefined-builtin + allows_multiple_answers: bool | None = None, + correct_option_id: CorrectOptionID | None = None, + is_closed: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - explanation: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + explanation: str | None = None, explanation_parse_mode: ODVInput[str] = DEFAULT_NONE, - open_period: Optional[int] = None, - close_date: Optional[Union[int, dtm.datetime]] = None, - explanation_entities: Optional[Sequence["MessageEntity"]] = None, + open_period: TimePeriod | None = None, + close_date: int | dtm.datetime | None = None, + explanation_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, question_parse_mode: ODVInput[str] = DEFAULT_NONE, - question_entities: Optional[Sequence["MessageEntity"]] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + question_entities: Sequence["MessageEntity"] | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send a native poll. @@ -7253,10 +7655,14 @@ async def send_poll( .. versionchanged:: 20.0 |sequenceargs| - open_period (:obj:`int`, optional): Amount of time in seconds the poll will be active + open_period (:obj:`int` | :class:`datetime.timedelta`, optional): Amount of time in + seconds the poll will be active after creation, :tg-const:`telegram.Poll.MIN_OPEN_PERIOD`- :tg-const:`telegram.Poll.MAX_OPEN_PERIOD`. Can't be used together with :paramref:`close_date`. + + .. versionchanged:: 21.11 + |time-period-input| close_date (:obj:`int` | :obj:`datetime.datetime`, optional): Point in time (Unix timestamp) when the poll will be automatically closed. Must be at least :tg-const:`telegram.Poll.MIN_OPEN_PERIOD` and no more than @@ -7370,16 +7776,16 @@ async def send_poll( async def stop_poll( self, - chat_id: Union[int, str], + chat_id: int | str, message_id: int, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - business_connection_id: Optional[str] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Poll: """ Use this method to stop a poll which was sent by the bot. @@ -7416,28 +7822,166 @@ async def stop_poll( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return Poll.de_json(result, self) # type: ignore[return-value] + return Poll.de_json(result, self) + + async def send_checklist( + self, + business_connection_id: str, + chat_id: int, + checklist: InputChecklist, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_effect_id: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Message: + """ + Use this method to send a checklist on behalf of a connected business account. + + .. versionadded:: 22.3 + + Args: + business_connection_id (:obj:`str`): + |business_id_str| + chat_id (:obj:`int`): + Unique identifier for the target chat. + checklist (:class:`telegram.InputChecklist`): + The checklist to send. + disable_notification (:obj:`bool`, optional): + |disable_notification| + protect_content (:obj:`bool`, optional): + |protect_content| + message_effect_id (:obj:`str`, optional): + |message_effect_id| + reply_parameters (:class:`telegram.ReplyParameters`, optional): + |reply_parameters| + reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): + An object for an inline keyboard + + Keyword Args: + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + Returns: + :class:`telegram.Message`: On success, the sent Message is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "chat_id": chat_id, + "checklist": checklist, + } + + return await self._send_message( + "sendChecklist", + data, + disable_notification=disable_notification, + reply_markup=reply_markup, + protect_content=protect_content, + reply_parameters=reply_parameters, + message_effect_id=message_effect_id, + business_connection_id=business_connection_id, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def edit_message_checklist( + self, + business_connection_id: str, + chat_id: int, + message_id: int, + checklist: InputChecklist, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Message: + """ + Use this method to edit a checklist on behalf of a connected business account. + + .. versionadded:: 22.3 + + Args: + business_connection_id (:obj:`str`): + |business_id_str| + chat_id (:obj:`int`): + Unique identifier for the target chat. + message_id (:obj:`int`): + Unique identifier for the target message. + checklist (:class:`telegram.InputChecklist`): + The new checklist. + reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): + An object for the new inline keyboard for the message. + + Returns: + :class:`telegram.Message`: On success, the sent Message is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "chat_id": chat_id, + "message_id": message_id, + "checklist": checklist, + } + + return await self._send_message( + "editMessageChecklist", + data, + reply_markup=reply_markup, + business_connection_id=business_connection_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) async def send_dice( self, - chat_id: Union[int, str], + chat_id: int | str, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - emoji: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + emoji: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send an animated emoji that will display a random value. @@ -7482,6 +8026,13 @@ async def send_dice( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -7531,17 +8082,19 @@ async def send_dice( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def get_my_default_administrator_rights( self, - for_channels: Optional[bool] = None, + for_channels: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> ChatAdministratorRights: """Use this method to get the current default administrator rights of the bot. @@ -7572,18 +8125,18 @@ async def get_my_default_administrator_rights( api_kwargs=api_kwargs, ) - return ChatAdministratorRights.de_json(result, self) # type: ignore[return-value] + return ChatAdministratorRights.de_json(result, self) async def set_my_default_administrator_rights( self, - rights: Optional[ChatAdministratorRights] = None, - for_channels: Optional[bool] = None, + rights: ChatAdministratorRights | None = None, + for_channels: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to change the default administrator rights requested by the bot when it's added as an administrator to groups or channels. These rights will be suggested to @@ -7622,14 +8175,14 @@ async def set_my_default_administrator_rights( async def get_my_commands( self, - scope: Optional[BotCommandScope] = None, - language_code: Optional[str] = None, + scope: BotCommandScope | None = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple[BotCommand, ...]: """ Use this method to get the current list of the bot's commands for the given scope and user @@ -7675,15 +8228,15 @@ async def get_my_commands( async def set_my_commands( self, - commands: Sequence[Union[BotCommand, tuple[str, str]]], - scope: Optional[BotCommandScope] = None, - language_code: Optional[str] = None, + commands: Sequence[BotCommand | tuple[str, str]], + scope: BotCommandScope | None = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the list of the bot's commands. See the @@ -7739,14 +8292,14 @@ async def set_my_commands( async def delete_my_commands( self, - scope: Optional[BotCommandScope] = None, - language_code: Optional[str] = None, + scope: BotCommandScope | None = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to delete the list of the bot's commands for the given scope and user @@ -7791,7 +8344,7 @@ async def log_out( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to log out from the cloud Bot API server before launching the bot locally. @@ -7823,7 +8376,7 @@ async def close( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to close the bot instance before moving it from one local server to @@ -7849,27 +8402,31 @@ async def close( async def copy_message( self, - chat_id: Union[int, str], - from_chat_id: Union[str, int], + chat_id: int | str, + from_chat_id: str | int, message_id: int, - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - show_caption_above_media: Optional[bool] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> MessageId: """Use this method to copy messages of any kind. Service messages, paid media messages, giveaway messages, giveaway winners messages, and invoice messages @@ -7881,6 +8438,10 @@ async def copy_message( from_chat_id (:obj:`int` | :obj:`str`): Unique identifier for the chat where the original message was sent (or channel username in the format ``@channelusername``). message_id (:obj:`int`): Message identifier in the chat specified in from_chat_id. + video_start_timestamp (:obj:`int`, optional): New start timestamp for the + copied video in the message + + .. versionadded:: 21.11 caption (:obj:`str`, optional): New caption for media, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. If not specified, the original caption is kept. @@ -7912,6 +8473,17 @@ async def copy_message( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 + message_effect_id (:obj:`str`, optional): Unique identifier of the message effect to be + added to the message; only available when copying to private chats + + .. versionadded:: 22.6 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -7971,6 +8543,10 @@ async def copy_message( "reply_parameters": reply_parameters, "show_caption_above_media": show_caption_above_media, "allow_paid_broadcast": allow_paid_broadcast, + "direct_messages_topic_id": direct_messages_topic_id, + "video_start_timestamp": video_start_timestamp, + "suggested_post_parameters": suggested_post_parameters, + "message_effect_id": message_effect_id, } result = await self._post( @@ -7982,23 +8558,24 @@ async def copy_message( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return MessageId.de_json(result, self) # type: ignore[return-value] + return MessageId.de_json(result, self) async def copy_messages( self, - chat_id: Union[int, str], - from_chat_id: Union[str, int], + chat_id: int | str, + from_chat_id: str | int, message_ids: Sequence[int], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - remove_caption: Optional[bool] = None, + message_thread_id: int | None = None, + remove_caption: bool | None = None, + direct_messages_topic_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple["MessageId", ...]: """ Use this method to copy messages of any kind. If some of the specified messages can't be @@ -8025,6 +8602,12 @@ async def copy_messages( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| remove_caption (:obj:`bool`, optional): Pass :obj:`True` to copy the messages without their captions. + direct_messages_topic_id (:obj:`int`, optional): Identifier of the direct messages + topic to which the message will be sent; required if the message is sent to a + direct messages chat. + + .. versionadded:: 22.4 + Returns: tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` @@ -8042,6 +8625,7 @@ async def copy_messages( "protect_content": protect_content, "message_thread_id": message_thread_id, "remove_caption": remove_caption, + "direct_messages_topic_id": direct_messages_topic_id, } result = await self._post( @@ -8057,14 +8641,14 @@ async def copy_messages( async def set_chat_menu_button( self, - chat_id: Optional[int] = None, - menu_button: Optional[MenuButton] = None, + chat_id: int | None = None, + menu_button: MenuButton | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to change the bot's menu button in a private chat, or the default menu button. @@ -8098,13 +8682,13 @@ async def set_chat_menu_button( async def get_chat_menu_button( self, - chat_id: Optional[int] = None, + chat_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> MenuButton: """Use this method to get the current value of the bot's menu button in a private chat, or the default menu button. @@ -8133,38 +8717,38 @@ async def get_chat_menu_button( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return MenuButton.de_json(result, bot=self) # type: ignore[return-value] + return MenuButton.de_json(result, bot=self) async def create_invoice_link( self, title: str, description: str, payload: str, - provider_token: Optional[str], # This arg is now optional as of Bot API 7.4 currency: str, prices: Sequence["LabeledPrice"], - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, - provider_data: Optional[Union[str, object]] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - is_flexible: Optional[bool] = None, - subscription_period: Optional[Union[int, dtm.timedelta]] = None, - business_connection_id: Optional[str] = None, + provider_token: str | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, + provider_data: str | object | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + is_flexible: bool | None = None, + subscription_period: TimePeriod | None = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> str: """Use this method to create a link for an invoice. @@ -8184,13 +8768,13 @@ async def create_invoice_link( :tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`- :tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be displayed to the user, use it for your internal processes. - provider_token (:obj:`str`): Payments provider token, obtained via + provider_token (:obj:`str`, optional): Payments provider token, obtained via `@BotFather `_. Pass an empty string for payments in |tg_stars|. - .. deprecated:: 21.3 - As of Bot API 7.4, this parameter is now optional and future versions of the - library will make it optional as well. + .. versionchanged:: 21.11 + Bot API 7.4 made this parameter is optional and this is now reflected in the + function signature. currency (:obj:`str`): Three-letter ISO 4217 currency code, see `more on currencies `_. Pass ``XTR`` for @@ -8278,11 +8862,7 @@ async def create_invoice_link( "is_flexible": is_flexible, "send_phone_number_to_provider": send_phone_number_to_provider, "send_email_to_provider": send_email_to_provider, - "subscription_period": ( - subscription_period.total_seconds() - if isinstance(subscription_period, dtm.timedelta) - else subscription_period - ), + "subscription_period": subscription_period, "business_connection_id": business_connection_id, } @@ -8303,7 +8883,7 @@ async def get_forum_topic_icon_stickers( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple[Sticker, ...]: """Use this method to get custom emoji stickers, which can be used as a forum topic icon by any user. Requires no parameters. @@ -8329,16 +8909,16 @@ async def get_forum_topic_icon_stickers( async def create_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, name: str, - icon_color: Optional[int] = None, - icon_custom_emoji_id: Optional[str] = None, + icon_color: int | None = None, + icon_custom_emoji_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> ForumTopic: """ Use this method to create a topic in a forum supergroup chat. The bot must be @@ -8384,24 +8964,25 @@ async def create_forum_topic( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return ForumTopic.de_json(result, self) # type: ignore[return-value] + return ForumTopic.de_json(result, self) async def edit_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, - name: Optional[str] = None, - icon_custom_emoji_id: Optional[str] = None, + name: str | None = None, + icon_custom_emoji_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ - Use this method to edit name and icon of a topic in a forum supergroup chat. The bot must - be an administrator in the chat for this to work and must have the + Use this method to edit name and icon of a topic in a forum supergroup chat or a private + chat with a user. In the case of a supergroup chat the bot must be an administrator in the + chat for this to work and must have the :paramref:`~telegram.ChatAdministratorRights.can_manage_topics` administrator rights, unless it is the creator of the topic. @@ -8444,14 +9025,14 @@ async def edit_forum_topic( async def close_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to close an open topic in a forum supergroup chat. The bot must @@ -8488,14 +9069,14 @@ async def close_forum_topic( async def reopen_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to reopen a closed topic in a forum supergroup chat. The bot must @@ -8532,18 +9113,19 @@ async def reopen_forum_topic( async def delete_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to delete a forum topic along with all its messages in a forum supergroup - chat. The bot must be an administrator in the chat for this to work and must have + chat or a private chat with a user. In the case of a supergroup chat the bot must be an + administrator in the chat for this to work and must have the :paramref:`~telegram.ChatAdministratorRights.can_delete_messages` administrator rights. .. versionadded:: 20.0 @@ -8575,20 +9157,21 @@ async def delete_forum_topic( async def unpin_all_forum_topic_messages( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ - Use this method to clear the list of pinned messages in a forum topic. The bot must - be an administrator in the chat for this to work and must have - :paramref:`~telegram.ChatAdministratorRights.can_pin_messages` administrator rights - in the supergroup. + Use this method to clear the list of pinned messages in a forum topic in a forum supergroup + chat or a private chat with a user. In the case of a supergroup chat the bot must be an + administrator in the chat for this to work and must have the + :paramref:`~telegram.ChatAdministratorRights.can_pin_messages` administrator right in + the supergroup. .. versionadded:: 20.0 @@ -8619,13 +9202,13 @@ async def unpin_all_forum_topic_messages( async def unpin_all_general_forum_topic_messages( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to clear the list of pinned messages in a General forum topic. The bot must @@ -8658,14 +9241,14 @@ async def unpin_all_general_forum_topic_messages( async def edit_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, name: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to edit the name of the 'General' topic in a forum supergroup chat. The bot @@ -8701,13 +9284,13 @@ async def edit_general_forum_topic( async def close_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to close an open 'General' topic in a forum supergroup chat. The bot must @@ -8740,13 +9323,13 @@ async def close_general_forum_topic( async def reopen_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to reopen a closed 'General' topic in a forum supergroup chat. The bot must @@ -8780,13 +9363,13 @@ async def reopen_general_forum_topic( async def hide_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to hide the 'General' topic in a forum supergroup chat. The bot must @@ -8820,13 +9403,13 @@ async def hide_general_forum_topic( async def unhide_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to unhide the 'General' topic in a forum supergroup chat. The bot must @@ -8859,14 +9442,14 @@ async def unhide_general_forum_topic( async def set_my_description( self, - description: Optional[str] = None, - language_code: Optional[str] = None, + description: str | None = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the bot's description, which is shown in the chat with the bot @@ -8904,14 +9487,14 @@ async def set_my_description( async def set_my_short_description( self, - short_description: Optional[str] = None, - language_code: Optional[str] = None, + short_description: str | None = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the bot's short description, which is shown on the bot's profile @@ -8949,13 +9532,13 @@ async def set_my_short_description( async def get_my_description( self, - language_code: Optional[str] = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> BotDescription: """ Use this method to get the current bot description for the given user language. @@ -8972,7 +9555,7 @@ async def get_my_description( """ data = {"language_code": language_code} - return BotDescription.de_json( # type: ignore[return-value] + return BotDescription.de_json( await self._post( "getMyDescription", data, @@ -8987,13 +9570,13 @@ async def get_my_description( async def get_my_short_description( self, - language_code: Optional[str] = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> BotShortDescription: """ Use this method to get the current bot short description for the given user language. @@ -9011,7 +9594,7 @@ async def get_my_short_description( """ data = {"language_code": language_code} - return BotShortDescription.de_json( # type: ignore[return-value] + return BotShortDescription.de_json( await self._post( "getMyShortDescription", data, @@ -9026,14 +9609,14 @@ async def get_my_short_description( async def set_my_name( self, - name: Optional[str] = None, - language_code: Optional[str] = None, + name: str | None = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the bot's name. @@ -9074,13 +9657,13 @@ async def set_my_name( async def get_my_name( self, - language_code: Optional[str] = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> BotName: """ Use this method to get the current bot name for the given user language. @@ -9097,7 +9680,7 @@ async def get_my_name( """ data = {"language_code": language_code} - return BotName.de_json( # type: ignore[return-value] + return BotName.de_json( await self._post( "getMyName", data, @@ -9112,14 +9695,14 @@ async def get_my_name( async def get_user_chat_boosts( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> UserChatBoosts: """ Use this method to get the list of boosts added to a chat by a user. Requires @@ -9139,7 +9722,7 @@ async def get_user_chat_boosts( :class:`telegram.error.TelegramError` """ data: JSONDict = {"chat_id": chat_id, "user_id": user_id} - return UserChatBoosts.de_json( # type: ignore[return-value] + return UserChatBoosts.de_json( await self._post( "getUserChatBoosts", data, @@ -9154,19 +9737,20 @@ async def get_user_chat_boosts( async def set_message_reaction( self, - chat_id: Union[str, int], + chat_id: str | int, message_id: int, - reaction: Optional[Union[Sequence[Union[ReactionType, str]], ReactionType, str]] = None, - is_big: Optional[bool] = None, + reaction: Sequence[ReactionType | str] | ReactionType | str | None = None, + is_big: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ - Use this method to change the chosen reactions on a message. Service messages can't be + Use this method to change the chosen reactions on a message. Service messages of some types + can't be reacted to. Automatically forwarded messages from a channel to its discussion group have the same available reactions as messages in the channel. Bots can't use paid reactions. @@ -9212,9 +9796,7 @@ async def set_message_reaction( else ReactionTypeCustomEmoji(custom_emoji_id=entry) ) ) - for entry in ( - [reaction] if isinstance(reaction, (ReactionType, str)) else reaction - ) + for entry in ([reaction] if isinstance(reaction, ReactionType | str) else reaction) ] if reaction is not None else None @@ -9237,57 +9819,1049 @@ async def set_message_reaction( api_kwargs=api_kwargs, ) - async def get_business_connection( + async def gift_premium_subscription( self, - business_connection_id: str, + user_id: int, + month_count: int, + star_count: int, + text: str | None = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence["MessageEntity"] | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> BusinessConnection: + api_kwargs: JSONDict | None = None, + ) -> bool: """ - Use this method to get information about the connection of the bot with a business account. - - .. versionadded:: 21.1 - - Args: - business_connection_id (:obj:`str`): Unique identifier of the business connection. + Gifts a Telegram Premium subscription to the given user. + + .. versionadded:: 22.1 + + Args: + user_id (:obj:`int`): Unique identifier of the target user who will receive a Telegram + Premium subscription. + month_count (:obj:`int`): Number of months the Telegram Premium subscription will be + active for the user; must be one of + :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_THREE`, + :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_SIX`, + or :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_TWELVE`. + star_count (:obj:`int`): Number of Telegram Stars to pay for the Telegram Premium + subscription; must be + :tg-const:`telegram.constants.PremiumSubscription.STARS_THREE_MONTHS` + for :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_THREE` months, + :tg-const:`telegram.constants.PremiumSubscription.STARS_SIX_MONTHS` + for :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_SIX` months, + and :tg-const:`telegram.constants.PremiumSubscription.STARS_TWELVE_MONTHS` + for :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_TWELVE` months. + text (:obj:`str`, optional): Text that will be shown along with the service message + about the subscription; + 0-:tg-const:`telegram.constants.PremiumSubscription.MAX_TEXT_LENGTH` characters. + text_parse_mode (:obj:`str`, optional): Mode for parsing entities. + See :class:`telegram.constants.ParseMode` and + `formatting options `__ for + more details. Entities other than :attr:`~MessageEntity.BOLD`, + :attr:`~MessageEntity.ITALIC`, :attr:`~MessageEntity.UNDERLINE`, + :attr:`~MessageEntity.STRIKETHROUGH`, :attr:`~MessageEntity.SPOILER`, and + :attr:`~MessageEntity.CUSTOM_EMOJI` are ignored. + text_entities (Sequence[:class:`telegram.MessageEntity`], optional): A list of special + entities that appear in the gift text. It can be specified instead of + :paramref:`text_parse_mode`. Entities other than :attr:`~MessageEntity.BOLD`, + :attr:`~MessageEntity.ITALIC`, :attr:`~MessageEntity.UNDERLINE`, + :attr:`~MessageEntity.STRIKETHROUGH`, :attr:`~MessageEntity.SPOILER`, and + :attr:`~MessageEntity.CUSTOM_EMOJI` are ignored. Returns: - :class:`telegram.BusinessConnection`: On success, the object containing the business - connection information is returned. + :obj:`bool`: On success, :obj:`True` is returned. Raises: :class:`telegram.error.TelegramError` """ - data: JSONDict = {"business_connection_id": business_connection_id} - return BusinessConnection.de_json( # type: ignore[return-value] - await self._post( - "getBusinessConnection", - data, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, + data: JSONDict = { + "user_id": user_id, + "month_count": month_count, + "star_count": star_count, + "text": text, + "text_entities": text_entities, + "text_parse_mode": text_parse_mode, + } + return await self._post( + "giftPremiumSubscription", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_business_connection( + self, + business_connection_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> BusinessConnection: + """ + Use this method to get information about the connection of the bot with a business account. + + .. versionadded:: 21.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + + Returns: + :class:`telegram.BusinessConnection`: On success, the object containing the business + connection information is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = {"business_connection_id": business_connection_id} + return BusinessConnection.de_json( + await self._post( + "getBusinessConnection", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ), + bot=self, + ) + + async def get_business_account_gifts( + self, + business_connection_id: str, + exclude_unsaved: bool | None = None, + exclude_saved: bool | None = None, + exclude_unlimited: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> OwnedGifts: + """ + Returns the gifts received and owned by a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_view_gifts_and_stars` business bot right. + + .. versionadded:: 22.1 + + .. versionremoved:: NEXT.VERSION + Bot API 9.3 removed the :paramref:`exclude_limited` parameter. Use + :paramref:`exclude_limited_upgradable` and :paramref:`exclude_limited_non_upgradable` + instead. + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + exclude_unsaved (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that aren't + saved to the account's profile page. + exclude_saved (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that are saved + to the account's profile page. + exclude_unlimited (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that can + be purchased an unlimited number of times. + exclude_limited_upgradable (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts + that can be purchased a limited number of times and can be upgraded to unique. + + .. versionadded:: 22.6 + exclude_limited_non_upgradable (:obj:`bool`, optional): Pass :obj:`True` to exclude + gifts that can be purchased a limited number of times and can't be upgraded to + unique + + .. versionadded:: 22.6 + exclude_unique (:obj:`bool`, optional): Pass :obj:`True` to exclude unique gifts. + exclude_from_blockchain (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts + that were assigned from the TON blockchain and can't be resold or transferred in + Telegram. + + .. versionadded:: 22.6 + sort_by_price (:obj:`bool`, optional): Pass :obj:`True` to sort results by gift price + instead of send date. Sorting is applied before pagination. + offset (:obj:`str`, optional): Offset of the first entry to return as received from + the previous request; use empty string to get the first chunk of results. + limit (:obj:`int`, optional): The maximum number of gifts to be returned; + :tg-const:`telegram.constants.BusinessLimit.MIN_GIFT_RESULTS`-\ + :tg-const:`telegram.constants.BusinessLimit.MAX_GIFT_RESULTS`. + Defaults to :tg-const:`telegram.constants.BusinessLimit.MAX_GIFT_RESULTS`. + + Returns: + :class:`telegram.OwnedGifts` + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "exclude_unsaved": exclude_unsaved, + "exclude_saved": exclude_saved, + "exclude_unlimited": exclude_unlimited, + "exclude_limited_upgradable": exclude_limited_upgradable, + "exclude_limited_non_upgradable": exclude_limited_non_upgradable, + "exclude_unique": exclude_unique, + "exclude_from_blockchain": exclude_from_blockchain, + "sort_by_price": sort_by_price, + "offset": offset, + "limit": limit, + } + + return OwnedGifts.de_json( + await self._post( + "getBusinessAccountGifts", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + ) + + async def get_business_account_star_balance( + self, + business_connection_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> StarAmount: + """ + Returns the amount of Telegram Stars owned by a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_view_gifts_and_stars` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + + Returns: + :class:`telegram.StarAmount` + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = {"business_connection_id": business_connection_id} + return StarAmount.de_json( + await self._post( + "getBusinessAccountStarBalance", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, api_kwargs=api_kwargs, ), bot=self, ) + async def read_business_message( + self, + business_connection_id: str, + chat_id: int, + message_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Marks incoming message as read on behalf of a business account. + Requires the :attr:`~telegram.BusinessBotRights.can_read_messages` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection on + behalf of which to read the message. + chat_id (:obj:`int`): Unique identifier of the chat in which the message was received. + The chat must have been active in the last + :tg-const:`~telegram.constants.BusinessLimit.\ +CHAT_ACTIVITY_TIMEOUT` seconds. + message_id (:obj:`int`): Unique identifier of the message to mark as read. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "chat_id": chat_id, + "message_id": message_id, + } + return await self._post( + "readBusinessMessage", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_business_messages( + self, + business_connection_id: str, + message_ids: Sequence[int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Delete messages on behalf of a business account. Requires the + :attr:`~telegram.BusinessBotRights.can_delete_sent_messages` business bot right to + delete messages sent by the bot itself, or the + :attr:`~telegram.BusinessBotRights.can_delete_all_messages` business bot right to delete + any message. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`int` | :obj:`str`): Unique identifier of the business + connection on behalf of which to delete the messages + message_ids (Sequence[:obj:`int`]): A list of + :tg-const:`telegram.constants.BulkRequestLimit.MIN_LIMIT`- + :tg-const:`telegram.constants.BulkRequestLimit.MAX_LIMIT` identifiers of messages + to delete. See :meth:`delete_message` for limitations on which messages can be + deleted. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "message_ids": message_ids, + } + return await self._post( + "deleteBusinessMessages", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def post_story( + self, + business_connection_id: str, + content: "InputStoryContent", + active_period: TimePeriod, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + areas: Sequence["StoryArea"] | None = None, + post_to_chat_page: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Story: + """ + Posts a story on behalf of a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_manage_stories` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + content (:class:`telegram.InputStoryContent`): Content of the story. + active_period (:obj:`int` | :class:`datetime.timedelta`, optional): Period after which + the story is moved to the archive, in seconds; must be one of + :tg-const:`~telegram.constants.StoryLimit.ACTIVITY_SIX_HOURS`, + :tg-const:`~telegram.constants.StoryLimit.ACTIVITY_TWELVE_HOURS`, + :tg-const:`~telegram.constants.StoryLimit.ACTIVITY_ONE_DAY`, + or :tg-const:`~telegram.constants.StoryLimit.ACTIVITY_TWO_DAYS`. + caption (:obj:`str`, optional): Caption of the story, + 0-:tg-const:`~telegram.constants.StoryLimit.CAPTION_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`, optional): Mode for parsing entities in the story caption. + See the constants in :class:`telegram.constants.ParseMode` for the + available modes. + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): + |caption_entities| + areas (Sequence[:class:`telegram.StoryArea`], optional): Sequence of clickable areas to + be shown on the story. + + Note: + Each type of clickable area in :paramref:`areas` has its own maximum limit: + + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_LOCATION_AREAS` + of :class:`telegram.StoryAreaTypeLocation`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.\ +MAX_SUGGESTED_REACTION_AREAS` of :class:`telegram.StoryAreaTypeSuggestedReaction`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_LINK_AREAS` + of :class:`telegram.StoryAreaTypeLink`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_WEATHER_AREAS` + of :class:`telegram.StoryAreaTypeWeather`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.\ +MAX_UNIQUE_GIFT_AREAS` of :class:`telegram.StoryAreaTypeUniqueGift`. + post_to_chat_page (:class:`telegram.InputStoryContent`, optional): Pass :obj:`True` to + keep the story accessible after it expires. + protect_content (:obj:`bool`, optional): Pass :obj:`True` if the content of the story + must be protected from forwarding and screenshotting + + Returns: + :class:`Story` + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "content": content, + "active_period": active_period, + "caption": caption, + "parse_mode": parse_mode, + "caption_entities": caption_entities, + "areas": areas, + "post_to_chat_page": post_to_chat_page, + "protect_content": protect_content, + } + return Story.de_json( + await self._post( + "postStory", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + ) + + async def edit_story( + self, + business_connection_id: str, + story_id: int, + content: "InputStoryContent", + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + areas: Sequence["StoryArea"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Story: + """ + Edits a story previously posted by the bot on behalf of a managed business account. + Requires the :attr:`~telegram.BusinessBotRights.can_manage_stories` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + story_id (:obj:`int`): Unique identifier of the story to edit. + content (:class:`telegram.InputStoryContent`): Content of the story. + caption (:obj:`str`, optional): Caption of the story, + 0-:tg-const:`~telegram.constants.StoryLimit.CAPTION_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`, optional): Mode for parsing entities in the story caption. + See the constants in :class:`telegram.constants.ParseMode` for the + available modes. + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): + |caption_entities| + areas (Sequence[:class:`telegram.StoryArea`], optional): Sequence of clickable areas to + be shown on the story. + + Note: + Each type of clickable area in :paramref:`areas` has its own maximum limit: + + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_LOCATION_AREAS` + of :class:`telegram.StoryAreaTypeLocation`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.\ +MAX_SUGGESTED_REACTION_AREAS` of :class:`telegram.StoryAreaTypeSuggestedReaction`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_LINK_AREAS` + of :class:`telegram.StoryAreaTypeLink`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_WEATHER_AREAS` + of :class:`telegram.StoryAreaTypeWeather`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.\ +MAX_UNIQUE_GIFT_AREAS` of :class:`telegram.StoryAreaTypeUniqueGift`. + + Returns: + :class:`Story` + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "story_id": story_id, + "content": content, + "caption": caption, + "parse_mode": parse_mode, + "caption_entities": caption_entities, + "areas": areas, + } + return Story.de_json( + await self._post( + "editStory", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + ) + + async def delete_story( + self, + business_connection_id: str, + story_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Deletes a story previously posted by the bot on behalf of a managed business account. + Requires the :attr:`~telegram.BusinessBotRights.can_manage_stories` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + story_id (:obj:`int`): Unique identifier of the story to delete. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "story_id": story_id, + } + return await self._post( + "deleteStory", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_business_account_name( + self, + business_connection_id: str, + first_name: str, + last_name: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Changes the first and last name of a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_edit_name` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`int` | :obj:`str`): Unique identifier of the business + connection + first_name (:obj:`str`): New first name of the business account; + :tg-const:`telegram.constants.BusinessLimit.MIN_NAME_LENGTH`- + :tg-const:`telegram.constants.BusinessLimit.MAX_NAME_LENGTH` characters. + last_name (:obj:`str`, optional): New last name of the business account; + 0-:tg-const:`telegram.constants.BusinessLimit.MAX_NAME_LENGTH` characters. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "first_name": first_name, + "last_name": last_name, + } + return await self._post( + "setBusinessAccountName", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_business_account_username( + self, + business_connection_id: str, + username: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Changes the username of a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_edit_username` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + username (:obj:`str`, optional): New business account username; + 0-:tg-const:`telegram.constants.BusinessLimit.MAX_USERNAME_LENGTH` characters. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "username": username, + } + return await self._post( + "setBusinessAccountUsername", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_business_account_bio( + self, + business_connection_id: str, + bio: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Changes the bio of a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_edit_bio` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + bio (:obj:`str`, optional): The new value of the bio for the business account; + 0-:tg-const:`telegram.constants.BusinessLimit.MAX_BIO_LENGTH` characters. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "bio": bio, + } + return await self._post( + "setBusinessAccountBio", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_business_account_gift_settings( + self, + business_connection_id: str, + show_gift_button: bool, + accepted_gift_types: AcceptedGiftTypes, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Changes the privacy settings pertaining to incoming gifts in a managed business account. + Requires the :attr:`~telegram.BusinessBotRights.can_change_gift_settings` business + bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + show_gift_button (:obj:`bool`): Pass :obj:`True`, if a button for sending a gift to the + user or by the business account must always be shown in the input field. + accepted_gift_types (:class:`telegram.AcceptedGiftTypes`): Types of gifts accepted by + the business account. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "show_gift_button": show_gift_button, + "accepted_gift_types": accepted_gift_types, + } + return await self._post( + "setBusinessAccountGiftSettings", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_business_account_profile_photo( + self, + business_connection_id: str, + photo: "InputProfilePhoto", + is_public: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Changes the profile photo of a managed business account. + Requires the :attr:`~telegram.BusinessBotRights.can_edit_profile_photo` business + bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + photo (:class:`telegram.InputProfilePhoto`): The new profile photo to set. + is_public (:obj:`bool`, optional): Pass :obj:`True` to set the public photo, which will + be visible even if the main photo is hidden by the business account's privacy + settings. An account can have only one public photo. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "photo": photo, + "is_public": is_public, + } + return await self._post( + "setBusinessAccountProfilePhoto", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def remove_business_account_profile_photo( + self, + business_connection_id: str, + is_public: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Removes the current profile photo of a managed business account. + Requires the :attr:`~telegram.BusinessBotRights.can_edit_profile_photo` business + bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + is_public (:obj:`bool`, optional): Pass :obj:`True` to remove the public photo, which + will be visible even if the main photo is hidden by the business account's privacy + settings. After the main photo is removed, the previous profile photo (if present) + becomes the main photo. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "is_public": is_public, + } + return await self._post( + "removeBusinessAccountProfilePhoto", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def convert_gift_to_stars( + self, + business_connection_id: str, + owned_gift_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Converts a given regular gift to Telegram Stars. Requires the + :attr:`~telegram.BusinessBotRights.can_convert_gifts_to_stars` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + owned_gift_id (:obj:`str`): Unique identifier of the regular gift that should be + converted to Telegram Stars. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "owned_gift_id": owned_gift_id, + } + return await self._post( + "convertGiftToStars", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def upgrade_gift( + self, + business_connection_id: str, + owned_gift_id: str, + keep_original_details: bool | None = None, + star_count: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Upgrades a given regular gift to a unique gift. Requires the + :attr:`~telegram.BusinessBotRights.can_transfer_and_upgrade_gifts` business bot right. + Additionally requires the :attr:`~telegram.BusinessBotRights.can_transfer_stars` business + bot right if the upgrade is paid. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + owned_gift_id (:obj:`str`): Unique identifier of the regular gift that should be + upgraded to a unique one. + keep_original_details (:obj:`bool`, optional): Pass :obj:`True` to keep the original + gift text, sender and receiver in the upgraded gift + star_count (:obj:`int`, optional): The amount of Telegram Stars that will + be paid for the upgrade from the business account balance. If + ``gift.prepaid_upgrade_star_count > 0``, then pass ``0``, otherwise, + the :attr:`~telegram.BusinessBotRights.can_transfer_stars` + business bot right is required and :attr:`telegram.Gift.upgrade_star_count` + must be passed. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "owned_gift_id": owned_gift_id, + "keep_original_details": keep_original_details, + "star_count": star_count, + } + return await self._post( + "upgradeGift", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def transfer_gift( + self, + business_connection_id: str, + owned_gift_id: str, + new_owner_chat_id: int, + star_count: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Transfers an owned unique gift to another user. Requires the + :attr:`~telegram.BusinessBotRights.can_transfer_and_upgrade_gifts` business bot right. + Requires :attr:`~telegram.BusinessBotRights.can_transfer_stars` business bot right if the + transfer is paid. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + owned_gift_id (:obj:`str`): Unique identifier of the regular gift that should be + transferred. + new_owner_chat_id (:obj:`int`): Unique identifier of the chat which will + own the gift. The chat must be active in the last + :tg-const:`~telegram.constants.BusinessLimit.\ +CHAT_ACTIVITY_TIMEOUT` seconds. + star_count (:obj:`int`, optional): The amount of Telegram Stars that will be paid for + the transfer from the business account balance. If positive, then + the :attr:`~telegram.BusinessBotRights.can_transfer_stars` business bot + right is required. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "owned_gift_id": owned_gift_id, + "new_owner_chat_id": new_owner_chat_id, + "star_count": star_count, + } + return await self._post( + "transferGift", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def transfer_business_account_stars( + self, + business_connection_id: str, + star_count: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Transfers Telegram Stars from the business account balance to the bot's balance. Requires + the :attr:`~telegram.BusinessBotRights.can_transfer_stars` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + star_count (:obj:`int`): Number of Telegram Stars to transfer; + :tg-const:`~telegram.constants.BusinessLimit.MIN_STAR_COUNT`\ +-:tg-const:`~telegram.constants.BusinessLimit.MAX_STAR_COUNT` + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "star_count": star_count, + } + return await self._post( + "transferBusinessAccountStars", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def replace_sticker_in_set( self, user_id: int, name: str, - old_sticker: Union[str, "Sticker"], + old_sticker: "str | Sticker", sticker: "InputSticker", *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to replace an existing sticker in a sticker set with a new one. The method is equivalent to calling :meth:`delete_sticker_from_set`, @@ -9339,7 +10913,7 @@ async def refund_star_payment( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Refunds a successful payment in |tg_stars|. @@ -9373,14 +10947,14 @@ async def refund_star_payment( async def get_star_transactions( self, - offset: Optional[int] = None, - limit: Optional[int] = None, + offset: int | None = None, + limit: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> StarTransactions: """Returns the bot's Telegram Star transactions in chronological order. @@ -9402,7 +10976,7 @@ async def get_star_transactions( data: JSONDict = {"offset": offset, "limit": limit} - return StarTransactions.de_json( # type: ignore[return-value] + return StarTransactions.de_json( await self._post( "getStarTransactions", data, @@ -9425,7 +10999,7 @@ async def edit_user_star_subscription( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Allows the bot to cancel or re-enable extension of a subscription paid in Telegram Stars. @@ -9453,7 +11027,7 @@ async def edit_user_star_subscription( "is_canceled": is_canceled, } return await self._post( - "editUserStartSubscription", + "editUserStarSubscription", data, read_timeout=read_timeout, write_timeout=write_timeout, @@ -9464,28 +11038,31 @@ async def edit_user_star_subscription( async def send_paid_media( self, - chat_id: Union[str, int], + chat_id: str | int, star_count: int, media: Sequence["InputPaidMedia"], - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - reply_markup: Optional[ReplyMarkup] = None, - business_connection_id: Optional[str] = None, - payload: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + business_connection_id: str | None = None, + payload: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_thread_id: int | None = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send paid media. @@ -9524,6 +11101,16 @@ async def send_paid_media( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 + message_thread_id (:obj:`int`, optional): |message_thread_id_arg| + + .. versionadded:: 22.4 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -9568,20 +11155,23 @@ async def send_paid_media( api_kwargs=api_kwargs, business_connection_id=business_connection_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_thread_id=message_thread_id, ) async def create_chat_subscription_invite_link( self, - chat_id: Union[str, int], - subscription_period: int, + chat_id: str | int, + subscription_period: TimePeriod, subscription_price: int, - name: Optional[str] = None, + name: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> ChatInviteLink: """ Use this method to create a `subscription invite link ChatInviteLink: """ Use this method to edit a subscription invite link created by the bot. The bot must have @@ -9681,7 +11275,7 @@ async def edit_chat_subscription_invite_link( api_kwargs=api_kwargs, ) - return ChatInviteLink.de_json(result, self) # type: ignore[return-value] + return ChatInviteLink.de_json(result, self) async def get_available_gifts( self, @@ -9690,9 +11284,9 @@ async def get_available_gifts( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Gifts: - """Returns the list of gifts that can be sent by the bot to users. + """Returns the list of gifts that can be sent by the bot to users and channel chats. Requires no parameters. .. versionadded:: 21.8 @@ -9703,7 +11297,7 @@ async def get_available_gifts( Raises: :class:`telegram.error.TelegramError` """ - return Gifts.de_json( # type: ignore[return-value] + return Gifts.de_json( await self._post( "getAvailableGifts", read_timeout=read_timeout, @@ -9716,28 +11310,40 @@ async def get_available_gifts( async def send_gift( self, - user_id: int, - gift_id: Union[str, Gift], - text: Optional[str] = None, + gift_id: "str | Gift", + text: str | None = None, text_parse_mode: ODVInput[str] = DEFAULT_NONE, - text_entities: Optional[Sequence["MessageEntity"]] = None, - pay_for_upgrade: Optional[bool] = None, + text_entities: Sequence["MessageEntity"] | None = None, + pay_for_upgrade: bool | None = None, + chat_id: str | int | None = None, + user_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: - """Sends a gift to the given user. - The gift can't be converted to Telegram Stars by the user + """Sends a gift to the given user or channel chat. + The gift can't be converted to Telegram Stars by the receiver. .. versionadded:: 21.8 + .. versionchanged:: 22.1 + Bot API 8.3 made :paramref:`user_id` optional. In version 22.1, the methods + signature was changed accordingly. Args: - user_id (:obj:`int`): Unique identifier of the target user that will receive the gift gift_id (:obj:`str` | :class:`~telegram.Gift`): Identifier of the gift or a - :class:`~telegram.Gift` object + :class:`~telegram.Gift` object; limited gifts can't be sent to channel chats + user_id (:obj:`int`, optional): Required if :paramref:`chat_id` is not specified. + Unique identifier of the target user that will receive the gift. + + .. versionchanged:: 21.11 + Now optional. + chat_id (:obj:`int` | :obj:`str`, optional): Required if :paramref:`user_id` + is not specified. |chat_id_channel| It will receive the gift. + + .. versionadded:: 21.11 text (:obj:`str`, optional): Text that will be shown along with the gift; 0- :tg-const:`telegram.constants.GiftLimit.MAX_TEXT_LENGTH` characters text_parse_mode (:obj:`str`, optional): Mode for parsing entities. @@ -9771,6 +11377,7 @@ async def send_gift( "text_parse_mode": text_parse_mode, "text_entities": text_entities, "pay_for_upgrade": pay_for_upgrade, + "chat_id": chat_id, } return await self._post( "sendGift", @@ -9784,16 +11391,16 @@ async def send_gift( async def verify_chat( self, - chat_id: Union[int, str], - custom_description: Optional[str] = None, + chat_id: int | str, + custom_description: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: - """Verifies a chat on behalf of the organization which is represented by the bot. + """Verifies a chat |org-verify| which is represented by the bot. .. versionadded:: 21.10 @@ -9827,15 +11434,15 @@ async def verify_chat( async def verify_user( self, user_id: int, - custom_description: Optional[str] = None, + custom_description: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: - """Verifies a user on behalf of the organization which is represented by the bot. + """Verifies a user |org-verify| which is represented by the bot. .. versionadded:: 21.10 @@ -9868,18 +11475,16 @@ async def verify_user( async def remove_chat_verification( self, - chat_id: Union[int, str], + chat_id: int | str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: - """Removes verification from a chat that is currently verified on behalf of the - organization represented by the bot. - - + """Removes verification from a chat that is currently verified |org-verify| + represented by the bot. .. versionadded:: 21.10 @@ -9913,12 +11518,10 @@ async def remove_user_verification( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: - """Removes verification from a user who is currently verified on behalf of the - organization represented by the bot. - - + """Removes verification from a user who is currently verified |org-verify| + represented by the bot. .. versionadded:: 21.10 @@ -9944,6 +11547,356 @@ async def remove_user_verification( api_kwargs=api_kwargs, ) + async def get_my_star_balance( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> StarAmount: + """A method to get the current Telegram Stars balance of the bot. Requires no parameters. + + .. versionadded:: 22.3 + + Returns: + :class:`telegram.StarAmount` + + Raises: + :class:`telegram.error.TelegramError` + """ + return StarAmount.de_json( + await self._post( + "getMyStarBalance", + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + ) + + async def approve_suggested_post( + self, + chat_id: int, + message_id: int, + send_date: int | dtm.datetime | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Use this method to approve a suggested post in a direct messages chat. + The bot must have the :attr:`~telegram.ChatMemberAdministrator.can_post_messages` + administrator right in the corresponding channel chat. + + .. versionadded:: 22.4 + + Args: + chat_id (:obj:`int`): Unique identifier of the target direct messages chat. + message_id (:obj:`int`): Identifier of a suggested post message to approve. + send_date (:obj:`int` | :obj:`datetime.datetime`, optional): Date when the post is + expected to be published; omit if the date has already been specified when the + suggested post was created. If specified, then the date must be not more than + :tg-const:`telegram.constants.SuggestedPost.MAX_SEND_DATE` seconds (30 days) + in the future. + + |tz-naive-dtms| + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "chat_id": chat_id, + "message_id": message_id, + "send_date": send_date, + } + + return await self._post( + "approveSuggestedPost", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def decline_suggested_post( + self, + chat_id: int, + message_id: int, + comment: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Use this method to decline a suggested post in a direct messages chat. + The bot must have the :attr:`~telegram.ChatMemberAdministrator.can_manage_direct_messages` + administrator right in the corresponding channel chat. + + .. versionadded:: 22.4 + + Args: + chat_id (:obj:`int`): Unique identifier of the target direct messages chat. + message_id (:obj:`int`): Identifier of a suggested post message to decline. + comment (:obj:`str`, optional): Comment for the creator of the suggested post. + 0-:tg-const:`telegram.constants.SuggestedPost.MAX_COMMENT_LENGTH` characters. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "chat_id": chat_id, + "message_id": message_id, + "comment": comment, + } + + return await self._post( + "declineSuggestedPost", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def repost_story( + self, + business_connection_id: str, + from_chat_id: int, + from_story_id: int, + active_period: TimePeriod, + post_to_chat_page: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Story: + """ + Reposts a story on behalf of a business account from another business account. + Both business accounts must be managed by the same bot, and the story on the source account + must have been posted (or reposted) by the bot. Requires the + :attr:`~telegram.BusinessBotRight.can_manage_stories` business bot right for both + business accounts. + + .. versionadded:: 22.6 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + from_chat_id (:obj:`int`): Unique identifier of the chat which posted the story that + should be reposted + from_story_id (:obj:`int`): Unique identifier of the story that should be reposted + active_period (:obj:`int` | :class:`datetime.timedelta`): Period after which the story + is moved to the archive, in seconds; must be one of + :tg-const:`telegram.constants.StoryLimit.SIX_HOURS`, + :tg-const:`telegram.constants.StoryLimit.TWELVE_HOURS`, + :tg-const:`telegram.constants.StoryLimit.ONE_DAY`, or + :tg-const:`telegram.constants.StoryLimit.TWO_DAYS`. + post_to_chat_page (:obj:`bool`, optional): Pass :obj:`True` to keep the story + accessible after it expires. + protect_content (:obj:`bool`, optional): Pass :obj:`True` if the content of the story + must be protected from forwarding and screenshotting + + Returns: + :class:`telegram.Story` + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "from_chat_id": from_chat_id, + "from_story_id": from_story_id, + "active_period": active_period, + "post_to_chat_page": post_to_chat_page, + "protect_content": protect_content, + } + return Story.de_json( + data=await self._post( + "repostStory", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ), + bot=self, + ) + + async def get_user_gifts( + self, + user_id: int, + exclude_unlimited: bool | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> OwnedGifts: + """Returns the gifts owned and hosted by a user. + + .. versionadded:: 22.6 + + user_id (:obj:`int`): Unique identifier of the user + exclude_unlimited (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that can be + purchased an unlimited number of times + exclude_limited_upgradable (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that + can be purchased a limited number of times and can be upgraded to unique + exclude_limited_non_upgradable (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts + that can be purchased a limited number of times and can't be upgraded to unique + exclude_from_blockchain (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that + were assigned from the TON blockchain and can't be resold or transferred in Telegram + exclude_unique (:obj:`bool`, optional): Pass :obj:`True` to exclude unique gifts + sort_by_price (:obj:`bool`, optional): Pass :obj:`True` to sort results by gift price + instead of send date. Sorting is applied before pagination. + offset (:obj:`str`, optional): Offset of the first entry to return as received from the + previous request; use an empty string to get the first chunk of results + limit (:obj:`int`, optional): The maximum number of gifts to be returned; + :tg-const:`~telegram.constants.BusinessLimit.MIN_GIFT_RESULTS` - + :tg-const:`~telegram.constants.BusinessLimit.MAX_GIFT_RESLUTS`. + Defaults to :tg-const:`~telegram.constants.BusinessLimit.MAX_GIFT_RESLUTS` + + Returns: + :class:`telegram.OwnedGifts`: The owned gifts for the user. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "user_id": user_id, + "exclude_unlimited": exclude_unlimited, + "exclude_limited_upgradable": exclude_limited_upgradable, + "exclude_limited_non_upgradable": exclude_limited_non_upgradable, + "exclude_from_blockchain": exclude_from_blockchain, + "exclude_unique": exclude_unique, + "sort_by_price": sort_by_price, + "offset": offset, + "limit": limit, + } + + result = await self._post( + "getUserGifts", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + return OwnedGifts.de_json(result, self) + + async def get_chat_gifts( + self, + chat_id: int | str, + exclude_unsaved: bool | None = None, + exclude_saved: bool | None = None, + exclude_unlimited: bool | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> OwnedGifts: + """Use this method to get gifts owned by a chat. + + .. versionadded:: 22.6 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + exclude_unsaved (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that aren't + saved to the chat's profile page. Always :obj:`True`, unless the bot has the + :attr:`~telegram.ChatAdministratorRights..can_post_messages` administrator right in the + channel. + exclude_saved (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that are saved to + the chat's profile page. Always :obj:`False`, unless the bot has the + :attr:`~telegram.ChatAdministratorRights..can_post_messages` administrator right in the + channel. + exclude_unlimited (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that can be + purchased an unlimited number of times + exclude_limited_upgradable (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that + can be purchased a limited number of times and can be upgraded to unique + exclude_limited_non_upgradable (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts + that can be purchased a limited number of times and can't be upgraded to unique + exclude_from_blockchain (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that + were assigned from the TON blockchain and can't be resold or transferred in Telegram + exclude_unique (:obj:`bool`, optional): Pass :obj:`True` to exclude unique gifts + sort_by_price (:obj:`bool`, optional): Pass :obj:`True` to sort results by gift price + instead of send date. Sorting is applied before pagination. + offset (:obj:`str`, optional): Offset of the first entry to return as received from the + previous request; use an empty string to get the first chunk of results + limit (:obj:`int`, optional): The maximum number of gifts to be returned; + :tg-const:`~telegram.constants.BusinessLimit.MIN_GIFT_RESULTS` - + :tg-const:`~telegram.constants.BusinessLimit.MAX_GIFT_RESLUTS`. + Defaults to :tg-const:`~telegram.constants.BusinessLimit.MAX_GIFT_RESLUTS` + + Returns: + :class:`telegram.OwnedGifts`: The owned gifts for the chat. + + Raises: + :class:`telegram.error.TelegramError` + """ + + data: JSONDict = { + "chat_id": chat_id, + "exclude_unsaved": exclude_unsaved, + "exclude_saved": exclude_saved, + "exclude_unlimited": exclude_unlimited, + "exclude_limited_upgradable": exclude_limited_upgradable, + "exclude_limited_non_upgradable": exclude_limited_non_upgradable, + "exclude_from_blockchain": exclude_from_blockchain, + "exclude_unique": exclude_unique, + "sort_by_price": sort_by_price, + "offset": offset, + "limit": limit, + } + + result = await self._post( + "getChatGifts", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + return OwnedGifts.de_json(result, self) + def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """See :meth:`telegram.TelegramObject.to_dict`.""" data: JSONDict = {"id": self.id, "username": self.username, "first_name": self.first_name} @@ -9958,6 +11911,8 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`get_me`""" sendMessage = send_message """Alias for :meth:`send_message`""" + sendMessageDraft = send_message_draft + """Alias for :meth:`send_message_draft`""" deleteMessage = delete_message """Alias for :meth:`delete_message`""" deleteMessages = delete_messages @@ -10116,6 +12071,10 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`send_poll`""" stopPoll = stop_poll """Alias for :meth:`stop_poll`""" + sendChecklist = send_checklist + """Alias for :meth:`send_checklist`""" + editMessageChecklist = edit_message_checklist + """Alias for :meth:`edit_message_checklist`""" sendDice = send_dice """Alias for :meth:`send_dice`""" getMyCommands = get_my_commands @@ -10194,8 +12153,44 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`get_user_chat_boosts`""" setMessageReaction = set_message_reaction """Alias for :meth:`set_message_reaction`""" + giftPremiumSubscription = gift_premium_subscription + """Alias for :meth:`gift_premium_subscription`""" + getBusinessAccountGifts = get_business_account_gifts + """Alias for :meth:`get_business_account_gifts`""" + getBusinessAccountStarBalance = get_business_account_star_balance + """Alias for :meth:`get_business_account_star_balance`""" getBusinessConnection = get_business_connection """Alias for :meth:`get_business_connection`""" + readBusinessMessage = read_business_message + """Alias for :meth:`read_business_message`""" + deleteBusinessMessages = delete_business_messages + """Alias for :meth:`delete_business_messages`""" + postStory = post_story + """Alias for :meth:`post_story`""" + editStory = edit_story + """Alias for :meth:`edit_story`""" + deleteStory = delete_story + """Alias for :meth:`delete_story`""" + setBusinessAccountName = set_business_account_name + """Alias for :meth:`set_business_account_name`""" + setBusinessAccountUsername = set_business_account_username + """Alias for :meth:`set_business_account_username`""" + setBusinessAccountBio = set_business_account_bio + """Alias for :meth:`set_business_account_bio`""" + setBusinessAccountGiftSettings = set_business_account_gift_settings + """Alias for :meth:`set_business_account_gift_settings`""" + setBusinessAccountProfilePhoto = set_business_account_profile_photo + """Alias for :meth:`set_business_account_profile_photo`""" + removeBusinessAccountProfilePhoto = remove_business_account_profile_photo + """Alias for :meth:`remove_business_account_profile_photo`""" + convertGiftToStars = convert_gift_to_stars + """Alias for :meth:`convert_gift_to_stars`""" + upgradeGift = upgrade_gift + """Alias for :meth:`upgrade_gift`""" + transferGift = transfer_gift + """Alias for :meth:`transfer_gift`""" + transferBusinessAccountStars = transfer_business_account_stars + """Alias for :meth:`transfer_business_account_stars`""" replaceStickerInSet = replace_sticker_in_set """Alias for :meth:`replace_sticker_in_set`""" refundStarPayment = refund_star_payment @@ -10222,3 +12217,15 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`remove_chat_verification`""" removeUserVerification = remove_user_verification """Alias for :meth:`remove_user_verification`""" + getMyStarBalance = get_my_star_balance + """Alias for :meth:`get_my_star_balance`""" + approveSuggestedPost = approve_suggested_post + """Alias for :meth:`approve_suggested_post`""" + declineSuggestedPost = decline_suggested_post + """Alias for :meth:`decline_suggested_post`""" + repostStory = repost_story + """Alias for :meth:`repost_story`""" + getUserGifts = get_user_gifts + """Alias for :meth:`get_user_gifts`""" + getChatGifts = get_chat_gifts + """Alias for :meth:`get_chat_gifts`""" diff --git a/telegram/_botcommand.py b/src/telegram/_botcommand.py similarity index 97% rename from telegram/_botcommand.py rename to src/telegram/_botcommand.py index 059740803d8..6b697128427 100644 --- a/telegram/_botcommand.py +++ b/src/telegram/_botcommand.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Bot Command.""" -from typing import Final, Optional +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -52,7 +52,7 @@ class BotCommand(TelegramObject): __slots__ = ("command", "description") - def __init__(self, command: str, description: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, command: str, description: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.command: str = command self.description: str = description diff --git a/telegram/_botcommandscope.py b/src/telegram/_botcommandscope.py similarity index 90% rename from telegram/_botcommandscope.py rename to src/telegram/_botcommandscope.py index 0c339a3da15..cde05a79ac6 100644 --- a/telegram/_botcommandscope.py +++ b/src/telegram/_botcommandscope.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,8 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. # pylint: disable=redefined-builtin """This module contains objects representing Telegram bot command scopes.""" -from typing import TYPE_CHECKING, Final, Optional, Union + +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -76,7 +77,7 @@ class BotCommandScope(TelegramObject): CHAT_MEMBER: Final[str] = constants.BotCommandScopeType.CHAT_MEMBER """:const:`telegram.constants.BotCommandScopeType.CHAT_MEMBER`""" - def __init__(self, type: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, type: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.type: str = enum.get_member(constants.BotCommandScopeType, type, type) self._id_attrs = (self.type,) @@ -84,9 +85,7 @@ def __init__(self, type: str, *, api_kwargs: Optional[JSONDict] = None): self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["BotCommandScope"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BotCommandScope": """Converts JSON data to the appropriate :class:`BotCommandScope` object, i.e. takes care of selecting the correct subclass. @@ -104,9 +103,6 @@ def de_json( """ data = cls._parse_data(data) - if not data: - return None - _class_mapping: dict[str, type[BotCommandScope]] = { cls.DEFAULT: BotCommandScopeDefault, cls.ALL_PRIVATE_CHATS: BotCommandScopeAllPrivateChats, @@ -135,7 +131,7 @@ class BotCommandScopeDefault(BotCommandScope): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.DEFAULT, api_kwargs=api_kwargs) self._freeze() @@ -151,7 +147,7 @@ class BotCommandScopeAllPrivateChats(BotCommandScope): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.ALL_PRIVATE_CHATS, api_kwargs=api_kwargs) self._freeze() @@ -166,7 +162,7 @@ class BotCommandScopeAllGroupChats(BotCommandScope): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.ALL_GROUP_CHATS, api_kwargs=api_kwargs) self._freeze() @@ -181,7 +177,7 @@ class BotCommandScopeAllChatAdministrators(BotCommandScope): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.ALL_CHAT_ADMINISTRATORS, api_kwargs=api_kwargs) self._freeze() @@ -204,10 +200,10 @@ class BotCommandScopeChat(BotCommandScope): __slots__ = ("chat_id",) - def __init__(self, chat_id: Union[str, int], *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, chat_id: str | int, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.CHAT, api_kwargs=api_kwargs) with self._unfrozen(): - self.chat_id: Union[str, int] = ( + self.chat_id: str | int = ( chat_id if isinstance(chat_id, str) and chat_id.startswith("@") else int(chat_id) ) self._id_attrs = (self.type, self.chat_id) @@ -231,10 +227,10 @@ class BotCommandScopeChatAdministrators(BotCommandScope): __slots__ = ("chat_id",) - def __init__(self, chat_id: Union[str, int], *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, chat_id: str | int, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.CHAT_ADMINISTRATORS, api_kwargs=api_kwargs) with self._unfrozen(): - self.chat_id: Union[str, int] = ( + self.chat_id: str | int = ( chat_id if isinstance(chat_id, str) and chat_id.startswith("@") else int(chat_id) ) self._id_attrs = (self.type, self.chat_id) @@ -261,12 +257,10 @@ class BotCommandScopeChatMember(BotCommandScope): __slots__ = ("chat_id", "user_id") - def __init__( - self, chat_id: Union[str, int], user_id: int, *, api_kwargs: Optional[JSONDict] = None - ): + def __init__(self, chat_id: str | int, user_id: int, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.CHAT_MEMBER, api_kwargs=api_kwargs) with self._unfrozen(): - self.chat_id: Union[str, int] = ( + self.chat_id: str | int = ( chat_id if isinstance(chat_id, str) and chat_id.startswith("@") else int(chat_id) ) self.user_id: int = user_id diff --git a/telegram/_botdescription.py b/src/telegram/_botdescription.py similarity index 90% rename from telegram/_botdescription.py rename to src/telegram/_botdescription.py index 9f53ef1be86..e7fe7211cb3 100644 --- a/telegram/_botdescription.py +++ b/src/telegram/_botdescription.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains two objects that represent a Telegram bots (short) description.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -41,7 +40,7 @@ class BotDescription(TelegramObject): __slots__ = ("description",) - def __init__(self, description: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, description: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.description: str = description @@ -68,7 +67,7 @@ class BotShortDescription(TelegramObject): __slots__ = ("short_description",) - def __init__(self, short_description: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, short_description: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.short_description: str = short_description diff --git a/telegram/_botname.py b/src/telegram/_botname.py similarity index 92% rename from telegram/_botname.py rename to src/telegram/_botname.py index a297027eae6..42021df472e 100644 --- a/telegram/_botname.py +++ b/src/telegram/_botname.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represent a Telegram bots name.""" -from typing import Final, Optional + +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -42,7 +43,7 @@ class BotName(TelegramObject): __slots__ = ("name",) - def __init__(self, name: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, name: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.name: str = name diff --git a/src/telegram/_business.py b/src/telegram/_business.py new file mode 100644 index 00000000000..5b2026c62d0 --- /dev/null +++ b/src/telegram/_business.py @@ -0,0 +1,691 @@ +#!/usr/bin/env python +# pylint: disable=redefined-builtin +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/] +"""This module contains the Telegram Business related classes.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING +from zoneinfo import ZoneInfo + +from telegram._chat import Chat +from telegram._files.location import Location +from telegram._files.sticker import Sticker +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_sequence_arg, +) +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_zone_info, +) +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class BusinessBotRights(TelegramObject): + """ + This object represents the rights of a business bot. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if all their attributes are equal. + + .. versionadded:: 22.1 + + Args: + can_reply (:obj:`bool`, optional): True, if the bot can send and edit messages in the + private chats that had incoming messages in the last 24 hours. + can_read_messages (:obj:`bool`, optional): True, if the bot can mark incoming private + messages as read. + can_delete_sent_messages (:obj:`bool`, optional): True, if the bot can delete messages + sent by the bot. + can_delete_all_messages (:obj:`bool`, optional): True, if the bot can delete all private + messages in managed chats. + can_edit_name (:obj:`bool`, optional): True, if the bot can edit the first and last name + of the business account. + can_edit_bio (:obj:`bool`, optional): True, if the bot can edit the bio of the + business account. + can_edit_profile_photo (:obj:`bool`, optional): True, if the bot can edit the profile + photo of the business account. + can_edit_username (:obj:`bool`, optional): True, if the bot can edit the username of the + business account. + can_change_gift_settings (:obj:`bool`, optional): True, if the bot can change the privacy + settings pertaining to gifts for the business account. + can_view_gifts_and_stars (:obj:`bool`, optional): True, if the bot can view gifts and the + amount of Telegram Stars owned by the business account. + can_convert_gifts_to_stars (:obj:`bool`, optional): True, if the bot can convert regular + gifts owned by the business account to Telegram Stars. + can_transfer_and_upgrade_gifts (:obj:`bool`, optional): True, if the bot can transfer and + upgrade gifts owned by the business account. + can_transfer_stars (:obj:`bool`, optional): True, if the bot can transfer Telegram Stars + received by the business account to its own account, or use them to upgrade and + transfer gifts. + can_manage_stories (:obj:`bool`, optional): True, if the bot can post, edit and delete + stories on behalf of the business account. + + Attributes: + can_reply (:obj:`bool`): Optional. True, if the bot can send and edit messages in the + private chats that had incoming messages in the last 24 hours. + can_read_messages (:obj:`bool`): Optional. True, if the bot can mark incoming private + messages as read. + can_delete_sent_messages (:obj:`bool`): Optional. True, if the bot can delete messages + sent by the bot. + can_delete_all_messages (:obj:`bool`): Optional. True, if the bot can delete all private + messages in managed chats. + can_edit_name (:obj:`bool`): Optional. True, if the bot can edit the first and last name + of the business account. + can_edit_bio (:obj:`bool`): Optional. True, if the bot can edit the bio of the + business account. + can_edit_profile_photo (:obj:`bool`): Optional. True, if the bot can edit the profile + photo of the business account. + can_edit_username (:obj:`bool`): Optional. True, if the bot can edit the username of the + business account. + can_change_gift_settings (:obj:`bool`): Optional. True, if the bot can change the privacy + settings pertaining to gifts for the business account. + can_view_gifts_and_stars (:obj:`bool`): Optional. True, if the bot can view gifts and the + amount of Telegram Stars owned by the business account. + can_convert_gifts_to_stars (:obj:`bool`): Optional. True, if the bot can convert regular + gifts owned by the business account to Telegram Stars. + can_transfer_and_upgrade_gifts (:obj:`bool`): Optional. True, if the bot can transfer and + upgrade gifts owned by the business account. + can_transfer_stars (:obj:`bool`): Optional. True, if the bot can transfer Telegram Stars + received by the business account to its own account, or use them to upgrade and + transfer gifts. + can_manage_stories (:obj:`bool`): Optional. True, if the bot can post, edit and delete + stories on behalf of the business account. + """ + + __slots__ = ( + "can_change_gift_settings", + "can_convert_gifts_to_stars", + "can_delete_all_messages", + "can_delete_sent_messages", + "can_edit_bio", + "can_edit_name", + "can_edit_profile_photo", + "can_edit_username", + "can_manage_stories", + "can_read_messages", + "can_reply", + "can_transfer_and_upgrade_gifts", + "can_transfer_stars", + "can_view_gifts_and_stars", + ) + + def __init__( + self, + can_reply: bool | None = None, + can_read_messages: bool | None = None, + can_delete_sent_messages: bool | None = None, + can_delete_all_messages: bool | None = None, + can_edit_name: bool | None = None, + can_edit_bio: bool | None = None, + can_edit_profile_photo: bool | None = None, + can_edit_username: bool | None = None, + can_change_gift_settings: bool | None = None, + can_view_gifts_and_stars: bool | None = None, + can_convert_gifts_to_stars: bool | None = None, + can_transfer_and_upgrade_gifts: bool | None = None, + can_transfer_stars: bool | None = None, + can_manage_stories: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.can_reply: bool | None = can_reply + self.can_read_messages: bool | None = can_read_messages + self.can_delete_sent_messages: bool | None = can_delete_sent_messages + self.can_delete_all_messages: bool | None = can_delete_all_messages + self.can_edit_name: bool | None = can_edit_name + self.can_edit_bio: bool | None = can_edit_bio + self.can_edit_profile_photo: bool | None = can_edit_profile_photo + self.can_edit_username: bool | None = can_edit_username + self.can_change_gift_settings: bool | None = can_change_gift_settings + self.can_view_gifts_and_stars: bool | None = can_view_gifts_and_stars + self.can_convert_gifts_to_stars: bool | None = can_convert_gifts_to_stars + self.can_transfer_and_upgrade_gifts: bool | None = can_transfer_and_upgrade_gifts + self.can_transfer_stars: bool | None = can_transfer_stars + self.can_manage_stories: bool | None = can_manage_stories + + self._id_attrs = ( + self.can_reply, + self.can_read_messages, + self.can_delete_sent_messages, + self.can_delete_all_messages, + self.can_edit_name, + self.can_edit_bio, + self.can_edit_profile_photo, + self.can_edit_username, + self.can_change_gift_settings, + self.can_view_gifts_and_stars, + self.can_convert_gifts_to_stars, + self.can_transfer_and_upgrade_gifts, + self.can_transfer_stars, + self.can_manage_stories, + ) + + self._freeze() + + +class BusinessConnection(TelegramObject): + """ + Describes the connection of the bot with a business account. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`id`, :attr:`user`, :attr:`user_chat_id`, :attr:`date`, + :attr:`rights`, and :attr:`is_enabled` are equal. + + .. versionadded:: 21.1 + .. versionchanged:: 22.1 + Equality comparison now considers :attr:`rights` instead of ``can_reply``. + + .. versionremoved:: 22.3 + Removed argument and attribute ``can_reply`` deprecated by API 9.0. + + Args: + id (:obj:`str`): Unique identifier of the business connection. + user (:class:`telegram.User`): Business account user that created the business connection. + user_chat_id (:obj:`int`): Identifier of a private chat with the user who created the + business connection. + date (:obj:`datetime.datetime`): Date the connection was established in Unix time. + is_enabled (:obj:`bool`): True, if the connection is active. + rights (:class:`BusinessBotRights`, optional): Rights of the business bot. + + .. versionadded:: 22.1 + + Attributes: + id (:obj:`str`): Unique identifier of the business connection. + user (:class:`telegram.User`): Business account user that created the business connection. + user_chat_id (:obj:`int`): Identifier of a private chat with the user who created the + business connection. + date (:obj:`datetime.datetime`): Date the connection was established in Unix time. + is_enabled (:obj:`bool`): True, if the connection is active. + rights (:class:`BusinessBotRights`): Optional. Rights of the business bot. + + .. versionadded:: 22.1 + """ + + __slots__ = ( + "date", + "id", + "is_enabled", + "rights", + "user", + "user_chat_id", + ) + + def __init__( + self, + id: str, + user: "User", + user_chat_id: int, + date: dtm.datetime, + is_enabled: bool, + rights: BusinessBotRights | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: str = id + self.user: User = user + self.user_chat_id: int = user_chat_id + self.date: dtm.datetime = date + self.is_enabled: bool = is_enabled + self.rights: BusinessBotRights | None = rights + + self._id_attrs = ( + self.id, + self.user, + self.user_chat_id, + self.date, + self.rights, + self.is_enabled, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BusinessConnection": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo) + data["user"] = de_json_optional(data.get("user"), User, bot) + data["rights"] = de_json_optional(data.get("rights"), BusinessBotRights, bot) + + return super().de_json(data=data, bot=bot) + + +class BusinessMessagesDeleted(TelegramObject): + """ + This object is received when messages are deleted from a connected business account. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`business_connection_id`, :attr:`message_ids`, and + :attr:`chat` are equal. + + .. versionadded:: 21.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + chat (:class:`telegram.Chat`): Information about a chat in the business account. The bot + may not have access to the chat or the corresponding user. + message_ids (Sequence[:obj:`int`]): A list of identifiers of the deleted messages in the + chat of the business account. + + Attributes: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + chat (:class:`telegram.Chat`): Information about a chat in the business account. The bot + may not have access to the chat or the corresponding user. + message_ids (tuple[:obj:`int`]): A list of identifiers of the deleted messages in the + chat of the business account. + """ + + __slots__ = ( + "business_connection_id", + "chat", + "message_ids", + ) + + def __init__( + self, + business_connection_id: str, + chat: Chat, + message_ids: Sequence[int], + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.business_connection_id: str = business_connection_id + self.chat: Chat = chat + self.message_ids: tuple[int, ...] = parse_sequence_arg(message_ids) + + self._id_attrs = ( + self.business_connection_id, + self.chat, + self.message_ids, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BusinessMessagesDeleted": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + + return super().de_json(data=data, bot=bot) + + +class BusinessIntro(TelegramObject): + """ + This object contains information about the start page settings of a Telegram Business account. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their + :attr:`title`, :attr:`message` and :attr:`sticker` are equal. + + .. versionadded:: 21.1 + + Args: + title (:obj:`str`, optional): Title text of the business intro. + message (:obj:`str`, optional): Message text of the business intro. + sticker (:class:`telegram.Sticker`, optional): Sticker of the business intro. + + Attributes: + title (:obj:`str`): Optional. Title text of the business intro. + message (:obj:`str`): Optional. Message text of the business intro. + sticker (:class:`telegram.Sticker`): Optional. Sticker of the business intro. + """ + + __slots__ = ( + "message", + "sticker", + "title", + ) + + def __init__( + self, + title: str | None = None, + message: str | None = None, + sticker: Sticker | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.title: str | None = title + self.message: str | None = message + self.sticker: Sticker | None = sticker + + self._id_attrs = (self.title, self.message, self.sticker) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BusinessIntro": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + + return super().de_json(data=data, bot=bot) + + +class BusinessLocation(TelegramObject): + """ + This object contains information about the location of a Telegram Business account. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their + :attr:`address` is equal. + + .. versionadded:: 21.1 + + Args: + address (:obj:`str`): Address of the business. + location (:class:`telegram.Location`, optional): Location of the business. + + Attributes: + address (:obj:`str`): Address of the business. + location (:class:`telegram.Location`): Optional. Location of the business. + """ + + __slots__ = ( + "address", + "location", + ) + + def __init__( + self, + address: str, + location: "Location | None" = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.address: str = address + self.location: Location | None = location + + self._id_attrs = (self.address,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BusinessLocation": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["location"] = de_json_optional(data.get("location"), Location, bot) + + return super().de_json(data=data, bot=bot) + + +class BusinessOpeningHoursInterval(TelegramObject): + """ + This object describes an interval of time during which a business is open. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their + :attr:`opening_minute` and :attr:`closing_minute` are equal. + + .. versionadded:: 21.1 + + Examples: + A day has (24 * 60 =) 1440 minutes, a week has (7 * 1440 =) 10080 minutes. + Starting the minute's sequence from Monday, example values of + :attr:`opening_minute`, :attr:`closing_minute` will map to the following day times: + + * Monday - 8am to 8:30pm: + - ``opening_minute = 480`` :guilabel:`8 * 60` + - ``closing_minute = 1230`` :guilabel:`20 * 60 + 30` + * Tuesday - 24 hours: + - ``opening_minute = 1440`` :guilabel:`24 * 60` + - ``closing_minute = 2879`` :guilabel:`2 * 24 * 60 - 1` + * Sunday - 12am - 11:58pm: + - ``opening_minute = 8640`` :guilabel:`6 * 24 * 60` + - ``closing_minute = 10078`` :guilabel:`7 * 24 * 60 - 2` + + Args: + opening_minute (:obj:`int`): The minute's sequence number in a week, starting on Monday, + marking the start of the time interval during which the business is open; + 0 - 7 * 24 * 60. + closing_minute (:obj:`int`): The minute's + sequence number in a week, starting on Monday, marking the end of the time interval + during which the business is open; 0 - 8 * 24 * 60 + + Attributes: + opening_minute (:obj:`int`): The minute's sequence number in a week, starting on Monday, + marking the start of the time interval during which the business is open; + 0 - 7 * 24 * 60. + closing_minute (:obj:`int`): The minute's + sequence number in a week, starting on Monday, marking the end of the time interval + during which the business is open; 0 - 8 * 24 * 60 + """ + + __slots__ = ("_closing_time", "_opening_time", "closing_minute", "opening_minute") + + def __init__( + self, + opening_minute: int, + closing_minute: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.opening_minute: int = opening_minute + self.closing_minute: int = closing_minute + + self._opening_time: tuple[int, int, int] | None = None + self._closing_time: tuple[int, int, int] | None = None + + self._id_attrs = (self.opening_minute, self.closing_minute) + + self._freeze() + + def _parse_minute(self, minute: int) -> tuple[int, int, int]: + return (minute // 1440, minute % 1440 // 60, minute % 1440 % 60) + + @property + def opening_time(self) -> tuple[int, int, int]: + """Convenience attribute. A :obj:`tuple` parsed from :attr:`opening_minute`. It contains + the `weekday`, `hour` and `minute` in the same ranges as :attr:`datetime.datetime.weekday`, + :attr:`datetime.datetime.hour` and :attr:`datetime.datetime.minute` + + Returns: + tuple[:obj:`int`, :obj:`int`, :obj:`int`]: + """ + if self._opening_time is None: + self._opening_time = self._parse_minute(self.opening_minute) + return self._opening_time + + @property + def closing_time(self) -> tuple[int, int, int]: + """Convenience attribute. A :obj:`tuple` parsed from :attr:`closing_minute`. It contains + the `weekday`, `hour` and `minute` in the same ranges as :attr:`datetime.datetime.weekday`, + :attr:`datetime.datetime.hour` and :attr:`datetime.datetime.minute` + + Returns: + tuple[:obj:`int`, :obj:`int`, :obj:`int`]: + """ + if self._closing_time is None: + self._closing_time = self._parse_minute(self.closing_minute) + return self._closing_time + + +class BusinessOpeningHours(TelegramObject): + """ + This object describes the opening hours of a business. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their + :attr:`time_zone_name` and :attr:`opening_hours` are equal. + + .. versionadded:: 21.1 + + Args: + time_zone_name (:obj:`str`): Unique name of the time zone for which the opening + hours are defined. + opening_hours (Sequence[:class:`telegram.BusinessOpeningHoursInterval`]): List of + time intervals describing business opening hours. + + Attributes: + time_zone_name (:obj:`str`): Unique name of the time zone for which the opening + hours are defined. + opening_hours (Sequence[:class:`telegram.BusinessOpeningHoursInterval`]): List of + time intervals describing business opening hours. + """ + + __slots__ = ("_cached_zone_info", "opening_hours", "time_zone_name") + + def __init__( + self, + time_zone_name: str, + opening_hours: Sequence[BusinessOpeningHoursInterval], + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.time_zone_name: str = time_zone_name + self.opening_hours: Sequence[BusinessOpeningHoursInterval] = parse_sequence_arg( + opening_hours + ) + + self._cached_zone_info: ZoneInfo | None = None + + self._id_attrs = (self.time_zone_name, self.opening_hours) + + self._freeze() + + @property + def _zone_info(self) -> ZoneInfo: + if self._cached_zone_info is None: + self._cached_zone_info = get_zone_info(self.time_zone_name) + return self._cached_zone_info + + def get_opening_hours_for_day( + self, date: dtm.date, time_zone: dtm.tzinfo | str | None = None + ) -> tuple[tuple[dtm.datetime, dtm.datetime], ...]: + """Returns the opening hours intervals for a specific day as datetime objects. + + .. versionadded:: 22.5 + + Args: + date (:obj:`datetime.date`): The date to get opening hours for. + time_zone (:obj:`datetime.tzinfo` | :obj:`str`, optional): Timezone to use for the + returned datetime objects. If not specified, then :attr:`time_zone_name` be used. + + Returns: + tuple[tuple[:obj:`datetime.datetime`, :obj:`datetime.datetime`], ...]: + A tuple of datetime pairs representing opening and closing times for the specified day. + Each pair consists of ``(opening_time, closing_time)``. + Returns an empty tuple if there are no opening hours for the given day. + """ + + week_day = date.weekday() + res = [] + if isinstance(time_zone, str): + tz_target: dtm.tzinfo = get_zone_info(time_zone) + elif time_zone is None: + tz_target = self._zone_info + else: + tz_target = time_zone + + for interval in self.opening_hours: + int_open = interval.opening_time + int_close = interval.closing_time + + if int_open[0] != week_day: + continue + + # To get the correct localization, we first need to create the dtm object in + # self.time_zone_name, then convert it to the target timezone. We could check if + # self._zone_info == tz_target and skip the conversion, but it's not worth the added + # complexity. + result_int_open = dtm.datetime( + year=date.year, + month=date.month, + day=date.day, + hour=int_open[1], + minute=int_open[2], + tzinfo=self._zone_info, + ).astimezone(tz_target) + + result_int_close = dtm.datetime( + year=date.year, + month=date.month, + day=date.day, + hour=int_close[1], + minute=int_close[2], + tzinfo=self._zone_info, + ).astimezone(tz_target) + + res.append((result_int_open, result_int_close)) + + # The sorting is currently an implementation detail + return tuple(sorted(res, key=lambda x: x[0])) + + def is_open(self, datetime: dtm.datetime) -> bool: + """Check if the business is open at the specified datetime. + + .. versionadded:: 22.5 + + Args: + datetime (:obj:`datetime.datetime`): The datetime to check. + If the object is timezone-naive, it is assumed to be in the + timezone specified by :attr:`time_zone_name`. + + Returns: + :obj:`bool`: True if the business is open at the specified time, False otherwise. + """ + + datetime_in_native_tz = ( + datetime.replace(tzinfo=self._zone_info) if datetime.tzinfo is None else datetime + ).astimezone(self._zone_info) + minute_of_week = ( + datetime_in_native_tz.weekday() * 1440 + + datetime_in_native_tz.hour * 60 + + datetime_in_native_tz.minute + ) + + for interval in self.opening_hours: + if interval.opening_minute <= minute_of_week < interval.closing_minute: + return True + + return False + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BusinessOpeningHours": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["opening_hours"] = de_list_optional( + data.get("opening_hours"), BusinessOpeningHoursInterval, bot + ) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_callbackquery.py b/src/telegram/_callbackquery.py similarity index 85% rename from telegram/_callbackquery.py rename to src/telegram/_callbackquery.py index 8febcca0387..9004de89388 100644 --- a/telegram/_callbackquery.py +++ b/src/telegram/_callbackquery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,16 +18,18 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. # pylint: disable=redefined-builtin """This module contains an object that represents a Telegram CallbackQuery""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Final, Optional, Union +from typing import TYPE_CHECKING, Final from telegram import constants -from telegram._files.location import Location +from telegram._inputchecklist import InputChecklist from telegram._message import MaybeInaccessibleMessage, Message from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput, ReplyMarkup +from telegram._utils.types import JSONDict, ODVInput, TimePeriod if TYPE_CHECKING: from telegram import ( @@ -39,7 +41,10 @@ MessageEntity, MessageId, ReplyParameters, + SuggestedPostParameters, ) + from telegram._files.location import Location + from telegram._utils.types import ReplyMarkup class CallbackQuery(TelegramObject): @@ -126,12 +131,12 @@ def __init__( id: str, from_user: User, chat_instance: str, - message: Optional[MaybeInaccessibleMessage] = None, - data: Optional[str] = None, - inline_message_id: Optional[str] = None, - game_short_name: Optional[str] = None, + message: MaybeInaccessibleMessage | None = None, + data: str | None = None, + inline_message_id: str | None = None, + game_short_name: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -139,42 +144,37 @@ def __init__( self.from_user: User = from_user self.chat_instance: str = chat_instance # Optionals - self.message: Optional[MaybeInaccessibleMessage] = message - self.data: Optional[str] = data - self.inline_message_id: Optional[str] = inline_message_id - self.game_short_name: Optional[str] = game_short_name + self.message: MaybeInaccessibleMessage | None = message + self.data: str | None = data + self.inline_message_id: str | None = inline_message_id + self.game_short_name: str | None = game_short_name self._id_attrs = (self.id,) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["CallbackQuery"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "CallbackQuery": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["from_user"] = User.de_json(data.pop("from", None), bot) - data["message"] = Message.de_json(data.get("message"), bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) + data["message"] = de_json_optional(data.get("message"), Message, bot) return super().de_json(data=data, bot=bot) async def answer( self, - text: Optional[str] = None, - show_alert: Optional[bool] = None, - url: Optional[str] = None, - cache_time: Optional[int] = None, + text: str | None = None, + show_alert: bool | None = None, + url: str | None = None, + cache_time: TimePeriod | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -212,17 +212,17 @@ async def edit_message_text( self, text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, *, - disable_web_page_preview: Optional[bool] = None, + disable_web_page_preview: bool | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.edit_text(*args, **kwargs) @@ -282,18 +282,18 @@ async def edit_message_text( async def edit_message_caption( self, - caption: Optional[str] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + caption: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.edit_caption(*args, **kwargs) @@ -349,16 +349,53 @@ async def edit_message_caption( show_caption_above_media=show_caption_above_media, ) + async def edit_message_checklist( + self, + checklist: InputChecklist, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": + """Shortcut for:: + + await update.callback_query.message.edit_checklist(*args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Message.edit_checklist`. + + .. versionadded:: 22.3 + + Returns: + :class:`telegram.Message`: On success, the edited Message is returned. + + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + + """ + return await self._get_message().edit_checklist( + checklist=checklist, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def edit_message_reply_markup( self, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.edit_reply_markup(*args, **kwargs) @@ -410,14 +447,14 @@ async def edit_message_reply_markup( async def edit_message_media( self, media: "InputMedia", - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.edit_media(*args, **kwargs) @@ -469,21 +506,21 @@ async def edit_message_media( async def edit_message_live_location( self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, - live_period: Optional[int] = None, + latitude: float | None = None, + longitude: float | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, + live_period: TimePeriod | None = None, *, - location: Optional[Location] = None, + location: "Location | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.edit_live_location(*args, **kwargs) @@ -548,14 +585,14 @@ async def edit_message_live_location( async def stop_message_live_location( self, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.stop_live_location(*args, **kwargs) @@ -608,15 +645,15 @@ async def set_game_score( self, user_id: int, score: int, - force: Optional[bool] = None, - disable_edit_message: Optional[bool] = None, + force: bool | None = None, + disable_edit_message: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.set_game_score(*args, **kwargs) @@ -676,7 +713,7 @@ async def get_game_high_scores( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple["GameHighScore", ...]: """Shortcut for either:: @@ -730,7 +767,7 @@ async def delete_message( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -764,7 +801,7 @@ async def pin_message( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -797,7 +834,7 @@ async def unpin_message( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -824,31 +861,35 @@ async def unpin_message( async def copy_message( self, - chat_id: Union[int, str], - caption: Optional[str] = None, + chat_id: int | str, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - show_caption_above_media: Optional[bool] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "MessageId": """Shortcut for:: await update.callback_query.message.copy( from_chat_id=update.message.chat_id, message_id=update.message.message_id, + direct_messages_topic_id=update.message.direct_messages_topic.topic_id, *args, **kwargs ) @@ -868,6 +909,7 @@ async def copy_message( chat_id=chat_id, caption=caption, parse_mode=parse_mode, + video_start_timestamp=video_start_timestamp, caption_entities=caption_entities, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, @@ -883,6 +925,8 @@ async def copy_message( reply_parameters=reply_parameters, show_caption_above_media=show_caption_above_media, allow_paid_broadcast=allow_paid_broadcast, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) MAX_ANSWER_TEXT_LENGTH: Final[int] = ( diff --git a/telegram/_chat.py b/src/telegram/_chat.py similarity index 74% rename from telegram/_chat.py rename to src/telegram/_chat.py index 16c12056709..927da4d8cf2 100644 --- a/telegram/_chat.py +++ b/src/telegram/_chat.py @@ -2,7 +2,7 @@ # pylint: disable=redefined-builtin # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,10 +18,11 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Chat.""" + import datetime as dtm from collections.abc import Sequence from html import escape -from typing import TYPE_CHECKING, Final, Optional, Union +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._chatpermissions import ChatPermissions @@ -31,7 +32,14 @@ from telegram._telegramobject import TelegramObject from telegram._utils import enum from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import CorrectOptionID, FileInput, JSONDict, ODVInput, ReplyMarkup +from telegram._utils.types import ( + CorrectOptionID, + FileInput, + JSONDict, + ODVInput, + TimePeriod, +) +from telegram._utils.usernames import get_full_name, get_link from telegram.helpers import escape_markdown from telegram.helpers import mention_html as helpers_mention_html from telegram.helpers import mention_markdown as helpers_mention_markdown @@ -46,6 +54,7 @@ Document, Gift, InlineKeyboardMarkup, + InputChecklist, InputMediaAudio, InputMediaDocument, InputMediaPhoto, @@ -58,15 +67,19 @@ Message, MessageEntity, MessageId, + OwnedGifts, PhotoSize, ReplyParameters, Sticker, + Story, + SuggestedPostParameters, UserChatBoosts, Venue, Video, VideoNote, Voice, ) + from telegram._utils.types import ReplyMarkup class _ChatBase(TelegramObject): @@ -75,30 +88,41 @@ class _ChatBase(TelegramObject): .. versionadded:: 21.3 """ - __slots__ = ("first_name", "id", "is_forum", "last_name", "title", "type", "username") + __slots__ = ( + "first_name", + "id", + "is_direct_messages", + "is_forum", + "last_name", + "title", + "type", + "username", + ) def __init__( self, id: int, type: str, - title: Optional[str] = None, - username: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - is_forum: Optional[bool] = None, - *, - api_kwargs: Optional[JSONDict] = None, + title: str | None = None, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + is_forum: bool | None = None, + is_direct_messages: bool | None = None, + *, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.id: int = id self.type: str = enum.get_member(constants.ChatType, type, type) # Optionals - self.title: Optional[str] = title - self.username: Optional[str] = username - self.first_name: Optional[str] = first_name - self.last_name: Optional[str] = last_name - self.is_forum: Optional[bool] = is_forum + self.title: str | None = title + self.username: str | None = username + self.first_name: str | None = first_name + self.last_name: str | None = last_name + self.is_forum: bool | None = is_forum + self.is_direct_messages: bool | None = is_direct_messages self._id_attrs = (self.id,) @@ -119,7 +143,7 @@ def __init__( """:const:`telegram.constants.ChatType.CHANNEL`""" @property - def effective_name(self) -> Optional[str]: + def effective_name(self) -> str | None: """ :obj:`str`: Convenience property. Gives :attr:`~Chat.title` if not :obj:`None`, else :attr:`~Chat.full_name` if not :obj:`None`. @@ -133,7 +157,7 @@ def effective_name(self) -> Optional[str]: return None @property - def full_name(self) -> Optional[str]: + def full_name(self) -> str | None: """ :obj:`str`: Convenience property. If :attr:`~Chat.first_name` is not :obj:`None`, gives :attr:`~Chat.first_name` followed by (if available) :attr:`~Chat.last_name`. @@ -144,22 +168,16 @@ def full_name(self) -> Optional[str]: .. versionadded:: 13.2 """ - if not self.first_name: - return None - if self.last_name: - return f"{self.first_name} {self.last_name}" - return self.first_name + return get_full_name(self) @property - def link(self) -> Optional[str]: + def link(self) -> str | None: """:obj:`str`: Convenience property. If the chat has a :attr:`~Chat.username`, returns a t.me link of the chat. """ - if self.username: - return f"https://t.me/{self.username}" - return None + return get_link(self) - def mention_markdown(self, name: Optional[str] = None) -> str: + def mention_markdown(self, name: str | None = None) -> str: """ Note: :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by @@ -197,7 +215,7 @@ def mention_markdown(self, name: Optional[str] = None) -> str: raise TypeError("Can not create a mention to a public chat without title") raise TypeError("Can not create a mention to a private group chat") - def mention_markdown_v2(self, name: Optional[str] = None) -> str: + def mention_markdown_v2(self, name: str | None = None) -> str: """ .. versionadded:: 20.0 @@ -230,7 +248,7 @@ def mention_markdown_v2(self, name: Optional[str] = None) -> str: raise TypeError("Can not create a mention to a public chat without title") raise TypeError("Can not create a mention to a private group chat") - def mention_html(self, name: Optional[str] = None) -> str: + def mention_html(self, name: str | None = None) -> str: """ .. versionadded:: 20.0 @@ -269,7 +287,7 @@ async def leave( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -297,7 +315,7 @@ async def get_administrators( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple["ChatMember", ...]: """Shortcut for:: @@ -329,7 +347,7 @@ async def get_member_count( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> int: """Shortcut for:: @@ -358,7 +376,7 @@ async def get_member( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "ChatMember": """Shortcut for:: @@ -383,14 +401,14 @@ async def get_member( async def ban_member( self, user_id: int, - revoke_messages: Optional[bool] = None, - until_date: Optional[Union[int, dtm.datetime]] = None, + revoke_messages: bool | None = None, + until_date: int | dtm.datetime | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -422,7 +440,7 @@ async def ban_sender_chat( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -449,13 +467,13 @@ async def ban_sender_chat( async def ban_chat( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -490,7 +508,7 @@ async def unban_sender_chat( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -517,13 +535,13 @@ async def unban_sender_chat( async def unban_chat( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -553,13 +571,13 @@ async def unban_chat( async def unban_member( self, user_id: int, - only_if_banned: Optional[bool] = None, + only_if_banned: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -585,27 +603,28 @@ async def unban_member( async def promote_member( self, user_id: int, - can_change_info: Optional[bool] = None, - can_post_messages: Optional[bool] = None, - can_edit_messages: Optional[bool] = None, - can_delete_messages: Optional[bool] = None, - can_invite_users: Optional[bool] = None, - can_restrict_members: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - can_promote_members: Optional[bool] = None, - is_anonymous: Optional[bool] = None, - can_manage_chat: Optional[bool] = None, - can_manage_video_chats: Optional[bool] = None, - can_manage_topics: Optional[bool] = None, - can_post_stories: Optional[bool] = None, - can_edit_stories: Optional[bool] = None, - can_delete_stories: Optional[bool] = None, + can_change_info: bool | None = None, + can_post_messages: bool | None = None, + can_edit_messages: bool | None = None, + can_delete_messages: bool | None = None, + can_invite_users: bool | None = None, + can_restrict_members: bool | None = None, + can_pin_messages: bool | None = None, + can_promote_members: bool | None = None, + is_anonymous: bool | None = None, + can_manage_chat: bool | None = None, + can_manage_video_chats: bool | None = None, + can_manage_topics: bool | None = None, + can_post_stories: bool | None = None, + can_edit_stories: bool | None = None, + can_delete_stories: bool | None = None, + can_manage_direct_messages: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -650,20 +669,21 @@ async def promote_member( can_post_stories=can_post_stories, can_edit_stories=can_edit_stories, can_delete_stories=can_delete_stories, + can_manage_direct_messages=can_manage_direct_messages, ) async def restrict_member( self, user_id: int, permissions: ChatPermissions, - until_date: Optional[Union[int, dtm.datetime]] = None, - use_independent_chat_permissions: Optional[bool] = None, + until_date: int | dtm.datetime | None = None, + use_independent_chat_permissions: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -697,13 +717,13 @@ async def restrict_member( async def set_permissions( self, permissions: ChatPermissions, - use_independent_chat_permissions: Optional[bool] = None, + use_independent_chat_permissions: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -739,7 +759,7 @@ async def set_administrator_custom_title( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -773,7 +793,7 @@ async def set_photo( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -807,7 +827,7 @@ async def delete_photo( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -841,7 +861,7 @@ async def set_title( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -870,13 +890,13 @@ async def set_title( async def set_description( self, - description: Optional[str] = None, + description: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -907,13 +927,13 @@ async def pin_message( self, message_id: int, disable_notification: ODVInput[bool] = DEFAULT_NONE, - business_connection_id: Optional[str] = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -940,14 +960,14 @@ async def pin_message( async def unpin_message( self, - message_id: Optional[int] = None, - business_connection_id: Optional[str] = None, + message_id: int | None = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -978,7 +998,7 @@ async def unpin_all_messages( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1005,24 +1025,26 @@ async def send_message( text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "ReplyMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - disable_web_page_preview: Optional[bool] = None, + disable_web_page_preview: bool | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1056,6 +1078,46 @@ async def send_message( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_message_draft( + self, + draft_id: int, + text: str, + message_thread_id: int | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Sequence["MessageEntity"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.send_message_draft(update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message_draft`. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().send_message_draft( + chat_id=self.id, + draft_id=draft_id, + text=text, + message_thread_id=message_thread_id, + parse_mode=parse_mode, + entities=entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, ) async def delete_message( @@ -1066,7 +1128,7 @@ async def delete_message( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1098,7 +1160,7 @@ async def delete_messages( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1125,26 +1187,27 @@ async def delete_messages( async def send_media_group( self, media: Sequence[ - Union["InputMediaAudio", "InputMediaDocument", "InputMediaPhoto", "InputMediaVideo"] + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - caption: Optional[str] = None, + api_kwargs: JSONDict | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, ) -> tuple["Message", ...]: """Shortcut for:: @@ -1177,19 +1240,20 @@ async def send_media_group( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, ) async def send_chat_action( self, action: str, - message_thread_id: Optional[int] = None, - business_connection_id: Optional[str] = None, + message_thread_id: int | None = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1218,29 +1282,31 @@ async def send_chat_action( async def send_photo( self, - photo: Union[FileInput, "PhotoSize"], - caption: Optional[str] = None, + photo: "FileInput | PhotoSize", + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1276,31 +1342,35 @@ async def send_photo( message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_contact( self, - phone_number: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, + phone_number: str | None = None, + first_name: str | None = None, + last_name: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - vcard: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + vcard: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - contact: Optional["Contact"] = None, + contact: "Contact | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1334,35 +1404,39 @@ async def send_contact( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_audio( self, - audio: Union[FileInput, "Audio"], - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - caption: Optional[str] = None, + audio: "FileInput | Audio", + duration: TimePeriod | None = None, + performer: str | None = None, + title: str | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1400,33 +1474,37 @@ async def send_audio( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_document( self, - document: Union[FileInput, "Document"], - caption: Optional[str] = None, + document: "FileInput | Document", + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_content_type_detection: Optional[bool] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + disable_content_type_detection: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1462,27 +1540,79 @@ async def send_document( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - async def send_dice( + async def send_checklist( self, + business_connection_id: str, + checklist: "InputChecklist", disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - emoji: Optional[str] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_checklist(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_checklist`. + + .. versionadded:: 22.3 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_checklist( + chat_id=self.id, + business_connection_id=business_connection_id, + checklist=checklist, + disable_notification=disable_notification, + protect_content=protect_content, + message_effect_id=message_effect_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + reply_to_message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def send_dice( + self, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + emoji: str | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1512,27 +1642,29 @@ async def send_dice( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_game( self, game_short_name: str, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1569,39 +1701,41 @@ async def send_invoice( title: str, description: str, payload: str, - provider_token: Optional[str], currency: str, prices: Sequence["LabeledPrice"], - start_parameter: Optional[str] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - is_flexible: Optional[bool] = None, + provider_token: str | None = None, + start_parameter: str | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + is_flexible: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - provider_data: Optional[Union[str, object]] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + provider_data: str | object | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1660,33 +1794,37 @@ async def send_invoice( reply_parameters=reply_parameters, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_location( self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, + latitude: float | None = None, + longitude: float | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - live_period: Optional[int] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + live_period: TimePeriod | None = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - location: Optional["Location"] = None, + location: "Location | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1722,37 +1860,41 @@ async def send_location( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_animation( self, - animation: Union[FileInput, "Animation"], - duration: Optional[int] = None, - width: Optional[int] = None, - height: Optional[int] = None, - caption: Optional[str] = None, + animation: "FileInput | Animation", + duration: TimePeriod | None = None, + width: int | None = None, + height: int | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "ReplyMarkup | None" = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1792,28 +1934,32 @@ async def send_animation( message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_sticker( self, - sticker: Union[FileInput, "Sticker"], + sticker: "FileInput | Sticker", disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - emoji: Optional[str] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + emoji: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1844,35 +1990,39 @@ async def send_sticker( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_venue( self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - title: Optional[str] = None, - address: Optional[str] = None, - foursquare_id: Optional[str] = None, + latitude: float | None = None, + longitude: float | None = None, + title: str | None = None, + address: str | None = None, + foursquare_id: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - venue: Optional["Venue"] = None, + venue: "Venue | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1910,38 +2060,44 @@ async def send_venue( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_video( self, - video: Union[FileInput, "Video"], - duration: Optional[int] = None, - caption: Optional[str] = None, + video: "FileInput | Video", + duration: TimePeriod | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - width: Optional[int] = None, - height: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + width: int | None = None, + height: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - supports_streaming: Optional[bool] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + supports_streaming: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1971,6 +2127,8 @@ async def send_video( parse_mode=parse_mode, supports_streaming=supports_streaming, thumbnail=thumbnail, + cover=cover, + start_timestamp=start_timestamp, api_kwargs=api_kwargs, allow_sending_without_reply=allow_sending_without_reply, caption_entities=caption_entities, @@ -1982,31 +2140,35 @@ async def send_video( message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_video_note( self, - video_note: Union[FileInput, "VideoNote"], - duration: Optional[int] = None, - length: Optional[int] = None, + video_note: "FileInput | VideoNote", + duration: TimePeriod | None = None, + length: int | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2040,32 +2202,36 @@ async def send_video_note( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_voice( self, - voice: Union[FileInput, "Voice"], - duration: Optional[int] = None, - caption: Optional[str] = None, + voice: "FileInput | Voice", + duration: TimePeriod | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2100,40 +2266,42 @@ async def send_voice( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_poll( self, question: str, - options: Sequence[Union[str, "InputPollOption"]], - is_anonymous: Optional[bool] = None, - type: Optional[str] = None, - allows_multiple_answers: Optional[bool] = None, - correct_option_id: Optional[CorrectOptionID] = None, - is_closed: Optional[bool] = None, + options: Sequence["str | InputPollOption"], + is_anonymous: bool | None = None, + type: str | None = None, + allows_multiple_answers: bool | None = None, + correct_option_id: CorrectOptionID | None = None, + is_closed: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - explanation: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + explanation: str | None = None, explanation_parse_mode: ODVInput[str] = DEFAULT_NONE, - open_period: Optional[int] = None, - close_date: Optional[Union[int, dtm.datetime]] = None, - explanation_entities: Optional[Sequence["MessageEntity"]] = None, + open_period: TimePeriod | None = None, + close_date: int | dtm.datetime | None = None, + explanation_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, question_parse_mode: ODVInput[str] = DEFAULT_NONE, - question_entities: Optional[Sequence["MessageEntity"]] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + question_entities: Sequence["MessageEntity"] | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2180,26 +2348,30 @@ async def send_poll( async def send_copy( self, - from_chat_id: Union[str, int], + from_chat_id: str | int, message_id: int, - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - show_caption_above_media: Optional[bool] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "MessageId": """Shortcut for:: @@ -2218,6 +2390,7 @@ async def send_copy( from_chat_id=from_chat_id, message_id=message_id, caption=caption, + video_start_timestamp=video_start_timestamp, parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, @@ -2234,30 +2407,37 @@ async def send_copy( message_thread_id=message_thread_id, show_caption_above_media=show_caption_above_media, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) async def copy_message( self, - chat_id: Union[int, str], + chat_id: int | str, message_id: int, - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - show_caption_above_media: Optional[bool] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "MessageId": """Shortcut for:: @@ -2276,6 +2456,7 @@ async def copy_message( chat_id=chat_id, message_id=message_id, caption=caption, + video_start_timestamp=video_start_timestamp, parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, @@ -2292,22 +2473,26 @@ async def copy_message( message_thread_id=message_thread_id, show_caption_above_media=show_caption_above_media, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) async def send_copies( self, - from_chat_id: Union[str, int], + from_chat_id: str | int, message_ids: Sequence[int], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - remove_caption: Optional[bool] = None, + message_thread_id: int | None = None, + remove_caption: bool | None = None, + direct_messages_topic_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple["MessageId", ...]: """Shortcut for:: @@ -2337,22 +2522,24 @@ async def send_copies( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, ) async def copy_messages( self, - chat_id: Union[str, int], + chat_id: str | int, message_ids: Sequence[int], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - remove_caption: Optional[bool] = None, + message_thread_id: int | None = None, + remove_caption: bool | None = None, + direct_messages_topic_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple["MessageId", ...]: """Shortcut for:: @@ -2382,21 +2569,26 @@ async def copy_messages( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, ) async def forward_from( self, - from_chat_id: Union[str, int], + from_chat_id: str | int, message_id: int, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2416,6 +2608,7 @@ async def forward_from( chat_id=self.id, from_chat_id=from_chat_id, message_id=message_id, + video_start_timestamp=video_start_timestamp, disable_notification=disable_notification, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2424,21 +2617,28 @@ async def forward_from( api_kwargs=api_kwargs, protect_content=protect_content, message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) async def forward_to( self, - chat_id: Union[int, str], + chat_id: int | str, message_id: int, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2459,6 +2659,7 @@ async def forward_to( from_chat_id=self.id, chat_id=chat_id, message_id=message_id, + video_start_timestamp=video_start_timestamp, disable_notification=disable_notification, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2467,21 +2668,25 @@ async def forward_to( api_kwargs=api_kwargs, protect_content=protect_content, message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) async def forward_messages_from( self, - from_chat_id: Union[str, int], + from_chat_id: str | int, message_ids: Sequence[int], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple["MessageId", ...]: """Shortcut for:: @@ -2510,21 +2715,23 @@ async def forward_messages_from( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, ) async def forward_messages_to( self, - chat_id: Union[int, str], + chat_id: int | str, message_ids: Sequence[int], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple["MessageId", ...]: """Shortcut for:: @@ -2553,6 +2760,7 @@ async def forward_messages_to( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, ) async def export_invite_link( @@ -2562,7 +2770,7 @@ async def export_invite_link( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> str: """Shortcut for:: @@ -2588,16 +2796,16 @@ async def export_invite_link( async def create_invite_link( self, - expire_date: Optional[Union[int, dtm.datetime]] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - creates_join_request: Optional[bool] = None, + expire_date: int | dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + creates_join_request: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "ChatInviteLink": """Shortcut for:: @@ -2631,17 +2839,17 @@ async def create_invite_link( async def edit_invite_link( self, - invite_link: Union[str, "ChatInviteLink"], - expire_date: Optional[Union[int, dtm.datetime]] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - creates_join_request: Optional[bool] = None, + invite_link: "str | ChatInviteLink", + expire_date: int | dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + creates_join_request: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "ChatInviteLink": """Shortcut for:: @@ -2675,13 +2883,13 @@ async def edit_invite_link( async def revoke_invite_link( self, - invite_link: Union[str, "ChatInviteLink"], + invite_link: "str | ChatInviteLink", *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "ChatInviteLink": """Shortcut for:: @@ -2708,15 +2916,15 @@ async def revoke_invite_link( async def create_subscription_invite_link( self, - subscription_period: int, + subscription_period: TimePeriod, subscription_price: int, - name: Optional[str] = None, + name: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "ChatInviteLink": """Shortcut for:: @@ -2746,14 +2954,14 @@ async def create_subscription_invite_link( async def edit_subscription_invite_link( self, - invite_link: Union[str, "ChatInviteLink"], - name: Optional[str] = None, + invite_link: "str | ChatInviteLink", + name: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "ChatInviteLink": """Shortcut for:: @@ -2789,7 +2997,7 @@ async def approve_join_request( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2822,7 +3030,7 @@ async def decline_join_request( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2849,13 +3057,13 @@ async def decline_join_request( async def set_menu_button( self, - menu_button: Optional[MenuButton] = None, + menu_button: MenuButton | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2887,14 +3095,14 @@ async def set_menu_button( async def create_forum_topic( self, name: str, - icon_color: Optional[int] = None, - icon_custom_emoji_id: Optional[str] = None, + icon_color: int | None = None, + icon_custom_emoji_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> ForumTopic: """Shortcut for:: @@ -2923,14 +3131,14 @@ async def create_forum_topic( async def edit_forum_topic( self, message_thread_id: int, - name: Optional[str] = None, - icon_custom_emoji_id: Optional[str] = None, + name: str | None = None, + icon_custom_emoji_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2964,7 +3172,7 @@ async def close_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2996,7 +3204,7 @@ async def reopen_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3028,7 +3236,7 @@ async def delete_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3060,7 +3268,7 @@ async def unpin_all_forum_topic_messages( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3092,7 +3300,7 @@ async def unpin_all_general_forum_topic_messages( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3124,7 +3332,7 @@ async def edit_general_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3157,7 +3365,7 @@ async def close_general_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3187,7 +3395,7 @@ async def reopen_general_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3219,7 +3427,7 @@ async def hide_general_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3249,7 +3457,7 @@ async def unhide_general_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3281,7 +3489,7 @@ async def get_menu_button( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> MenuButton: """Shortcut for:: @@ -3317,7 +3525,7 @@ async def get_user_chat_boosts( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "UserChatBoosts": """Shortcut for:: @@ -3344,14 +3552,14 @@ async def get_user_chat_boosts( async def set_message_reaction( self, message_id: int, - reaction: Optional[Union[Sequence[Union[ReactionType, str]], ReactionType, str]] = None, - is_big: Optional[bool] = None, + reaction: Sequence[ReactionType | str] | ReactionType | str | None = None, + is_big: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3381,25 +3589,28 @@ async def send_paid_media( self, star_count: int, media: Sequence["InputPaidMedia"], - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - reply_markup: Optional[ReplyMarkup] = None, - business_connection_id: Optional[str] = None, - payload: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + business_connection_id: str | None = None, + payload: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_thread_id: int | None = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -3435,38 +3646,48 @@ async def send_paid_media( business_connection_id=business_connection_id, payload=payload, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_thread_id=message_thread_id, ) async def send_gift( self, - gift_id: Union[str, "Gift"], - text: Optional[str] = None, + gift_id: "str | Gift", + text: str | None = None, text_parse_mode: ODVInput[str] = DEFAULT_NONE, - text_entities: Optional[Sequence["MessageEntity"]] = None, - pay_for_upgrade: Optional[bool] = None, + text_entities: Sequence["MessageEntity"] | None = None, + pay_for_upgrade: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: await bot.send_gift(user_id=update.effective_chat.id, *args, **kwargs ) + or:: + + await bot.send_gift(chat_id=update.effective_chat.id, *args, **kwargs ) + For the documentation of the arguments, please see :meth:`telegram.Bot.send_gift`. Caution: - Can only work, if the chat is a private chat, see :attr:`type`. + Will only work if the chat is a private or channel chat, see :attr:`type`. .. versionadded:: 21.8 + .. versionchanged:: 21.11 + + Added support for channel chats. + Returns: :obj:`bool`: On success, :obj:`True` is returned. """ return await self.get_bot().send_gift( - user_id=self.id, gift_id=gift_id, text=text, text_parse_mode=text_parse_mode, @@ -3477,17 +3698,53 @@ async def send_gift( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + **{"chat_id" if self.type == Chat.CHANNEL else "user_id": self.id}, + ) + + async def transfer_gift( + self, + business_connection_id: str, + owned_gift_id: str, + star_count: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.transfer_gift(new_owner_chat_id=update.effective_chat.id, *args, **kwargs ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.transfer_gift`. + + .. versionadded:: 22.1 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().transfer_gift( + new_owner_chat_id=self.id, + business_connection_id=business_connection_id, + owned_gift_id=owned_gift_id, + star_count=star_count, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, ) async def verify( self, - custom_description: Optional[str] = None, + custom_description: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3518,7 +3775,7 @@ async def remove_verification( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3541,6 +3798,203 @@ async def remove_verification( api_kwargs=api_kwargs, ) + async def read_business_message( + self, + business_connection_id: str, + message_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.read_business_message(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.read_business_message`. + + .. versionadded:: 22.1 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().read_business_message( + chat_id=self.id, + business_connection_id=business_connection_id, + message_id=message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def approve_suggested_post( + self, + message_id: int, + send_date: int | dtm.datetime | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Shortcut for:: + + await bot.approve_suggested_post(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.approve_suggested_post`. + + .. versionadded:: 22.4 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().approve_suggested_post( + chat_id=self.id, + message_id=message_id, + send_date=send_date, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def decline_suggested_post( + self, + message_id: int, + comment: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Shortcut for:: + + await bot.decline_suggested_post(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.decline_suggested_post`. + + .. versionadded:: 22.4 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().decline_suggested_post( + chat_id=self.id, + message_id=message_id, + comment=comment, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def repost_story( + self, + business_connection_id: str, + from_story_id: int, + active_period: TimePeriod, + post_to_chat_page: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Story": + """Shortcut for:: + + await bot.repost_story( + from_chat_id=update.effective_chat.id, + *args, **kwargs + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.repost_story`. + + .. versionadded:: 22.6 + + Returns: + :class:`Story`: On success, :class:`Story` is returned. + + """ + return await self.get_bot().repost_story( + business_connection_id=business_connection_id, + from_chat_id=self.id, + from_story_id=from_story_id, + active_period=active_period, + post_to_chat_page=post_to_chat_page, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_gifts( + self, + exclude_unsaved: bool | None = None, + exclude_saved: bool | None = None, + exclude_unlimited: bool | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "OwnedGifts": + """Shortcut for:: + + await bot.get_chat_gifts(chat_id=update.effective_chat.id) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_chat_gifts`. + + .. versionadded:: 22.6 + + Returns: + :class:`telegram.OwnedGifts`: On success, returns the gifts owned by the chat. + """ + return await self.get_bot().get_chat_gifts( + chat_id=self.id, + exclude_unsaved=exclude_unsaved, + exclude_saved=exclude_saved, + exclude_unlimited=exclude_unlimited, + exclude_limited_upgradable=exclude_limited_upgradable, + exclude_limited_non_upgradable=exclude_limited_non_upgradable, + exclude_from_blockchain=exclude_from_blockchain, + exclude_unique=exclude_unique, + sort_by_price=sort_by_price, + offset=offset, + limit=limit, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + class Chat(_ChatBase): """This object represents a chat. @@ -3578,6 +4032,10 @@ class Chat(_ChatBase): (has topics_ enabled). .. versionadded:: 20.0 + is_direct_messages (:obj:`bool`, optional): :obj:`True`, if the chat is the direct messages + chat of a channel. + + .. versionadded:: 22.4 Attributes: id (:obj:`int`): Unique identifier for this chat. @@ -3592,6 +4050,10 @@ class Chat(_ChatBase): (has topics_ enabled). .. versionadded:: 20.0 + is_direct_messages (:obj:`bool`): Optional. :obj:`True`, if the chat is the direct messages + chat of a channel. + + .. versionadded:: 22.4 .. _topics: https://telegram.org/blog/topics-in-groups-collectible-usernames#topics-in-groups """ diff --git a/telegram/_chatadministratorrights.py b/src/telegram/_chatadministratorrights.py similarity index 88% rename from telegram/_chatadministratorrights.py rename to src/telegram/_chatadministratorrights.py index 6b6c43715eb..31fbda4b721 100644 --- a/telegram/_chatadministratorrights.py +++ b/src/telegram/_chatadministratorrights.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the class which represents a Telegram ChatAdministratorRights.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -31,8 +30,8 @@ class ChatAdministratorRights(TelegramObject): :attr:`can_delete_messages`, :attr:`can_manage_video_chats`, :attr:`can_restrict_members`, :attr:`can_promote_members`, :attr:`can_change_info`, :attr:`can_invite_users`, :attr:`can_post_messages`, :attr:`can_edit_messages`, :attr:`can_pin_messages`, - :attr:`can_manage_topics`, :attr:`can_post_stories`, :attr:`can_delete_stories`, and - :attr:`can_edit_stories` are equal. + :attr:`can_manage_topics`, :attr:`can_post_stories`, :attr:`can_delete_stories`, + :attr:`can_edit_stories` and :attr:`can_manage_direct_messages` are equal. .. versionadded:: 20.0 @@ -49,6 +48,10 @@ class ChatAdministratorRights(TelegramObject): and :attr:`can_delete_stories` is now required. Thus, the order of arguments had to be changed. + .. versionchanged:: 22.4 + :attr:`can_manage_direct_messages` is considered as well when comparing objects of + this type in terms of equality. + Args: is_anonymous (:obj:`bool`): :obj:`True`, if the user's presence in the chat is hidden. can_manage_chat (:obj:`bool`): :obj:`True`, if the administrator can access the chat event @@ -97,6 +100,10 @@ class ChatAdministratorRights(TelegramObject): to create, rename, close, and reopen forum topics; for supergroups only. .. versionadded:: 20.0 + can_manage_direct_messages (:obj:`bool`, optional): :obj:`True`, if the administrator can + manage direct messages of the channel and decline suggested posts; for channels only. + + .. versionadded:: 22.4 Attributes: is_anonymous (:obj:`bool`): :obj:`True`, if the user's presence in the chat is hidden. @@ -146,6 +153,10 @@ class ChatAdministratorRights(TelegramObject): to create, rename, close, and reopen forum topics; for supergroups only. .. versionadded:: 20.0 + can_manage_direct_messages (:obj:`bool`): Optional. :obj:`True`, if the administrator can + manage direct messages of the channel and decline suggested posts; for channels only. + + .. versionadded:: 22.4 """ __slots__ = ( @@ -156,6 +167,7 @@ class ChatAdministratorRights(TelegramObject): "can_edit_stories", "can_invite_users", "can_manage_chat", + "can_manage_direct_messages", "can_manage_topics", "can_manage_video_chats", "can_pin_messages", @@ -179,12 +191,13 @@ def __init__( can_post_stories: bool, can_edit_stories: bool, can_delete_stories: bool, - can_post_messages: Optional[bool] = None, - can_edit_messages: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - can_manage_topics: Optional[bool] = None, + can_post_messages: bool | None = None, + can_edit_messages: bool | None = None, + can_pin_messages: bool | None = None, + can_manage_topics: bool | None = None, + can_manage_direct_messages: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) # Required @@ -200,10 +213,11 @@ def __init__( self.can_edit_stories: bool = can_edit_stories self.can_delete_stories: bool = can_delete_stories # Optionals - self.can_post_messages: Optional[bool] = can_post_messages - self.can_edit_messages: Optional[bool] = can_edit_messages - self.can_pin_messages: Optional[bool] = can_pin_messages - self.can_manage_topics: Optional[bool] = can_manage_topics + self.can_post_messages: bool | None = can_post_messages + self.can_edit_messages: bool | None = can_edit_messages + self.can_pin_messages: bool | None = can_pin_messages + self.can_manage_topics: bool | None = can_manage_topics + self.can_manage_direct_messages: bool | None = can_manage_direct_messages self._id_attrs = ( self.is_anonymous, @@ -221,6 +235,7 @@ def __init__( self.can_post_stories, self.can_edit_stories, self.can_delete_stories, + self.can_manage_direct_messages, ) self._freeze() diff --git a/telegram/_chatbackground.py b/src/telegram/_chatbackground.py similarity index 90% rename from telegram/_chatbackground.py rename to src/telegram/_chatbackground.py index 4d41e55ef64..5b71259d58e 100644 --- a/telegram/_chatbackground.py +++ b/src/telegram/_chatbackground.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,15 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains objects related to chat backgrounds.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Final, Optional +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._files.document import Document from telegram._telegramobject import TelegramObject from telegram._utils import enum -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, parse_sequence_arg from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -69,7 +70,7 @@ def __init__( self, type: str, # pylint: disable=redefined-builtin *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required by all subclasses @@ -79,15 +80,10 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["BackgroundFill"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BackgroundFill": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - _class_mapping: dict[str, type[BackgroundFill]] = { cls.SOLID: BackgroundFillSolid, cls.GRADIENT: BackgroundFillGradient, @@ -124,7 +120,7 @@ def __init__( self, color: int, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(type=self.SOLID, api_kwargs=api_kwargs) @@ -170,7 +166,7 @@ def __init__( bottom_color: int, rotation_angle: int, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(type=self.GRADIENT, api_kwargs=api_kwargs) @@ -208,7 +204,7 @@ def __init__( self, colors: Sequence[int], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(type=self.FREEFORM_GRADIENT, api_kwargs=api_kwargs) @@ -260,7 +256,7 @@ def __init__( self, type: str, # pylint: disable=redefined-builtin *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required by all subclasses @@ -270,15 +266,10 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["BackgroundType"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BackgroundType": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - _class_mapping: dict[str, type[BackgroundType]] = { cls.FILL: BackgroundTypeFill, cls.WALLPAPER: BackgroundTypeWallpaper, @@ -290,10 +281,10 @@ def de_json( return _class_mapping[data.pop("type")].de_json(data=data, bot=bot) if "fill" in data: - data["fill"] = BackgroundFill.de_json(data.get("fill"), bot) + data["fill"] = de_json_optional(data.get("fill"), BackgroundFill, bot) if "document" in data: - data["document"] = Document.de_json(data.get("document"), bot) + data["document"] = de_json_optional(data.get("document"), Document, bot) return super().de_json(data=data, bot=bot) @@ -329,7 +320,7 @@ def __init__( fill: BackgroundFill, dark_theme_dimming: int, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(type=self.FILL, api_kwargs=api_kwargs) @@ -378,10 +369,10 @@ def __init__( self, document: Document, dark_theme_dimming: int, - is_blurred: Optional[bool] = None, - is_moving: Optional[bool] = None, + is_blurred: bool | None = None, + is_moving: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(type=self.WALLPAPER, api_kwargs=api_kwargs) @@ -390,16 +381,16 @@ def __init__( self.document: Document = document self.dark_theme_dimming: int = dark_theme_dimming # Optionals - self.is_blurred: Optional[bool] = is_blurred - self.is_moving: Optional[bool] = is_moving + self.is_blurred: bool | None = is_blurred + self.is_moving: bool | None = is_moving self._id_attrs = (self.document, self.dark_theme_dimming) class BackgroundTypePattern(BackgroundType): """ - The background is a `PNG` or `TGV` (gzipped subset of `SVG` with `MIME` type - `"application/x-tgwallpattern"`) pattern to be combined with the background fill + The background is a ``.PNG`` or ``.TGV`` (gzipped subset of ``SVG`` with ``MIME`` type + ``"application/x-tgwallpattern"``) pattern to be combined with the background fill chosen by the user. Objects of this class are comparable in terms of equality. Two objects of this class are @@ -449,10 +440,10 @@ def __init__( document: Document, fill: BackgroundFill, intensity: int, - is_inverted: Optional[bool] = None, - is_moving: Optional[bool] = None, + is_inverted: bool | None = None, + is_moving: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(type=self.PATTERN, api_kwargs=api_kwargs) @@ -462,8 +453,8 @@ def __init__( self.fill: BackgroundFill = fill self.intensity: int = intensity # Optionals - self.is_inverted: Optional[bool] = is_inverted - self.is_moving: Optional[bool] = is_moving + self.is_inverted: bool | None = is_inverted + self.is_moving: bool | None = is_moving self._id_attrs = (self.document, self.fill, self.intensity) @@ -492,7 +483,7 @@ def __init__( self, theme_name: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(type=self.CHAT_THEME, api_kwargs=api_kwargs) @@ -524,7 +515,7 @@ def __init__( self, type: BackgroundType, # pylint: disable=redefined-builtin *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.type: BackgroundType = type @@ -533,15 +524,10 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChatBackground"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatBackground": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["type"] = BackgroundType.de_json(data.get("type"), bot) + data["type"] = de_json_optional(data.get("type"), BackgroundType, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_chatboost.py b/src/telegram/_chatboost.py similarity index 84% rename from telegram/_chatboost.py rename to src/telegram/_chatboost.py index 6f45a275677..2386f2e6a5c 100644 --- a/telegram/_chatboost.py +++ b/src/telegram/_chatboost.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,16 +17,17 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram ChatBoosts.""" + import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Final, Optional +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._chat import Chat from telegram._telegramobject import TelegramObject from telegram._user import User from telegram._utils import enum -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict @@ -58,7 +59,7 @@ def __init__( self, boost_count: int, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) self.boost_count: int = boost_count @@ -100,7 +101,7 @@ class ChatBoostSource(TelegramObject): GIVEAWAY: Final[str] = constants.ChatBoostSources.GIVEAWAY """:const:`telegram.constants.ChatBoostSources.GIVEAWAY`""" - def __init__(self, source: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, source: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) # Required by all subclasses: @@ -110,15 +111,10 @@ def __init__(self, source: str, *, api_kwargs: Optional[JSONDict] = None): self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChatBoostSource"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatBoostSource": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - _class_mapping: dict[str, type[ChatBoostSource]] = { cls.PREMIUM: ChatBoostSourcePremium, cls.GIFT_CODE: ChatBoostSourceGiftCode, @@ -129,7 +125,7 @@ def de_json( return _class_mapping[data.pop("source")].de_json(data=data, bot=bot) if "user" in data: - data["user"] = User.de_json(data.get("user"), bot) + data["user"] = de_json_optional(data.get("user"), User, bot) return super().de_json(data=data, bot=bot) @@ -152,7 +148,7 @@ class ChatBoostSourcePremium(ChatBoostSource): __slots__ = ("user",) - def __init__(self, user: User, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, user: User, *, api_kwargs: JSONDict | None = None): super().__init__(source=self.PREMIUM, api_kwargs=api_kwargs) with self._unfrozen(): @@ -178,7 +174,7 @@ class ChatBoostSourceGiftCode(ChatBoostSource): __slots__ = ("user",) - def __init__(self, user: User, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, user: User, *, api_kwargs: JSONDict | None = None): super().__init__(source=self.GIFT_CODE, api_kwargs=api_kwargs) with self._unfrozen(): @@ -225,19 +221,19 @@ class ChatBoostSourceGiveaway(ChatBoostSource): def __init__( self, giveaway_message_id: int, - user: Optional[User] = None, - is_unclaimed: Optional[bool] = None, - prize_star_count: Optional[int] = None, + user: User | None = None, + is_unclaimed: bool | None = None, + prize_star_count: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(source=self.GIVEAWAY, api_kwargs=api_kwargs) with self._unfrozen(): self.giveaway_message_id: int = giveaway_message_id - self.user: Optional[User] = user - self.prize_star_count: Optional[int] = prize_star_count - self.is_unclaimed: Optional[bool] = is_unclaimed + self.user: User | None = user + self.prize_star_count: int | None = prize_star_count + self.is_unclaimed: bool | None = is_unclaimed class ChatBoost(TelegramObject): @@ -277,7 +273,7 @@ def __init__( expiration_date: dtm.datetime, source: ChatBoostSource, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -290,19 +286,14 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChatBoost"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatBoost": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["source"] = ChatBoostSource.de_json(data.get("source"), bot) + data["source"] = de_json_optional(data.get("source"), ChatBoostSource, bot) loc_tzinfo = extract_tzinfo_from_defaults(bot) - data["add_date"] = from_timestamp(data["add_date"], tzinfo=loc_tzinfo) - data["expiration_date"] = from_timestamp(data["expiration_date"], tzinfo=loc_tzinfo) + data["add_date"] = from_timestamp(data.get("add_date"), tzinfo=loc_tzinfo) + data["expiration_date"] = from_timestamp(data.get("expiration_date"), tzinfo=loc_tzinfo) return super().de_json(data=data, bot=bot) @@ -331,7 +322,7 @@ def __init__( chat: Chat, boost: ChatBoost, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -342,17 +333,12 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChatBoostUpdated"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatBoostUpdated": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["chat"] = Chat.de_json(data.get("chat"), bot) - data["boost"] = ChatBoost.de_json(data.get("boost"), bot) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["boost"] = de_json_optional(data.get("boost"), ChatBoost, bot) return super().de_json(data=data, bot=bot) @@ -388,7 +374,7 @@ def __init__( remove_date: dtm.datetime, source: ChatBoostSource, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -401,19 +387,14 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChatBoostRemoved"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatBoostRemoved": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["chat"] = Chat.de_json(data.get("chat"), bot) - data["source"] = ChatBoostSource.de_json(data.get("source"), bot) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["source"] = de_json_optional(data.get("source"), ChatBoostSource, bot) loc_tzinfo = extract_tzinfo_from_defaults(bot) - data["remove_date"] = from_timestamp(data["remove_date"], tzinfo=loc_tzinfo) + data["remove_date"] = from_timestamp(data.get("remove_date"), tzinfo=loc_tzinfo) return super().de_json(data=data, bot=bot) @@ -440,7 +421,7 @@ def __init__( self, boosts: Sequence[ChatBoost], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -450,15 +431,10 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["UserChatBoosts"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UserChatBoosts": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["boosts"] = ChatBoost.de_list(data.get("boosts"), bot) + data["boosts"] = de_list_optional(data.get("boosts"), ChatBoost, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_chatfullinfo.py b/src/telegram/_chatfullinfo.py similarity index 63% rename from telegram/_chatfullinfo.py rename to src/telegram/_chatfullinfo.py index ebf8e8e249d..2225ab3cd47 100644 --- a/telegram/_chatfullinfo.py +++ b/src/telegram/_chatfullinfo.py @@ -2,7 +2,7 @@ # pylint: disable=redefined-builtin # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,19 +18,32 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ChatFullInfo.""" + import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._birthdate import Birthdate from telegram._chat import Chat, _ChatBase from telegram._chatlocation import ChatLocation from telegram._chatpermissions import ChatPermissions from telegram._files.chatphoto import ChatPhoto +from telegram._gifts import AcceptedGiftTypes from telegram._reaction import ReactionType -from telegram._utils.argumentparsing import parse_sequence_arg -from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp -from telegram._utils.types import JSONDict +from telegram._uniquegift import UniqueGiftColors +from telegram._userrating import UserRating +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_sequence_arg, + to_timedelta, +) +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_timedelta_value, +) +from telegram._utils.types import JSONDict, TimePeriod if TYPE_CHECKING: from telegram import Bot, BusinessIntro, BusinessLocation, BusinessOpeningHours, Message @@ -50,6 +63,9 @@ class ChatFullInfo(_ChatBase): object. Previously those were only available because this class inherited from :class:`telegram.Chat`. + .. versionremoved:: 22.3 + Removed argument and attribute ``can_send_gift`` deprecated by API 9.0. + Args: id (:obj:`int`): Unique identifier for this chat. type (:obj:`str`): Type of chat, can be either :attr:`PRIVATE`, :attr:`GROUP`, @@ -64,6 +80,10 @@ class ChatFullInfo(_ChatBase): message in the chat. .. versionadded:: 21.2 + accepted_gift_types (:class:`telegram.AcceptedGiftTypes`): Information about types of + gifts that are accepted by the chat or by the corresponding user for private chats. + + .. versionadded:: 22.1 title (:obj:`str`, optional): Title, for supergroups, channels and group chats. username (:obj:`str`, optional): Username, for private chats, supergroups and channels if available. @@ -155,17 +175,23 @@ class ChatFullInfo(_ChatBase): (by sending date). permissions (:class:`telegram.ChatPermissions`): Optional. Default chat member permissions, for groups and supergroups. - slow_mode_delay (:obj:`int`, optional): For supergroups, the minimum allowed delay between - consecutive messages sent by each unprivileged user. + slow_mode_delay (:obj:`int` | :class:`datetime.timedelta`, optional): For supergroups, + the minimum allowed delay between consecutive messages sent by each unprivileged user. + + .. versionchanged:: v22.2 + |time-period-input| unrestrict_boost_count (:obj:`int`, optional): For supergroups, the minimum number of boosts that a non-administrator user needs to add in order to ignore slow mode and chat permissions. .. versionadded:: 21.0 - message_auto_delete_time (:obj:`int`, optional): The time after which all messages sent to - the chat will be automatically deleted; in seconds. + message_auto_delete_time (:obj:`int` | :class:`datetime.timedelta`, optional): The time + after which all messages sent to the chat will be automatically deleted; in seconds. .. versionadded:: 13.4 + + .. versionchanged:: v22.2 + |time-period-input| has_aggressive_anti_spam_enabled (:obj:`bool`, optional): :obj:`True`, if aggressive anti-spam checks are enabled in the supergroup. The field is only available to chat administrators. @@ -200,6 +226,27 @@ class ChatFullInfo(_ChatBase): sent or forwarded to the channel chat. The field is available only for channel chats. .. versionadded:: 21.4 + is_direct_messages (:obj:`bool`, optional): :obj:`True`, if the chat is the direct messages + chat of a channel. + + .. versionadded:: 22.4 + parent_chat (:obj:`telegram.Chat`, optional): Information about the corresponding channel + chat; for direct messages chats only. + + .. versionadded:: 22.4 + rating (:class:`telegram.UserRating`, optional): For private chats, the rating of the user + if any. + + .. versionadded:: 22.6 + unique_gift_colors (:class:`telegram.UniqueGiftColors`, optional): The color scheme based + on a unique gift that must be used for the chat's name, message replies and link + previews + + .. versionadded:: 22.6 + paid_message_star_count (:obj:`int`, optional): The number of Telegram Stars a general user + have to pay to send a message to the chat + + .. versionadded:: 22.6 Attributes: id (:obj:`int`): Unique identifier for this chat. @@ -215,6 +262,10 @@ class ChatFullInfo(_ChatBase): message in the chat. .. versionadded:: 21.2 + accepted_gift_types (:class:`telegram.AcceptedGiftTypes`): Information about types of + gifts that are accepted by the chat or by the corresponding user for private chats. + + .. versionadded:: 22.1 title (:obj:`str`, optional): Title, for supergroups, channels and group chats. username (:obj:`str`, optional): Username, for private chats, supergroups and channels if available. @@ -309,17 +360,23 @@ class ChatFullInfo(_ChatBase): (by sending date). permissions (:class:`telegram.ChatPermissions`): Optional. Default chat member permissions, for groups and supergroups. - slow_mode_delay (:obj:`int`): Optional. For supergroups, the minimum allowed delay between - consecutive messages sent by each unprivileged user. + slow_mode_delay (:obj:`int` | :class:`datetime.timedelta`): Optional. For supergroups, + the minimum allowed delay between consecutive messages sent by each unprivileged user. + + .. deprecated:: v22.2 + |time-period-int-deprecated| unrestrict_boost_count (:obj:`int`): Optional. For supergroups, the minimum number of boosts that a non-administrator user needs to add in order to ignore slow mode and chat permissions. .. versionadded:: 21.0 - message_auto_delete_time (:obj:`int`): Optional. The time after which all messages sent to - the chat will be automatically deleted; in seconds. + message_auto_delete_time (:obj:`int` | :class:`datetime.timedelta`): Optional. The time + after which all messages sent to the chat will be automatically deleted; in seconds. .. versionadded:: 13.4 + + .. deprecated:: v22.2 + |time-period-int-deprecated| has_aggressive_anti_spam_enabled (:obj:`bool`): Optional. :obj:`True`, if aggressive anti-spam checks are enabled in the supergroup. The field is only available to chat administrators. @@ -354,13 +411,37 @@ class ChatFullInfo(_ChatBase): sent or forwarded to the channel chat. The field is available only for channel chats. .. versionadded:: 21.4 + is_direct_messages (:obj:`bool`): Optional. :obj:`True`, if the chat is the direct messages + chat of a channel. + + .. versionadded:: 22.4 + parent_chat (:obj:`telegram.Chat`): Optional. Information about the corresponding channel + chat; for direct messages chats only. + + .. versionadded:: 22.4 + rating (:class:`telegram.UserRating`): Optional. For private chats, the rating of the user + if any. + + .. versionadded:: 22.6 + unique_gift_colors (:class:`telegram.UniqueGiftColors`): Optional. The color scheme based + on a unique gift that must be used for the chat's name, message replies and link + previews + + .. versionadded:: 22.6 + paid_message_star_count (:obj:`int`): Optional. The number of Telegram Stars a general user + have to pay to send a message to the chat + + .. versionadded:: 22.6 .. _accent colors: https://core.telegram.org/bots/api#accent-colors .. _topics: https://telegram.org/blog/topics-in-groups-collectible-usernames#topics-in-groups """ __slots__ = ( + "_message_auto_delete_time", + "_slow_mode_delay", "accent_color_id", + "accepted_gift_types", "active_usernames", "available_reactions", "background_custom_emoji_id", @@ -387,15 +468,17 @@ class ChatFullInfo(_ChatBase): "linked_chat_id", "location", "max_reaction_count", - "message_auto_delete_time", + "paid_message_star_count", + "parent_chat", "permissions", "personal_chat", "photo", "pinned_message", "profile_accent_color_id", "profile_background_custom_emoji_id", - "slow_mode_delay", + "rating", "sticker_set_name", + "unique_gift_colors", "unrestrict_boost_count", ) @@ -405,48 +488,54 @@ def __init__( type: str, accent_color_id: int, max_reaction_count: int, - title: Optional[str] = None, - username: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - is_forum: Optional[bool] = None, - photo: Optional[ChatPhoto] = None, - active_usernames: Optional[Sequence[str]] = None, - birthdate: Optional[Birthdate] = None, - business_intro: Optional["BusinessIntro"] = None, - business_location: Optional["BusinessLocation"] = None, - business_opening_hours: Optional["BusinessOpeningHours"] = None, - personal_chat: Optional["Chat"] = None, - available_reactions: Optional[Sequence[ReactionType]] = None, - background_custom_emoji_id: Optional[str] = None, - profile_accent_color_id: Optional[int] = None, - profile_background_custom_emoji_id: Optional[str] = None, - emoji_status_custom_emoji_id: Optional[str] = None, - emoji_status_expiration_date: Optional[dtm.datetime] = None, - bio: Optional[str] = None, - has_private_forwards: Optional[bool] = None, - has_restricted_voice_and_video_messages: Optional[bool] = None, - join_to_send_messages: Optional[bool] = None, - join_by_request: Optional[bool] = None, - description: Optional[str] = None, - invite_link: Optional[str] = None, - pinned_message: Optional["Message"] = None, - permissions: Optional[ChatPermissions] = None, - slow_mode_delay: Optional[int] = None, - unrestrict_boost_count: Optional[int] = None, - message_auto_delete_time: Optional[int] = None, - has_aggressive_anti_spam_enabled: Optional[bool] = None, - has_hidden_members: Optional[bool] = None, - has_protected_content: Optional[bool] = None, - has_visible_history: Optional[bool] = None, - sticker_set_name: Optional[str] = None, - can_set_sticker_set: Optional[bool] = None, - custom_emoji_sticker_set_name: Optional[str] = None, - linked_chat_id: Optional[int] = None, - location: Optional[ChatLocation] = None, - can_send_paid_media: Optional[bool] = None, + accepted_gift_types: AcceptedGiftTypes, + title: str | None = None, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + is_forum: bool | None = None, + photo: ChatPhoto | None = None, + active_usernames: Sequence[str] | None = None, + birthdate: Birthdate | None = None, + business_intro: "BusinessIntro | None" = None, + business_location: "BusinessLocation | None" = None, + business_opening_hours: "BusinessOpeningHours | None" = None, + personal_chat: "Chat | None" = None, + available_reactions: Sequence[ReactionType] | None = None, + background_custom_emoji_id: str | None = None, + profile_accent_color_id: int | None = None, + profile_background_custom_emoji_id: str | None = None, + emoji_status_custom_emoji_id: str | None = None, + emoji_status_expiration_date: dtm.datetime | None = None, + bio: str | None = None, + has_private_forwards: bool | None = None, + has_restricted_voice_and_video_messages: bool | None = None, + join_to_send_messages: bool | None = None, + join_by_request: bool | None = None, + description: str | None = None, + invite_link: str | None = None, + pinned_message: "Message | None" = None, + permissions: ChatPermissions | None = None, + slow_mode_delay: TimePeriod | None = None, + unrestrict_boost_count: int | None = None, + message_auto_delete_time: TimePeriod | None = None, + has_aggressive_anti_spam_enabled: bool | None = None, + has_hidden_members: bool | None = None, + has_protected_content: bool | None = None, + has_visible_history: bool | None = None, + sticker_set_name: str | None = None, + can_set_sticker_set: bool | None = None, + custom_emoji_sticker_set_name: str | None = None, + linked_chat_id: int | None = None, + location: ChatLocation | None = None, + can_send_paid_media: bool | None = None, + is_direct_messages: bool | None = None, + parent_chat: Chat | None = None, + rating: UserRating | None = None, + unique_gift_colors: UniqueGiftColors | None = None, + paid_message_star_count: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( id=id, @@ -456,71 +545,77 @@ def __init__( first_name=first_name, last_name=last_name, is_forum=is_forum, + is_direct_messages=is_direct_messages, api_kwargs=api_kwargs, ) - # Required and unique to this class- with self._unfrozen(): self.max_reaction_count: int = max_reaction_count - self.photo: Optional[ChatPhoto] = photo - self.bio: Optional[str] = bio - self.has_private_forwards: Optional[bool] = has_private_forwards - self.description: Optional[str] = description - self.invite_link: Optional[str] = invite_link - self.pinned_message: Optional[Message] = pinned_message - self.permissions: Optional[ChatPermissions] = permissions - self.slow_mode_delay: Optional[int] = slow_mode_delay - self.message_auto_delete_time: Optional[int] = ( - int(message_auto_delete_time) if message_auto_delete_time is not None else None + self.photo: ChatPhoto | None = photo + self.bio: str | None = bio + self.has_private_forwards: bool | None = has_private_forwards + self.description: str | None = description + self.invite_link: str | None = invite_link + self.pinned_message: Message | None = pinned_message + self.permissions: ChatPermissions | None = permissions + self._slow_mode_delay: dtm.timedelta | None = to_timedelta(slow_mode_delay) + self._message_auto_delete_time: dtm.timedelta | None = to_timedelta( + message_auto_delete_time ) - self.has_protected_content: Optional[bool] = has_protected_content - self.has_visible_history: Optional[bool] = has_visible_history - self.sticker_set_name: Optional[str] = sticker_set_name - self.can_set_sticker_set: Optional[bool] = can_set_sticker_set - self.linked_chat_id: Optional[int] = linked_chat_id - self.location: Optional[ChatLocation] = location - self.join_to_send_messages: Optional[bool] = join_to_send_messages - self.join_by_request: Optional[bool] = join_by_request - self.has_restricted_voice_and_video_messages: Optional[bool] = ( + self.has_protected_content: bool | None = has_protected_content + self.has_visible_history: bool | None = has_visible_history + self.sticker_set_name: str | None = sticker_set_name + self.can_set_sticker_set: bool | None = can_set_sticker_set + self.linked_chat_id: int | None = linked_chat_id + self.location: ChatLocation | None = location + self.join_to_send_messages: bool | None = join_to_send_messages + self.join_by_request: bool | None = join_by_request + self.has_restricted_voice_and_video_messages: bool | None = ( has_restricted_voice_and_video_messages ) self.active_usernames: tuple[str, ...] = parse_sequence_arg(active_usernames) - self.emoji_status_custom_emoji_id: Optional[str] = emoji_status_custom_emoji_id - self.emoji_status_expiration_date: Optional[dtm.datetime] = ( - emoji_status_expiration_date - ) - self.has_aggressive_anti_spam_enabled: Optional[bool] = ( - has_aggressive_anti_spam_enabled - ) - self.has_hidden_members: Optional[bool] = has_hidden_members - self.available_reactions: Optional[tuple[ReactionType, ...]] = parse_sequence_arg( + self.emoji_status_custom_emoji_id: str | None = emoji_status_custom_emoji_id + self.emoji_status_expiration_date: dtm.datetime | None = emoji_status_expiration_date + self.has_aggressive_anti_spam_enabled: bool | None = has_aggressive_anti_spam_enabled + self.has_hidden_members: bool | None = has_hidden_members + self.available_reactions: tuple[ReactionType, ...] | None = parse_sequence_arg( available_reactions ) - self.accent_color_id: Optional[int] = accent_color_id - self.background_custom_emoji_id: Optional[str] = background_custom_emoji_id - self.profile_accent_color_id: Optional[int] = profile_accent_color_id - self.profile_background_custom_emoji_id: Optional[str] = ( + self.accent_color_id: int | None = accent_color_id + self.background_custom_emoji_id: str | None = background_custom_emoji_id + self.profile_accent_color_id: int | None = profile_accent_color_id + self.profile_background_custom_emoji_id: str | None = ( profile_background_custom_emoji_id ) - self.unrestrict_boost_count: Optional[int] = unrestrict_boost_count - self.custom_emoji_sticker_set_name: Optional[str] = custom_emoji_sticker_set_name - self.birthdate: Optional[Birthdate] = birthdate - self.personal_chat: Optional[Chat] = personal_chat - self.business_intro: Optional[BusinessIntro] = business_intro - self.business_location: Optional[BusinessLocation] = business_location - self.business_opening_hours: Optional[BusinessOpeningHours] = business_opening_hours - self.can_send_paid_media: Optional[bool] = can_send_paid_media + self.unrestrict_boost_count: int | None = unrestrict_boost_count + self.custom_emoji_sticker_set_name: str | None = custom_emoji_sticker_set_name + self.birthdate: Birthdate | None = birthdate + self.personal_chat: Chat | None = personal_chat + self.business_intro: BusinessIntro | None = business_intro + self.business_location: BusinessLocation | None = business_location + self.business_opening_hours: BusinessOpeningHours | None = business_opening_hours + self.can_send_paid_media: bool | None = can_send_paid_media + self.accepted_gift_types: AcceptedGiftTypes = accepted_gift_types + self.parent_chat: Chat | None = parent_chat + self.rating: UserRating | None = rating + self.unique_gift_colors: UniqueGiftColors | None = unique_gift_colors + self.paid_message_star_count: int | None = paid_message_star_count + + @property + def slow_mode_delay(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._slow_mode_delay, attribute="slow_mode_delay") + + @property + def message_auto_delete_time(self) -> int | dtm.timedelta | None: + return get_timedelta_value( + self._message_auto_delete_time, attribute="message_auto_delete_time" + ) @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChatFullInfo"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatFullInfo": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) @@ -528,25 +623,38 @@ def de_json( data.get("emoji_status_expiration_date"), tzinfo=loc_tzinfo ) - data["photo"] = ChatPhoto.de_json(data.get("photo"), bot) + data["photo"] = de_json_optional(data.get("photo"), ChatPhoto, bot) + data["accepted_gift_types"] = de_json_optional( + data.get("accepted_gift_types"), AcceptedGiftTypes, bot + ) - from telegram import ( # pylint: disable=import-outside-toplevel + from telegram import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 BusinessIntro, BusinessLocation, BusinessOpeningHours, Message, ) - data["pinned_message"] = Message.de_json(data.get("pinned_message"), bot) - data["permissions"] = ChatPermissions.de_json(data.get("permissions"), bot) - data["location"] = ChatLocation.de_json(data.get("location"), bot) - data["available_reactions"] = ReactionType.de_list(data.get("available_reactions"), bot) - data["birthdate"] = Birthdate.de_json(data.get("birthdate"), bot) - data["personal_chat"] = Chat.de_json(data.get("personal_chat"), bot) - data["business_intro"] = BusinessIntro.de_json(data.get("business_intro"), bot) - data["business_location"] = BusinessLocation.de_json(data.get("business_location"), bot) - data["business_opening_hours"] = BusinessOpeningHours.de_json( - data.get("business_opening_hours"), bot + data["pinned_message"] = de_json_optional(data.get("pinned_message"), Message, bot) + data["permissions"] = de_json_optional(data.get("permissions"), ChatPermissions, bot) + data["location"] = de_json_optional(data.get("location"), ChatLocation, bot) + data["available_reactions"] = de_list_optional( + data.get("available_reactions"), ReactionType, bot + ) + data["birthdate"] = de_json_optional(data.get("birthdate"), Birthdate, bot) + data["personal_chat"] = de_json_optional(data.get("personal_chat"), Chat, bot) + data["business_intro"] = de_json_optional(data.get("business_intro"), BusinessIntro, bot) + data["business_location"] = de_json_optional( + data.get("business_location"), BusinessLocation, bot + ) + data["business_opening_hours"] = de_json_optional( + data.get("business_opening_hours"), BusinessOpeningHours, bot + ) + data["parent_chat"] = de_json_optional(data.get("parent_chat"), Chat, bot) + + data["rating"] = de_json_optional(data.get("rating"), UserRating, bot) + data["unique_gift_colors"] = de_json_optional( + data.get("unique_gift_colors"), UniqueGiftColors, bot ) return super().de_json(data=data, bot=bot) diff --git a/telegram/_chatinvitelink.py b/src/telegram/_chatinvitelink.py similarity index 77% rename from telegram/_chatinvitelink.py rename to src/telegram/_chatinvitelink.py index b8067242087..880349fd0fb 100644 --- a/telegram/_chatinvitelink.py +++ b/src/telegram/_chatinvitelink.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,19 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents an invite link for a chat.""" + import datetime as dtm -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._user import User -from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import de_json_optional, to_timedelta +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_timedelta_value, +) +from telegram._utils.types import JSONDict, TimePeriod if TYPE_CHECKING: from telegram import Bot @@ -69,10 +75,13 @@ class ChatInviteLink(TelegramObject): created using this link. .. versionadded:: 13.8 - subscription_period (:obj:`int`, optional): The number of seconds the subscription will be - active for before the next payment. + subscription_period (:obj:`int` | :class:`datetime.timedelta`, optional): The number of + seconds the subscription will be active for before the next payment. .. versionadded:: 21.5 + + .. versionchanged:: v22.2 + |time-period-input| subscription_price (:obj:`int`, optional): The amount of Telegram Stars a user must pay initially and after each subsequent subscription period to be a member of the chat using the link. @@ -106,10 +115,13 @@ class ChatInviteLink(TelegramObject): created using this link. .. versionadded:: 13.8 - subscription_period (:obj:`int`): Optional. The number of seconds the subscription will be - active for before the next payment. + subscription_period (:obj:`int` | :class:`datetime.timedelta`): Optional. The number of + seconds the subscription will be active for before the next payment. .. versionadded:: 21.5 + + .. deprecated:: v22.2 + |time-period-int-deprecated| subscription_price (:obj:`int`): Optional. The amount of Telegram Stars a user must pay initially and after each subsequent subscription period to be a member of the chat using the link. @@ -119,6 +131,7 @@ class ChatInviteLink(TelegramObject): """ __slots__ = ( + "_subscription_period", "creates_join_request", "creator", "expire_date", @@ -128,7 +141,6 @@ class ChatInviteLink(TelegramObject): "member_limit", "name", "pending_join_request_count", - "subscription_period", "subscription_price", ) @@ -139,14 +151,14 @@ def __init__( creates_join_request: bool, is_primary: bool, is_revoked: bool, - expire_date: Optional[dtm.datetime] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - pending_join_request_count: Optional[int] = None, - subscription_period: Optional[int] = None, - subscription_price: Optional[int] = None, + expire_date: dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + pending_join_request_count: int | None = None, + subscription_period: TimePeriod | None = None, + subscription_price: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -157,14 +169,14 @@ def __init__( self.is_revoked: bool = is_revoked # Optionals - self.expire_date: Optional[dtm.datetime] = expire_date - self.member_limit: Optional[int] = member_limit - self.name: Optional[str] = name - self.pending_join_request_count: Optional[int] = ( + self.expire_date: dtm.datetime | None = expire_date + self.member_limit: int | None = member_limit + self.name: str | None = name + self.pending_join_request_count: int | None = ( int(pending_join_request_count) if pending_join_request_count is not None else None ) - self.subscription_period: Optional[int] = subscription_period - self.subscription_price: Optional[int] = subscription_price + self._subscription_period: dtm.timedelta | None = to_timedelta(subscription_period) + self.subscription_price: int | None = subscription_price self._id_attrs = ( self.invite_link, @@ -176,20 +188,19 @@ def __init__( self._freeze() + @property + def subscription_period(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._subscription_period, attribute="subscription_period") + @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChatInviteLink"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatInviteLink": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) - data["creator"] = User.de_json(data.get("creator"), bot) + data["creator"] = de_json_optional(data.get("creator"), User, bot) data["expire_date"] = from_timestamp(data.get("expire_date", None), tzinfo=loc_tzinfo) return super().de_json(data=data, bot=bot) diff --git a/telegram/_chatjoinrequest.py b/src/telegram/_chatjoinrequest.py similarity index 90% rename from telegram/_chatjoinrequest.py rename to src/telegram/_chatjoinrequest.py index 87305521035..75c2087d794 100644 --- a/telegram/_chatjoinrequest.py +++ b/src/telegram/_chatjoinrequest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,15 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ChatJoinRequest.""" + import datetime as dtm -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._chat import Chat from telegram._chatinvitelink import ChatInviteLink from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput @@ -83,7 +85,7 @@ class ChatJoinRequest(TelegramObject): request. This number may have more than 32 significant bits and some programming languages may have difficulty/silent defects in interpreting it. But it has at most 52 significant bits, so a 64-bit integer or double-precision float type are safe for - storing this identifier. The bot can use this identifier for 24 hours to send messages + storing this identifier. The bot can use this identifier for 5 minutes to send messages until the join request is processed, assuming no other administrator contacted the user. @@ -108,10 +110,10 @@ def __init__( from_user: User, date: dtm.datetime, user_chat_id: int, - bio: Optional[str] = None, - invite_link: Optional[ChatInviteLink] = None, + bio: str | None = None, + invite_link: ChatInviteLink | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -121,30 +123,25 @@ def __init__( self.user_chat_id: int = user_chat_id # Optionals - self.bio: Optional[str] = bio - self.invite_link: Optional[ChatInviteLink] = invite_link + self.bio: str | None = bio + self.invite_link: ChatInviteLink | None = invite_link self._id_attrs = (self.chat, self.from_user, self.date) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChatJoinRequest"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatJoinRequest": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) - data["chat"] = Chat.de_json(data.get("chat"), bot) - data["from_user"] = User.de_json(data.pop("from", None), bot) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) data["date"] = from_timestamp(data.get("date", None), tzinfo=loc_tzinfo) - data["invite_link"] = ChatInviteLink.de_json(data.get("invite_link"), bot) + data["invite_link"] = de_json_optional(data.get("invite_link"), ChatInviteLink, bot) return super().de_json(data=data, bot=bot) @@ -155,7 +152,7 @@ async def approve( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -187,7 +184,7 @@ async def decline( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: diff --git a/telegram/_chatlocation.py b/src/telegram/_chatlocation.py similarity index 89% rename from telegram/_chatlocation.py rename to src/telegram/_chatlocation.py index 7e227226f1a..b93cfc8a624 100644 --- a/telegram/_chatlocation.py +++ b/src/telegram/_chatlocation.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,11 +18,12 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a location to which a chat is connected.""" -from typing import TYPE_CHECKING, Final, Optional +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._files.location import Location from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -57,7 +58,7 @@ def __init__( location: Location, address: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.location: Location = location @@ -68,16 +69,11 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChatLocation"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatLocation": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["location"] = Location.de_json(data.get("location"), bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_chatmember.py b/src/telegram/_chatmember.py similarity index 92% rename from telegram/_chatmember.py rename to src/telegram/_chatmember.py index 85fd2e9304e..42323ea2cfd 100644 --- a/telegram/_chatmember.py +++ b/src/telegram/_chatmember.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,11 +19,13 @@ """This module contains an object that represents a Telegram ChatMember.""" import datetime as dtm -from typing import TYPE_CHECKING, Final, Optional +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict @@ -93,27 +95,22 @@ def __init__( user: User, status: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required by all subclasses self.user: User = user - self.status: str = status + self.status: str = enum.get_member(constants.ChatMemberStatus, status, status) self._id_attrs = (self.user, self.status) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChatMember"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatMember": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - _class_mapping: dict[str, type[ChatMember]] = { cls.OWNER: ChatMemberOwner, cls.ADMINISTRATOR: ChatMemberAdministrator, @@ -126,12 +123,12 @@ def de_json( if cls is ChatMember and data.get("status") in _class_mapping: return _class_mapping[data.pop("status")].de_json(data=data, bot=bot) - data["user"] = User.de_json(data.get("user"), bot) + data["user"] = de_json_optional(data.get("user"), User, bot) if "until_date" in data: # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) - data["until_date"] = from_timestamp(data["until_date"], tzinfo=loc_tzinfo) + data["until_date"] = from_timestamp(data.get("until_date"), tzinfo=loc_tzinfo) # This is a deprecated field that TG still returns for backwards compatibility # Let's filter it out to speed up the de-json process @@ -171,14 +168,14 @@ def __init__( self, user: User, is_anonymous: bool, - custom_title: Optional[str] = None, + custom_title: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.OWNER, user=user, api_kwargs=api_kwargs) with self._unfrozen(): self.is_anonymous: bool = is_anonymous - self.custom_title: Optional[str] = custom_title + self.custom_title: str | None = custom_title class ChatMemberAdministrator(ChatMember): @@ -256,6 +253,10 @@ class ChatMemberAdministrator(ChatMember): .. versionadded:: 20.0 custom_title (:obj:`str`, optional): Custom title for this user. + can_manage_direct_messages (:obj:`bool`, optional): :obj:`True`, if the administrator can + manage direct messages of the channel and decline suggested posts; for channels only. + + .. versionadded:: 22.4 Attributes: status (:obj:`str`): The member's status in the chat, @@ -316,6 +317,10 @@ class ChatMemberAdministrator(ChatMember): .. versionadded:: 20.0 custom_title (:obj:`str`): Optional. Custom title for this user. + can_manage_direct_messages (:obj:`bool`, optional): :obj:`True`, if the administrator can + manage direct messages of the channel and decline suggested posts; for channels only. + + .. versionadded:: 22.4 """ __slots__ = ( @@ -327,6 +332,7 @@ class ChatMemberAdministrator(ChatMember): "can_edit_stories", "can_invite_users", "can_manage_chat", + "can_manage_direct_messages", "can_manage_topics", "can_manage_video_chats", "can_pin_messages", @@ -353,13 +359,14 @@ def __init__( can_post_stories: bool, can_edit_stories: bool, can_delete_stories: bool, - can_post_messages: Optional[bool] = None, - can_edit_messages: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - can_manage_topics: Optional[bool] = None, - custom_title: Optional[str] = None, + can_post_messages: bool | None = None, + can_edit_messages: bool | None = None, + can_pin_messages: bool | None = None, + can_manage_topics: bool | None = None, + custom_title: str | None = None, + can_manage_direct_messages: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.ADMINISTRATOR, user=user, api_kwargs=api_kwargs) with self._unfrozen(): @@ -376,11 +383,12 @@ def __init__( self.can_edit_stories: bool = can_edit_stories self.can_delete_stories: bool = can_delete_stories # Optionals - self.can_post_messages: Optional[bool] = can_post_messages - self.can_edit_messages: Optional[bool] = can_edit_messages - self.can_pin_messages: Optional[bool] = can_pin_messages - self.can_manage_topics: Optional[bool] = can_manage_topics - self.custom_title: Optional[str] = custom_title + self.can_post_messages: bool | None = can_post_messages + self.can_edit_messages: bool | None = can_edit_messages + self.can_pin_messages: bool | None = can_pin_messages + self.can_manage_topics: bool | None = can_manage_topics + self.custom_title: str | None = custom_title + self.can_manage_direct_messages: bool | None = can_manage_direct_messages class ChatMemberMember(ChatMember): @@ -413,13 +421,13 @@ class ChatMemberMember(ChatMember): def __init__( self, user: User, - until_date: Optional[dtm.datetime] = None, + until_date: dtm.datetime | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.MEMBER, user=user, api_kwargs=api_kwargs) with self._unfrozen(): - self.until_date: Optional[dtm.datetime] = until_date + self.until_date: dtm.datetime | None = until_date class ChatMemberRestricted(ChatMember): @@ -574,7 +582,7 @@ def __init__( can_send_video_notes: bool, can_send_voice_notes: bool, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.RESTRICTED, user=user, api_kwargs=api_kwargs) with self._unfrozen(): @@ -618,7 +626,7 @@ def __init__( self, user: User, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.LEFT, user=user, api_kwargs=api_kwargs) self._freeze() @@ -658,7 +666,7 @@ def __init__( user: User, until_date: dtm.datetime, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.BANNED, user=user, api_kwargs=api_kwargs) with self._unfrozen(): diff --git a/telegram/_chatmemberupdated.py b/src/telegram/_chatmemberupdated.py similarity index 86% rename from telegram/_chatmemberupdated.py rename to src/telegram/_chatmemberupdated.py index ca6822fed3a..2434376d459 100644 --- a/telegram/_chatmemberupdated.py +++ b/src/telegram/_chatmemberupdated.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,16 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ChatMemberUpdated.""" + import datetime as dtm -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING from telegram._chat import Chat from telegram._chatinvitelink import ChatInviteLink from telegram._chatmember import ChatMember from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict @@ -111,11 +113,11 @@ def __init__( date: dtm.datetime, old_chat_member: ChatMember, new_chat_member: ChatMember, - invite_link: Optional[ChatInviteLink] = None, - via_chat_folder_invite_link: Optional[bool] = None, - via_join_request: Optional[bool] = None, + invite_link: ChatInviteLink | None = None, + via_chat_folder_invite_link: bool | None = None, + via_join_request: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -124,11 +126,11 @@ def __init__( self.date: dtm.datetime = date self.old_chat_member: ChatMember = old_chat_member self.new_chat_member: ChatMember = new_chat_member - self.via_chat_folder_invite_link: Optional[bool] = via_chat_folder_invite_link + self.via_chat_folder_invite_link: bool | None = via_chat_folder_invite_link # Optionals - self.invite_link: Optional[ChatInviteLink] = invite_link - self.via_join_request: Optional[bool] = via_join_request + self.invite_link: ChatInviteLink | None = invite_link + self.via_join_request: bool | None = via_join_request self._id_attrs = ( self.chat, @@ -141,24 +143,19 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChatMemberUpdated"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatMemberUpdated": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) - data["chat"] = Chat.de_json(data.get("chat"), bot) - data["from_user"] = User.de_json(data.pop("from", None), bot) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo) - data["old_chat_member"] = ChatMember.de_json(data.get("old_chat_member"), bot) - data["new_chat_member"] = ChatMember.de_json(data.get("new_chat_member"), bot) - data["invite_link"] = ChatInviteLink.de_json(data.get("invite_link"), bot) + data["old_chat_member"] = de_json_optional(data.get("old_chat_member"), ChatMember, bot) + data["new_chat_member"] = de_json_optional(data.get("new_chat_member"), ChatMember, bot) + data["invite_link"] = de_json_optional(data.get("invite_link"), ChatInviteLink, bot) return super().de_json(data=data, bot=bot) @@ -179,7 +176,7 @@ def difference( self, ) -> dict[ str, - tuple[Union[str, bool, dtm.datetime, User], Union[str, bool, dtm.datetime, User]], + tuple[str | bool | dtm.datetime | User, str | bool | dtm.datetime | User], ]: """Computes the difference between :attr:`old_chat_member` and :attr:`new_chat_member`. diff --git a/telegram/_chatpermissions.py b/src/telegram/_chatpermissions.py similarity index 82% rename from telegram/_chatpermissions.py rename to src/telegram/_chatpermissions.py index a7b1b1097ab..cfea9129d7b 100644 --- a/telegram/_chatpermissions.py +++ b/src/telegram/_chatpermissions.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ChatPermission.""" -from typing import TYPE_CHECKING, Optional + +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -155,39 +156,39 @@ class ChatPermissions(TelegramObject): def __init__( self, - can_send_messages: Optional[bool] = None, - can_send_polls: Optional[bool] = None, - can_send_other_messages: Optional[bool] = None, - can_add_web_page_previews: Optional[bool] = None, - can_change_info: Optional[bool] = None, - can_invite_users: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - can_manage_topics: Optional[bool] = None, - can_send_audios: Optional[bool] = None, - can_send_documents: Optional[bool] = None, - can_send_photos: Optional[bool] = None, - can_send_videos: Optional[bool] = None, - can_send_video_notes: Optional[bool] = None, - can_send_voice_notes: Optional[bool] = None, + can_send_messages: bool | None = None, + can_send_polls: bool | None = None, + can_send_other_messages: bool | None = None, + can_add_web_page_previews: bool | None = None, + can_change_info: bool | None = None, + can_invite_users: bool | None = None, + can_pin_messages: bool | None = None, + can_manage_topics: bool | None = None, + can_send_audios: bool | None = None, + can_send_documents: bool | None = None, + can_send_photos: bool | None = None, + can_send_videos: bool | None = None, + can_send_video_notes: bool | None = None, + can_send_voice_notes: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required - self.can_send_messages: Optional[bool] = can_send_messages - self.can_send_polls: Optional[bool] = can_send_polls - self.can_send_other_messages: Optional[bool] = can_send_other_messages - self.can_add_web_page_previews: Optional[bool] = can_add_web_page_previews - self.can_change_info: Optional[bool] = can_change_info - self.can_invite_users: Optional[bool] = can_invite_users - self.can_pin_messages: Optional[bool] = can_pin_messages - self.can_manage_topics: Optional[bool] = can_manage_topics - self.can_send_audios: Optional[bool] = can_send_audios - self.can_send_documents: Optional[bool] = can_send_documents - self.can_send_photos: Optional[bool] = can_send_photos - self.can_send_videos: Optional[bool] = can_send_videos - self.can_send_video_notes: Optional[bool] = can_send_video_notes - self.can_send_voice_notes: Optional[bool] = can_send_voice_notes + self.can_send_messages: bool | None = can_send_messages + self.can_send_polls: bool | None = can_send_polls + self.can_send_other_messages: bool | None = can_send_other_messages + self.can_add_web_page_previews: bool | None = can_add_web_page_previews + self.can_change_info: bool | None = can_change_info + self.can_invite_users: bool | None = can_invite_users + self.can_pin_messages: bool | None = can_pin_messages + self.can_manage_topics: bool | None = can_manage_topics + self.can_send_audios: bool | None = can_send_audios + self.can_send_documents: bool | None = can_send_documents + self.can_send_photos: bool | None = can_send_photos + self.can_send_videos: bool | None = can_send_videos + self.can_send_video_notes: bool | None = can_send_video_notes + self.can_send_voice_notes: bool | None = can_send_voice_notes self._id_attrs = ( self.can_send_messages, @@ -231,15 +232,10 @@ def no_permissions(cls) -> "ChatPermissions": return cls(*(14 * (False,))) @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChatPermissions"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatPermissions": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility # Let's filter it out to speed up the de-json process diff --git a/src/telegram/_checklists.py b/src/telegram/_checklists.py new file mode 100644 index 00000000000..16b7676eb5b --- /dev/null +++ b/src/telegram/_checklists.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an objects related to Telegram checklists.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Optional + +from telegram._chat import Chat +from telegram._messageentity import MessageEntity +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.entities import parse_message_entities, parse_message_entity +from telegram._utils.types import JSONDict +from telegram.constants import ZERO_DATE + +if TYPE_CHECKING: + from telegram import Bot, Message + + +class ChecklistTask(TelegramObject): + """ + Describes a task in a checklist. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their :attr:`id` is equal. + + .. versionadded:: 22.3 + + Args: + id (:obj:`int`): Unique identifier of the task. + text (:obj:`str`): Text of the task. + text_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special + entities that appear in the task text. + completed_by_user (:class:`telegram.User`, optional): User that completed the task; omitted + if the task wasn't completed + completed_by_chat (:class:`telegram.Chat`, optional): Chat that completed the task; omitted + if the task wasn't completed by a chat + + .. versionadded:: 22.6 + completion_date (:class:`datetime.datetime`, optional): Point in time when + the task was completed; :attr:`~telegram.constants.ZERO_DATE` if the task wasn't + completed + + |datetime_localization| + + Attributes: + id (:obj:`int`): Unique identifier of the task. + text (:obj:`str`): Text of the task. + text_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. Special + entities that appear in the task text. + completed_by_user (:class:`telegram.User`): Optional. User that completed the task; omitted + if the task wasn't completed + completed_by_chat (:class:`telegram.Chat`): Optional. Chat that completed the task; omitted + if the task wasn't completed by a chat + + .. versionadded:: 22.6 + completion_date (:class:`datetime.datetime`): Optional. Point in time when + the task was completed; :attr:`~telegram.constants.ZERO_DATE` if the task wasn't + completed + + |datetime_localization| + """ + + __slots__ = ( + "completed_by_chat", + "completed_by_user", + "completion_date", + "id", + "text", + "text_entities", + ) + + def __init__( + self, + id: int, # pylint: disable=redefined-builtin + text: str, + text_entities: Sequence[MessageEntity] | None = None, + completed_by_user: User | None = None, + completion_date: dtm.datetime | None = None, + completed_by_chat: Chat | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: int = id + self.text: str = text + self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) + self.completed_by_user: User | None = completed_by_user + self.completed_by_chat: Chat | None = completed_by_chat + self.completion_date: dtm.datetime | None = completion_date + + self._id_attrs = (self.id,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChecklistTask": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + if (date := data.get("completion_date")) == 0: + data["completion_date"] = ZERO_DATE + else: + data["completion_date"] = from_timestamp(date, tzinfo=loc_tzinfo) + + data["completed_by_user"] = de_json_optional(data.get("completed_by_user"), User, bot) + data["completed_by_chat"] = de_json_optional(data.get("completed_by_chat"), Chat, bot) + data["text_entities"] = de_list_optional(data.get("text_entities"), MessageEntity, bot) + + return super().de_json(data=data, bot=bot) + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`text` + from a given :class:`telegram.MessageEntity` of :attr:`text_entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``ChecklistTask.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`text_entities`. + + Returns: + :obj:`str`: The text of the given entity. + """ + return parse_message_entity(self.text, entity) + + def parse_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this checklist task filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`text_entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + """ + return parse_message_entities(self.text, self.text_entities, types) + + +class Checklist(TelegramObject): + """ + Describes a checklist. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if all their :attr:`tasks` are equal. + + .. versionadded:: 22.3 + + Args: + title (:obj:`str`): Title of the checklist. + title_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special + entities that appear in the checklist title. + tasks (Sequence[:class:`telegram.ChecklistTask`]): List of tasks in the checklist. + others_can_add_tasks (:obj:`bool`, optional): :obj:`True` if users other than the creator + of the list can add tasks to the list + others_can_mark_tasks_as_done (:obj:`bool`, optional): :obj:`True` if users other than the + creator of the list can mark tasks as done or not done + + Attributes: + title (:obj:`str`): Title of the checklist. + title_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. Special + entities that appear in the checklist title. + tasks (Tuple[:class:`telegram.ChecklistTask`]): List of tasks in the checklist. + others_can_add_tasks (:obj:`bool`): Optional. :obj:`True` if users other than the creator + of the list can add tasks to the list + others_can_mark_tasks_as_done (:obj:`bool`): Optional. :obj:`True` if users other than the + creator of the list can mark tasks as done or not done + """ + + __slots__ = ( + "others_can_add_tasks", + "others_can_mark_tasks_as_done", + "tasks", + "title", + "title_entities", + ) + + def __init__( + self, + title: str, + tasks: Sequence[ChecklistTask], + title_entities: Sequence[MessageEntity] | None = None, + others_can_add_tasks: bool | None = None, + others_can_mark_tasks_as_done: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.title: str = title + self.title_entities: tuple[MessageEntity, ...] = parse_sequence_arg(title_entities) + self.tasks: tuple[ChecklistTask, ...] = parse_sequence_arg(tasks) + self.others_can_add_tasks: bool | None = others_can_add_tasks + self.others_can_mark_tasks_as_done: bool | None = others_can_mark_tasks_as_done + + self._id_attrs = (self.tasks,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Checklist": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["title_entities"] = de_list_optional(data.get("title_entities"), MessageEntity, bot) + data["tasks"] = de_list_optional(data.get("tasks"), ChecklistTask, bot) + + return super().de_json(data=data, bot=bot) + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`title` + from a given :class:`telegram.MessageEntity` of :attr:`title_entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice :attr:`title` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`title_entities`. + + Returns: + :obj:`str`: The text of the given entity. + """ + return parse_message_entity(self.title, entity) + + def parse_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this checklist's title filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`title_entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + """ + return parse_message_entities(self.title, self.title_entities, types) + + +class ChecklistTasksDone(TelegramObject): + """ + Describes a service message about checklist tasks marked as done or not done. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their :attr:`marked_as_done_task_ids` and + :attr:`marked_as_not_done_task_ids` are equal. + + .. versionadded:: 22.3 + + Args: + checklist_message (:class:`telegram.Message`, optional): Message containing the checklist + whose tasks were marked as done or not done. Note that the ~:class:`telegram.Message` + object in this field will not contain the :attr:`~telegram.Message.reply_to_message` + field even if it itself is a reply. + marked_as_done_task_ids (Sequence[:obj:`int`], optional): Identifiers of the tasks that + were marked as done + marked_as_not_done_task_ids (Sequence[:obj:`int`], optional): Identifiers of the tasks that + were marked as not done + + Attributes: + checklist_message (:class:`telegram.Message`): Optional. Message containing the checklist + whose tasks were marked as done or not done. Note that the ~:class:`telegram.Message` + object in this field will not contain the :attr:`~telegram.Message.reply_to_message` + field even if it itself is a reply. + marked_as_done_task_ids (Tuple[:obj:`int`]): Optional. Identifiers of the tasks that were + marked as done + marked_as_not_done_task_ids (Tuple[:obj:`int`]): Optional. Identifiers of the tasks that + were marked as not done + """ + + __slots__ = ( + "checklist_message", + "marked_as_done_task_ids", + "marked_as_not_done_task_ids", + ) + + def __init__( + self, + checklist_message: Optional["Message"] = None, + marked_as_done_task_ids: Sequence[int] | None = None, + marked_as_not_done_task_ids: Sequence[int] | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.checklist_message: Message | None = checklist_message + self.marked_as_done_task_ids: tuple[int, ...] = parse_sequence_arg(marked_as_done_task_ids) + self.marked_as_not_done_task_ids: tuple[int, ...] = parse_sequence_arg( + marked_as_not_done_task_ids + ) + + self._id_attrs = (self.marked_as_done_task_ids, self.marked_as_not_done_task_ids) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChecklistTasksDone": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # needs to be imported here to avoid circular import issues + from telegram import Message # pylint: disable=import-outside-toplevel # noqa: PLC0415 + + data["checklist_message"] = de_json_optional(data.get("checklist_message"), Message, bot) + + return super().de_json(data=data, bot=bot) + + +class ChecklistTasksAdded(TelegramObject): + """ + Describes a service message about tasks added to a checklist. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their :attr:`tasks` are equal. + + .. versionadded:: 22.3 + + Args: + checklist_message (:class:`telegram.Message`, optional): Message containing the checklist + to which tasks were added. Note that the ~:class:`telegram.Message` + object in this field will not contain the :attr:`~telegram.Message.reply_to_message` + field even if it itself is a reply. + tasks (Sequence[:class:`telegram.ChecklistTask`]): List of tasks added to the checklist + + Attributes: + checklist_message (:class:`telegram.Message`): Optional. Message containing the checklist + to which tasks were added. Note that the ~:class:`telegram.Message` + object in this field will not contain the :attr:`~telegram.Message.reply_to_message` + field even if it itself is a reply. + tasks (Tuple[:class:`telegram.ChecklistTask`]): List of tasks added to the checklist + """ + + __slots__ = ("checklist_message", "tasks") + + def __init__( + self, + tasks: Sequence[ChecklistTask], + checklist_message: Optional["Message"] = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.checklist_message: Message | None = checklist_message + self.tasks: tuple[ChecklistTask, ...] = parse_sequence_arg(tasks) + + self._id_attrs = (self.tasks,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChecklistTasksAdded": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # needs to be imported here to avoid circular import issues + from telegram import Message # pylint: disable=import-outside-toplevel # noqa: PLC0415 + + data["checklist_message"] = de_json_optional(data.get("checklist_message"), Message, bot) + data["tasks"] = ChecklistTask.de_list(data.get("tasks", []), bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_choseninlineresult.py b/src/telegram/_choseninlineresult.py similarity index 85% rename from telegram/_choseninlineresult.py rename to src/telegram/_choseninlineresult.py index ee17d9376cd..41022e066da 100644 --- a/telegram/_choseninlineresult.py +++ b/src/telegram/_choseninlineresult.py @@ -2,7 +2,7 @@ # pylint: disable=too-many-arguments # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,11 +19,12 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ChosenInlineResult.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._files.location import Location from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -72,10 +73,10 @@ def __init__( result_id: str, from_user: User, query: str, - location: Optional[Location] = None, - inline_message_id: Optional[str] = None, + location: "Location | None" = None, + inline_message_id: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -84,26 +85,21 @@ def __init__( self.from_user: User = from_user self.query: str = query # Optionals - self.location: Optional[Location] = location - self.inline_message_id: Optional[str] = inline_message_id + self.location: Location | None = location + self.inline_message_id: str | None = inline_message_id self._id_attrs = (self.result_id,) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChosenInlineResult"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChosenInlineResult": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Required - data["from_user"] = User.de_json(data.pop("from", None), bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) # Optionals - data["location"] = Location.de_json(data.get("location"), bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_copytextbutton.py b/src/telegram/_copytextbutton.py similarity index 93% rename from telegram/_copytextbutton.py rename to src/telegram/_copytextbutton.py index 4a3cdb90590..0bd5939081a 100644 --- a/telegram/_copytextbutton.py +++ b/src/telegram/_copytextbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram CopyTextButton.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -46,7 +45,7 @@ class CopyTextButton(TelegramObject): __slots__ = ("text",) - def __init__(self, text: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, text: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.text: str = text diff --git a/telegram/_dice.py b/src/telegram/_dice.py similarity index 97% rename from telegram/_dice.py rename to src/telegram/_dice.py index a549aefb09d..4f8893e6745 100644 --- a/telegram/_dice.py +++ b/src/telegram/_dice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Dice.""" -from typing import Final, Optional + +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -89,7 +90,7 @@ class Dice(TelegramObject): __slots__ = ("emoji", "value") - def __init__(self, value: int, emoji: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, value: int, emoji: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.value: int = value self.emoji: str = emoji diff --git a/src/telegram/_directmessagepricechanged.py b/src/telegram/_directmessagepricechanged.py new file mode 100644 index 00000000000..0e9f750d446 --- /dev/null +++ b/src/telegram/_directmessagepricechanged.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Direct Message Price.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class DirectMessagePriceChanged(TelegramObject): + """ + Describes a service message about a change in the price of direct messages sent to a channel + chat. + + .. versionadded:: 22.3 + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`are_direct_messages_enabled`, and + :attr:`direct_message_star_count` are equal. + + Args: + are_direct_messages_enabled (:obj:`bool`): + :obj:`True`, if direct messages are enabled for the channel chat; :obj:`False` + otherwise. + direct_message_star_count (:obj:`int`, optional): + The new number of Telegram Stars that must be paid by users for each direct message + sent to the channel. Does not apply to users who have been exempted by administrators. + Defaults to ``0``. + + Attributes: + are_direct_messages_enabled (:obj:`bool`): + :obj:`True`, if direct messages are enabled for the channel chat; :obj:`False` + otherwise. + direct_message_star_count (:obj:`int`): + Optional. The new number of Telegram Stars that must be paid by users for each direct + message sent to the channel. Does not apply to users who have been exempted by + administrators. Defaults to ``0``. + """ + + __slots__ = ("are_direct_messages_enabled", "direct_message_star_count") + + def __init__( + self, + are_direct_messages_enabled: bool, + direct_message_star_count: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.are_direct_messages_enabled: bool = are_direct_messages_enabled + self.direct_message_star_count: int | None = direct_message_star_count + + self._id_attrs = (self.are_direct_messages_enabled, self.direct_message_star_count) + + self._freeze() diff --git a/src/telegram/_directmessagestopic.py b/src/telegram/_directmessagestopic.py new file mode 100644 index 00000000000..384c4db71c8 --- /dev/null +++ b/src/telegram/_directmessagestopic.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains the DirectMessagesTopic class.""" + +from typing import TYPE_CHECKING, Optional + +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram._bot import Bot + + +class DirectMessagesTopic(TelegramObject): + """ + This class represents a topic for direct messages in a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`topic_id` and :attr:`user` is equal. + + .. versionadded:: 22.4 + + Args: + topic_id (:obj:`int`): Unique identifier of the topic. This number may have more than 32 + significant bits and some programming languages may have difficulty/silent defects in + interpreting it. But it has at most 52 significant bits, so a 64-bit integer or + double-precision float type are safe for storing this identifier. + user (:class:`telegram.User`, optional): Information about the user that created the topic. + + .. hint:: + According to Telegram, this field is always present as of Bot API 9.2. + + Attributes: + topic_id (:obj:`int`): Unique identifier of the topic. This number may have more than 32 + significant bits and some programming languages may have difficulty/silent defects in + interpreting it. But it has at most 52 significant bits, so a 64-bit integer or + double-precision float type are safe for storing this identifier. + user (:class:`telegram.User`): Optional. Information about the user that created the topic. + + .. hint:: + According to Telegram, this field is always present as of Bot API 9.2. + + """ + + __slots__ = ("topic_id", "user") + + def __init__( + self, topic_id: int, user: User | None = None, *, api_kwargs: JSONDict | None = None + ): + super().__init__(api_kwargs=api_kwargs) + + # Required: + self.topic_id: int = topic_id + + # Optionals: + self.user: User | None = user + + self._id_attrs = (self.topic_id, self.user) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "DirectMessagesTopic": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["user"] = de_json_optional(data.get("user"), User, bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_games/__init__.py b/src/telegram/_files/__init__.py similarity index 100% rename from telegram/_games/__init__.py rename to src/telegram/_files/__init__.py diff --git a/telegram/_files/_basemedium.py b/src/telegram/_files/_basemedium.py similarity index 92% rename from telegram/_files/_basemedium.py rename to src/telegram/_files/_basemedium.py index 4dd76b10e4b..a6feb4325f3 100644 --- a/telegram/_files/_basemedium.py +++ b/src/telegram/_files/_basemedium.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Common base class for media objects""" -from typing import TYPE_CHECKING, Optional + +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._utils.defaultvalue import DEFAULT_NONE @@ -56,9 +57,9 @@ def __init__( self, file_id: str, file_unique_id: str, - file_size: Optional[int] = None, + file_size: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -66,7 +67,7 @@ def __init__( self.file_id: str = str(file_id) self.file_unique_id: str = str(file_unique_id) # Optionals - self.file_size: Optional[int] = file_size + self.file_size: int | None = file_size self._id_attrs = (self.file_unique_id,) @@ -77,7 +78,7 @@ async def get_file( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "File": """Convenience wrapper over :meth:`telegram.Bot.get_file` diff --git a/telegram/_files/_basethumbedmedium.py b/src/telegram/_files/_basethumbedmedium.py similarity index 86% rename from telegram/_files/_basethumbedmedium.py rename to src/telegram/_files/_basethumbedmedium.py index a3dff3abfe4..bf03f37b3c0 100644 --- a/telegram/_files/_basethumbedmedium.py +++ b/src/telegram/_files/_basethumbedmedium.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,12 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Common base class for media objects with thumbnails""" -from typing import TYPE_CHECKING, Optional, TypeVar + +from typing import TYPE_CHECKING, TypeVar from telegram._files._basemedium import _BaseMedium from telegram._files.photosize import PhotoSize +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -66,10 +68,10 @@ def __init__( self, file_id: str, file_unique_id: str, - file_size: Optional[int] = None, - thumbnail: Optional[PhotoSize] = None, + file_size: int | None = None, + thumbnail: PhotoSize | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, @@ -78,21 +80,16 @@ def __init__( api_kwargs=api_kwargs, ) - self.thumbnail: Optional[PhotoSize] = thumbnail + self.thumbnail: PhotoSize | None = thumbnail @classmethod - def de_json( - cls: type[ThumbedMT_co], data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional[ThumbedMT_co]: + def de_json(cls: type[ThumbedMT_co], data: JSONDict, bot: "Bot | None" = None) -> ThumbedMT_co: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # In case this wasn't already done by the subclass if not isinstance(data.get("thumbnail"), PhotoSize): - data["thumbnail"] = PhotoSize.de_json(data.get("thumbnail"), bot) + data["thumbnail"] = de_json_optional(data.get("thumbnail"), PhotoSize, bot) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility diff --git a/src/telegram/_files/_inputstorycontent.py b/src/telegram/_files/_inputstorycontent.py new file mode 100644 index 00000000000..d9edd2dfdbf --- /dev/null +++ b/src/telegram/_files/_inputstorycontent.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains objects that represent paid media in Telegram.""" + +import datetime as dtm +from typing import Final + +from telegram import constants +from telegram._files.inputfile import InputFile +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.files import parse_file_input +from telegram._utils.types import FileInput, JSONDict + + +class InputStoryContent(TelegramObject): + """This object describes the content of a story to post. Currently, it can be one of: + + * :class:`telegram.InputStoryContentPhoto` + * :class:`telegram.InputStoryContentVideo` + + .. versionadded:: 22.1 + + Args: + type (:obj:`str`): Type of the content. + + Attributes: + type (:obj:`str`): Type of the content. + """ + + __slots__ = ("type",) + + PHOTO: Final[str] = constants.InputStoryContentType.PHOTO + """:const:`telegram.constants.InputStoryContentType.PHOTO`""" + VIDEO: Final[str] = constants.InputStoryContentType.VIDEO + """:const:`telegram.constants.InputStoryContentType.VIDEO`""" + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.InputStoryContentType, type, type) + + self._freeze() + + @staticmethod + def _parse_file_input(file_input: FileInput) -> str | InputFile: + # We use local_mode=True because we don't have access to the actual setting and want + # things to work in local mode. + return parse_file_input(file_input, attach=True, local_mode=True) + + +class InputStoryContentPhoto(InputStoryContent): + """Describes a photo to post as a story. + + .. versionadded:: 22.1 + + Args: + photo (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + optional): The photo to post as a story. The photo must be of the + size :tg-const:`telegram.constants.InputStoryContentLimit.PHOTO_WIDTH` + x :tg-const:`telegram.constants.InputStoryContentLimit.PHOTO_HEIGHT` and must not + exceed :tg-const:`telegram.constants.InputStoryContentLimit.PHOTOSIZE_UPLOAD` MB. + |uploadinputnopath|. + + Attributes: + type (:obj:`str`): Type of the content, must be :attr:`~telegram.InputStoryContent.PHOTO`. + photo (:class:`telegram.InputFile`): The photo to post as a story. The photo must be of the + size :tg-const:`telegram.constants.InputStoryContentLimit.PHOTO_WIDTH` + x :tg-const:`telegram.constants.InputStoryContentLimit.PHOTO_HEIGHT` and must not + exceed :tg-const:`telegram.constants.InputStoryContentLimit.PHOTOSIZE_UPLOAD` MB. + + """ + + __slots__ = ("photo",) + + def __init__( + self, + photo: FileInput, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=InputStoryContent.PHOTO, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.photo: str | InputFile = self._parse_file_input(photo) + + +class InputStoryContentVideo(InputStoryContent): + """ + Describes a video to post as a story. + + .. versionadded:: 22.1 + + Args: + video (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + optional): The video to post as a story. The video must be of + the size :tg-const:`telegram.constants.InputStoryContentLimit.VIDEO_WIDTH` + x :tg-const:`telegram.constants.InputStoryContentLimit.VIDEO_HEIGHT`, + streamable, encoded with ``H.265`` codec, with key frames added + each second in the ``MPEG4`` format, and must not exceed + :tg-const:`telegram.constants.InputStoryContentLimit.VIDEOSIZE_UPLOAD` MB. + |uploadinputnopath|. + duration (:class:`datetime.timedelta` | :obj:`int` | :obj:`float`, optional): Precise + duration of the video in seconds; + 0-:tg-const:`telegram.constants.InputStoryContentLimit.MAX_VIDEO_DURATION` + cover_frame_timestamp (:class:`datetime.timedelta` | :obj:`int` | :obj:`float`, optional): + Timestamp in seconds of the frame that will be used as the static cover for the story. + Defaults to ``0.0``. + is_animation (:obj:`bool`, optional): Pass :obj:`True` if the video has no sound + + Attributes: + type (:obj:`str`): Type of the content, must be :attr:`~telegram.InputStoryContent.VIDEO`. + video (:class:`telegram.InputFile`): The video to post as a story. The video must be of + the size :tg-const:`telegram.constants.InputStoryContentLimit.VIDEO_WIDTH` + x :tg-const:`telegram.constants.InputStoryContentLimit.VIDEO_HEIGHT`, + streamable, encoded with ``H.265`` codec, with key frames added + each second in the ``MPEG4`` format, and must not exceed + :tg-const:`telegram.constants.InputStoryContentLimit.VIDEOSIZE_UPLOAD` MB. + duration (:class:`datetime.timedelta`): Optional. Precise duration of the video in seconds; + 0-:tg-const:`telegram.constants.InputStoryContentLimit.MAX_VIDEO_DURATION` + cover_frame_timestamp (:class:`datetime.timedelta`): Optional. Timestamp in seconds of the + frame that will be used as the static cover for the story. Defaults to ``0.0``. + is_animation (:obj:`bool`): Optional. Pass :obj:`True` if the video has no sound + """ + + __slots__ = ("cover_frame_timestamp", "duration", "is_animation", "video") + + def __init__( + self, + video: FileInput, + duration: float | dtm.timedelta | None = None, + cover_frame_timestamp: float | dtm.timedelta | None = None, + is_animation: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=InputStoryContent.VIDEO, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.video: str | InputFile = self._parse_file_input(video) + self.duration: dtm.timedelta | None = to_timedelta(duration) + self.cover_frame_timestamp: dtm.timedelta | None = to_timedelta(cover_frame_timestamp) + self.is_animation: bool | None = is_animation diff --git a/telegram/_files/animation.py b/src/telegram/_files/animation.py similarity index 73% rename from telegram/_files/animation.py rename to src/telegram/_files/animation.py index 537ffc0a0db..ff7fc4d9432 100644 --- a/telegram/_files/animation.py +++ b/src/telegram/_files/animation.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,14 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Animation.""" -from typing import Optional + +import datetime as dtm from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class Animation(_BaseThumbedMedium): @@ -41,7 +44,11 @@ class Animation(_BaseThumbedMedium): Can't be used to download or reuse the file. width (:obj:`int`): Video width as defined by the sender. height (:obj:`int`): Video height as defined by the sender. - duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of the video + in seconds as defined by the sender. + + .. versionchanged:: v22.2 + |time-period-input| file_name (:obj:`str`, optional): Original animation filename as defined by the sender. mime_type (:obj:`str`, optional): MIME type of the file as defined by the sender. file_size (:obj:`int`, optional): File size in bytes. @@ -58,7 +65,11 @@ class Animation(_BaseThumbedMedium): Can't be used to download or reuse the file. width (:obj:`int`): Video width as defined by the sender. height (:obj:`int`): Video height as defined by the sender. - duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video in seconds + as defined by the sender. + + .. deprecated:: v22.2 + |time-period-int-deprecated| file_name (:obj:`str`): Optional. Original animation filename as defined by the sender. mime_type (:obj:`str`): Optional. MIME type of the file as defined by the sender. file_size (:obj:`int`): Optional. File size in bytes. @@ -69,7 +80,7 @@ class Animation(_BaseThumbedMedium): """ - __slots__ = ("duration", "file_name", "height", "mime_type", "width") + __slots__ = ("_duration", "file_name", "height", "mime_type", "width") def __init__( self, @@ -77,13 +88,13 @@ def __init__( file_unique_id: str, width: int, height: int, - duration: int, - file_name: Optional[str] = None, - mime_type: Optional[str] = None, - file_size: Optional[int] = None, - thumbnail: Optional[PhotoSize] = None, + duration: TimePeriod, + file_name: str | None = None, + mime_type: str | None = None, + file_size: int | None = None, + thumbnail: PhotoSize | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, @@ -96,7 +107,13 @@ def __init__( # Required self.width: int = width self.height: int = height - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) # Optional - self.mime_type: Optional[str] = mime_type - self.file_name: Optional[str] = file_name + self.mime_type: str | None = mime_type + self.file_name: str | None = file_name + + @property + def duration(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) diff --git a/telegram/_files/audio.py b/src/telegram/_files/audio.py similarity index 71% rename from telegram/_files/audio.py rename to src/telegram/_files/audio.py index af5e420e1b2..6d1db7d67ab 100644 --- a/telegram/_files/audio.py +++ b/src/telegram/_files/audio.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,14 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Audio.""" -from typing import Optional + +import datetime as dtm from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class Audio(_BaseThumbedMedium): @@ -39,7 +42,11 @@ class Audio(_BaseThumbedMedium): or reuse the file. file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - duration (:obj:`int`): Duration of the audio in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the audio in + seconds as defined by the sender. + + .. versionchanged:: v22.2 + |time-period-input| performer (:obj:`str`, optional): Performer of the audio as defined by the sender or by audio tags. title (:obj:`str`, optional): Title of the audio as defined by the sender or by audio tags. @@ -56,7 +63,11 @@ class Audio(_BaseThumbedMedium): or reuse the file. file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - duration (:obj:`int`): Duration of the audio in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the audio in seconds as + defined by the sender. + + .. deprecated:: v22.2 + |time-period-int-deprecated| performer (:obj:`str`): Optional. Performer of the audio as defined by the sender or by audio tags. title (:obj:`str`): Optional. Title of the audio as defined by the sender or by audio tags. @@ -71,21 +82,21 @@ class Audio(_BaseThumbedMedium): """ - __slots__ = ("duration", "file_name", "mime_type", "performer", "title") + __slots__ = ("_duration", "file_name", "mime_type", "performer", "title") def __init__( self, file_id: str, file_unique_id: str, - duration: int, - performer: Optional[str] = None, - title: Optional[str] = None, - mime_type: Optional[str] = None, - file_size: Optional[int] = None, - file_name: Optional[str] = None, - thumbnail: Optional[PhotoSize] = None, + duration: TimePeriod, + performer: str | None = None, + title: str | None = None, + mime_type: str | None = None, + file_size: int | None = None, + file_name: str | None = None, + thumbnail: PhotoSize | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, @@ -96,9 +107,15 @@ def __init__( ) with self._unfrozen(): # Required - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) # Optional - self.performer: Optional[str] = performer - self.title: Optional[str] = title - self.mime_type: Optional[str] = mime_type - self.file_name: Optional[str] = file_name + self.performer: str | None = performer + self.title: str | None = title + self.mime_type: str | None = mime_type + self.file_name: str | None = file_name + + @property + def duration(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) diff --git a/telegram/_files/chatphoto.py b/src/telegram/_files/chatphoto.py similarity index 96% rename from telegram/_files/chatphoto.py rename to src/telegram/_files/chatphoto.py index 5d6e91471d7..6fe3eca726c 100644 --- a/telegram/_files/chatphoto.py +++ b/src/telegram/_files/chatphoto.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ChatPhoto.""" -from typing import TYPE_CHECKING, Final, Optional + +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -87,7 +88,7 @@ def __init__( big_file_id: str, big_file_unique_id: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.small_file_id: str = small_file_id @@ -109,7 +110,7 @@ async def get_small_file( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "File": """Convenience wrapper over :meth:`telegram.Bot.get_file` for getting the small (:tg-const:`telegram.ChatPhoto.SIZE_SMALL` x :tg-const:`telegram.ChatPhoto.SIZE_SMALL`) @@ -140,7 +141,7 @@ async def get_big_file( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "File": """Convenience wrapper over :meth:`telegram.Bot.get_file` for getting the big (:tg-const:`telegram.ChatPhoto.SIZE_BIG` x :tg-const:`telegram.ChatPhoto.SIZE_BIG`) diff --git a/telegram/_files/contact.py b/src/telegram/_files/contact.py similarity index 86% rename from telegram/_files/contact.py rename to src/telegram/_files/contact.py index 1ff05b36dc0..c3951c31b8b 100644 --- a/telegram/_files/contact.py +++ b/src/telegram/_files/contact.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Contact.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -51,20 +50,20 @@ def __init__( self, phone_number: str, first_name: str, - last_name: Optional[str] = None, - user_id: Optional[int] = None, - vcard: Optional[str] = None, + last_name: str | None = None, + user_id: int | None = None, + vcard: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.phone_number: str = str(phone_number) self.first_name: str = first_name # Optionals - self.last_name: Optional[str] = last_name - self.user_id: Optional[int] = user_id - self.vcard: Optional[str] = vcard + self.last_name: str | None = last_name + self.user_id: int | None = user_id + self.vcard: str | None = vcard self._id_attrs = (self.phone_number,) diff --git a/telegram/_files/document.py b/src/telegram/_files/document.py similarity index 89% rename from telegram/_files/document.py rename to src/telegram/_files/document.py index 7ddaeaf592e..f58fbee95da 100644 --- a/telegram/_files/document.py +++ b/src/telegram/_files/document.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Document.""" -from typing import Optional from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize @@ -68,12 +67,12 @@ def __init__( self, file_id: str, file_unique_id: str, - file_name: Optional[str] = None, - mime_type: Optional[str] = None, - file_size: Optional[int] = None, - thumbnail: Optional[PhotoSize] = None, + file_name: str | None = None, + mime_type: str | None = None, + file_size: int | None = None, + thumbnail: PhotoSize | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, @@ -84,5 +83,5 @@ def __init__( ) with self._unfrozen(): # Optional - self.mime_type: Optional[str] = mime_type - self.file_name: Optional[str] = file_name + self.mime_type: str | None = mime_type + self.file_name: str | None = file_name diff --git a/telegram/_files/file.py b/src/telegram/_files/file.py similarity index 97% rename from telegram/_files/file.py rename to src/telegram/_files/file.py index 38fdac7fd66..3e9d162fd88 100644 --- a/telegram/_files/file.py +++ b/src/telegram/_files/file.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,12 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram File.""" + import shutil import urllib.parse as urllib_parse from base64 import b64decode from pathlib import Path -from typing import TYPE_CHECKING, BinaryIO, Optional +from typing import TYPE_CHECKING, BinaryIO from telegram._passport.credentials import decrypt from telegram._telegramobject import TelegramObject @@ -85,10 +86,10 @@ def __init__( self, file_id: str, file_unique_id: str, - file_size: Optional[int] = None, - file_path: Optional[str] = None, + file_size: int | None = None, + file_path: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -96,10 +97,10 @@ def __init__( self.file_id: str = str(file_id) self.file_unique_id: str = str(file_unique_id) # Optionals - self.file_size: Optional[int] = file_size - self.file_path: Optional[str] = file_path + self.file_size: int | None = file_size + self.file_path: str | None = file_path - self._credentials: Optional[FileCredentials] = None + self._credentials: FileCredentials | None = None self._id_attrs = (self.file_unique_id,) @@ -119,7 +120,7 @@ def _prepare_decrypt(self, buf: bytes) -> bytes: async def download_to_drive( self, - custom_path: Optional[FilePathInput] = None, + custom_path: FilePathInput | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -293,7 +294,7 @@ async def download_to_memory( async def download_as_bytearray( self, - buf: Optional[bytearray] = None, + buf: bytearray | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, diff --git a/telegram/_files/inputfile.py b/src/telegram/_files/inputfile.py similarity index 94% rename from telegram/_files/inputfile.py rename to src/telegram/_files/inputfile.py index 8c88a9dece2..74423914391 100644 --- a/telegram/_files/inputfile.py +++ b/src/telegram/_files/inputfile.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,7 +19,7 @@ """This module contains an object that represents a Telegram InputFile.""" import mimetypes -from typing import IO, Optional, Union +from typing import IO from uuid import uuid4 from telegram._utils.files import guess_file_name, load_file @@ -95,13 +95,13 @@ class InputFile: def __init__( self, - obj: Union[IO[bytes], bytes, str], - filename: Optional[str] = None, + obj: IO[bytes] | bytes | str, + filename: str | None = None, attach: bool = False, read_file_handle: bool = True, ): if isinstance(obj, bytes): - self.input_file_content: Union[bytes, IO[bytes]] = obj + self.input_file_content: bytes | IO[bytes] = obj elif isinstance(obj, str): self.input_file_content = obj.encode(TextEncoding.UTF_8) elif read_file_handle: @@ -111,7 +111,7 @@ def __init__( self.input_file_content = obj filename = filename or guess_file_name(obj) - self.attach_name: Optional[str] = "attached" + uuid4().hex if attach else None + self.attach_name: str | None = "attached" + uuid4().hex if attach else None if filename: self.mimetype: str = ( @@ -135,7 +135,7 @@ def field_tuple(self) -> FieldTuple: return self.filename, self.input_file_content, self.mimetype @property - def attach_uri(self) -> Optional[str]: + def attach_uri(self) -> str | None: """URI to insert into the JSON data for uploading the file. Returns :obj:`None`, if :attr:`attach_name` is :obj:`None`. """ diff --git a/telegram/_files/inputmedia.py b/src/telegram/_files/inputmedia.py similarity index 73% rename from telegram/_files/inputmedia.py rename to src/telegram/_files/inputmedia.py index 0ac17c2d55d..23b6620985a 100644 --- a/telegram/_files/inputmedia.py +++ b/src/telegram/_files/inputmedia.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,10 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Base class for Telegram InputMedia Objects.""" + +import datetime as dtm from collections.abc import Sequence -from typing import Final, Optional, Union +from typing import TYPE_CHECKING, Final, TypeAlias from telegram import constants from telegram._files.animation import Animation @@ -30,13 +32,17 @@ from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject from telegram._utils import enum -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.files import parse_file_input -from telegram._utils.types import FileInput, JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InputMediaType -MediaType = Union[Animation, Audio, Document, PhotoSize, Video] +if TYPE_CHECKING: + from telegram._utils.types import FileInput + +MediaType: TypeAlias = Animation | Audio | Document | PhotoSize | Video class InputMedia(TelegramObject): @@ -51,13 +57,8 @@ class InputMedia(TelegramObject): Args: media_type (:obj:`str`): Type of media that the instance represents. - media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ - :class:`pathlib.Path` | :class:`telegram.Animation` | :class:`telegram.Audio` | \ - :class:`telegram.Document` | :class:`telegram.PhotoSize` | \ - :class:`telegram.Video`): File to send. + media (:obj:`str` | :class:`~telegram.InputFile`): File to send. |fileinputnopath| - Lastly you can pass an existing telegram media object of the corresponding type - to send. caption (:obj:`str`, optional): Caption of the media to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -89,24 +90,24 @@ class InputMedia(TelegramObject): def __init__( self, media_type: str, - media: Union[str, InputFile, MediaType], - caption: Optional[str] = None, - caption_entities: Optional[Sequence[MessageEntity]] = None, + media: str | InputFile, + caption: str | None = None, + caption_entities: Sequence[MessageEntity] | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.type: str = enum.get_member(constants.InputMediaType, media_type, media_type) - self.media: Union[str, InputFile, Animation, Audio, Document, PhotoSize, Video] = media - self.caption: Optional[str] = caption + self.media: str | InputFile = media + self.caption: str | None = caption self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) self.parse_mode: ODVInput[str] = parse_mode self._freeze() @staticmethod - def _parse_thumbnail_input(thumbnail: Optional[FileInput]) -> Optional[Union[str, InputFile]]: + def _parse_thumbnail_input(thumbnail: "FileInput | None") -> str | InputFile | None: # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. return ( @@ -120,8 +121,8 @@ class InputPaidMedia(TelegramObject): """ Base class for Telegram InputPaidMedia Objects. Currently, it can be one of: - * :class:`telegram.InputMediaPhoto` - * :class:`telegram.InputMediaVideo` + * :class:`telegram.InputPaidMediaPhoto` + * :class:`telegram.InputPaidMediaVideo` .. seealso:: :wiki:`Working with Files and Media ` @@ -129,11 +130,8 @@ class InputPaidMedia(TelegramObject): Args: type (:obj:`str`): Type of media that the instance represents. - media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ - :class:`pathlib.Path` | :class:`telegram.PhotoSize` | :class:`telegram.Video`): File + media (:obj:`str` | :class:`~telegram.InputFile`): File to send. |fileinputnopath| - Lastly you can pass an existing telegram media object of the corresponding type - to send. Attributes: type (:obj:`str`): Type of the input media. @@ -150,13 +148,13 @@ class InputPaidMedia(TelegramObject): def __init__( self, type: str, # pylint: disable=redefined-builtin - media: Union[str, InputFile, PhotoSize, Video], + media: str | InputFile, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.type: str = enum.get_member(constants.InputPaidMediaType, type, type) - self.media: Union[str, InputFile, PhotoSize, Video] = media + self.media: str | InputFile = media self._freeze() @@ -183,9 +181,9 @@ class InputPaidMediaPhoto(InputPaidMedia): def __init__( self, - media: Union[FileInput, PhotoSize], + media: "FileInput | PhotoSize", *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): media = parse_file_input(media, PhotoSize, attach=True, local_mode=True) super().__init__(type=InputPaidMedia.PHOTO, media=media, api_kwargs=api_kwargs) @@ -214,9 +212,19 @@ class InputPaidMediaVideo(InputPaidMedia): Lastly you can pass an existing :class:`telegram.Video` object to send. thumbnail (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ optional): |thumbdocstringnopath| + cover (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + optional): Cover for the video in the message. |fileinputnopath| + + .. versionchanged:: 21.11 + start_timestamp (:obj:`int`, optional): Start timestamp for the video in the message + + .. versionchanged:: 21.11 width (:obj:`int`, optional): Video width. height (:obj:`int`, optional): Video height. - duration (:obj:`int`, optional): Video duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Video duration in seconds. + + .. versionchanged:: v22.2 + |time-period-input| supports_streaming (:obj:`bool`, optional): Pass :obj:`True`, if the uploaded video is suitable for streaming. @@ -225,30 +233,50 @@ class InputPaidMediaVideo(InputPaidMedia): :tg-const:`telegram.constants.InputPaidMediaType.VIDEO`. media (:obj:`str` | :class:`telegram.InputFile`): Video to send. thumbnail (:class:`telegram.InputFile`): Optional. |thumbdocstringbase| + cover (:class:`telegram.InputFile`): Optional. Cover for the video in the message. + |fileinputnopath| + + .. versionchanged:: 21.11 + start_timestamp (:obj:`int`): Optional. Start timestamp for the video in the message + + .. versionchanged:: 21.11 width (:obj:`int`): Optional. Video width. height (:obj:`int`): Optional. Video height. - duration (:obj:`int`): Optional. Video duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Video duration in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| supports_streaming (:obj:`bool`): Optional. :obj:`True`, if the uploaded video is suitable for streaming. """ - __slots__ = ("duration", "height", "supports_streaming", "thumbnail", "width") + __slots__ = ( + "_duration", + "cover", + "height", + "start_timestamp", + "supports_streaming", + "thumbnail", + "width", + ) def __init__( self, - media: Union[FileInput, Video], - thumbnail: Optional[FileInput] = None, - width: Optional[int] = None, - height: Optional[int] = None, - duration: Optional[int] = None, - supports_streaming: Optional[bool] = None, + media: "FileInput | Video", + thumbnail: "FileInput | None" = None, + width: int | None = None, + height: int | None = None, + duration: TimePeriod | None = None, + supports_streaming: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): if isinstance(media, Video): width = width if width is not None else media.width height = height if height is not None else media.height - duration = duration if duration is not None else media.duration + duration = duration if duration is not None else media._duration media = media.file_id else: # We use local_mode=True because we don't have access to the actual setting and want @@ -257,13 +285,19 @@ def __init__( super().__init__(type=InputPaidMedia.VIDEO, media=media, api_kwargs=api_kwargs) with self._unfrozen(): - self.thumbnail: Optional[Union[str, InputFile]] = InputMedia._parse_thumbnail_input( - thumbnail + self.thumbnail: str | InputFile | None = InputMedia._parse_thumbnail_input(thumbnail) + self.width: int | None = width + self.height: int | None = height + self._duration: dtm.timedelta | None = to_timedelta(duration) + self.supports_streaming: bool | None = supports_streaming + self.cover: InputFile | str | None = ( + parse_file_input(cover, attach=True, local_mode=True) if cover else None ) - self.width: Optional[int] = width - self.height: Optional[int] = height - self.duration: Optional[int] = duration - self.supports_streaming: Optional[bool] = supports_streaming + self.start_timestamp: int | None = start_timestamp + + @property + def duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._duration, attribute="duration") class InputMediaAnimation(InputMedia): @@ -302,7 +336,11 @@ class InputMediaAnimation(InputMedia): width (:obj:`int`, optional): Animation width. height (:obj:`int`, optional): Animation height. - duration (:obj:`int`, optional): Animation duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Animation duration + in seconds. + + .. versionchanged:: v22.2 + |time-period-input| has_spoiler (:obj:`bool`, optional): Pass :obj:`True`, if the animation needs to be covered with a spoiler animation. @@ -330,7 +368,11 @@ class InputMediaAnimation(InputMedia): * |alwaystuple| width (:obj:`int`): Optional. Animation width. height (:obj:`int`): Optional. Animation height. - duration (:obj:`int`): Optional. Animation duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Animation duration + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| has_spoiler (:obj:`bool`): Optional. :obj:`True`, if the animation is covered with a spoiler animation. @@ -344,7 +386,7 @@ class InputMediaAnimation(InputMedia): """ __slots__ = ( - "duration", + "_duration", "has_spoiler", "height", "show_caption_above_media", @@ -354,24 +396,24 @@ class InputMediaAnimation(InputMedia): def __init__( self, - media: Union[FileInput, Animation], - caption: Optional[str] = None, + media: "FileInput | Animation", + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - width: Optional[int] = None, - height: Optional[int] = None, - duration: Optional[int] = None, - caption_entities: Optional[Sequence[MessageEntity]] = None, - filename: Optional[str] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - show_caption_above_media: Optional[bool] = None, + width: int | None = None, + height: int | None = None, + duration: TimePeriod | None = None, + caption_entities: Sequence[MessageEntity] | None = None, + filename: str | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): if isinstance(media, Animation): width = media.width if width is None else width height = media.height if height is None else height - duration = media.duration if duration is None else duration + duration = duration if duration is not None else media._duration media = media.file_id else: # We use local_mode=True because we don't have access to the actual setting and want @@ -387,14 +429,16 @@ def __init__( api_kwargs=api_kwargs, ) with self._unfrozen(): - self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumbnail_input( - thumbnail - ) - self.width: Optional[int] = width - self.height: Optional[int] = height - self.duration: Optional[int] = duration - self.has_spoiler: Optional[bool] = has_spoiler - self.show_caption_above_media: Optional[bool] = show_caption_above_media + self.thumbnail: str | InputFile | None = self._parse_thumbnail_input(thumbnail) + self.width: int | None = width + self.height: int | None = height + self._duration: dtm.timedelta | None = to_timedelta(duration) + self.has_spoiler: bool | None = has_spoiler + self.show_caption_above_media: bool | None = show_caption_above_media + + @property + def duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._duration, attribute="duration") class InputMediaPhoto(InputMedia): @@ -459,15 +503,15 @@ class InputMediaPhoto(InputMedia): def __init__( self, - media: Union[FileInput, PhotoSize], - caption: Optional[str] = None, + media: "FileInput | PhotoSize", + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - filename: Optional[str] = None, - has_spoiler: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence[MessageEntity] | None = None, + filename: str | None = None, + has_spoiler: bool | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. @@ -482,8 +526,8 @@ def __init__( ) with self._unfrozen(): - self.has_spoiler: Optional[bool] = has_spoiler - self.show_caption_above_media: Optional[bool] = show_caption_above_media + self.has_spoiler: bool | None = has_spoiler + self.show_caption_above_media: bool | None = show_caption_above_media class InputMediaVideo(InputMedia): @@ -525,7 +569,10 @@ class InputMediaVideo(InputMedia): width (:obj:`int`, optional): Video width. height (:obj:`int`, optional): Video height. - duration (:obj:`int`, optional): Video duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Video duration in seconds. + + .. versionchanged:: v22.2 + |time-period-input| supports_streaming (:obj:`bool`, optional): Pass :obj:`True`, if the uploaded video is suitable for streaming. has_spoiler (:obj:`bool`, optional): Pass :obj:`True`, if the video needs to be covered @@ -536,6 +583,13 @@ class InputMediaVideo(InputMedia): optional): |thumbdocstringnopath| .. versionadded:: 20.2 + cover (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + optional): Cover for the video in the message. |fileinputnopath| + + .. versionchanged:: 21.11 + start_timestamp (:obj:`int`, optional): Start timestamp for the video in the message + + .. versionchanged:: 21.11 show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| .. versionadded:: 21.3 @@ -555,7 +609,10 @@ class InputMediaVideo(InputMedia): * |alwaystuple| width (:obj:`int`): Optional. Video width. height (:obj:`int`): Optional. Video height. - duration (:obj:`int`): Optional. Video duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Video duration in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| supports_streaming (:obj:`bool`): Optional. :obj:`True`, if the uploaded video is suitable for streaming. has_spoiler (:obj:`bool`): Optional. :obj:`True`, if the video is covered with a @@ -568,13 +625,22 @@ class InputMediaVideo(InputMedia): show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| .. versionadded:: 21.3 + cover (:class:`telegram.InputFile`): Optional. Cover for the video in the message. + |fileinputnopath| + + .. versionchanged:: 21.11 + start_timestamp (:obj:`int`): Optional. Start timestamp for the video in the message + + .. versionchanged:: 21.11 """ __slots__ = ( - "duration", + "_duration", + "cover", "has_spoiler", "height", "show_caption_above_media", + "start_timestamp", "supports_streaming", "thumbnail", "width", @@ -582,25 +648,27 @@ class InputMediaVideo(InputMedia): def __init__( self, - media: Union[FileInput, Video], - caption: Optional[str] = None, - width: Optional[int] = None, - height: Optional[int] = None, - duration: Optional[int] = None, - supports_streaming: Optional[bool] = None, + media: "FileInput | Video", + caption: str | None = None, + width: int | None = None, + height: int | None = None, + duration: TimePeriod | None = None, + supports_streaming: bool | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - filename: Optional[str] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence[MessageEntity] | None = None, + filename: str | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + show_caption_above_media: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): if isinstance(media, Video): width = width if width is not None else media.width height = height if height is not None else media.height - duration = duration if duration is not None else media.duration + duration = duration if duration is not None else media._duration media = media.file_id else: # We use local_mode=True because we don't have access to the actual setting and want @@ -616,15 +684,21 @@ def __init__( api_kwargs=api_kwargs, ) with self._unfrozen(): - self.width: Optional[int] = width - self.height: Optional[int] = height - self.duration: Optional[int] = duration - self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumbnail_input( - thumbnail + self.width: int | None = width + self.height: int | None = height + self._duration: dtm.timedelta | None = to_timedelta(duration) + self.thumbnail: str | InputFile | None = self._parse_thumbnail_input(thumbnail) + self.supports_streaming: bool | None = supports_streaming + self.has_spoiler: bool | None = has_spoiler + self.show_caption_above_media: bool | None = show_caption_above_media + self.cover: InputFile | str | None = ( + parse_file_input(cover, attach=True, local_mode=True) if cover else None ) - self.supports_streaming: Optional[bool] = supports_streaming - self.has_spoiler: Optional[bool] = has_spoiler - self.show_caption_above_media: Optional[bool] = show_caption_above_media + self.start_timestamp: int | None = start_timestamp + + @property + def duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._duration, attribute="duration") class InputMediaAudio(InputMedia): @@ -661,7 +735,11 @@ class InputMediaAudio(InputMedia): .. versionchanged:: 20.0 |sequenceclassargs| - duration (:obj:`int`, optional): Duration of the audio in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of the audio + in seconds as defined by the sender. + + .. versionchanged:: v22.2 + |time-period-input| performer (:obj:`str`, optional): Performer of the audio as defined by the sender or by audio tags. title (:obj:`str`, optional): Title of the audio as defined by the sender or by audio tags. @@ -683,7 +761,11 @@ class InputMediaAudio(InputMedia): * |tupleclassattrs| * |alwaystuple| - duration (:obj:`int`): Optional. Duration of the audio in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Duration of the audio + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| performer (:obj:`str`): Optional. Performer of the audio as defined by the sender or by audio tags. title (:obj:`str`): Optional. Title of the audio as defined by the sender or by audio tags. @@ -693,24 +775,24 @@ class InputMediaAudio(InputMedia): """ - __slots__ = ("duration", "performer", "thumbnail", "title") + __slots__ = ("_duration", "performer", "thumbnail", "title") def __init__( self, - media: Union[FileInput, Audio], - caption: Optional[str] = None, + media: "FileInput | Audio", + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - caption_entities: Optional[Sequence[MessageEntity]] = None, - filename: Optional[str] = None, - thumbnail: Optional[FileInput] = None, + duration: TimePeriod | None = None, + performer: str | None = None, + title: str | None = None, + caption_entities: Sequence[MessageEntity] | None = None, + filename: str | None = None, + thumbnail: "FileInput | None" = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): if isinstance(media, Audio): - duration = media.duration if duration is None else duration + duration = duration if duration is not None else media._duration performer = media.performer if performer is None else performer title = media.title if title is None else title media = media.file_id @@ -728,12 +810,14 @@ def __init__( api_kwargs=api_kwargs, ) with self._unfrozen(): - self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumbnail_input( - thumbnail - ) - self.duration: Optional[int] = duration - self.title: Optional[str] = title - self.performer: Optional[str] = performer + self.thumbnail: str | InputFile | None = self._parse_thumbnail_input(thumbnail) + self._duration: dtm.timedelta | None = to_timedelta(duration) + self.title: str | None = title + self.performer: str | None = performer + + @property + def duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._duration, attribute="duration") class InputMediaDocument(InputMedia): @@ -798,15 +882,15 @@ class InputMediaDocument(InputMedia): def __init__( self, - media: Union[FileInput, Document], - caption: Optional[str] = None, + media: "FileInput | Document", + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_content_type_detection: Optional[bool] = None, - caption_entities: Optional[Sequence[MessageEntity]] = None, - filename: Optional[str] = None, - thumbnail: Optional[FileInput] = None, + disable_content_type_detection: bool | None = None, + caption_entities: Sequence[MessageEntity] | None = None, + filename: str | None = None, + thumbnail: "FileInput | None" = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. @@ -821,7 +905,5 @@ def __init__( api_kwargs=api_kwargs, ) with self._unfrozen(): - self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumbnail_input( - thumbnail - ) - self.disable_content_type_detection: Optional[bool] = disable_content_type_detection + self.thumbnail: str | InputFile | None = self._parse_thumbnail_input(thumbnail) + self.disable_content_type_detection: bool | None = disable_content_type_detection diff --git a/src/telegram/_files/inputprofilephoto.py b/src/telegram/_files/inputprofilephoto.py new file mode 100644 index 00000000000..130d061020f --- /dev/null +++ b/src/telegram/_files/inputprofilephoto.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an objects that represents a InputProfilePhoto and subclasses.""" + +import datetime as dtm +from typing import TYPE_CHECKING + +from telegram import constants +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.files import parse_file_input +from telegram._utils.types import FileInput, JSONDict + +if TYPE_CHECKING: + from telegram import InputFile + + +class InputProfilePhoto(TelegramObject): + """This object describes a profile photo to set. Currently, it can be one of + + * :class:`InputProfilePhotoStatic` + * :class:`InputProfilePhotoAnimated` + + .. versionadded:: 22.1 + + Args: + type (:obj:`str`): Type of the profile photo. + + Attributes: + type (:obj:`str`): Type of the profile photo. + + """ + + STATIC = constants.InputProfilePhotoType.STATIC + """:obj:`str`: :tg-const:`telegram.constants.InputProfilePhotoType.STATIC`.""" + ANIMATED = constants.InputProfilePhotoType.ANIMATED + """:obj:`str`: :tg-const:`telegram.constants.InputProfilePhotoType.ANIMATED`.""" + + __slots__ = ("type",) + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.InputProfilePhotoType, type, type) + + self._freeze() + + +class InputProfilePhotoStatic(InputProfilePhoto): + """A static profile photo in the .JPG format. + + .. versionadded:: 22.1 + + Args: + photo (:term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path`): The static profile photo. |uploadinputnopath| + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.InputProfilePhotoType.STATIC`. + photo (:class:`telegram.InputFile` | :obj:`str`): The static profile photo. + + """ + + __slots__ = ("photo",) + + def __init__( + self, + photo: FileInput, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=constants.InputProfilePhotoType.STATIC, api_kwargs=api_kwargs) + with self._unfrozen(): + # We use local_mode=True because we don't have access to the actual setting and want + # things to work in local mode. + self.photo: str | InputFile = parse_file_input(photo, attach=True, local_mode=True) + + +class InputProfilePhotoAnimated(InputProfilePhoto): + """An animated profile photo in the MPEG4 format. + + .. versionadded:: 22.1 + + Args: + animation (:term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path`): The animated profile photo. |uploadinputnopath| + main_frame_timestamp (:class:`datetime.timedelta` | :obj:`int` | :obj:`float`, optional): + Timestamp in seconds of the frame that will be used as the static profile photo. + Defaults to ``0.0``. + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.InputProfilePhotoType.ANIMATED`. + animation (:class:`telegram.InputFile` | :obj:`str`): The animated profile photo. + main_frame_timestamp (:class:`datetime.timedelta`): Optional. Timestamp in seconds of the + frame that will be used as the static profile photo. Defaults to ``0.0``. + """ + + __slots__ = ("animation", "main_frame_timestamp") + + def __init__( + self, + animation: FileInput, + main_frame_timestamp: float | dtm.timedelta | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=constants.InputProfilePhotoType.ANIMATED, api_kwargs=api_kwargs) + with self._unfrozen(): + # We use local_mode=True because we don't have access to the actual setting and want + # things to work in local mode. + self.animation: str | InputFile = parse_file_input( + animation, attach=True, local_mode=True + ) + + self.main_frame_timestamp: dtm.timedelta | None = to_timedelta(main_frame_timestamp) diff --git a/telegram/_files/inputsticker.py b/src/telegram/_files/inputsticker.py similarity index 91% rename from telegram/_files/inputsticker.py rename to src/telegram/_files/inputsticker.py index 1edc51acdec..38fafc4de3d 100644 --- a/telegram/_files/inputsticker.py +++ b/src/telegram/_files/inputsticker.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,7 +19,7 @@ """This module contains an object that represents a Telegram InputSticker.""" from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING from telegram._files.sticker import MaskPosition from telegram._telegramobject import TelegramObject @@ -61,8 +61,8 @@ class InputSticker(TelegramObject): format (:obj:`str`): Format of the added sticker, must be one of :tg-const:`telegram.constants.StickerFormat.STATIC` for a ``.WEBP`` or ``.PNG`` image, :tg-const:`telegram.constants.StickerFormat.ANIMATED` - for a ``.TGS`` animation, :tg-const:`telegram.constants.StickerFormat.VIDEO` for a WEBM - video. + for a ``.TGS`` animation, :tg-const:`telegram.constants.StickerFormat.VIDEO` for a + ``.WEBM`` video. .. versionadded:: 21.1 @@ -84,8 +84,8 @@ class InputSticker(TelegramObject): format (:obj:`str`): Format of the added sticker, must be one of :tg-const:`telegram.constants.StickerFormat.STATIC` for a ``.WEBP`` or ``.PNG`` image, :tg-const:`telegram.constants.StickerFormat.ANIMATED` - for a ``.TGS`` animation, :tg-const:`telegram.constants.StickerFormat.VIDEO` for a WEBM - video. + for a ``.TGS`` animation, :tg-const:`telegram.constants.StickerFormat.VIDEO` for a + ``.WEBM`` video. .. versionadded:: 21.1 """ @@ -97,23 +97,23 @@ def __init__( sticker: FileInput, emoji_list: Sequence[str], format: str, # pylint: disable=redefined-builtin - mask_position: Optional[MaskPosition] = None, - keywords: Optional[Sequence[str]] = None, + mask_position: MaskPosition | None = None, + keywords: Sequence[str] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. - self.sticker: Union[str, InputFile] = parse_file_input( + self.sticker: str | InputFile = parse_file_input( sticker, local_mode=True, attach=True, ) self.emoji_list: tuple[str, ...] = parse_sequence_arg(emoji_list) self.format: str = format - self.mask_position: Optional[MaskPosition] = mask_position + self.mask_position: MaskPosition | None = mask_position self.keywords: tuple[str, ...] = parse_sequence_arg(keywords) self._freeze() diff --git a/telegram/_files/location.py b/src/telegram/_files/location.py similarity index 71% rename from telegram/_files/location.py rename to src/telegram/_files/location.py index 87c895b711a..14e1b7afc0c 100644 --- a/telegram/_files/location.py +++ b/src/telegram/_files/location.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,11 +18,14 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Location.""" -from typing import Final, Optional +import datetime as dtm +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class Location(TelegramObject): @@ -36,8 +39,12 @@ class Location(TelegramObject): latitude (:obj:`float`): Latitude as defined by the sender. horizontal_accuracy (:obj:`float`, optional): The radius of uncertainty for the location, measured in meters; 0-:tg-const:`telegram.Location.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`, optional): Time relative to the message sending date, during which - the location can be updated, in seconds. For active live locations only. + live_period (:obj:`int` | :class:`datetime.timedelta`, optional): Time relative to the + message sending date, during which the location can be updated, in seconds. For active + live locations only. + + .. versionchanged:: v22.2 + |time-period-input| heading (:obj:`int`, optional): The direction in which user is moving, in degrees; :tg-const:`telegram.Location.MIN_HEADING`-:tg-const:`telegram.Location.MAX_HEADING`. For active live locations only. @@ -49,8 +56,12 @@ class Location(TelegramObject): latitude (:obj:`float`): Latitude as defined by the sender. horizontal_accuracy (:obj:`float`): Optional. The radius of uncertainty for the location, measured in meters; 0-:tg-const:`telegram.Location.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`): Optional. Time relative to the message sending date, during which - the location can be updated, in seconds. For active live locations only. + live_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Time relative to the + message sending date, during which the location can be updated, in seconds. For active + live locations only. + + .. deprecated:: v22.2 + |time-period-int-deprecated| heading (:obj:`int`): Optional. The direction in which user is moving, in degrees; :tg-const:`telegram.Location.MIN_HEADING`-:tg-const:`telegram.Location.MAX_HEADING`. For active live locations only. @@ -60,10 +71,10 @@ class Location(TelegramObject): """ __slots__ = ( + "_live_period", "heading", "horizontal_accuracy", "latitude", - "live_period", "longitude", "proximity_alert_radius", ) @@ -72,12 +83,12 @@ def __init__( self, longitude: float, latitude: float, - horizontal_accuracy: Optional[float] = None, - live_period: Optional[int] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, + horizontal_accuracy: float | None = None, + live_period: TimePeriod | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -85,10 +96,10 @@ def __init__( self.latitude: float = latitude # Optionals - self.horizontal_accuracy: Optional[float] = horizontal_accuracy - self.live_period: Optional[int] = live_period - self.heading: Optional[int] = heading - self.proximity_alert_radius: Optional[int] = ( + self.horizontal_accuracy: float | None = horizontal_accuracy + self._live_period: dtm.timedelta | None = to_timedelta(live_period) + self.heading: int | None = heading + self.proximity_alert_radius: int | None = ( int(proximity_alert_radius) if proximity_alert_radius else None ) @@ -96,6 +107,10 @@ def __init__( self._freeze() + @property + def live_period(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._live_period, attribute="live_period") + HORIZONTAL_ACCURACY: Final[int] = constants.LocationLimit.HORIZONTAL_ACCURACY """:const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY` diff --git a/telegram/_files/photosize.py b/src/telegram/_files/photosize.py similarity index 94% rename from telegram/_files/photosize.py rename to src/telegram/_files/photosize.py index e06dc3bb772..95481031b09 100644 --- a/telegram/_files/photosize.py +++ b/src/telegram/_files/photosize.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram PhotoSize.""" -from typing import Optional - from telegram._files._basemedium import _BaseMedium from telegram._utils.types import JSONDict @@ -61,9 +59,9 @@ def __init__( file_unique_id: str, width: int, height: int, - file_size: Optional[int] = None, + file_size: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, diff --git a/telegram/_files/sticker.py b/src/telegram/_files/sticker.py similarity index 88% rename from telegram/_files/sticker.py rename to src/telegram/_files/sticker.py index d5e6d8b90c9..e7854b53ab9 100644 --- a/telegram/_files/sticker.py +++ b/src/telegram/_files/sticker.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains objects that represent stickers.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Final, Optional +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._files._basethumbedmedium import _BaseThumbedMedium @@ -26,7 +27,7 @@ from telegram._files.photosize import PhotoSize from telegram._telegramobject import TelegramObject from telegram._utils import enum -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -153,16 +154,16 @@ def __init__( is_animated: bool, is_video: bool, type: str, # pylint: disable=redefined-builtin - emoji: Optional[str] = None, - file_size: Optional[int] = None, - set_name: Optional[str] = None, - mask_position: Optional["MaskPosition"] = None, - premium_animation: Optional["File"] = None, - custom_emoji_id: Optional[str] = None, - thumbnail: Optional[PhotoSize] = None, - needs_repainting: Optional[bool] = None, + emoji: str | None = None, + file_size: int | None = None, + set_name: str | None = None, + mask_position: "MaskPosition | None" = None, + premium_animation: "File | None" = None, + custom_emoji_id: str | None = None, + thumbnail: PhotoSize | None = None, + needs_repainting: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, @@ -179,12 +180,12 @@ def __init__( self.is_video: bool = is_video self.type: str = enum.get_member(constants.StickerType, type, type) # Optional - self.emoji: Optional[str] = emoji - self.set_name: Optional[str] = set_name - self.mask_position: Optional[MaskPosition] = mask_position - self.premium_animation: Optional[File] = premium_animation - self.custom_emoji_id: Optional[str] = custom_emoji_id - self.needs_repainting: Optional[bool] = needs_repainting + self.emoji: str | None = emoji + self.set_name: str | None = set_name + self.mask_position: MaskPosition | None = mask_position + self.premium_animation: File | None = premium_animation + self.custom_emoji_id: str | None = custom_emoji_id + self.needs_repainting: bool | None = needs_repainting REGULAR: Final[str] = constants.StickerType.REGULAR """:const:`telegram.constants.StickerType.REGULAR`""" @@ -194,16 +195,13 @@ def __init__( """:const:`telegram.constants.StickerType.CUSTOM_EMOJI`""" @classmethod - def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Sticker"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Sticker": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["thumbnail"] = PhotoSize.de_json(data.get("thumbnail"), bot) - data["mask_position"] = MaskPosition.de_json(data.get("mask_position"), bot) - data["premium_animation"] = File.de_json(data.get("premium_animation"), bot) + data["thumbnail"] = de_json_optional(data.get("thumbnail"), PhotoSize, bot) + data["mask_position"] = de_json_optional(data.get("mask_position"), MaskPosition, bot) + data["premium_animation"] = de_json_optional(data.get("premium_animation"), File, bot) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility @@ -290,9 +288,9 @@ def __init__( title: str, stickers: Sequence[Sticker], sticker_type: str, - thumbnail: Optional[PhotoSize] = None, + thumbnail: PhotoSize | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.name: str = name @@ -300,21 +298,18 @@ def __init__( self.stickers: tuple[Sticker, ...] = parse_sequence_arg(stickers) self.sticker_type: str = sticker_type # Optional - self.thumbnail: Optional[PhotoSize] = thumbnail + self.thumbnail: PhotoSize | None = thumbnail self._id_attrs = (self.name,) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["StickerSet"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "StickerSet": """See :meth:`telegram.TelegramObject.de_json`.""" - if not data: - return None + data = cls._parse_data(data) - data["thumbnail"] = PhotoSize.de_json(data.get("thumbnail"), bot) - data["stickers"] = Sticker.de_list(data.get("stickers"), bot) + data["thumbnail"] = de_json_optional(data.get("thumbnail"), PhotoSize, bot) + data["stickers"] = de_list_optional(data.get("stickers"), Sticker, bot) api_kwargs = {} # These are deprecated fields that TG still returns for backwards compatibility @@ -375,7 +370,7 @@ def __init__( y_shift: float, scale: float, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.point: str = point diff --git a/telegram/_files/venue.py b/src/telegram/_files/venue.py similarity index 81% rename from telegram/_files/venue.py rename to src/telegram/_files/venue.py index c169a52b3be..3594e91e10a 100644 --- a/telegram/_files/venue.py +++ b/src/telegram/_files/venue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,10 +18,11 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Venue.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._files.location import Location from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -48,7 +49,7 @@ class Venue(TelegramObject): google_place_id (:obj:`str`, optional): Google Places identifier of the venue. google_place_type (:obj:`str`, optional): Google Places type of the venue. (See `supported types `_.) + /place-types>`_.) Attributes: location (:class:`telegram.Location`): Venue location. @@ -60,7 +61,7 @@ class Venue(TelegramObject): google_place_id (:obj:`str`): Optional. Google Places identifier of the venue. google_place_type (:obj:`str`): Optional. Google Places type of the venue. (See `supported types `_.) + /place-types>`_.) """ @@ -79,12 +80,12 @@ def __init__( location: Location, title: str, address: str, - foursquare_id: Optional[str] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, + foursquare_id: str | None = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -93,23 +94,20 @@ def __init__( self.title: str = title self.address: str = address # Optionals - self.foursquare_id: Optional[str] = foursquare_id - self.foursquare_type: Optional[str] = foursquare_type - self.google_place_id: Optional[str] = google_place_id - self.google_place_type: Optional[str] = google_place_type + self.foursquare_id: str | None = foursquare_id + self.foursquare_type: str | None = foursquare_type + self.google_place_id: str | None = google_place_id + self.google_place_type: str | None = google_place_type self._id_attrs = (self.location, self.title) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Venue"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Venue": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["location"] = Location.de_json(data.get("location"), bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_files/video.py b/src/telegram/_files/video.py similarity index 51% rename from telegram/_files/video.py rename to src/telegram/_files/video.py index c5ca6f3b946..d1b543096fb 100644 --- a/telegram/_files/video.py +++ b/src/telegram/_files/video.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,19 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Video.""" -from typing import Optional + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod + +if TYPE_CHECKING: + from telegram import Bot class Video(_BaseThumbedMedium): @@ -41,13 +49,27 @@ class Video(_BaseThumbedMedium): Can't be used to download or reuse the file. width (:obj:`int`): Video width as defined by the sender. height (:obj:`int`): Video height as defined by the sender. - duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video + in seconds as defined by the sender. + + .. versionchanged:: v22.2 + |time-period-input| file_name (:obj:`str`, optional): Original filename as defined by the sender. mime_type (:obj:`str`, optional): MIME type of a file as defined by the sender. file_size (:obj:`int`, optional): File size in bytes. thumbnail (:class:`telegram.PhotoSize`, optional): Video thumbnail. .. versionadded:: 20.2 + cover (Sequence[:class:`telegram.PhotoSize`], optional): Available sizes of the cover of + the video in the message. + + .. versionadded:: 21.11 + start_timestamp (:obj:`int` | :class:`datetime.timedelta`, optional): Timestamp in seconds + from which the video will play in the message + .. versionadded:: 21.11 + + .. versionchanged:: v22.2 + |time-period-input| Attributes: file_id (:obj:`str`): Identifier for this file, which can be used to download @@ -57,16 +79,38 @@ class Video(_BaseThumbedMedium): Can't be used to download or reuse the file. width (:obj:`int`): Video width as defined by the sender. height (:obj:`int`): Video height as defined by the sender. - duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video in seconds + as defined by the sender. + + .. deprecated:: v22.2 + |time-period-int-deprecated| file_name (:obj:`str`): Optional. Original filename as defined by the sender. mime_type (:obj:`str`): Optional. MIME type of a file as defined by the sender. file_size (:obj:`int`): Optional. File size in bytes. thumbnail (:class:`telegram.PhotoSize`): Optional. Video thumbnail. .. versionadded:: 20.2 + cover (tuple[:class:`telegram.PhotoSize`]): Optional, Available sizes of the cover of + the video in the message. + + .. versionadded:: 21.11 + start_timestamp (:obj:`int` | :class:`datetime.timedelta`): Optional. Timestamp in seconds + from which the video will play in the message + .. versionadded:: 21.11 + + .. deprecated:: v22.2 + |time-period-int-deprecated| """ - __slots__ = ("duration", "file_name", "height", "mime_type", "width") + __slots__ = ( + "_duration", + "_start_timestamp", + "cover", + "file_name", + "height", + "mime_type", + "width", + ) def __init__( self, @@ -74,13 +118,15 @@ def __init__( file_unique_id: str, width: int, height: int, - duration: int, - mime_type: Optional[str] = None, - file_size: Optional[int] = None, - file_name: Optional[str] = None, - thumbnail: Optional[PhotoSize] = None, + duration: TimePeriod, + mime_type: str | None = None, + file_size: int | None = None, + file_name: str | None = None, + thumbnail: PhotoSize | None = None, + cover: Sequence[PhotoSize] | None = None, + start_timestamp: TimePeriod | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, @@ -93,7 +139,28 @@ def __init__( # Required self.width: int = width self.height: int = height - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) # Optional - self.mime_type: Optional[str] = mime_type - self.file_name: Optional[str] = file_name + self.mime_type: str | None = mime_type + self.file_name: str | None = file_name + self.cover: Sequence[PhotoSize] | None = parse_sequence_arg(cover) + self._start_timestamp: dtm.timedelta | None = to_timedelta(start_timestamp) + + @property + def duration(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) + + @property + def start_timestamp(self) -> dtm.timedelta | None | int: + return get_timedelta_value(self._start_timestamp, attribute="start_timestamp") + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Video": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["cover"] = de_list_optional(data.get("cover"), PhotoSize, bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_files/videonote.py b/src/telegram/_files/videonote.py similarity index 73% rename from telegram/_files/videonote.py rename to src/telegram/_files/videonote.py index edb9e555372..edd4edc5d2b 100644 --- a/telegram/_files/videonote.py +++ b/src/telegram/_files/videonote.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,11 +18,13 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram VideoNote.""" -from typing import Optional +import datetime as dtm from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class VideoNote(_BaseThumbedMedium): @@ -42,7 +44,11 @@ class VideoNote(_BaseThumbedMedium): Can't be used to download or reuse the file. length (:obj:`int`): Video width and height (diameter of the video message) as defined by sender. - duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video in + seconds as defined by the sender. + + .. versionchanged:: v22.2 + |time-period-input| file_size (:obj:`int`, optional): File size in bytes. thumbnail (:class:`telegram.PhotoSize`, optional): Video thumbnail. @@ -56,7 +62,11 @@ class VideoNote(_BaseThumbedMedium): Can't be used to download or reuse the file. length (:obj:`int`): Video width and height (diameter of the video message) as defined by sender. - duration (:obj:`int`): Duration of the video in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video in seconds as + defined by the sender. + + .. deprecated:: v22.2 + |time-period-int-deprecated| file_size (:obj:`int`): Optional. File size in bytes. thumbnail (:class:`telegram.PhotoSize`): Optional. Video thumbnail. @@ -64,18 +74,18 @@ class VideoNote(_BaseThumbedMedium): """ - __slots__ = ("duration", "length") + __slots__ = ("_duration", "length") def __init__( self, file_id: str, file_unique_id: str, length: int, - duration: int, - file_size: Optional[int] = None, - thumbnail: Optional[PhotoSize] = None, + duration: TimePeriod, + file_size: int | None = None, + thumbnail: PhotoSize | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, @@ -87,4 +97,10 @@ def __init__( with self._unfrozen(): # Required self.length: int = length - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) + + @property + def duration(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) diff --git a/telegram/_files/voice.py b/src/telegram/_files/voice.py similarity index 68% rename from telegram/_files/voice.py rename to src/telegram/_files/voice.py index 19c0e856d14..fb7691fe2df 100644 --- a/telegram/_files/voice.py +++ b/src/telegram/_files/voice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,13 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Voice.""" -from typing import Optional + +import datetime as dtm from telegram._files._basemedium import _BaseMedium -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class Voice(_BaseMedium): @@ -35,7 +38,11 @@ class Voice(_BaseMedium): file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - duration (:obj:`int`): Duration of the audio in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the audio in + seconds as defined by the sender. + + .. versionchanged:: v22.2 + |time-period-input| mime_type (:obj:`str`, optional): MIME type of the file as defined by the sender. file_size (:obj:`int`, optional): File size in bytes. @@ -45,23 +52,27 @@ class Voice(_BaseMedium): file_unique_id (:obj:`str`): Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. - duration (:obj:`int`): Duration of the audio in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the audio in seconds as + defined by the sender. + + .. deprecated:: v22.2 + |time-period-int-deprecated| mime_type (:obj:`str`): Optional. MIME type of the file as defined by the sender. file_size (:obj:`int`): Optional. File size in bytes. """ - __slots__ = ("duration", "mime_type") + __slots__ = ("_duration", "mime_type") def __init__( self, file_id: str, file_unique_id: str, - duration: int, - mime_type: Optional[str] = None, - file_size: Optional[int] = None, + duration: TimePeriod, + mime_type: str | None = None, + file_size: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, @@ -71,6 +82,12 @@ def __init__( ) with self._unfrozen(): # Required - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) # Optional - self.mime_type: Optional[str] = mime_type + self.mime_type: str | None = mime_type + + @property + def duration(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) diff --git a/telegram/_forcereply.py b/src/telegram/_forcereply.py similarity index 92% rename from telegram/_forcereply.py rename to src/telegram/_forcereply.py index b24b2719af9..bf69c93aa43 100644 --- a/telegram/_forcereply.py +++ b/src/telegram/_forcereply.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ForceReply.""" -from typing import Final, Optional +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -80,15 +80,15 @@ class ForceReply(TelegramObject): def __init__( self, - selective: Optional[bool] = None, - input_field_placeholder: Optional[str] = None, + selective: bool | None = None, + input_field_placeholder: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.force_reply: bool = True - self.selective: Optional[bool] = selective - self.input_field_placeholder: Optional[str] = input_field_placeholder + self.selective: bool | None = selective + self.input_field_placeholder: str | None = input_field_placeholder self._id_attrs = (self.selective,) diff --git a/telegram/_forumtopic.py b/src/telegram/_forumtopic.py similarity index 72% rename from telegram/_forumtopic.py rename to src/telegram/_forumtopic.py index 81b64e28c8e..51c6d8df71f 100644 --- a/telegram/_forumtopic.py +++ b/src/telegram/_forumtopic.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains objects related to Telegram forum topics.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -39,6 +38,10 @@ class ForumTopic(TelegramObject): icon_color (:obj:`int`): Color of the topic icon in RGB format icon_custom_emoji_id (:obj:`str`, optional): Unique identifier of the custom emoji shown as the topic icon. + is_name_implicit (:obj:`bool`, optional): :obj:`True`, if the name of the topic wasn't + specified explicitly by its creator and likely needs to be changed by the bot. + + .. versionadded:: 22.6 Attributes: message_thread_id (:obj:`int`): Unique identifier of the forum topic @@ -46,24 +49,36 @@ class ForumTopic(TelegramObject): icon_color (:obj:`int`): Color of the topic icon in RGB format icon_custom_emoji_id (:obj:`str`): Optional. Unique identifier of the custom emoji shown as the topic icon. + is_name_implicit (:obj:`bool`): Optional. :obj:`True`, if the name of the topic wasn't + specified explicitly by its creator and likely needs to be changed by the bot. + + .. versionadded:: 22.6 """ - __slots__ = ("icon_color", "icon_custom_emoji_id", "message_thread_id", "name") + __slots__ = ( + "icon_color", + "icon_custom_emoji_id", + "is_name_implicit", + "message_thread_id", + "name", + ) def __init__( self, message_thread_id: int, name: str, icon_color: int, - icon_custom_emoji_id: Optional[str] = None, + icon_custom_emoji_id: str | None = None, + is_name_implicit: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.message_thread_id: int = message_thread_id self.name: str = name self.icon_color: int = icon_color - self.icon_custom_emoji_id: Optional[str] = icon_custom_emoji_id + self.icon_custom_emoji_id: str | None = icon_custom_emoji_id + self.is_name_implicit: bool | None = is_name_implicit self._id_attrs = (self.message_thread_id, self.name, self.icon_color) @@ -85,28 +100,38 @@ class ForumTopicCreated(TelegramObject): icon_color (:obj:`int`): Color of the topic icon in RGB format icon_custom_emoji_id (:obj:`str`, optional): Unique identifier of the custom emoji shown as the topic icon. + is_name_implicit (:obj:`bool`, optional): :obj:`True`, if the name of the topic wasn't + specified explicitly by its creator and likely needs to be changed by the bot. + + .. versionadded:: 22.6 Attributes: name (:obj:`str`): Name of the topic icon_color (:obj:`int`): Color of the topic icon in RGB format icon_custom_emoji_id (:obj:`str`): Optional. Unique identifier of the custom emoji shown as the topic icon. + is_name_implicit (:obj:`bool`): Optional. :obj:`True`, if the name of the topic wasn't + specified explicitly by its creator and likely needs to be changed by the bot. + + .. versionadded:: 22.6 """ - __slots__ = ("icon_color", "icon_custom_emoji_id", "name") + __slots__ = ("icon_color", "icon_custom_emoji_id", "is_name_implicit", "name") def __init__( self, name: str, icon_color: int, - icon_custom_emoji_id: Optional[str] = None, + icon_custom_emoji_id: str | None = None, + is_name_implicit: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.name: str = name self.icon_color: int = icon_color - self.icon_custom_emoji_id: Optional[str] = icon_custom_emoji_id + self.icon_custom_emoji_id: str | None = icon_custom_emoji_id + self.is_name_implicit: bool | None = is_name_implicit self._id_attrs = (self.name, self.icon_color) @@ -123,7 +148,7 @@ class ForumTopicClosed(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(api_kwargs=api_kwargs) self._freeze() @@ -139,7 +164,7 @@ class ForumTopicReopened(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(api_kwargs=api_kwargs) self._freeze() @@ -169,14 +194,14 @@ class ForumTopicEdited(TelegramObject): def __init__( self, - name: Optional[str] = None, - icon_custom_emoji_id: Optional[str] = None, + name: str | None = None, + icon_custom_emoji_id: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.name: Optional[str] = name - self.icon_custom_emoji_id: Optional[str] = icon_custom_emoji_id + self.name: str | None = name + self.icon_custom_emoji_id: str | None = icon_custom_emoji_id self._id_attrs = (self.name, self.icon_custom_emoji_id) @@ -193,7 +218,7 @@ class GeneralForumTopicHidden(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self._freeze() @@ -209,7 +234,7 @@ class GeneralForumTopicUnhidden(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self._freeze() diff --git a/telegram/_inline/__init__.py b/src/telegram/_games/__init__.py similarity index 100% rename from telegram/_inline/__init__.py rename to src/telegram/_games/__init__.py diff --git a/telegram/_games/callbackgame.py b/src/telegram/_games/callbackgame.py similarity index 90% rename from telegram/_games/callbackgame.py rename to src/telegram/_games/callbackgame.py index 0917a116b7f..093a5449b81 100644 --- a/telegram/_games/callbackgame.py +++ b/src/telegram/_games/callbackgame.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram CallbackGame.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -29,7 +27,7 @@ class CallbackGame(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(api_kwargs=api_kwargs) self._freeze() diff --git a/telegram/_games/game.py b/src/telegram/_games/game.py similarity index 89% rename from telegram/_games/game.py rename to src/telegram/_games/game.py index ab74276ced0..898838e8b97 100644 --- a/telegram/_games/game.py +++ b/src/telegram/_games/game.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,15 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Game.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._files.animation import Animation from telegram._files.photosize import PhotoSize from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.strings import TextEncoding from telegram._utils.types import JSONDict @@ -103,11 +104,11 @@ def __init__( title: str, description: str, photo: Sequence[PhotoSize], - text: Optional[str] = None, - text_entities: Optional[Sequence[MessageEntity]] = None, - animation: Optional[Animation] = None, + text: str | None = None, + text_entities: Sequence[MessageEntity] | None = None, + animation: Animation | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -115,25 +116,22 @@ def __init__( self.description: str = description self.photo: tuple[PhotoSize, ...] = parse_sequence_arg(photo) # Optionals - self.text: Optional[str] = text + self.text: str | None = text self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) - self.animation: Optional[Animation] = animation + self.animation: Animation | None = animation self._id_attrs = (self.title, self.description, self.photo) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Game"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Game": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["photo"] = PhotoSize.de_list(data.get("photo"), bot) - data["text_entities"] = MessageEntity.de_list(data.get("text_entities"), bot) - data["animation"] = Animation.de_json(data.get("animation"), bot) + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + data["text_entities"] = de_list_optional(data.get("text_entities"), MessageEntity, bot) + data["animation"] = de_json_optional(data.get("animation"), Animation, bot) return super().de_json(data=data, bot=bot) @@ -164,7 +162,7 @@ def parse_text_entity(self, entity: MessageEntity) -> str: return entity_text.decode(TextEncoding.UTF_16_LE) - def parse_text_entities(self, types: Optional[list[str]] = None) -> dict[MessageEntity, str]: + def parse_text_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: """ Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. It contains entities from this message filtered by their diff --git a/telegram/_games/gamehighscore.py b/src/telegram/_games/gamehighscore.py similarity index 87% rename from telegram/_games/gamehighscore.py rename to src/telegram/_games/gamehighscore.py index ad3dbd7b910..482a61c9659 100644 --- a/telegram/_games/gamehighscore.py +++ b/src/telegram/_games/gamehighscore.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,10 +18,11 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram GameHighScore.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -49,7 +50,7 @@ class GameHighScore(TelegramObject): __slots__ = ("position", "score", "user") def __init__( - self, position: int, user: User, score: int, *, api_kwargs: Optional[JSONDict] = None + self, position: int, user: User, score: int, *, api_kwargs: JSONDict | None = None ): super().__init__(api_kwargs=api_kwargs) self.position: int = position @@ -61,15 +62,10 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["GameHighScore"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "GameHighScore": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["user"] = User.de_json(data.get("user"), bot) + data["user"] = de_json_optional(data.get("user"), User, bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_gifts.py b/src/telegram/_gifts.py new file mode 100644 index 00000000000..84a9bad7100 --- /dev/null +++ b/src/telegram/_gifts.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python +# pylint: disable=redefined-builtin +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/] +"""This module contains classes related to gifs sent by bots.""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._chat import Chat +from telegram._files.sticker import Sticker +from telegram._messageentity import MessageEntity +from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.entities import parse_message_entities, parse_message_entity +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class GiftBackground(TelegramObject): + """This object describes the background of a gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`center_color`, :attr:`edge_color` and :attr:`text_color` are + equal. + + .. versionadded:: 22.6 + + Args: + center_color (:obj:`int`): Center color of the background in RGB format. + edge_color (:obj:`int`): Edge color of the background in RGB format. + text_color (:obj:`int`): Text color of the background in RGB format. + + Attributes: + center_color (:obj:`int`): Center color of the background in RGB format. + edge_color (:obj:`int`): Edge color of the background in RGB format. + text_color (:obj:`int`): Text color of the background in RGB format. + + """ + + __slots__ = ( + "center_color", + "edge_color", + "text_color", + ) + + def __init__( + self, + center_color: int, + edge_color: int, + text_color: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.center_color: int = center_color + self.edge_color: int = edge_color + self.text_color: int = text_color + + self._id_attrs = ( + self.center_color, + self.edge_color, + self.text_color, + ) + + self._freeze() + + +class Gift(TelegramObject): + """This object represents a gift that can be sent by the bot. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`id` is equal. + + .. versionadded:: 21.8 + + Args: + id (:obj:`str`): Unique identifier of the gift. + sticker (:class:`~telegram.Sticker`): The sticker that represents the gift. + star_count (:obj:`int`): The number of Telegram Stars that must be paid to send the + sticker. + total_count (:obj:`int`, optional): The total number of the gifts of this type that can be + sent by all users; for limited gifts only. + remaining_count (:obj:`int`, optional): The number of remaining gifts of this type that can + be sent by all users; for limited gifts only. + upgrade_star_count (:obj:`int`, optional): The number of Telegram Stars that must be paid + to upgrade the gift to a unique one. + + .. versionadded:: 21.10 + publisher_chat (:class:`telegram.Chat`, optional): Information about the chat that + published the gift. + + .. versionadded:: 22.4 + personal_total_count (:obj:`int`, optional): The total number of gifts of this type that + can be sent by the bot; for limited gifts only. + + .. versionadded:: 22.6 + personal_remaining_count (:obj:`int`, optional): The number of remaining gifts of this type + that can be sent by the bot; for limited gifts only. + + .. versionadded:: 22.6 + background (:class:`GiftBackground`, optional): Background of the gift. + + .. versionadded:: 22.6 + is_premium (:obj:`bool`, optional): :obj:`True`, if the gift can only be purchased by + Telegram Premium subscribers. + + .. versionadded:: 22.6 + has_colors (:obj:`bool`, optional): :obj:`True`, if the gift can be used (after being + upgraded) to customize a user's appearance. + + .. versionadded:: 22.6 + unique_gift_variant_count (:obj:`int`, optional): The total number of different unique + gifts that can be obtained by upgrading the gift. + + .. versionadded:: 22.6 + + Attributes: + id (:obj:`str`): Unique identifier of the gift. + sticker (:class:`~telegram.Sticker`): The sticker that represents the gift. + star_count (:obj:`int`): The number of Telegram Stars that must be paid to send the + sticker. + total_count (:obj:`int`): Optional. The total number of the gifts of this type that can be + sent by all users; for limited gifts only. + remaining_count (:obj:`int`): Optional. The number of remaining gifts of this type that can + be sent by all users; for limited gifts only. + upgrade_star_count (:obj:`int`): Optional. The number of Telegram Stars that must be paid + to upgrade the gift to a unique one. + + .. versionadded:: 21.10 + publisher_chat (:class:`telegram.Chat`): Optional. Information about the chat that + published the gift. + + .. versionadded:: 22.4 + personal_total_count (:obj:`int`): Optional. The total number of gifts of this type that + can be sent by the bot; for limited gifts only. + + .. versionadded:: 22.6 + personal_remaining_count (:obj:`int`): Optional. The number of remaining gifts of this type + that can be sent by the bot; for limited gifts only. + + .. versionadded:: 22.6 + background (:class:`GiftBackground`): Optional. Background of the gift. + + .. versionadded:: 22.6 + is_premium (:obj:`bool`): Optional. :obj:`True`, if the gift can only be purchased by + Telegram Premium subscribers. + + .. versionadded:: 22.6 + has_colors (:obj:`bool`): Optional. :obj:`True`, if the gift can be used (after being + upgraded) to customize a user's appearance. + + .. versionadded:: 22.6 + unique_gift_variant_count (:obj:`int`): Optional. The total number of different unique + gifts that can be obtained by upgrading the gift. + + .. versionadded:: 22.6 + + """ + + __slots__ = ( + "background", + "has_colors", + "id", + "is_premium", + "personal_remaining_count", + "personal_total_count", + "publisher_chat", + "remaining_count", + "star_count", + "sticker", + "total_count", + "unique_gift_variant_count", + "upgrade_star_count", + ) + + def __init__( + self, + id: str, + sticker: Sticker, + star_count: int, + total_count: int | None = None, + remaining_count: int | None = None, + upgrade_star_count: int | None = None, + publisher_chat: Chat | None = None, + personal_total_count: int | None = None, + personal_remaining_count: int | None = None, + background: GiftBackground | None = None, + is_premium: bool | None = None, + has_colors: bool | None = None, + unique_gift_variant_count: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: str = id + self.sticker: Sticker = sticker + self.star_count: int = star_count + self.total_count: int | None = total_count + self.remaining_count: int | None = remaining_count + self.upgrade_star_count: int | None = upgrade_star_count + self.publisher_chat: Chat | None = publisher_chat + self.personal_total_count: int | None = personal_total_count + self.personal_remaining_count: int | None = personal_remaining_count + self.background: GiftBackground | None = background + self.is_premium: bool | None = is_premium + self.has_colors: bool | None = has_colors + self.unique_gift_variant_count: int | None = unique_gift_variant_count + + self._id_attrs = (self.id,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Gift": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + data["publisher_chat"] = de_json_optional(data.get("publisher_chat"), Chat, bot) + data["background"] = de_json_optional(data.get("background"), GiftBackground, bot) + return super().de_json(data=data, bot=bot) + + +class Gifts(TelegramObject): + """This object represent a list of gifts. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`gifts` are equal. + + .. versionadded:: 21.8 + + Args: + gifts (Sequence[:class:`Gift`]): The sequence of gifts. + + Attributes: + gifts (tuple[:class:`Gift`]): The sequence of gifts. + + """ + + __slots__ = ("gifts",) + + def __init__( + self, + gifts: Sequence[Gift], + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.gifts: tuple[Gift, ...] = parse_sequence_arg(gifts) + + self._id_attrs = (self.gifts,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Gifts": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["gifts"] = de_list_optional(data.get("gifts"), Gift, bot) + return super().de_json(data=data, bot=bot) + + +class GiftInfo(TelegramObject): + """Describes a service message about a regular gift that was sent or received. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`gift` is equal. + + .. versionadded:: 22.1 + + Args: + gift (:class:`Gift`): Information about the gift. + owned_gift_id (:obj:`str`, optional): Unique identifier of the received gift for the bot; + only present for gifts received on behalf of business accounts. + convert_star_count (:obj:`int`, optional) Number of Telegram Stars that can be claimed by + the receiver by converting the gift; omitted if conversion to Telegram Stars + is impossible. + prepaid_upgrade_star_count (:obj:`int`, optional): Number of Telegram Stars that were + prepaid for the ability to upgrade the gift. + can_be_upgraded (:obj:`bool`, optional): :obj:`True`, if the gift can be upgraded + to a unique gift. + text (:obj:`str`, optional): Text of the message that was added to the gift. + entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities that + appear in the text. + is_private (:obj:`bool`, optional): :obj:`True`, if the sender and gift text are + shown only to the gift receiver; otherwise, everyone will be able to see them. + is_upgrade_separate (:obj:`bool`, optional): :obj:`True`, if the gift's upgrade was + purchased after the gift was sent. + + .. versionadded:: 22.6 + unique_gift_number (:obj:`int`, optional): Unique number reserved for this gift when + upgraded. See the number field in :class:`~telegram.UniqueGift`. + + .. versionadded:: 22.6 + + Attributes: + gift (:class:`Gift`): Information about the gift. + owned_gift_id (:obj:`str`): Optional. Unique identifier of the received gift for the bot; + only present for gifts received on behalf of business accounts. + convert_star_count (:obj:`int`): Optional. Number of Telegram Stars that can be claimed by + the receiver by converting the gift; omitted if conversion to Telegram Stars + is impossible. + prepaid_upgrade_star_count (:obj:`int`): Optional. Number of Telegram Stars that were + prepaid for the ability to upgrade the gift. + can_be_upgraded (:obj:`bool`): Optional. :obj:`True`, if the gift can be upgraded + to a unique gift. + text (:obj:`str`): Optional. Text of the message that was added to the gift. + entities (Sequence[:class:`telegram.MessageEntity`]): Optional. Special entities that + appear in the text. + is_private (:obj:`bool`): Optional. :obj:`True`, if the sender and gift text are + shown only to the gift receiver; otherwise, everyone will be able to see them. + is_upgrade_separate (:obj:`bool`): Optional. :obj:`True`, if the gift's upgrade was + purchased after the gift was sent. + + .. versionadded:: 22.6 + unique_gift_number (:obj:`int`): Optional. Unique number reserved for this gift when + upgraded. See the number field in :class:`~telegram.UniqueGift`. + + .. versionadded:: 22.6 + + """ + + __slots__ = ( + "can_be_upgraded", + "convert_star_count", + "entities", + "gift", + "is_private", + "is_upgrade_separate", + "owned_gift_id", + "prepaid_upgrade_star_count", + "text", + "unique_gift_number", + ) + + def __init__( + self, + gift: Gift, + owned_gift_id: str | None = None, + convert_star_count: int | None = None, + prepaid_upgrade_star_count: int | None = None, + can_be_upgraded: bool | None = None, + text: str | None = None, + entities: Sequence[MessageEntity] | None = None, + is_private: bool | None = None, + unique_gift_number: int | None = None, + is_upgrade_separate: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.gift: Gift = gift + # Optional + self.owned_gift_id: str | None = owned_gift_id + self.convert_star_count: int | None = convert_star_count + self.prepaid_upgrade_star_count: int | None = prepaid_upgrade_star_count + self.can_be_upgraded: bool | None = can_be_upgraded + self.text: str | None = text + self.entities: tuple[MessageEntity, ...] = parse_sequence_arg(entities) + self.is_private: bool | None = is_private + self.unique_gift_number: int | None = unique_gift_number + self.is_upgrade_separate: bool | None = is_upgrade_separate + + self._id_attrs = (self.gift,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "GiftInfo": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["gift"] = de_json_optional(data.get("gift"), Gift, bot) + data["entities"] = de_list_optional(data.get("entities"), MessageEntity, bot) + + return super().de_json(data=data, bot=bot) + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`text` + from a given :class:`telegram.MessageEntity` of :attr:`entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`entities`. + + Returns: + :obj:`str`: The text of the given entity. + + Raises: + RuntimeError: If the gift info has no text. + + """ + if not self.text: + raise RuntimeError("This GiftInfo has no 'text'.") + + return parse_message_entity(self.text, entity) + + def parse_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this gift info's text filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + + Raises: + RuntimeError: If the gift info has no text. + + """ + if not self.text: + raise RuntimeError("This GiftInfo has no 'text'.") + + return parse_message_entities(self.text, self.entities, types) + + +class AcceptedGiftTypes(TelegramObject): + """This object describes the types of gifts that can be gifted to a user or a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`unlimited_gifts`, :attr:`limited_gifts`, + :attr:`unique_gifts`, :attr:`premium_subscription` and :attr:`gifts_from_channels` are equal. + + .. versionadded:: 22.1 + .. versionchanged:: 22.6 + :attr:`gifts_from_channels` is now considered for equality checks. + + Args: + unlimited_gifts (:class:`bool`): :obj:`True`, if unlimited regular gifts are accepted. + limited_gifts (:class:`bool`): :obj:`True`, if limited regular gifts are accepted. + unique_gifts (:class:`bool`): :obj:`True`, if unique gifts or gifts that can be upgraded + to unique for free are accepted. + premium_subscription (:class:`bool`): :obj:`True`, if a Telegram Premium subscription + is accepted. + gifts_from_channels (:obj:`bool`): :obj:`True`, if transfers of unique gifts from channels + are accepted + + .. versionadded:: 22.6 + + Attributes: + unlimited_gifts (:class:`bool`): :obj:`True`, if unlimited regular gifts are accepted. + limited_gifts (:class:`bool`): :obj:`True`, if limited regular gifts are accepted. + unique_gifts (:class:`bool`): :obj:`True`, if unique gifts or gifts that can be upgraded + to unique for free are accepted. + premium_subscription (:class:`bool`): :obj:`True`, if a Telegram Premium subscription + is accepted. + gifts_from_channels (:obj:`bool`): :obj:`True`, if transfers of unique gifts from channels + are accepted + + .. versionadded:: 22.6 + + """ + + __slots__ = ( + "gifts_from_channels", + "limited_gifts", + "premium_subscription", + "unique_gifts", + "unlimited_gifts", + ) + + def __init__( + self, + unlimited_gifts: bool, + limited_gifts: bool, + unique_gifts: bool, + premium_subscription: bool, + gifts_from_channels: bool, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.unlimited_gifts: bool = unlimited_gifts + self.limited_gifts: bool = limited_gifts + self.unique_gifts: bool = unique_gifts + self.premium_subscription: bool = premium_subscription + self.gifts_from_channels: bool = gifts_from_channels + + self._id_attrs = ( + self.unlimited_gifts, + self.limited_gifts, + self.unique_gifts, + self.premium_subscription, + self.gifts_from_channels, + ) + + self._freeze() diff --git a/telegram/_giveaway.py b/src/telegram/_giveaway.py similarity index 81% rename from telegram/_giveaway.py rename to src/telegram/_giveaway.py index 67c6b235c14..3fe25c40920 100644 --- a/telegram/_giveaway.py +++ b/src/telegram/_giveaway.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,15 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an objects that are related to Telegram giveaways.""" + import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._chat import Chat from telegram._telegramobject import TelegramObject from telegram._user import User -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict @@ -107,26 +108,26 @@ def __init__( chats: Sequence[Chat], winners_selection_date: dtm.datetime, winner_count: int, - only_new_members: Optional[bool] = None, - has_public_winners: Optional[bool] = None, - prize_description: Optional[str] = None, - country_codes: Optional[Sequence[str]] = None, - premium_subscription_month_count: Optional[int] = None, - prize_star_count: Optional[int] = None, + only_new_members: bool | None = None, + has_public_winners: bool | None = None, + prize_description: str | None = None, + country_codes: Sequence[str] | None = None, + premium_subscription_month_count: int | None = None, + prize_star_count: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.chats: tuple[Chat, ...] = tuple(chats) self.winners_selection_date: dtm.datetime = winners_selection_date self.winner_count: int = winner_count - self.only_new_members: Optional[bool] = only_new_members - self.has_public_winners: Optional[bool] = has_public_winners - self.prize_description: Optional[str] = prize_description + self.only_new_members: bool | None = only_new_members + self.has_public_winners: bool | None = has_public_winners + self.prize_description: str | None = prize_description self.country_codes: tuple[str, ...] = parse_sequence_arg(country_codes) - self.premium_subscription_month_count: Optional[int] = premium_subscription_month_count - self.prize_star_count: Optional[int] = prize_star_count + self.premium_subscription_month_count: int | None = premium_subscription_month_count + self.prize_star_count: int | None = prize_star_count self._id_attrs = ( self.chats, @@ -137,19 +138,14 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["Giveaway"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Giveaway": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if data is None: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) - data["chats"] = tuple(Chat.de_list(data.get("chats"), bot)) + data["chats"] = de_list_optional(data.get("chats"), Chat, bot) data["winners_selection_date"] = from_timestamp( data.get("winners_selection_date"), tzinfo=loc_tzinfo ) @@ -176,11 +172,9 @@ class GiveawayCreated(TelegramObject): __slots__ = ("prize_star_count",) - def __init__( - self, prize_star_count: Optional[int] = None, *, api_kwargs: Optional[JSONDict] = None - ): + def __init__(self, prize_star_count: int | None = None, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) - self.prize_star_count: Optional[int] = prize_star_count + self.prize_star_count: int | None = prize_star_count self._freeze() @@ -263,15 +257,15 @@ def __init__( winners_selection_date: dtm.datetime, winner_count: int, winners: Sequence[User], - additional_chat_count: Optional[int] = None, - premium_subscription_month_count: Optional[int] = None, - unclaimed_prize_count: Optional[int] = None, - only_new_members: Optional[bool] = None, - was_refunded: Optional[bool] = None, - prize_description: Optional[str] = None, - prize_star_count: Optional[int] = None, + additional_chat_count: int | None = None, + premium_subscription_month_count: int | None = None, + unclaimed_prize_count: int | None = None, + only_new_members: bool | None = None, + was_refunded: bool | None = None, + prize_description: str | None = None, + prize_star_count: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -280,13 +274,13 @@ def __init__( self.winners_selection_date: dtm.datetime = winners_selection_date self.winner_count: int = winner_count self.winners: tuple[User, ...] = tuple(winners) - self.additional_chat_count: Optional[int] = additional_chat_count - self.premium_subscription_month_count: Optional[int] = premium_subscription_month_count - self.unclaimed_prize_count: Optional[int] = unclaimed_prize_count - self.only_new_members: Optional[bool] = only_new_members - self.was_refunded: Optional[bool] = was_refunded - self.prize_description: Optional[str] = prize_description - self.prize_star_count: Optional[int] = prize_star_count + self.additional_chat_count: int | None = additional_chat_count + self.premium_subscription_month_count: int | None = premium_subscription_month_count + self.unclaimed_prize_count: int | None = unclaimed_prize_count + self.only_new_members: bool | None = only_new_members + self.was_refunded: bool | None = was_refunded + self.prize_description: str | None = prize_description + self.prize_star_count: int | None = prize_star_count self._id_attrs = ( self.chat, @@ -299,20 +293,15 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["GiveawayWinners"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "GiveawayWinners": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if data is None: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) - data["chat"] = Chat.de_json(data.get("chat"), bot) - data["winners"] = tuple(User.de_list(data.get("winners"), bot)) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["winners"] = de_list_optional(data.get("winners"), User, bot) data["winners_selection_date"] = from_timestamp( data.get("winners_selection_date"), tzinfo=loc_tzinfo ) @@ -355,18 +344,18 @@ class GiveawayCompleted(TelegramObject): def __init__( self, winner_count: int, - unclaimed_prize_count: Optional[int] = None, - giveaway_message: Optional["Message"] = None, - is_star_giveaway: Optional[bool] = None, + unclaimed_prize_count: int | None = None, + giveaway_message: "Message | None" = None, + is_star_giveaway: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.winner_count: int = winner_count - self.unclaimed_prize_count: Optional[int] = unclaimed_prize_count - self.giveaway_message: Optional[Message] = giveaway_message - self.is_star_giveaway: Optional[bool] = is_star_giveaway + self.unclaimed_prize_count: int | None = unclaimed_prize_count + self.giveaway_message: Message | None = giveaway_message + self.is_star_giveaway: bool | None = is_star_giveaway self._id_attrs = ( self.winner_count, @@ -376,18 +365,15 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["GiveawayCompleted"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "GiveawayCompleted": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if data is None: - return None - # Unfortunately, this needs to be here due to cyclic imports - from telegram._message import Message # pylint: disable=import-outside-toplevel + from telegram._message import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + Message, + ) - data["giveaway_message"] = Message.de_json(data.get("giveaway_message"), bot) + data["giveaway_message"] = de_json_optional(data.get("giveaway_message"), Message, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_passport/__init__.py b/src/telegram/_inline/__init__.py similarity index 100% rename from telegram/_passport/__init__.py rename to src/telegram/_inline/__init__.py diff --git a/telegram/_inline/inlinekeyboardbutton.py b/src/telegram/_inline/inlinekeyboardbutton.py similarity index 87% rename from telegram/_inline/inlinekeyboardbutton.py rename to src/telegram/_inline/inlinekeyboardbutton.py index 61169cac38c..7114fac60e5 100644 --- a/telegram/_inline/inlinekeyboardbutton.py +++ b/src/telegram/_inline/inlinekeyboardbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram InlineKeyboardButton.""" -from typing import TYPE_CHECKING, Final, Optional, Union +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._copytextbutton import CopyTextButton @@ -26,6 +26,7 @@ from telegram._loginurl import LoginUrl from telegram._switchinlinequerychosenchat import SwitchInlineQueryChosenChat from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict from telegram._webappinfo import WebAppInfo @@ -247,36 +248,36 @@ class InlineKeyboardButton(TelegramObject): def __init__( self, text: str, - url: Optional[str] = None, - callback_data: Optional[Union[str, object]] = None, - switch_inline_query: Optional[str] = None, - switch_inline_query_current_chat: Optional[str] = None, - callback_game: Optional[CallbackGame] = None, - pay: Optional[bool] = None, - login_url: Optional[LoginUrl] = None, - web_app: Optional[WebAppInfo] = None, - switch_inline_query_chosen_chat: Optional[SwitchInlineQueryChosenChat] = None, - copy_text: Optional[CopyTextButton] = None, + url: str | None = None, + callback_data: str | object | None = None, + switch_inline_query: str | None = None, + switch_inline_query_current_chat: str | None = None, + callback_game: CallbackGame | None = None, + pay: bool | None = None, + login_url: LoginUrl | None = None, + web_app: WebAppInfo | None = None, + switch_inline_query_chosen_chat: SwitchInlineQueryChosenChat | None = None, + copy_text: CopyTextButton | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.text: str = text # Optionals - self.url: Optional[str] = url - self.login_url: Optional[LoginUrl] = login_url - self.callback_data: Optional[Union[str, object]] = callback_data - self.switch_inline_query: Optional[str] = switch_inline_query - self.switch_inline_query_current_chat: Optional[str] = switch_inline_query_current_chat - self.callback_game: Optional[CallbackGame] = callback_game - self.pay: Optional[bool] = pay - self.web_app: Optional[WebAppInfo] = web_app - self.switch_inline_query_chosen_chat: Optional[SwitchInlineQueryChosenChat] = ( + self.url: str | None = url + self.login_url: LoginUrl | None = login_url + self.callback_data: str | object | None = callback_data + self.switch_inline_query: str | None = switch_inline_query + self.switch_inline_query_current_chat: str | None = switch_inline_query_current_chat + self.callback_game: CallbackGame | None = callback_game + self.pay: bool | None = pay + self.web_app: WebAppInfo | None = web_app + self.switch_inline_query_chosen_chat: SwitchInlineQueryChosenChat | None = ( switch_inline_query_chosen_chat ) - self.copy_text: Optional[CopyTextButton] = copy_text + self.copy_text: CopyTextButton | None = copy_text self._id_attrs = () self._set_id_attrs() @@ -296,26 +297,21 @@ def _set_id_attrs(self) -> None: ) @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["InlineKeyboardButton"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InlineKeyboardButton": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["login_url"] = LoginUrl.de_json(data.get("login_url"), bot) - data["web_app"] = WebAppInfo.de_json(data.get("web_app"), bot) - data["callback_game"] = CallbackGame.de_json(data.get("callback_game"), bot) - data["switch_inline_query_chosen_chat"] = SwitchInlineQueryChosenChat.de_json( - data.get("switch_inline_query_chosen_chat"), bot + data["login_url"] = de_json_optional(data.get("login_url"), LoginUrl, bot) + data["web_app"] = de_json_optional(data.get("web_app"), WebAppInfo, bot) + data["callback_game"] = de_json_optional(data.get("callback_game"), CallbackGame, bot) + data["switch_inline_query_chosen_chat"] = de_json_optional( + data.get("switch_inline_query_chosen_chat"), SwitchInlineQueryChosenChat, bot ) - data["copy_text"] = CopyTextButton.de_json(data.get("copy_text"), bot) + data["copy_text"] = de_json_optional(data.get("copy_text"), CopyTextButton, bot) return super().de_json(data=data, bot=bot) - def update_callback_data(self, callback_data: Union[str, object]) -> None: + def update_callback_data(self, callback_data: str | object) -> None: """ Sets :attr:`callback_data` to the passed object. Intended to be used by :class:`telegram.ext.CallbackDataCache`. diff --git a/telegram/_inline/inlinekeyboardmarkup.py b/src/telegram/_inline/inlinekeyboardmarkup.py similarity index 94% rename from telegram/_inline/inlinekeyboardmarkup.py rename to src/telegram/_inline/inlinekeyboardmarkup.py index 834225e761e..37040bc9ec5 100644 --- a/telegram/_inline/inlinekeyboardmarkup.py +++ b/src/telegram/_inline/inlinekeyboardmarkup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram InlineKeyboardMarkup.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardbutton import InlineKeyboardButton from telegram._telegramobject import TelegramObject @@ -73,7 +74,7 @@ def __init__( self, inline_keyboard: Sequence[Sequence[InlineKeyboardButton]], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) if not check_keyboard_type(inline_keyboard): @@ -91,12 +92,8 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["InlineKeyboardMarkup"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InlineKeyboardMarkup": """See :meth:`telegram.TelegramObject.de_json`.""" - if not data: - return None keyboard = [] for row in data["inline_keyboard"]: diff --git a/telegram/_inline/inlinequery.py b/src/telegram/_inline/inlinequery.py similarity index 83% rename from telegram/_inline/inlinequery.py rename to src/telegram/_inline/inlinequery.py index dd6b957d0a7..48ff6162f17 100644 --- a/telegram/_inline/inlinequery.py +++ b/src/telegram/_inline/inlinequery.py @@ -2,7 +2,7 @@ # pylint: disable=too-many-arguments # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,16 +19,17 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram InlineQuery.""" -from collections.abc import Sequence -from typing import TYPE_CHECKING, Callable, Final, Optional, Union +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._files.location import Location from telegram._inline.inlinequeryresultsbutton import InlineQueryResultsButton from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod if TYPE_CHECKING: from telegram import Bot, InlineQueryResult @@ -61,6 +62,12 @@ class InlineQuery(TelegramObject): ``auto_pagination``. Use a named argument for those, and notice that some positional arguments changed position as a result. + .. versionchanged:: 22.0 + Removed constants ``MIN_START_PARAMETER_LENGTH`` and ``MAX_START_PARAMETER_LENGTH``. + Use :attr:`telegram.constants.InlineQueryResultsButtonLimit.MIN_START_PARAMETER_LENGTH` and + :attr:`telegram.constants.InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH` + instead. + Args: id (:obj:`str`): Unique identifier for this query. from_user (:class:`telegram.User`): Sender. @@ -105,10 +112,10 @@ def __init__( from_user: User, query: str, offset: str, - location: Optional[Location] = None, - chat_type: Optional[str] = None, + location: "Location | None" = None, + chat_type: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -118,45 +125,40 @@ def __init__( self.offset: str = offset # Optional - self.location: Optional[Location] = location - self.chat_type: Optional[str] = chat_type + self.location: Location | None = location + self.chat_type: str | None = chat_type self._id_attrs = (self.id,) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["InlineQuery"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InlineQuery": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["from_user"] = User.de_json(data.pop("from", None), bot) - data["location"] = Location.de_json(data.get("location"), bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) return super().de_json(data=data, bot=bot) async def answer( self, - results: Union[ - Sequence["InlineQueryResult"], Callable[[int], Optional[Sequence["InlineQueryResult"]]] - ], - cache_time: Optional[int] = None, - is_personal: Optional[bool] = None, - next_offset: Optional[str] = None, - button: Optional[InlineQueryResultsButton] = None, + results: ( + Sequence["InlineQueryResult"] | Callable[[int], Sequence["InlineQueryResult"] | None] + ), + cache_time: TimePeriod | None = None, + is_personal: bool | None = None, + next_offset: str | None = None, + button: InlineQueryResultsButton | None = None, *, - current_offset: Optional[str] = None, + current_offset: str | None = None, auto_pagination: bool = False, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -206,16 +208,6 @@ async def answer( .. versionadded:: 13.2 """ - MIN_SWITCH_PM_TEXT_LENGTH: Final[int] = constants.InlineQueryLimit.MIN_SWITCH_PM_TEXT_LENGTH - """:const:`telegram.constants.InlineQueryLimit.MIN_SWITCH_PM_TEXT_LENGTH` - - .. versionadded:: 20.0 - """ - MAX_SWITCH_PM_TEXT_LENGTH: Final[int] = constants.InlineQueryLimit.MAX_SWITCH_PM_TEXT_LENGTH - """:const:`telegram.constants.InlineQueryLimit.MAX_SWITCH_PM_TEXT_LENGTH` - - .. versionadded:: 20.0 - """ MAX_OFFSET_LENGTH: Final[int] = constants.InlineQueryLimit.MAX_OFFSET_LENGTH """:const:`telegram.constants.InlineQueryLimit.MAX_OFFSET_LENGTH` diff --git a/telegram/_inline/inlinequeryresult.py b/src/telegram/_inline/inlinequeryresult.py similarity index 94% rename from telegram/_inline/inlinequeryresult.py rename to src/telegram/_inline/inlinequeryresult.py index 67ce6e421f3..7e708ebbfe2 100644 --- a/telegram/_inline/inlinequeryresult.py +++ b/src/telegram/_inline/inlinequeryresult.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,7 +19,7 @@ # pylint: disable=redefined-builtin """This module contains the classes that represent Telegram InlineQueryResult.""" -from typing import Final, Optional +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -56,7 +56,7 @@ class InlineQueryResult(TelegramObject): __slots__ = ("id", "type") - def __init__(self, type: str, id: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, type: str, id: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) # Required diff --git a/telegram/_inline/inlinequeryresultarticle.py b/src/telegram/_inline/inlinequeryresultarticle.py similarity index 68% rename from telegram/_inline/inlinequeryresultarticle.py rename to src/telegram/_inline/inlinequeryresultarticle.py index 5fcee852325..cca861b76f7 100644 --- a/telegram/_inline/inlinequeryresultarticle.py +++ b/src/telegram/_inline/inlinequeryresultarticle.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,14 +18,12 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultArticle.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._utils.types import JSONDict -from telegram._utils.warnings import warn from telegram.constants import InlineQueryResultType -from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import InputMessageContent @@ -40,6 +38,9 @@ class InlineQueryResultArticle(InlineQueryResult): .. versionchanged:: 20.5 Removed the deprecated arguments and attributes ``thumb_*``. + .. versionchanged:: 21.11 + Removed the deprecated argument and attribute ``hide_url``. + Args: id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- @@ -50,12 +51,9 @@ class InlineQueryResultArticle(InlineQueryResult): reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached to the message. url (:obj:`str`, optional): URL of the result. - hide_url (:obj:`bool`, optional): Pass :obj:`True`, if you don't want the URL to be shown - in the message. - .. deprecated:: 21.10 - This attribute will be removed in future PTB versions. Pass an empty string as URL - instead. + Tip: + Pass an empty string as URL if you don't want the URL to be shown in the message. description (:obj:`str`, optional): Short description of the result. thumbnail_url (:obj:`str`, optional): Url of the thumbnail for the result. @@ -78,12 +76,6 @@ class InlineQueryResultArticle(InlineQueryResult): reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached to the message. url (:obj:`str`): Optional. URL of the result. - hide_url (:obj:`bool`): Optional. Pass :obj:`True`, if you don't want the URL to be shown - in the message. - - .. deprecated:: 21.10 - This attribute will be removed in future PTB versions. Pass an empty string as URL - instead. description (:obj:`str`): Optional. Short description of the result. thumbnail_url (:obj:`str`): Optional. Url of the thumbnail for the result. @@ -99,7 +91,6 @@ class InlineQueryResultArticle(InlineQueryResult): __slots__ = ( "description", - "hide_url", "input_message_content", "reply_markup", "thumbnail_height", @@ -114,15 +105,14 @@ def __init__( id: str, # pylint: disable=redefined-builtin title: str, input_message_content: "InputMessageContent", - reply_markup: Optional[InlineKeyboardMarkup] = None, - url: Optional[str] = None, - hide_url: Optional[bool] = None, - description: Optional[str] = None, - thumbnail_url: Optional[str] = None, - thumbnail_width: Optional[int] = None, - thumbnail_height: Optional[int] = None, + reply_markup: InlineKeyboardMarkup | None = None, + url: str | None = None, + description: str | None = None, + thumbnail_url: str | None = None, + thumbnail_width: int | None = None, + thumbnail_height: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.ARTICLE, id, api_kwargs=api_kwargs) @@ -131,19 +121,9 @@ def __init__( self.input_message_content: InputMessageContent = input_message_content # Optional - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.url: Optional[str] = url - if hide_url is not None: - warn( - PTBDeprecationWarning( - "21.10", - "The argument `hide_url` will be removed in future PTB" - "versions. Pass an empty string as URL instead.", - ), - stacklevel=2, - ) - self.hide_url: Optional[bool] = hide_url - self.description: Optional[str] = description - self.thumbnail_url: Optional[str] = thumbnail_url - self.thumbnail_width: Optional[int] = thumbnail_width - self.thumbnail_height: Optional[int] = thumbnail_height + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.url: str | None = url + self.description: str | None = description + self.thumbnail_url: str | None = thumbnail_url + self.thumbnail_width: int | None = thumbnail_width + self.thumbnail_height: int | None = thumbnail_height diff --git a/telegram/_inline/inlinequeryresultaudio.py b/src/telegram/_inline/inlinequeryresultaudio.py similarity index 74% rename from telegram/_inline/inlinequeryresultaudio.py rename to src/telegram/_inline/inlinequeryresultaudio.py index 8e3376a458f..02ce1a1eea1 100644 --- a/telegram/_inline/inlinequeryresultaudio.py +++ b/src/telegram/_inline/inlinequeryresultaudio.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,15 +17,18 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultAudio.""" + +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -47,7 +50,11 @@ class InlineQueryResultAudio(InlineQueryResult): audio_url (:obj:`str`): A valid URL for the audio file. title (:obj:`str`): Title. performer (:obj:`str`, optional): Performer. - audio_duration (:obj:`str`, optional): Audio duration in seconds. + audio_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Audio duration + in seconds. + + .. versionchanged:: v22.2 + |time-period-input| caption (:obj:`str`, optional): Caption, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -69,7 +76,11 @@ class InlineQueryResultAudio(InlineQueryResult): audio_url (:obj:`str`): A valid URL for the audio file. title (:obj:`str`): Title. performer (:obj:`str`): Optional. Performer. - audio_duration (:obj:`str`): Optional. Audio duration in seconds. + audio_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Audio duration + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| caption (:obj:`str`): Optional. Caption, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -88,7 +99,7 @@ class InlineQueryResultAudio(InlineQueryResult): """ __slots__ = ( - "audio_duration", + "_audio_duration", "audio_url", "caption", "caption_entities", @@ -104,15 +115,15 @@ def __init__( id: str, # pylint: disable=redefined-builtin audio_url: str, title: str, - performer: Optional[str] = None, - audio_duration: Optional[int] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + performer: str | None = None, + audio_duration: TimePeriod | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.AUDIO, id, api_kwargs=api_kwargs) @@ -121,10 +132,14 @@ def __init__( self.title: str = title # Optionals - self.performer: Optional[str] = performer - self.audio_duration: Optional[int] = audio_duration - self.caption: Optional[str] = caption + self.performer: str | None = performer + self._audio_duration: dtm.timedelta | None = to_timedelta(audio_duration) + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + + @property + def audio_duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._audio_duration, attribute="audio_duration") diff --git a/telegram/_inline/inlinequeryresultcachedaudio.py b/src/telegram/_inline/inlinequeryresultcachedaudio.py similarity index 88% rename from telegram/_inline/inlinequeryresultcachedaudio.py rename to src/telegram/_inline/inlinequeryresultcachedaudio.py index f1f75a12a6e..d925d92d33a 100644 --- a/telegram/_inline/inlinequeryresultcachedaudio.py +++ b/src/telegram/_inline/inlinequeryresultcachedaudio.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultCachedAudio.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -95,13 +96,13 @@ def __init__( self, id: str, # pylint: disable=redefined-builtin audio_file_id: str, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.AUDIO, id, api_kwargs=api_kwargs) @@ -109,8 +110,8 @@ def __init__( self.audio_file_id: str = audio_file_id # Optionals - self.caption: Optional[str] = caption + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content diff --git a/telegram/_inline/inlinequeryresultcacheddocument.py b/src/telegram/_inline/inlinequeryresultcacheddocument.py similarity index 88% rename from telegram/_inline/inlinequeryresultcacheddocument.py rename to src/telegram/_inline/inlinequeryresultcacheddocument.py index af2e6ef7989..0f7771ccba7 100644 --- a/telegram/_inline/inlinequeryresultcacheddocument.py +++ b/src/telegram/_inline/inlinequeryresultcacheddocument.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultCachedDocument.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -102,14 +103,14 @@ def __init__( id: str, # pylint: disable=redefined-builtin title: str, document_file_id: str, - description: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + description: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.DOCUMENT, id, api_kwargs=api_kwargs) @@ -118,9 +119,9 @@ def __init__( self.document_file_id: str = document_file_id # Optionals - self.description: Optional[str] = description - self.caption: Optional[str] = caption + self.description: str | None = description + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content diff --git a/telegram/_inline/inlinequeryresultcachedgif.py b/src/telegram/_inline/inlinequeryresultcachedgif.py similarity index 86% rename from telegram/_inline/inlinequeryresultcachedgif.py rename to src/telegram/_inline/inlinequeryresultcachedgif.py index f682ec0c7d4..e9fbd38a37c 100644 --- a/telegram/_inline/inlinequeryresultcachedgif.py +++ b/src/telegram/_inline/inlinequeryresultcachedgif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultCachedGif.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -106,15 +107,15 @@ def __init__( self, id: str, # pylint: disable=redefined-builtin gif_file_id: str, - title: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + title: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.GIF, id, api_kwargs=api_kwargs) @@ -122,10 +123,10 @@ def __init__( self.gif_file_id: str = gif_file_id # Optionals - self.title: Optional[str] = title - self.caption: Optional[str] = caption + self.title: str | None = title + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.show_caption_above_media: Optional[bool] = show_caption_above_media + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.show_caption_above_media: bool | None = show_caption_above_media diff --git a/telegram/_inline/inlinequeryresultcachedmpeg4gif.py b/src/telegram/_inline/inlinequeryresultcachedmpeg4gif.py similarity index 86% rename from telegram/_inline/inlinequeryresultcachedmpeg4gif.py rename to src/telegram/_inline/inlinequeryresultcachedmpeg4gif.py index 6dc7e557e92..6832e7febdd 100644 --- a/telegram/_inline/inlinequeryresultcachedmpeg4gif.py +++ b/src/telegram/_inline/inlinequeryresultcachedmpeg4gif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultMpeg4Gif.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -106,15 +107,15 @@ def __init__( self, id: str, # pylint: disable=redefined-builtin mpeg4_file_id: str, - title: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + title: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.MPEG4GIF, id, api_kwargs=api_kwargs) @@ -122,10 +123,10 @@ def __init__( self.mpeg4_file_id: str = mpeg4_file_id # Optionals - self.title: Optional[str] = title - self.caption: Optional[str] = caption + self.title: str | None = title + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.show_caption_above_media: Optional[bool] = show_caption_above_media + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.show_caption_above_media: bool | None = show_caption_above_media diff --git a/telegram/_inline/inlinequeryresultcachedphoto.py b/src/telegram/_inline/inlinequeryresultcachedphoto.py similarity index 85% rename from telegram/_inline/inlinequeryresultcachedphoto.py rename to src/telegram/_inline/inlinequeryresultcachedphoto.py index adf8ea6b6b4..774fe3257a4 100644 --- a/telegram/_inline/inlinequeryresultcachedphoto.py +++ b/src/telegram/_inline/inlinequeryresultcachedphoto.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultPhoto""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -109,16 +110,16 @@ def __init__( self, id: str, # pylint: disable=redefined-builtin photo_file_id: str, - title: Optional[str] = None, - description: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + title: str | None = None, + description: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.PHOTO, id, api_kwargs=api_kwargs) @@ -126,11 +127,11 @@ def __init__( self.photo_file_id: str = photo_file_id # Optionals - self.title: Optional[str] = title - self.description: Optional[str] = description - self.caption: Optional[str] = caption + self.title: str | None = title + self.description: str | None = description + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.show_caption_above_media: Optional[bool] = show_caption_above_media + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.show_caption_above_media: bool | None = show_caption_above_media diff --git a/telegram/_inline/inlinequeryresultcachedsticker.py b/src/telegram/_inline/inlinequeryresultcachedsticker.py similarity index 88% rename from telegram/_inline/inlinequeryresultcachedsticker.py rename to src/telegram/_inline/inlinequeryresultcachedsticker.py index 0dd8c55ad26..437a9ceef69 100644 --- a/telegram/_inline/inlinequeryresultcachedsticker.py +++ b/src/telegram/_inline/inlinequeryresultcachedsticker.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultCachedSticker.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -66,10 +66,10 @@ def __init__( self, id: str, # pylint: disable=redefined-builtin sticker_file_id: str, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.STICKER, id, api_kwargs=api_kwargs) @@ -77,5 +77,5 @@ def __init__( self.sticker_file_id: str = sticker_file_id # Optionals - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content diff --git a/telegram/_inline/inlinequeryresultcachedvideo.py b/src/telegram/_inline/inlinequeryresultcachedvideo.py similarity index 86% rename from telegram/_inline/inlinequeryresultcachedvideo.py rename to src/telegram/_inline/inlinequeryresultcachedvideo.py index 3595330361a..19b602da112 100644 --- a/telegram/_inline/inlinequeryresultcachedvideo.py +++ b/src/telegram/_inline/inlinequeryresultcachedvideo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultCachedVideo.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -106,15 +107,15 @@ def __init__( id: str, # pylint: disable=redefined-builtin video_file_id: str, title: str, - description: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + description: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.VIDEO, id, api_kwargs=api_kwargs) @@ -123,10 +124,10 @@ def __init__( self.title: str = title # Optionals - self.description: Optional[str] = description - self.caption: Optional[str] = caption + self.description: str | None = description + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.show_caption_above_media: Optional[bool] = show_caption_above_media + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.show_caption_above_media: bool | None = show_caption_above_media diff --git a/telegram/_inline/inlinequeryresultcachedvoice.py b/src/telegram/_inline/inlinequeryresultcachedvoice.py similarity index 89% rename from telegram/_inline/inlinequeryresultcachedvoice.py rename to src/telegram/_inline/inlinequeryresultcachedvoice.py index 139fdabff18..c2114cb8906 100644 --- a/telegram/_inline/inlinequeryresultcachedvoice.py +++ b/src/telegram/_inline/inlinequeryresultcachedvoice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultCachedVoice.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -99,13 +100,13 @@ def __init__( id: str, # pylint: disable=redefined-builtin voice_file_id: str, title: str, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.VOICE, id, api_kwargs=api_kwargs) @@ -114,8 +115,8 @@ def __init__( self.title: str = title # Optionals - self.caption: Optional[str] = caption + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content diff --git a/telegram/_inline/inlinequeryresultcontact.py b/src/telegram/_inline/inlinequeryresultcontact.py similarity index 83% rename from telegram/_inline/inlinequeryresultcontact.py rename to src/telegram/_inline/inlinequeryresultcontact.py index 7ededbbd0b9..53c5670820b 100644 --- a/telegram/_inline/inlinequeryresultcontact.py +++ b/src/telegram/_inline/inlinequeryresultcontact.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultContact.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -104,15 +104,15 @@ def __init__( id: str, # pylint: disable=redefined-builtin phone_number: str, first_name: str, - last_name: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, - vcard: Optional[str] = None, - thumbnail_url: Optional[str] = None, - thumbnail_width: Optional[int] = None, - thumbnail_height: Optional[int] = None, + last_name: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, + vcard: str | None = None, + thumbnail_url: str | None = None, + thumbnail_width: int | None = None, + thumbnail_height: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.CONTACT, id, api_kwargs=api_kwargs) @@ -121,10 +121,10 @@ def __init__( self.first_name: str = first_name # Optionals - self.last_name: Optional[str] = last_name - self.vcard: Optional[str] = vcard - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.thumbnail_url: Optional[str] = thumbnail_url - self.thumbnail_width: Optional[int] = thumbnail_width - self.thumbnail_height: Optional[int] = thumbnail_height + self.last_name: str | None = last_name + self.vcard: str | None = vcard + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.thumbnail_url: str | None = thumbnail_url + self.thumbnail_width: int | None = thumbnail_width + self.thumbnail_height: int | None = thumbnail_height diff --git a/telegram/_inline/inlinequeryresultdocument.py b/src/telegram/_inline/inlinequeryresultdocument.py similarity index 85% rename from telegram/_inline/inlinequeryresultdocument.py rename to src/telegram/_inline/inlinequeryresultdocument.py index e7114ef60aa..1bc2d97a887 100644 --- a/telegram/_inline/inlinequeryresultdocument.py +++ b/src/telegram/_inline/inlinequeryresultdocument.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultDocument""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -134,17 +135,17 @@ def __init__( document_url: str, title: str, mime_type: str, - caption: Optional[str] = None, - description: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + caption: str | None = None, + description: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - thumbnail_url: Optional[str] = None, - thumbnail_width: Optional[int] = None, - thumbnail_height: Optional[int] = None, + caption_entities: Sequence[MessageEntity] | None = None, + thumbnail_url: str | None = None, + thumbnail_width: int | None = None, + thumbnail_height: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.DOCUMENT, id, api_kwargs=api_kwargs) @@ -154,12 +155,12 @@ def __init__( self.mime_type: str = mime_type # Optionals - self.caption: Optional[str] = caption + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.description: Optional[str] = description - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.thumbnail_url: Optional[str] = thumbnail_url - self.thumbnail_width: Optional[int] = thumbnail_width - self.thumbnail_height: Optional[int] = thumbnail_height + self.description: str | None = description + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.thumbnail_url: str | None = thumbnail_url + self.thumbnail_width: int | None = thumbnail_width + self.thumbnail_height: int | None = thumbnail_height diff --git a/telegram/_inline/inlinequeryresultgame.py b/src/telegram/_inline/inlinequeryresultgame.py similarity index 91% rename from telegram/_inline/inlinequeryresultgame.py rename to src/telegram/_inline/inlinequeryresultgame.py index 27b12c87915..579721dcc45 100644 --- a/telegram/_inline/inlinequeryresultgame.py +++ b/src/telegram/_inline/inlinequeryresultgame.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultGame.""" -from typing import Optional from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -53,9 +52,9 @@ def __init__( self, id: str, # pylint: disable=redefined-builtin game_short_name: str, - reply_markup: Optional[InlineKeyboardMarkup] = None, + reply_markup: InlineKeyboardMarkup | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.GAME, id, api_kwargs=api_kwargs) @@ -63,4 +62,4 @@ def __init__( self.id: str = id self.game_short_name: str = game_short_name - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup + self.reply_markup: InlineKeyboardMarkup | None = reply_markup diff --git a/telegram/_inline/inlinequeryresultgif.py b/src/telegram/_inline/inlinequeryresultgif.py similarity index 73% rename from telegram/_inline/inlinequeryresultgif.py rename to src/telegram/_inline/inlinequeryresultgif.py index f454f44f0fd..67d4cdf23e5 100644 --- a/telegram/_inline/inlinequeryresultgif.py +++ b/src/telegram/_inline/inlinequeryresultgif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,15 +17,18 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultGif.""" + +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -47,16 +50,20 @@ class InlineQueryResultGif(InlineQueryResult): id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- :tg-const:`telegram.InlineQueryResult.MAX_ID_LENGTH` Bytes. - gif_url (:obj:`str`): A valid URL for the GIF file. File size must not exceed 1MB. + gif_url (:obj:`str`): A valid URL for the GIF file. gif_width (:obj:`int`, optional): Width of the GIF. gif_height (:obj:`int`, optional): Height of the GIF. - gif_duration (:obj:`int`, optional): Duration of the GIF in seconds. + gif_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of the GIF + in seconds. + + .. versionchanged:: v22.2 + |time-period-input| thumbnail_url (:obj:`str`): URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the result. .. versionadded:: 20.2 - ..versionchanged:: 20.5 + .. versionchanged:: 20.5 |thumbnail_url_mandatory| thumbnail_mime_type (:obj:`str`, optional): MIME type of the thumbnail, must be one of @@ -86,10 +93,14 @@ class InlineQueryResultGif(InlineQueryResult): id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- :tg-const:`telegram.InlineQueryResult.MAX_ID_LENGTH` Bytes. - gif_url (:obj:`str`): A valid URL for the GIF file. File size must not exceed 1MB. + gif_url (:obj:`str`): A valid URL for the GIF file. gif_width (:obj:`int`): Optional. Width of the GIF. gif_height (:obj:`int`): Optional. Height of the GIF. - gif_duration (:obj:`int`): Optional. Duration of the GIF in seconds. + gif_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Duration of the GIF + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| thumbnail_url (:obj:`str`): URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the result. @@ -120,9 +131,9 @@ class InlineQueryResultGif(InlineQueryResult): """ __slots__ = ( + "_gif_duration", "caption", "caption_entities", - "gif_duration", "gif_height", "gif_url", "gif_width", @@ -140,19 +151,19 @@ def __init__( id: str, # pylint: disable=redefined-builtin gif_url: str, thumbnail_url: str, - gif_width: Optional[int] = None, - gif_height: Optional[int] = None, - title: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, - gif_duration: Optional[int] = None, + gif_width: int | None = None, + gif_height: int | None = None, + title: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, + gif_duration: TimePeriod | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - thumbnail_mime_type: Optional[str] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence[MessageEntity] | None = None, + thumbnail_mime_type: str | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.GIF, id, api_kwargs=api_kwargs) @@ -161,14 +172,18 @@ def __init__( self.thumbnail_url: str = thumbnail_url # Optionals - self.gif_width: Optional[int] = gif_width - self.gif_height: Optional[int] = gif_height - self.gif_duration: Optional[int] = gif_duration - self.title: Optional[str] = title - self.caption: Optional[str] = caption + self.gif_width: int | None = gif_width + self.gif_height: int | None = gif_height + self._gif_duration: dtm.timedelta | None = to_timedelta(gif_duration) + self.title: str | None = title + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.thumbnail_mime_type: Optional[str] = thumbnail_mime_type - self.show_caption_above_media: Optional[bool] = show_caption_above_media + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.thumbnail_mime_type: str | None = thumbnail_mime_type + self.show_caption_above_media: bool | None = show_caption_above_media + + @property + def gif_duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._gif_duration, attribute="gif_duration") diff --git a/telegram/_inline/inlinequeryresultlocation.py b/src/telegram/_inline/inlinequeryresultlocation.py similarity index 79% rename from telegram/_inline/inlinequeryresultlocation.py rename to src/telegram/_inline/inlinequeryresultlocation.py index 01035537840..c4ce7db7346 100644 --- a/telegram/_inline/inlinequeryresultlocation.py +++ b/src/telegram/_inline/inlinequeryresultlocation.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,12 +18,15 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultLocation.""" -from typing import TYPE_CHECKING, Final, Optional +import datetime as dtm +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod if TYPE_CHECKING: from telegram import InputMessageContent @@ -48,10 +51,13 @@ class InlineQueryResultLocation(InlineQueryResult): horizontal_accuracy (:obj:`float`, optional): The radius of uncertainty for the location, measured in meters; 0- :tg-const:`telegram.InlineQueryResultLocation.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`, optional): Period in seconds for which the location will be - updated, should be between + live_period (:obj:`int` | :class:`datetime.timedelta`, optional): Period in seconds for + which the location will be updated, should be between :tg-const:`telegram.InlineQueryResultLocation.MIN_LIVE_PERIOD` and :tg-const:`telegram.InlineQueryResultLocation.MAX_LIVE_PERIOD`. + + .. versionchanged:: v22.2 + |time-period-input| heading (:obj:`int`, optional): For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InlineQueryResultLocation.MIN_HEADING` and @@ -86,12 +92,15 @@ class InlineQueryResultLocation(InlineQueryResult): horizontal_accuracy (:obj:`float`): Optional. The radius of uncertainty for the location, measured in meters; 0- :tg-const:`telegram.InlineQueryResultLocation.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`): Optional. Period in seconds for which the location will be - updated, should be between + live_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Period in seconds for + which the location will be updated, should be between :tg-const:`telegram.InlineQueryResultLocation.MIN_LIVE_PERIOD` and :tg-const:`telegram.InlineQueryResultLocation.MAX_LIVE_PERIOD` or :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` for live locations that can be edited indefinitely. + + .. deprecated:: v22.2 + |time-period-int-deprecated| heading (:obj:`int`): Optional. For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InlineQueryResultLocation.MIN_HEADING` and @@ -118,11 +127,11 @@ class InlineQueryResultLocation(InlineQueryResult): """ __slots__ = ( + "_live_period", "heading", "horizontal_accuracy", "input_message_content", "latitude", - "live_period", "longitude", "proximity_alert_radius", "reply_markup", @@ -138,17 +147,17 @@ def __init__( latitude: float, longitude: float, title: str, - live_period: Optional[int] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, - thumbnail_url: Optional[str] = None, - thumbnail_width: Optional[int] = None, - thumbnail_height: Optional[int] = None, + live_period: TimePeriod | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, + thumbnail_url: str | None = None, + thumbnail_width: int | None = None, + thumbnail_height: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(constants.InlineQueryResultType.LOCATION, id, api_kwargs=api_kwargs) @@ -158,18 +167,22 @@ def __init__( self.title: str = title # Optionals - self.live_period: Optional[int] = live_period - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.thumbnail_url: Optional[str] = thumbnail_url - self.thumbnail_width: Optional[int] = thumbnail_width - self.thumbnail_height: Optional[int] = thumbnail_height - self.horizontal_accuracy: Optional[float] = horizontal_accuracy - self.heading: Optional[int] = heading - self.proximity_alert_radius: Optional[int] = ( + self._live_period: dtm.timedelta | None = to_timedelta(live_period) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.thumbnail_url: str | None = thumbnail_url + self.thumbnail_width: int | None = thumbnail_width + self.thumbnail_height: int | None = thumbnail_height + self.horizontal_accuracy: float | None = horizontal_accuracy + self.heading: int | None = heading + self.proximity_alert_radius: int | None = ( int(proximity_alert_radius) if proximity_alert_radius else None ) + @property + def live_period(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._live_period, attribute="live_period") + HORIZONTAL_ACCURACY: Final[int] = constants.LocationLimit.HORIZONTAL_ACCURACY """:const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY` diff --git a/telegram/_inline/inlinequeryresultmpeg4gif.py b/src/telegram/_inline/inlinequeryresultmpeg4gif.py similarity index 74% rename from telegram/_inline/inlinequeryresultmpeg4gif.py rename to src/telegram/_inline/inlinequeryresultmpeg4gif.py index ee2ed79875b..817eda536d7 100644 --- a/telegram/_inline/inlinequeryresultmpeg4gif.py +++ b/src/telegram/_inline/inlinequeryresultmpeg4gif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,15 +17,18 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultMpeg4Gif.""" + +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -48,16 +51,20 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult): id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- :tg-const:`telegram.InlineQueryResult.MAX_ID_LENGTH` Bytes. - mpeg4_url (:obj:`str`): A valid URL for the MP4 file. File size must not exceed 1MB. + mpeg4_url (:obj:`str`): A valid URL for the MP4 file. mpeg4_width (:obj:`int`, optional): Video width. mpeg4_height (:obj:`int`, optional): Video height. - mpeg4_duration (:obj:`int`, optional): Video duration in seconds. + mpeg4_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Video duration + in seconds. + + .. versionchanged:: v22.2 + |time-period-input| thumbnail_url (:obj:`str`): URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the result. .. versionadded:: 20.2 - ..versionchanged:: 20.5 + .. versionchanged:: 20.5 |thumbnail_url_mandatory| thumbnail_mime_type (:obj:`str`, optional): MIME type of the thumbnail, must be one of @@ -88,10 +95,14 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult): id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- :tg-const:`telegram.InlineQueryResult.MAX_ID_LENGTH` Bytes. - mpeg4_url (:obj:`str`): A valid URL for the MP4 file. File size must not exceed 1MB. + mpeg4_url (:obj:`str`): A valid URL for the MP4 file. mpeg4_width (:obj:`int`): Optional. Video width. mpeg4_height (:obj:`int`): Optional. Video height. - mpeg4_duration (:obj:`int`): Optional. Video duration in seconds. + mpeg4_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Video duration + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| thumbnail_url (:obj:`str`): URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the result. @@ -122,10 +133,10 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult): """ __slots__ = ( + "_mpeg4_duration", "caption", "caption_entities", "input_message_content", - "mpeg4_duration", "mpeg4_height", "mpeg4_url", "mpeg4_width", @@ -142,19 +153,19 @@ def __init__( id: str, # pylint: disable=redefined-builtin mpeg4_url: str, thumbnail_url: str, - mpeg4_width: Optional[int] = None, - mpeg4_height: Optional[int] = None, - title: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, - mpeg4_duration: Optional[int] = None, + mpeg4_width: int | None = None, + mpeg4_height: int | None = None, + title: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, + mpeg4_duration: TimePeriod | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - thumbnail_mime_type: Optional[str] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence[MessageEntity] | None = None, + thumbnail_mime_type: str | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.MPEG4GIF, id, api_kwargs=api_kwargs) @@ -163,14 +174,18 @@ def __init__( self.thumbnail_url: str = thumbnail_url # Optional - self.mpeg4_width: Optional[int] = mpeg4_width - self.mpeg4_height: Optional[int] = mpeg4_height - self.mpeg4_duration: Optional[int] = mpeg4_duration - self.title: Optional[str] = title - self.caption: Optional[str] = caption + self.mpeg4_width: int | None = mpeg4_width + self.mpeg4_height: int | None = mpeg4_height + self._mpeg4_duration: dtm.timedelta | None = to_timedelta(mpeg4_duration) + self.title: str | None = title + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.thumbnail_mime_type: Optional[str] = thumbnail_mime_type - self.show_caption_above_media: Optional[bool] = show_caption_above_media + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.thumbnail_mime_type: str | None = thumbnail_mime_type + self.show_caption_above_media: bool | None = show_caption_above_media + + @property + def mpeg4_duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._mpeg4_duration, attribute="mpeg4_duration") diff --git a/telegram/_inline/inlinequeryresultphoto.py b/src/telegram/_inline/inlinequeryresultphoto.py similarity index 83% rename from telegram/_inline/inlinequeryresultphoto.py rename to src/telegram/_inline/inlinequeryresultphoto.py index e4556d62d49..74ca99baa0d 100644 --- a/telegram/_inline/inlinequeryresultphoto.py +++ b/src/telegram/_inline/inlinequeryresultphoto.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultPhoto.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -53,7 +54,7 @@ class InlineQueryResultPhoto(InlineQueryResult): .. versionadded:: 20.2 - ..versionchanged:: 20.5 + .. versionchanged:: 20.5 |thumbnail_url_mandatory| photo_width (:obj:`int`, optional): Width of the photo. @@ -129,18 +130,18 @@ def __init__( id: str, # pylint: disable=redefined-builtin photo_url: str, thumbnail_url: str, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - title: Optional[str] = None, - description: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + photo_width: int | None = None, + photo_height: int | None = None, + title: str | None = None, + description: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.PHOTO, id, api_kwargs=api_kwargs) @@ -149,13 +150,13 @@ def __init__( self.thumbnail_url: str = thumbnail_url # Optionals - self.photo_width: Optional[int] = photo_width - self.photo_height: Optional[int] = photo_height - self.title: Optional[str] = title - self.description: Optional[str] = description - self.caption: Optional[str] = caption + self.photo_width: int | None = photo_width + self.photo_height: int | None = photo_height + self.title: str | None = title + self.description: str | None = description + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.show_caption_above_media: Optional[bool] = show_caption_above_media + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.show_caption_above_media: bool | None = show_caption_above_media diff --git a/telegram/_inline/inlinequeryresultsbutton.py b/src/telegram/_inline/inlinequeryresultsbutton.py similarity index 78% rename from telegram/_inline/inlinequeryresultsbutton.py rename to src/telegram/_inline/inlinequeryresultsbutton.py index 11ba092c540..7f124fe7fc8 100644 --- a/telegram/_inline/inlinequeryresultsbutton.py +++ b/src/telegram/_inline/inlinequeryresultsbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,10 +18,11 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the class that represent a Telegram InlineQueryResultsButton.""" -from typing import TYPE_CHECKING, Final, Optional +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict from telegram._webappinfo import WebAppInfo @@ -46,9 +47,10 @@ class InlineQueryResultsButton(TelegramObject): inside the Web App. start_parameter (:obj:`str`, optional): Deep-linking parameter for the :guilabel:`/start` message sent to the bot when user presses the switch button. - :tg-const:`telegram.InlineQuery.MIN_SWITCH_PM_TEXT_LENGTH`- - :tg-const:`telegram.InlineQuery.MAX_SWITCH_PM_TEXT_LENGTH` characters, - only ``A-Z``, ``a-z``, ``0-9``, ``_`` and ``-`` are allowed. + :tg-const:`telegram.constants.InlineQueryResultsButtonLimit.MIN_START_PARAMETER_LENGTH` + - + :tg-const:`telegram.constants.InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH` + characters, only ``A-Z``, ``a-z``, ``0-9``, ``_`` and ``-`` are allowed. Example: An inline bot that sends YouTube videos can ask the user to connect the bot to @@ -66,10 +68,10 @@ class InlineQueryResultsButton(TelegramObject): user presses the button. The Web App will be able to switch back to the inline mode using the method ``web_app_switch_inline_query`` inside the Web App. start_parameter (:obj:`str`): Optional. Deep-linking parameter for the - :guilabel:`/start` message sent to the bot when user presses the switch button. - :tg-const:`telegram.InlineQuery.MIN_SWITCH_PM_TEXT_LENGTH`- - :tg-const:`telegram.InlineQuery.MAX_SWITCH_PM_TEXT_LENGTH` characters, - only ``A-Z``, ``a-z``, ``0-9``, ``_`` and ``-`` are allowed. + :tg-const:`telegram.constants.InlineQueryResultsButtonLimit.MIN_START_PARAMETER_LENGTH` + - + :tg-const:`telegram.constants.InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH` + characters, only ``A-Z``, ``a-z``, ``0-9``, ``_`` and ``-`` are allowed. """ @@ -78,10 +80,10 @@ class InlineQueryResultsButton(TelegramObject): def __init__( self, text: str, - web_app: Optional[WebAppInfo] = None, - start_parameter: Optional[str] = None, + web_app: WebAppInfo | None = None, + start_parameter: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -89,22 +91,18 @@ def __init__( self.text: str = text # Optional - self.web_app: Optional[WebAppInfo] = web_app - self.start_parameter: Optional[str] = start_parameter + self.web_app: WebAppInfo | None = web_app + self.start_parameter: str | None = start_parameter self._id_attrs = (self.text, self.web_app, self.start_parameter) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["InlineQueryResultsButton"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InlineQueryResultsButton": """See :meth:`telegram.TelegramObject.de_json`.""" - if not data: - return None - data["web_app"] = WebAppInfo.de_json(data.get("web_app"), bot) + data["web_app"] = de_json_optional(data.get("web_app"), WebAppInfo, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_inline/inlinequeryresultvenue.py b/src/telegram/_inline/inlinequeryresultvenue.py similarity index 82% rename from telegram/_inline/inlinequeryresultvenue.py rename to src/telegram/_inline/inlinequeryresultvenue.py index 639b0daf008..162ff4ed8c4 100644 --- a/telegram/_inline/inlinequeryresultvenue.py +++ b/src/telegram/_inline/inlinequeryresultvenue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultVenue.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -57,7 +57,7 @@ class InlineQueryResultVenue(InlineQueryResult): google_place_id (:obj:`str`, optional): Google Places identifier of the venue. google_place_type (:obj:`str`, optional): Google Places type of the venue. (See `supported types `_.) + /place-types>`_.) reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the @@ -88,7 +88,7 @@ class InlineQueryResultVenue(InlineQueryResult): google_place_id (:obj:`str`): Optional. Google Places identifier of the venue. google_place_type (:obj:`str`): Optional. Google Places type of the venue. (See `supported types `_.) + /place-types>`_.) reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached to the message. input_message_content (:class:`telegram.InputMessageContent`): Optional. Content of the @@ -128,17 +128,17 @@ def __init__( longitude: float, title: str, address: str, - foursquare_id: Optional[str] = None, - foursquare_type: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, - thumbnail_url: Optional[str] = None, - thumbnail_width: Optional[int] = None, - thumbnail_height: Optional[int] = None, + foursquare_id: str | None = None, + foursquare_type: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, + google_place_id: str | None = None, + google_place_type: str | None = None, + thumbnail_url: str | None = None, + thumbnail_width: int | None = None, + thumbnail_height: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.VENUE, id, api_kwargs=api_kwargs) @@ -149,12 +149,12 @@ def __init__( self.address: str = address # Optional - self.foursquare_id: Optional[str] = foursquare_id - self.foursquare_type: Optional[str] = foursquare_type - self.google_place_id: Optional[str] = google_place_id - self.google_place_type: Optional[str] = google_place_type - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.thumbnail_url: Optional[str] = thumbnail_url - self.thumbnail_width: Optional[int] = thumbnail_width - self.thumbnail_height: Optional[int] = thumbnail_height + self.foursquare_id: str | None = foursquare_id + self.foursquare_type: str | None = foursquare_type + self.google_place_id: str | None = google_place_id + self.google_place_type: str | None = google_place_type + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.thumbnail_url: str | None = thumbnail_url + self.thumbnail_width: int | None = thumbnail_width + self.thumbnail_height: int | None = thumbnail_height diff --git a/telegram/_inline/inlinequeryresultvideo.py b/src/telegram/_inline/inlinequeryresultvideo.py similarity index 77% rename from telegram/_inline/inlinequeryresultvideo.py rename to src/telegram/_inline/inlinequeryresultvideo.py index edc6ce343ac..a9ab9bc5241 100644 --- a/telegram/_inline/inlinequeryresultvideo.py +++ b/src/telegram/_inline/inlinequeryresultvideo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,15 +17,18 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultVideo.""" + +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -58,7 +61,7 @@ class InlineQueryResultVideo(InlineQueryResult): .. versionadded:: 20.2 - ..versionchanged:: 20.5 + .. versionchanged:: 20.5 |thumbnail_url_mandatory| title (:obj:`str`): Title for the result. @@ -73,7 +76,11 @@ class InlineQueryResultVideo(InlineQueryResult): video_width (:obj:`int`, optional): Video width. video_height (:obj:`int`, optional): Video height. - video_duration (:obj:`int`, optional): Video duration in seconds. + video_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Video duration + in seconds. + + .. versionchanged:: v22.2 + |time-period-input| description (:obj:`str`, optional): Short description of the result. reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached to the message. @@ -110,7 +117,11 @@ class InlineQueryResultVideo(InlineQueryResult): video_width (:obj:`int`): Optional. Video width. video_height (:obj:`int`): Optional. Video height. - video_duration (:obj:`int`): Optional. Video duration in seconds. + video_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Video duration + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| description (:obj:`str`): Optional. Short description of the result. reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached to the message. @@ -125,6 +136,7 @@ class InlineQueryResultVideo(InlineQueryResult): """ __slots__ = ( + "_video_duration", "caption", "caption_entities", "description", @@ -135,7 +147,6 @@ class InlineQueryResultVideo(InlineQueryResult): "show_caption_above_media", "thumbnail_url", "title", - "video_duration", "video_height", "video_url", "video_width", @@ -148,18 +159,18 @@ def __init__( mime_type: str, thumbnail_url: str, title: str, - caption: Optional[str] = None, - video_width: Optional[int] = None, - video_height: Optional[int] = None, - video_duration: Optional[int] = None, - description: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + caption: str | None = None, + video_width: int | None = None, + video_height: int | None = None, + video_duration: TimePeriod | None = None, + description: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.VIDEO, id, api_kwargs=api_kwargs) @@ -170,13 +181,17 @@ def __init__( self.title: str = title # Optional - self.caption: Optional[str] = caption + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.video_width: Optional[int] = video_width - self.video_height: Optional[int] = video_height - self.video_duration: Optional[int] = video_duration - self.description: Optional[str] = description - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.show_caption_above_media: Optional[bool] = show_caption_above_media + self.video_width: int | None = video_width + self.video_height: int | None = video_height + self._video_duration: dtm.timedelta | None = to_timedelta(video_duration) + self.description: str | None = description + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.show_caption_above_media: bool | None = show_caption_above_media + + @property + def video_duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._video_duration, attribute="video_duration") diff --git a/telegram/_inline/inlinequeryresultvoice.py b/src/telegram/_inline/inlinequeryresultvoice.py similarity index 75% rename from telegram/_inline/inlinequeryresultvoice.py rename to src/telegram/_inline/inlinequeryresultvoice.py index b798040b1aa..92119fe121d 100644 --- a/telegram/_inline/inlinequeryresultvoice.py +++ b/src/telegram/_inline/inlinequeryresultvoice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,15 +17,18 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultVoice.""" + +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -56,7 +59,11 @@ class InlineQueryResultVoice(InlineQueryResult): .. versionchanged:: 20.0 |sequenceclassargs| - voice_duration (:obj:`int`, optional): Recording duration in seconds. + voice_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Recording duration + in seconds. + + .. versionchanged:: v22.2 + |time-period-input| reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the @@ -79,7 +86,11 @@ class InlineQueryResultVoice(InlineQueryResult): * |tupleclassattrs| * |alwaystuple| - voice_duration (:obj:`int`): Optional. Recording duration in seconds. + voice_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Recording duration + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached to the message. input_message_content (:class:`telegram.InputMessageContent`): Optional. Content of the @@ -88,13 +99,13 @@ class InlineQueryResultVoice(InlineQueryResult): """ __slots__ = ( + "_voice_duration", "caption", "caption_entities", "input_message_content", "parse_mode", "reply_markup", "title", - "voice_duration", "voice_url", ) @@ -103,14 +114,14 @@ def __init__( id: str, # pylint: disable=redefined-builtin voice_url: str, title: str, - voice_duration: Optional[int] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + voice_duration: TimePeriod | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.VOICE, id, api_kwargs=api_kwargs) @@ -119,9 +130,13 @@ def __init__( self.title: str = title # Optional - self.voice_duration: Optional[int] = voice_duration - self.caption: Optional[str] = caption + self._voice_duration: dtm.timedelta | None = to_timedelta(voice_duration) + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + + @property + def voice_duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._voice_duration, attribute="voice_duration") diff --git a/telegram/_inline/inputcontactmessagecontent.py b/src/telegram/_inline/inputcontactmessagecontent.py similarity index 89% rename from telegram/_inline/inputcontactmessagecontent.py rename to src/telegram/_inline/inputcontactmessagecontent.py index f7a76dff823..cead1136dd0 100644 --- a/telegram/_inline/inputcontactmessagecontent.py +++ b/src/telegram/_inline/inputcontactmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InputContactMessageContent.""" -from typing import Optional from telegram._inline.inputmessagecontent import InputMessageContent from telegram._utils.types import JSONDict @@ -51,10 +50,10 @@ def __init__( self, phone_number: str, first_name: str, - last_name: Optional[str] = None, - vcard: Optional[str] = None, + last_name: str | None = None, + vcard: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) with self._unfrozen(): @@ -62,7 +61,7 @@ def __init__( self.phone_number: str = phone_number self.first_name: str = first_name # Optionals - self.last_name: Optional[str] = last_name - self.vcard: Optional[str] = vcard + self.last_name: str | None = last_name + self.vcard: str | None = vcard self._id_attrs = (self.phone_number,) diff --git a/telegram/_inline/inputinvoicemessagecontent.py b/src/telegram/_inline/inputinvoicemessagecontent.py similarity index 82% rename from telegram/_inline/inputinvoicemessagecontent.py rename to src/telegram/_inline/inputinvoicemessagecontent.py index ecef3940a70..536726e3afa 100644 --- a/telegram/_inline/inputinvoicemessagecontent.py +++ b/src/telegram/_inline/inputinvoicemessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,12 +17,13 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains a class that represents a Telegram InputInvoiceMessageContent.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inputmessagecontent import InputMessageContent from telegram._payment.labeledprice import LabeledPrice -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -35,9 +36,11 @@ class InputInvoiceMessageContent(InputMessageContent): Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`title`, :attr:`description`, :attr:`payload`, - :attr:`provider_token`, :attr:`currency` and :attr:`prices` are equal. + :attr:`currency` and :attr:`prices` are equal. .. versionadded:: 13.5 + .. versionchanged:: 21.11 + :attr:`provider_token` is no longer considered for equality comparison. Args: title (:obj:`str`): Product name. :tg-const:`telegram.Invoice.MIN_TITLE_LENGTH`- @@ -49,13 +52,13 @@ class InputInvoiceMessageContent(InputMessageContent): :tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`- :tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be displayed to the user, use it for your internal processes. - provider_token (:obj:`str`): Payment provider token, obtained via + provider_token (:obj:`str`, optional): Payment provider token, obtained via `@Botfather `_. Pass an empty string for payments in |tg_stars|. - .. deprecated:: 21.3 - As of Bot API 7.4, this parameter is now optional and future versions of the - library will make it optional as well. + .. versionchanged:: 21.11 + Bot API 7.4 made this parameter is optional and this is now reflected in the + class signature. currency (:obj:`str`): Three-letter ISO 4217 currency code, see more on `currencies `_. Pass ``XTR`` for payments in |tg_stars|. @@ -199,25 +202,25 @@ def __init__( title: str, description: str, payload: str, - provider_token: Optional[str], # This arg is now optional since Bot API 7.4 currency: str, prices: Sequence[LabeledPrice], - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, - provider_data: Optional[str] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - is_flexible: Optional[bool] = None, + provider_token: str | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, + provider_data: str | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + is_flexible: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) with self._unfrozen(): @@ -225,44 +228,38 @@ def __init__( self.title: str = title self.description: str = description self.payload: str = payload - self.provider_token: Optional[str] = provider_token self.currency: str = currency self.prices: tuple[LabeledPrice, ...] = parse_sequence_arg(prices) # Optionals - self.max_tip_amount: Optional[int] = max_tip_amount + self.provider_token: str | None = provider_token + self.max_tip_amount: int | None = max_tip_amount self.suggested_tip_amounts: tuple[int, ...] = parse_sequence_arg(suggested_tip_amounts) - self.provider_data: Optional[str] = provider_data - self.photo_url: Optional[str] = photo_url - self.photo_size: Optional[int] = photo_size - self.photo_width: Optional[int] = photo_width - self.photo_height: Optional[int] = photo_height - self.need_name: Optional[bool] = need_name - self.need_phone_number: Optional[bool] = need_phone_number - self.need_email: Optional[bool] = need_email - self.need_shipping_address: Optional[bool] = need_shipping_address - self.send_phone_number_to_provider: Optional[bool] = send_phone_number_to_provider - self.send_email_to_provider: Optional[bool] = send_email_to_provider - self.is_flexible: Optional[bool] = is_flexible + self.provider_data: str | None = provider_data + self.photo_url: str | None = photo_url + self.photo_size: int | None = photo_size + self.photo_width: int | None = photo_width + self.photo_height: int | None = photo_height + self.need_name: bool | None = need_name + self.need_phone_number: bool | None = need_phone_number + self.need_email: bool | None = need_email + self.need_shipping_address: bool | None = need_shipping_address + self.send_phone_number_to_provider: bool | None = send_phone_number_to_provider + self.send_email_to_provider: bool | None = send_email_to_provider + self.is_flexible: bool | None = is_flexible self._id_attrs = ( self.title, self.description, self.payload, - self.provider_token, self.currency, self.prices, ) @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["InputInvoiceMessageContent"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InputInvoiceMessageContent": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["prices"] = LabeledPrice.de_list(data.get("prices"), bot) + data["prices"] = de_list_optional(data.get("prices"), LabeledPrice, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_inline/inputlocationmessagecontent.py b/src/telegram/_inline/inputlocationmessagecontent.py similarity index 80% rename from telegram/_inline/inputlocationmessagecontent.py rename to src/telegram/_inline/inputlocationmessagecontent.py index f71a716c259..6e2a77e8e1d 100644 --- a/telegram/_inline/inputlocationmessagecontent.py +++ b/src/telegram/_inline/inputlocationmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,11 +18,14 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InputLocationMessageContent.""" -from typing import Final, Optional +import datetime as dtm +from typing import Final from telegram import constants from telegram._inline.inputmessagecontent import InputMessageContent -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class InputLocationMessageContent(InputMessageContent): @@ -39,12 +42,15 @@ class InputLocationMessageContent(InputMessageContent): horizontal_accuracy (:obj:`float`, optional): The radius of uncertainty for the location, measured in meters; 0- :tg-const:`telegram.InputLocationMessageContent.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`, optional): Period in seconds for which the location will be - updated, should be between + live_period (:obj:`int` | :class:`datetime.timedelta`, optional): Period in seconds for + which the location will be updated, should be between :tg-const:`telegram.InputLocationMessageContent.MIN_LIVE_PERIOD` and :tg-const:`telegram.InputLocationMessageContent.MAX_LIVE_PERIOD` or :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` for live locations that can be edited indefinitely. + + .. versionchanged:: v22.2 + |time-period-input| heading (:obj:`int`, optional): For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InputLocationMessageContent.MIN_HEADING` and @@ -61,10 +67,13 @@ class InputLocationMessageContent(InputMessageContent): horizontal_accuracy (:obj:`float`): Optional. The radius of uncertainty for the location, measured in meters; 0- :tg-const:`telegram.InputLocationMessageContent.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`): Optional. Period in seconds for which the location can be - updated, should be between + live_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Period in seconds for + which the location can be updated, should be between :tg-const:`telegram.InputLocationMessageContent.MIN_LIVE_PERIOD` and :tg-const:`telegram.InputLocationMessageContent.MAX_LIVE_PERIOD`. + + .. deprecated:: v22.2 + |time-period-int-deprecated| heading (:obj:`int`): Optional. For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InputLocationMessageContent.MIN_HEADING` and @@ -78,24 +87,25 @@ class InputLocationMessageContent(InputMessageContent): """ __slots__ = ( + "_live_period", "heading", "horizontal_accuracy", "latitude", - "live_period", "longitude", - "proximity_alert_radius") + "proximity_alert_radius", + ) # fmt: on def __init__( self, latitude: float, longitude: float, - live_period: Optional[int] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, + live_period: TimePeriod | None = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) with self._unfrozen(): @@ -104,15 +114,19 @@ def __init__( self.longitude: float = longitude # Optionals - self.live_period: Optional[int] = live_period - self.horizontal_accuracy: Optional[float] = horizontal_accuracy - self.heading: Optional[int] = heading - self.proximity_alert_radius: Optional[int] = ( + self._live_period: dtm.timedelta | None = to_timedelta(live_period) + self.horizontal_accuracy: float | None = horizontal_accuracy + self.heading: int | None = heading + self.proximity_alert_radius: int | None = ( int(proximity_alert_radius) if proximity_alert_radius else None ) self._id_attrs = (self.latitude, self.longitude) + @property + def live_period(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._live_period, attribute="live_period") + HORIZONTAL_ACCURACY: Final[int] = constants.LocationLimit.HORIZONTAL_ACCURACY """:const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY` diff --git a/telegram/_inline/inputmessagecontent.py b/src/telegram/_inline/inputmessagecontent.py similarity index 91% rename from telegram/_inline/inputmessagecontent.py rename to src/telegram/_inline/inputmessagecontent.py index e37fa12868f..6e56dc2e43c 100644 --- a/telegram/_inline/inputmessagecontent.py +++ b/src/telegram/_inline/inputmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InputMessageContent.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -36,7 +34,7 @@ class InputMessageContent(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(api_kwargs=api_kwargs) self._freeze() diff --git a/telegram/_inline/inputtextmessagecontent.py b/src/telegram/_inline/inputtextmessagecontent.py similarity index 95% rename from telegram/_inline/inputtextmessagecontent.py rename to src/telegram/_inline/inputtextmessagecontent.py index 11a2373bb88..6edde810673 100644 --- a/telegram/_inline/inputtextmessagecontent.py +++ b/src/telegram/_inline/inputtextmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InputTextMessageContent.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inputmessagecontent import InputMessageContent from telegram._messageentity import MessageEntity @@ -95,11 +96,11 @@ def __init__( self, message_text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, - entities: Optional[Sequence[MessageEntity]] = None, + entities: Sequence[MessageEntity] | None = None, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, *, - disable_web_page_preview: Optional[bool] = None, - api_kwargs: Optional[JSONDict] = None, + disable_web_page_preview: bool | None = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) diff --git a/telegram/_inline/inputvenuemessagecontent.py b/src/telegram/_inline/inputvenuemessagecontent.py similarity index 86% rename from telegram/_inline/inputvenuemessagecontent.py rename to src/telegram/_inline/inputvenuemessagecontent.py index c836ea11e11..28c039fb1a9 100644 --- a/telegram/_inline/inputvenuemessagecontent.py +++ b/src/telegram/_inline/inputvenuemessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InputVenueMessageContent.""" -from typing import Optional from telegram._inline.inputmessagecontent import InputMessageContent from telegram._utils.types import JSONDict @@ -46,7 +45,7 @@ class InputVenueMessageContent(InputMessageContent): google_place_id (:obj:`str`, optional): Google Places identifier of the venue. google_place_type (:obj:`str`, optional): Google Places type of the venue. (See `supported types `_.) + /place-types>`_.) Attributes: latitude (:obj:`float`): Latitude of the location in degrees. @@ -60,7 +59,7 @@ class InputVenueMessageContent(InputMessageContent): google_place_id (:obj:`str`): Optional. Google Places identifier of the venue. google_place_type (:obj:`str`): Optional. Google Places type of the venue. (See `supported types `_.) + /place-types>`_.) """ @@ -81,12 +80,12 @@ def __init__( longitude: float, title: str, address: str, - foursquare_id: Optional[str] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, + foursquare_id: str | None = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) with self._unfrozen(): @@ -96,10 +95,10 @@ def __init__( self.title: str = title self.address: str = address # Optionals - self.foursquare_id: Optional[str] = foursquare_id - self.foursquare_type: Optional[str] = foursquare_type - self.google_place_id: Optional[str] = google_place_id - self.google_place_type: Optional[str] = google_place_type + self.foursquare_id: str | None = foursquare_id + self.foursquare_type: str | None = foursquare_type + self.google_place_id: str | None = google_place_id + self.google_place_type: str | None = google_place_type self._id_attrs = ( self.latitude, diff --git a/telegram/_inline/preparedinlinemessage.py b/src/telegram/_inline/preparedinlinemessage.py similarity index 90% rename from telegram/_inline/preparedinlinemessage.py rename to src/telegram/_inline/preparedinlinemessage.py index 35f4628ea88..b390c228e3e 100644 --- a/telegram/_inline/preparedinlinemessage.py +++ b/src/telegram/_inline/preparedinlinemessage.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Prepared inline Message.""" + import datetime as dtm -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp @@ -56,7 +57,7 @@ def __init__( id: str, # pylint: disable=redefined-builtin expiration_date: dtm.datetime, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.id: str = id @@ -67,15 +68,10 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["PreparedInlineMessage"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PreparedInlineMessage": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if data is None: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) data["expiration_date"] = from_timestamp(data.get("expiration_date"), tzinfo=loc_tzinfo) diff --git a/src/telegram/_inputchecklist.py b/src/telegram/_inputchecklist.py new file mode 100644 index 00000000000..d8fd06258ca --- /dev/null +++ b/src/telegram/_inputchecklist.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an objects that are related to Telegram input checklists.""" + +from collections.abc import Sequence + +from telegram._messageentity import MessageEntity +from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.types import JSONDict, ODVInput + + +class InputChecklistTask(TelegramObject): + """ + Describes a task to add to a checklist. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal if their :attr:`id` is equal. + + .. versionadded:: 22.3 + + Args: + id (:obj:`int`): + Unique identifier of the task; must be positive and unique among all task identifiers + currently present in the checklist. + text (:obj:`str`): + Text of the task; + :tg-const:`telegram.constants.InputChecklistLimit.MIN_TEXT_LENGTH`\ +-:tg-const:`telegram.constants.InputChecklistLimit.MAX_TEXT_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`, optional): + |parse_mode| + text_entities (Sequence[:class:`telegram.MessageEntity`], optional): + List of special entities that appear in the text, which can be specified instead of + parse_mode. Currently, only bold, italic, underline, strikethrough, spoiler, and + custom_emoji entities are allowed. + + Attributes: + id (:obj:`int`): + Unique identifier of the task; must be positive and unique among all task identifiers + currently present in the checklist. + text (:obj:`str`): + Text of the task; + :tg-const:`telegram.constants.InputChecklistLimit.MIN_TEXT_LENGTH`\ +-:tg-const:`telegram.constants.InputChecklistLimit.MAX_TEXT_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`): + Optional. |parse_mode| + text_entities (Sequence[:class:`telegram.MessageEntity`]): + Optional. List of special entities that appear in the text, which can be specified + instead of parse_mode. Currently, only bold, italic, underline, strikethrough, spoiler, + and custom_emoji entities are allowed. + + """ + + __slots__ = ( + "id", + "parse_mode", + "text", + "text_entities", + ) + + def __init__( + self, + id: int, # pylint: disable=redefined-builtin + text: str, + parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence[MessageEntity] | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: int = id + self.text: str = text + self.parse_mode: ODVInput[str] = parse_mode + self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) + + self._id_attrs = (self.id,) + + self._freeze() + + +class InputChecklist(TelegramObject): + """ + Describes a checklist to create. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal if their :attr:`tasks` is equal. + + .. versionadded:: 22.3 + + Args: + title (:obj:`str`): + Title of the checklist; + :tg-const:`telegram.constants.InputChecklistLimit.MIN_TITLE_LENGTH`\ +-:tg-const:`telegram.constants.InputChecklistLimit.MAX_TITLE_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`, optional): + |parse_mode| + title_entities (Sequence[:class:`telegram.MessageEntity`], optional): + List of special entities that appear in the title, which + can be specified instead of :paramref:`parse_mode`. Currently, only bold, italic, + underline, strikethrough, spoiler, and custom_emoji entities are allowed. + tasks (Sequence[:class:`telegram.InputChecklistTask`]): + List of + :tg-const:`telegram.constants.InputChecklistLimit.MIN_TASK_NUMBER`\ +-:tg-const:`telegram.constants.InputChecklistLimit.MAX_TASK_NUMBER` tasks in + the checklist. + others_can_add_tasks (:obj:`bool`, optional): + Pass :obj:`True` if other users can add tasks to the checklist. + others_can_mark_tasks_as_done (:obj:`bool`, optional): + Pass :obj:`True` if other users can mark tasks as done or not done in the checklist. + + Attributes: + title (:obj:`str`): + Title of the checklist; + :tg-const:`telegram.constants.InputChecklistLimit.MIN_TITLE_LENGTH`\ +-:tg-const:`telegram.constants.InputChecklistLimit.MAX_TITLE_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`): + Optional. |parse_mode| + title_entities (Sequence[:class:`telegram.MessageEntity`]): + Optional. List of special entities that appear in the title, which + can be specified instead of :paramref:`parse_mode`. Currently, only bold, italic, + underline, strikethrough, spoiler, and custom_emoji entities are allowed. + tasks (Sequence[:class:`telegram.InputChecklistTask`]): + List of + :tg-const:`telegram.constants.InputChecklistLimit.MIN_TASK_NUMBER`\ +-:tg-const:`telegram.constants.InputChecklistLimit.MAX_TASK_NUMBER` tasks in + the checklist. + others_can_add_tasks (:obj:`bool`): + Optional. Pass :obj:`True` if other users can add tasks to the checklist. + others_can_mark_tasks_as_done (:obj:`bool`): + Optional. Pass :obj:`True` if other users can mark tasks as done or not done in + the checklist. + + """ + + __slots__ = ( + "others_can_add_tasks", + "others_can_mark_tasks_as_done", + "parse_mode", + "tasks", + "title", + "title_entities", + ) + + def __init__( + self, + title: str, + tasks: Sequence[InputChecklistTask], + parse_mode: ODVInput[str] = DEFAULT_NONE, + title_entities: Sequence[MessageEntity] | None = None, + others_can_add_tasks: bool | None = None, + others_can_mark_tasks_as_done: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.title: str = title + self.tasks: tuple[InputChecklistTask, ...] = parse_sequence_arg(tasks) + self.parse_mode: ODVInput[str] = parse_mode + self.title_entities: tuple[MessageEntity, ...] = parse_sequence_arg(title_entities) + self.others_can_add_tasks: bool | None = others_can_add_tasks + self.others_can_mark_tasks_as_done: bool | None = others_can_mark_tasks_as_done + + self._id_attrs = (self.tasks,) + + self._freeze() diff --git a/telegram/_keyboardbutton.py b/src/telegram/_keyboardbutton.py similarity index 84% rename from telegram/_keyboardbutton.py rename to src/telegram/_keyboardbutton.py index 7bb8949a459..a08e966c35e 100644 --- a/telegram/_keyboardbutton.py +++ b/src/telegram/_keyboardbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,11 +18,12 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram KeyboardButton.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._keyboardbuttonpolltype import KeyboardButtonPollType from telegram._keyboardbuttonrequest import KeyboardButtonRequestChat, KeyboardButtonRequestUsers from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict from telegram._webappinfo import WebAppInfo @@ -134,26 +135,26 @@ class KeyboardButton(TelegramObject): def __init__( self, text: str, - request_contact: Optional[bool] = None, - request_location: Optional[bool] = None, - request_poll: Optional[KeyboardButtonPollType] = None, - web_app: Optional[WebAppInfo] = None, - request_chat: Optional[KeyboardButtonRequestChat] = None, - request_users: Optional[KeyboardButtonRequestUsers] = None, + request_contact: bool | None = None, + request_location: bool | None = None, + request_poll: KeyboardButtonPollType | None = None, + web_app: WebAppInfo | None = None, + request_chat: KeyboardButtonRequestChat | None = None, + request_users: KeyboardButtonRequestUsers | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.text: str = text # Optionals - self.request_contact: Optional[bool] = request_contact - self.request_location: Optional[bool] = request_location - self.request_poll: Optional[KeyboardButtonPollType] = request_poll - self.web_app: Optional[WebAppInfo] = web_app - self.request_users: Optional[KeyboardButtonRequestUsers] = request_users - self.request_chat: Optional[KeyboardButtonRequestChat] = request_chat + self.request_contact: bool | None = request_contact + self.request_location: bool | None = request_location + self.request_poll: KeyboardButtonPollType | None = request_poll + self.web_app: WebAppInfo | None = web_app + self.request_users: KeyboardButtonRequestUsers | None = request_users + self.request_chat: KeyboardButtonRequestChat | None = request_chat self._id_attrs = ( self.text, @@ -168,19 +169,20 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["KeyboardButton"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "KeyboardButton": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["request_poll"] = KeyboardButtonPollType.de_json(data.get("request_poll"), bot) - data["request_users"] = KeyboardButtonRequestUsers.de_json(data.get("request_users"), bot) - data["request_chat"] = KeyboardButtonRequestChat.de_json(data.get("request_chat"), bot) - data["web_app"] = WebAppInfo.de_json(data.get("web_app"), bot) + data["request_poll"] = de_json_optional( + data.get("request_poll"), KeyboardButtonPollType, bot + ) + data["request_users"] = de_json_optional( + data.get("request_users"), KeyboardButtonRequestUsers, bot + ) + data["request_chat"] = de_json_optional( + data.get("request_chat"), KeyboardButtonRequestChat, bot + ) + data["web_app"] = de_json_optional(data.get("web_app"), WebAppInfo, bot) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility diff --git a/telegram/_keyboardbuttonpolltype.py b/src/telegram/_keyboardbuttonpolltype.py similarity index 90% rename from telegram/_keyboardbuttonpolltype.py rename to src/telegram/_keyboardbuttonpolltype.py index fb21cfe0c5f..86baadb8fa4 100644 --- a/telegram/_keyboardbuttonpolltype.py +++ b/src/telegram/_keyboardbuttonpolltype.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a type of a Telegram Poll.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils import enum @@ -51,12 +50,12 @@ class KeyboardButtonPollType(TelegramObject): def __init__( self, - type: Optional[str] = None, # pylint: disable=redefined-builtin + type: str | None = None, # pylint: disable=redefined-builtin *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.type: Optional[str] = enum.get_member(PollType, type, type) + self.type: str | None = enum.get_member(PollType, type, type) self._id_attrs = (self.type,) diff --git a/telegram/_keyboardbuttonrequest.py b/src/telegram/_keyboardbuttonrequest.py similarity index 81% rename from telegram/_keyboardbuttonrequest.py rename to src/telegram/_keyboardbuttonrequest.py index b929f0b00d2..b5e0b050436 100644 --- a/telegram/_keyboardbuttonrequest.py +++ b/src/telegram/_keyboardbuttonrequest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,10 +18,11 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains two objects to request chats/users.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._chatadministratorrights import ChatAdministratorRights from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -105,26 +106,26 @@ class KeyboardButtonRequestUsers(TelegramObject): def __init__( self, request_id: int, - user_is_bot: Optional[bool] = None, - user_is_premium: Optional[bool] = None, - max_quantity: Optional[int] = None, - request_name: Optional[bool] = None, - request_username: Optional[bool] = None, - request_photo: Optional[bool] = None, + user_is_bot: bool | None = None, + user_is_premium: bool | None = None, + max_quantity: int | None = None, + request_name: bool | None = None, + request_username: bool | None = None, + request_photo: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.request_id: int = request_id # Optionals - self.user_is_bot: Optional[bool] = user_is_bot - self.user_is_premium: Optional[bool] = user_is_premium - self.max_quantity: Optional[int] = max_quantity - self.request_name: Optional[bool] = request_name - self.request_username: Optional[bool] = request_username - self.request_photo: Optional[bool] = request_photo + self.user_is_bot: bool | None = user_is_bot + self.user_is_premium: bool | None = user_is_premium + self.max_quantity: int | None = max_quantity + self.request_name: bool | None = request_name + self.request_username: bool | None = request_username + self.request_photo: bool | None = request_photo self._id_attrs = (self.request_id,) @@ -222,17 +223,17 @@ def __init__( self, request_id: int, chat_is_channel: bool, - chat_is_forum: Optional[bool] = None, - chat_has_username: Optional[bool] = None, - chat_is_created: Optional[bool] = None, - user_administrator_rights: Optional[ChatAdministratorRights] = None, - bot_administrator_rights: Optional[ChatAdministratorRights] = None, - bot_is_member: Optional[bool] = None, - request_title: Optional[bool] = None, - request_username: Optional[bool] = None, - request_photo: Optional[bool] = None, + chat_is_forum: bool | None = None, + chat_has_username: bool | None = None, + chat_is_created: bool | None = None, + user_administrator_rights: ChatAdministratorRights | None = None, + bot_administrator_rights: ChatAdministratorRights | None = None, + bot_is_member: bool | None = None, + request_title: bool | None = None, + request_username: bool | None = None, + request_photo: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # required @@ -240,37 +241,30 @@ def __init__( self.chat_is_channel: bool = chat_is_channel # optional - self.chat_is_forum: Optional[bool] = chat_is_forum - self.chat_has_username: Optional[bool] = chat_has_username - self.chat_is_created: Optional[bool] = chat_is_created - self.user_administrator_rights: Optional[ChatAdministratorRights] = ( - user_administrator_rights - ) - self.bot_administrator_rights: Optional[ChatAdministratorRights] = bot_administrator_rights - self.bot_is_member: Optional[bool] = bot_is_member - self.request_title: Optional[bool] = request_title - self.request_username: Optional[bool] = request_username - self.request_photo: Optional[bool] = request_photo + self.chat_is_forum: bool | None = chat_is_forum + self.chat_has_username: bool | None = chat_has_username + self.chat_is_created: bool | None = chat_is_created + self.user_administrator_rights: ChatAdministratorRights | None = user_administrator_rights + self.bot_administrator_rights: ChatAdministratorRights | None = bot_administrator_rights + self.bot_is_member: bool | None = bot_is_member + self.request_title: bool | None = request_title + self.request_username: bool | None = request_username + self.request_photo: bool | None = request_photo self._id_attrs = (self.request_id,) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["KeyboardButtonRequestChat"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "KeyboardButtonRequestChat": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["user_administrator_rights"] = ChatAdministratorRights.de_json( - data.get("user_administrator_rights"), bot + data["user_administrator_rights"] = de_json_optional( + data.get("user_administrator_rights"), ChatAdministratorRights, bot ) - data["bot_administrator_rights"] = ChatAdministratorRights.de_json( - data.get("bot_administrator_rights"), bot + data["bot_administrator_rights"] = de_json_optional( + data.get("bot_administrator_rights"), ChatAdministratorRights, bot ) return super().de_json(data=data, bot=bot) diff --git a/telegram/_linkpreviewoptions.py b/src/telegram/_linkpreviewoptions.py similarity index 97% rename from telegram/_linkpreviewoptions.py rename to src/telegram/_linkpreviewoptions.py index 6e28c92fbf3..5ad13de7a16 100644 --- a/telegram/_linkpreviewoptions.py +++ b/src/telegram/_linkpreviewoptions.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,9 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the LinkPreviewOptions class.""" - -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput @@ -81,7 +78,7 @@ def __init__( prefer_large_media: ODVInput[bool] = DEFAULT_NONE, show_above_text: ODVInput[bool] = DEFAULT_NONE, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) diff --git a/telegram/_loginurl.py b/src/telegram/_loginurl.py similarity index 91% rename from telegram/_loginurl.py rename to src/telegram/_loginurl.py index 340054268f2..9b0ce84c377 100644 --- a/telegram/_loginurl.py +++ b/src/telegram/_loginurl.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram LoginUrl.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -86,19 +85,19 @@ class LoginUrl(TelegramObject): def __init__( self, url: str, - forward_text: Optional[str] = None, - bot_username: Optional[str] = None, - request_write_access: Optional[bool] = None, + forward_text: str | None = None, + bot_username: str | None = None, + request_write_access: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.url: str = url # Optional - self.forward_text: Optional[str] = forward_text - self.bot_username: Optional[str] = bot_username - self.request_write_access: Optional[bool] = request_write_access + self.forward_text: str | None = forward_text + self.bot_username: str | None = bot_username + self.request_write_access: bool | None = request_write_access self._id_attrs = (self.url,) diff --git a/telegram/_menubutton.py b/src/telegram/_menubutton.py similarity index 89% rename from telegram/_menubutton.py rename to src/telegram/_menubutton.py index 76c0ee49cdb..e570c5a6648 100644 --- a/telegram/_menubutton.py +++ b/src/telegram/_menubutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,13 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains objects related to Telegram menu buttons.""" -from typing import TYPE_CHECKING, Final, Optional + +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._telegramobject import TelegramObject from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict from telegram._webappinfo import WebAppInfo @@ -59,7 +61,7 @@ def __init__( self, type: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # pylint: disable=redefined-builtin super().__init__(api_kwargs=api_kwargs) self.type: str = enum.get_member(constants.MenuButtonType, type, type) @@ -69,9 +71,7 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["MenuButton"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "MenuButton": """Converts JSON data to the appropriate :class:`MenuButton` object, i.e. takes care of selecting the correct subclass. @@ -89,12 +89,6 @@ def de_json( """ data = cls._parse_data(data) - if data is None: - return None - - if not data and cls is MenuButton: - return None - _class_mapping: dict[str, type[MenuButton]] = { cls.COMMANDS: MenuButtonCommands, cls.WEB_APP: MenuButtonWebApp, @@ -125,7 +119,7 @@ class MenuButtonCommands(MenuButton): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(type=constants.MenuButtonType.COMMANDS, api_kwargs=api_kwargs) self._freeze() @@ -163,7 +157,7 @@ class MenuButtonWebApp(MenuButton): __slots__ = ("text", "web_app") - def __init__(self, text: str, web_app: WebAppInfo, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, text: str, web_app: WebAppInfo, *, api_kwargs: JSONDict | None = None): super().__init__(type=constants.MenuButtonType.WEB_APP, api_kwargs=api_kwargs) with self._unfrozen(): self.text: str = text @@ -172,16 +166,11 @@ def __init__(self, text: str, web_app: WebAppInfo, *, api_kwargs: Optional[JSOND self._id_attrs = (self.type, self.text, self.web_app) @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["MenuButtonWebApp"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "MenuButtonWebApp": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["web_app"] = WebAppInfo.de_json(data.get("web_app"), bot) + data["web_app"] = de_json_optional(data.get("web_app"), WebAppInfo, bot) return super().de_json(data=data, bot=bot) # type: ignore[return-value] @@ -196,6 +185,6 @@ class MenuButtonDefault(MenuButton): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(type=constants.MenuButtonType.DEFAULT, api_kwargs=api_kwargs) self._freeze() diff --git a/telegram/_message.py b/src/telegram/_message.py similarity index 71% rename from telegram/_message.py rename to src/telegram/_message.py index df8ea36a2fc..4fbcaec3269 100644 --- a/telegram/_message.py +++ b/src/telegram/_message.py @@ -2,7 +2,7 @@ # pylint: disable=too-many-instance-attributes, too-many-arguments # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,12 +23,15 @@ import re from collections.abc import Sequence from html import escape -from typing import TYPE_CHECKING, Optional, TypedDict, Union +from typing import TYPE_CHECKING, TypedDict from telegram._chat import Chat from telegram._chatbackground import ChatBackground from telegram._chatboost import ChatBoostAdded +from telegram._checklists import Checklist, ChecklistTasksAdded, ChecklistTasksDone from telegram._dice import Dice +from telegram._directmessagepricechanged import DirectMessagePriceChanged +from telegram._directmessagestopic import DirectMessagesTopic from telegram._files.animation import Animation from telegram._files.audio import Audio from telegram._files.contact import Contact @@ -49,11 +52,14 @@ GeneralForumTopicUnhidden, ) from telegram._games.game import Game +from telegram._gifts import GiftInfo from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup +from telegram._inputchecklist import InputChecklist from telegram._linkpreviewoptions import LinkPreviewOptions from telegram._messageautodeletetimerchanged import MessageAutoDeleteTimerChanged from telegram._messageentity import MessageEntity from telegram._paidmedia import PaidMediaInfo +from telegram._paidmessagepricechanged import PaidMessagePriceChanged from telegram._passport.passportdata import PassportData from telegram._payment.invoice import Invoice from telegram._payment.refundedpayment import RefundedPayment @@ -64,19 +70,19 @@ from telegram._shared import ChatShared, UsersShared from telegram._story import Story from telegram._telegramobject import TelegramObject +from telegram._uniquegift import UniqueGiftInfo from telegram._user import User -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue from telegram._utils.entities import parse_message_entities, parse_message_entity from telegram._utils.strings import TextEncoding from telegram._utils.types import ( CorrectOptionID, - FileInput, JSONDict, MarkdownVersion, ODVInput, - ReplyMarkup, + TimePeriod, ) from telegram._utils.warnings import warn from telegram._videochat import ( @@ -111,14 +117,22 @@ MessageId, MessageOrigin, ReactionType, + SuggestedPostApprovalFailed, + SuggestedPostApproved, + SuggestedPostDeclined, + SuggestedPostInfo, + SuggestedPostPaid, + SuggestedPostParameters, + SuggestedPostRefunded, TextQuote, ) + from telegram._utils.types import FileInput, ReplyMarkup class _ReplyKwargs(TypedDict): __slots__ = ("chat_id", "reply_parameters") # type: ignore[misc] - chat_id: Union[str, int] + chat_id: str | int reply_parameters: ReplyParameters @@ -160,7 +174,7 @@ def __init__( message_id: int, date: dtm.datetime, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.chat: Chat = chat @@ -177,23 +191,18 @@ def is_accessible(self) -> bool: .. versionadded:: 20.8 """ - # Once we drop support for python 3.9, this can be made a TypeGuard function: - # def is_accessible(self) -> TypeGuard[Message]: return self.date != ZERO_DATE @classmethod def _de_json( cls, - data: Optional[JSONDict], - bot: Optional["Bot"] = None, - api_kwargs: Optional[JSONDict] = None, - ) -> Optional["MaybeInaccessibleMessage"]: + data: JSONDict | None, + bot: "Bot | None" = None, + api_kwargs: JSONDict | None = None, + ) -> "MaybeInaccessibleMessage | None": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - if cls is MaybeInaccessibleMessage: if data["date"] == 0: return InaccessibleMessage.de_json(data=data, bot=bot) @@ -206,9 +215,9 @@ def _de_json( if data["date"] == 0: data["date"] = ZERO_DATE else: - data["date"] = from_timestamp(data["date"], tzinfo=loc_tzinfo) + data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo) - data["chat"] = Chat.de_json(data.get("chat"), bot) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) return super()._de_json(data=data, bot=bot, api_kwargs=api_kwargs) @@ -240,7 +249,7 @@ def __init__( chat: Chat, message_id: int, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(chat=chat, message_id=message_id, date=ZERO_DATE, api_kwargs=api_kwargs) self._freeze() @@ -338,6 +347,13 @@ class Message(MaybeInaccessibleMessage): .. versionadded:: 20.8 + suggested_post_info (:class:`telegram.SuggestedPostInfo`, optional): Information about + suggested post parameters if the message is a suggested post in a channel direct + messages chat. If the message is an approved or declined suggested post, then it can't + be edited. + + .. versionadded:: 22.4 + effect_id (:obj:`str`, optional): Unique identifier of the message effect added to the message. @@ -445,6 +461,10 @@ class Message(MaybeInaccessibleMessage): `More about Telegram Login >> `_. author_signature (:obj:`str`, optional): Signature of the post author for messages in channels, or the custom title of an anonymous group administrator. + paid_star_count (:obj:`int`, optional): The number of Telegram Stars that were paid by the + sender of the message to send it + + .. versionadded:: 22.1 passport_data (:class:`telegram.PassportData`, optional): Telegram Passport data. poll (:class:`telegram.Poll`, optional): Message is a native poll, information about the poll. @@ -476,12 +496,12 @@ class Message(MaybeInaccessibleMessage): reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached to the message. :paramref:`~telegram.InlineKeyboardButton.login_url` buttons are represented as ordinary url buttons. - is_topic_message (:obj:`bool`, optional): :obj:`True`, if the message is sent to a forum - topic. + is_topic_message (:obj:`bool`, optional): :obj:`True`, if the message is sent to a topic + in a forum supergroup or a private chat with the bot. .. versionadded:: 20.0 - message_thread_id (:obj:`int`, optional): Unique identifier of a message thread to which - the message belongs; for supergroups only. + message_thread_id (:obj:`int`, optional): Unique identifier of a message thread or forum + topic to which the message belongs; for supergroups and private chats only. .. versionadded:: 20.0 forum_topic_created (:class:`telegram.ForumTopicCreated`, optional): Service message: @@ -519,6 +539,9 @@ class Message(MaybeInaccessibleMessage): by a spoiler animation. .. versionadded:: 20.0 + checklist (:class:`telegram.Checklist`, optional): Message is a checklist + + .. versionadded:: 22.3 users_shared (:class:`telegram.UsersShared`, optional): Service message: users were shared with the bot @@ -527,6 +550,18 @@ class Message(MaybeInaccessibleMessage): with the bot. .. versionadded:: 20.1 + gift (:class:`telegram.GiftInfo`, optional): Service message: a regular gift was sent + or received. + + .. versionadded:: 22.1 + unique_gift (:class:`telegram.UniqueGiftInfo`, optional): Service message: a unique gift + was sent or received + + .. versionadded:: 22.1 + gift_upgrade_sent (:class:`telegram.GiftInfo`, optional): Service message: upgrade of a + gift was purchased after the gift was sent + + .. versionadded:: 22.6 giveaway_created (:class:`telegram.GiveawayCreated`, optional): Service message: a scheduled giveaway was created @@ -543,6 +578,30 @@ class Message(MaybeInaccessibleMessage): giveaway without public winners was completed .. versionadded:: 20.8 + paid_message_price_changed (:class:`telegram.PaidMessagePriceChanged`, optional): Service + message: the price for paid messages has changed in the chat + + .. versionadded:: 22.1 + suggested_post_approved (:class:`telegram.SuggestedPostApproved`, optional): Service + message: a suggested post was approved. + + .. versionadded:: 22.4 + suggested_post_approval_failed (:class:`telegram.SuggestedPostApproved`, optional): Service + message: approval of a suggested post has failed. + + .. versionadded:: 22.4 + suggested_post_declined (:class:`telegram.SuggestedPostDeclined`, optional): Service + message: a suggested post was declined. + + .. versionadded:: 22.4 + suggested_post_paid (:class:`telegram.SuggestedPostPaid`, optional): Service + message: payment for a suggested post was received. + + .. versionadded:: 22.4 + suggested_post_refunded (:class:`telegram.SuggestedPostRefunded`, optional): Service + message: payment for a suggested post was refunded. + + .. versionadded:: 22.4 external_reply (:class:`telegram.ExternalReplyInfo`, optional): Information about the message that is being replied to, which may come from another chat or forum topic. @@ -584,6 +643,14 @@ class Message(MaybeInaccessibleMessage): background set. .. versionadded:: 21.2 + checklist_tasks_done (:class:`telegram.ChecklistTasksDone`, optional): Service message: + some tasks in a checklist were marked as done or not done + + .. versionadded:: 22.3 + checklist_tasks_added (:class:`telegram.ChecklistTasksAdded`, optional): Service message: + tasks were added to a checklist + + .. versionadded:: 22.3 paid_media (:class:`telegram.PaidMediaInfo`, optional): Message contains paid media; information about the paid media. @@ -592,6 +659,23 @@ class Message(MaybeInaccessibleMessage): message about a refunded payment, information about the payment. .. versionadded:: 21.4 + direct_message_price_changed (:class:`telegram.DirectMessagePriceChanged`, optional): + Service message: the price for paid messages in the corresponding direct messages chat + of a channel has changed. + + .. versionadded:: 22.3 + is_paid_post (:obj:`bool`, optional): :obj:`True`, if the message is a paid post. Note that + such posts must not be deleted for 24 hours to receive the payment and can't be edited. + + .. versionadded:: 22.4 + direct_messages_topic (:class:`telegram.DirectMessagesTopic`, optional): Information about + the direct messages chat topic that contains the message. + + .. versionadded:: 22.4 + reply_to_checklist_task_id (:obj:`int`, optional): Identifier of the specific checklist + task that is being replied to. + + .. versionadded:: 22.4 Attributes: message_id (:obj:`int`): Unique message identifier inside this chat. In specific instances @@ -651,10 +735,17 @@ class Message(MaybeInaccessibleMessage): .. versionadded:: 20.8 + suggested_post_info (:class:`telegram.SuggestedPostInfo`): Optional. Information about + suggested post parameters if the message is a suggested post in a channel direct + messages chat. If the message is an approved or declined suggested post, then it can't + be edited. + + .. versionadded:: 22.4 + effect_id (:obj:`str`): Optional. Unique identifier of the message effect added to the message. - ..versionadded:: 21.3 + .. versionadded:: 21.3 caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. For messages with a Caption. Special entities like usernames, URLs, bot commands, etc. that appear in the @@ -773,6 +864,10 @@ class Message(MaybeInaccessibleMessage): `More about Telegram Login >> `_. author_signature (:obj:`str`): Optional. Signature of the post author for messages in channels, or the custom title of an anonymous group administrator. + paid_star_count (:obj:`int`): Optional. The number of Telegram Stars that were paid by the + sender of the message to send it + + .. versionadded:: 22.1 passport_data (:class:`telegram.PassportData`): Optional. Telegram Passport data. Examples: @@ -807,12 +902,12 @@ class Message(MaybeInaccessibleMessage): reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached to the message. :paramref:`~telegram.InlineKeyboardButton.login_url` buttons are represented as ordinary url buttons. - is_topic_message (:obj:`bool`): Optional. :obj:`True`, if the message is sent to a forum - topic. + is_topic_message (:obj:`bool`): Optional. :obj:`True`, if the message is sent to a topic + in a forum supergroup or a private chat with the bot. .. versionadded:: 20.0 - message_thread_id (:obj:`int`): Optional. Unique identifier of a message thread to which - the message belongs; for supergroups only. + message_thread_id (:obj:`int`): Optional. Unique identifier of a message thread or forum + topic to which the message belongs; for supergroups and private chats only. .. versionadded:: 20.0 forum_topic_created (:class:`telegram.ForumTopicCreated`): Optional. Service message: @@ -847,6 +942,9 @@ class Message(MaybeInaccessibleMessage): by a spoiler animation. .. versionadded:: 20.0 + checklist (:class:`telegram.Checklist`): Optional. Message is a checklist + + .. versionadded:: 22.3 users_shared (:class:`telegram.UsersShared`): Optional. Service message: users were shared with the bot @@ -855,6 +953,18 @@ class Message(MaybeInaccessibleMessage): with the bot. .. versionadded:: 20.1 + gift (:class:`telegram.GiftInfo`): Optional. Service message: a regular gift was sent + or received. + + .. versionadded:: 22.1 + unique_gift (:class:`telegram.UniqueGiftInfo`): Optional. Service message: a unique gift + was sent or received + + .. versionadded:: 22.1 + gift_upgrade_sent (:class:`telegram.GiftInfo`): Optional. Service message: upgrade of a + gift was purchased after the gift was sent + + .. versionadded:: 22.6 giveaway_created (:class:`telegram.GiveawayCreated`): Optional. Service message: a scheduled giveaway was created @@ -871,6 +981,30 @@ class Message(MaybeInaccessibleMessage): giveaway without public winners was completed .. versionadded:: 20.8 + paid_message_price_changed (:class:`telegram.PaidMessagePriceChanged`): Optional. Service + message: the price for paid messages has changed in the chat + + .. versionadded:: 22.1 + suggested_post_approved (:class:`telegram.SuggestedPostApproved`): Optional. Service + message: a suggested post was approved. + + .. versionadded:: 22.4 + suggested_post_approval_failed (:class:`telegram.SuggestedPostApproved`): Optional. Service + message: approval of a suggested post has failed. + + .. versionadded:: 22.4 + suggested_post_declined (:class:`telegram.SuggestedPostDeclined`): Optional. Service + message: a suggested post was declined. + + .. versionadded:: 22.4 + suggested_post_paid (:class:`telegram.SuggestedPostPaid`): Optional. Service + message: payment for a suggested post was received. + + .. versionadded:: 22.4 + suggested_post_refunded (:class:`telegram.SuggestedPostRefunded`): Optional. Service + message: payment for a suggested post was refunded. + + .. versionadded:: 22.4 external_reply (:class:`telegram.ExternalReplyInfo`): Optional. Information about the message that is being replied to, which may come from another chat or forum topic. @@ -913,6 +1047,14 @@ class Message(MaybeInaccessibleMessage): background set .. versionadded:: 21.2 + checklist_tasks_done (:class:`telegram.ChecklistTasksDone`): Optional. Service message: + some tasks in a checklist were marked as done or not done + + .. versionadded:: 22.3 + checklist_tasks_added (:class:`telegram.ChecklistTasksAdded`): Optional. Service message: + tasks were added to a checklist + + .. versionadded:: 22.3 paid_media (:class:`telegram.PaidMediaInfo`): Optional. Message contains paid media; information about the paid media. @@ -921,6 +1063,23 @@ class Message(MaybeInaccessibleMessage): message about a refunded payment, information about the payment. .. versionadded:: 21.4 + direct_message_price_changed (:class:`telegram.DirectMessagePriceChanged`): + Optional. Service message: the price for paid messages in the corresponding direct + messages chat of a channel has changed. + + .. versionadded:: 22.3 + is_paid_post (:obj:`bool`): Optional. :obj:`True`, if the message is a paid post. Note that + such posts must not be deleted for 24 hours to receive the payment and can't be edited. + + .. versionadded:: 22.4 + direct_messages_topic (:class:`telegram.DirectMessagesTopic`): Optional. Information about + the direct messages chat topic that contains the message. + + .. versionadded:: 22.4 + reply_to_checklist_task_id (:obj:`int`): Optional. Identifier of the specific checklist + task that is being replied to. + + .. versionadded:: 22.4 .. |custom_emoji_no_md1_support| replace:: Since custom emoji entities are not supported by :attr:`~telegram.constants.ParseMode.MARKDOWN`, this method now raises a @@ -932,6 +1091,9 @@ class Message(MaybeInaccessibleMessage): .. |reply_same_thread| replace:: If :paramref:`message_thread_id` is not provided, this will reply to the same thread (topic) of the original message. + + .. |quote_removed| replace:: Removed deprecated parameter ``quote``. Use :paramref:`do_quote` + instead. """ # fmt: on @@ -947,10 +1109,15 @@ class Message(MaybeInaccessibleMessage): "channel_chat_created", "chat_background_set", "chat_shared", + "checklist", + "checklist_tasks_added", + "checklist_tasks_done", "connected_website", "contact", "delete_chat_photo", "dice", + "direct_message_price_changed", + "direct_messages_topic", "document", "edit_date", "effect_id", @@ -965,6 +1132,8 @@ class Message(MaybeInaccessibleMessage): "game", "general_forum_topic_hidden", "general_forum_topic_unhidden", + "gift", + "gift_upgrade_sent", "giveaway", "giveaway_completed", "giveaway_created", @@ -975,6 +1144,7 @@ class Message(MaybeInaccessibleMessage): "invoice", "is_automatic_forward", "is_from_offline", + "is_paid_post", "is_topic_message", "left_chat_member", "link_preview_options", @@ -988,6 +1158,8 @@ class Message(MaybeInaccessibleMessage): "new_chat_photo", "new_chat_title", "paid_media", + "paid_message_price_changed", + "paid_star_count", "passport_data", "photo", "pinned_message", @@ -996,6 +1168,7 @@ class Message(MaybeInaccessibleMessage): "quote", "refunded_payment", "reply_markup", + "reply_to_checklist_task_id", "reply_to_message", "reply_to_story", "sender_boost_count", @@ -1005,8 +1178,15 @@ class Message(MaybeInaccessibleMessage): "sticker", "story", "successful_payment", + "suggested_post_approval_failed", + "suggested_post_approved", + "suggested_post_declined", + "suggested_post_info", + "suggested_post_paid", + "suggested_post_refunded", "supergroup_chat_created", "text", + "unique_gift", "users_shared", "venue", "via_bot", @@ -1026,90 +1206,108 @@ def __init__( message_id: int, date: dtm.datetime, chat: Chat, - from_user: Optional[User] = None, - reply_to_message: Optional["Message"] = None, - edit_date: Optional[dtm.datetime] = None, - text: Optional[str] = None, - entities: Optional[Sequence["MessageEntity"]] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - audio: Optional[Audio] = None, - document: Optional[Document] = None, - game: Optional[Game] = None, - photo: Optional[Sequence[PhotoSize]] = None, - sticker: Optional[Sticker] = None, - video: Optional[Video] = None, - voice: Optional[Voice] = None, - video_note: Optional[VideoNote] = None, - new_chat_members: Optional[Sequence[User]] = None, - caption: Optional[str] = None, - contact: Optional[Contact] = None, - location: Optional[Location] = None, - venue: Optional[Venue] = None, - left_chat_member: Optional[User] = None, - new_chat_title: Optional[str] = None, - new_chat_photo: Optional[Sequence[PhotoSize]] = None, - delete_chat_photo: Optional[bool] = None, - group_chat_created: Optional[bool] = None, - supergroup_chat_created: Optional[bool] = None, - channel_chat_created: Optional[bool] = None, - migrate_to_chat_id: Optional[int] = None, - migrate_from_chat_id: Optional[int] = None, - pinned_message: Optional[MaybeInaccessibleMessage] = None, - invoice: Optional[Invoice] = None, - successful_payment: Optional[SuccessfulPayment] = None, - author_signature: Optional[str] = None, - media_group_id: Optional[str] = None, - connected_website: Optional[str] = None, - animation: Optional[Animation] = None, - passport_data: Optional[PassportData] = None, - poll: Optional[Poll] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - dice: Optional[Dice] = None, - via_bot: Optional[User] = None, - proximity_alert_triggered: Optional[ProximityAlertTriggered] = None, - sender_chat: Optional[Chat] = None, - video_chat_started: Optional[VideoChatStarted] = None, - video_chat_ended: Optional[VideoChatEnded] = None, - video_chat_participants_invited: Optional[VideoChatParticipantsInvited] = None, - message_auto_delete_timer_changed: Optional[MessageAutoDeleteTimerChanged] = None, - video_chat_scheduled: Optional[VideoChatScheduled] = None, - is_automatic_forward: Optional[bool] = None, - has_protected_content: Optional[bool] = None, - web_app_data: Optional[WebAppData] = None, - is_topic_message: Optional[bool] = None, - message_thread_id: Optional[int] = None, - forum_topic_created: Optional[ForumTopicCreated] = None, - forum_topic_closed: Optional[ForumTopicClosed] = None, - forum_topic_reopened: Optional[ForumTopicReopened] = None, - forum_topic_edited: Optional[ForumTopicEdited] = None, - general_forum_topic_hidden: Optional[GeneralForumTopicHidden] = None, - general_forum_topic_unhidden: Optional[GeneralForumTopicUnhidden] = None, - write_access_allowed: Optional[WriteAccessAllowed] = None, - has_media_spoiler: Optional[bool] = None, - chat_shared: Optional[ChatShared] = None, - story: Optional[Story] = None, - giveaway: Optional["Giveaway"] = None, - giveaway_completed: Optional["GiveawayCompleted"] = None, - giveaway_created: Optional["GiveawayCreated"] = None, - giveaway_winners: Optional["GiveawayWinners"] = None, - users_shared: Optional[UsersShared] = None, - link_preview_options: Optional[LinkPreviewOptions] = None, - external_reply: Optional["ExternalReplyInfo"] = None, - quote: Optional["TextQuote"] = None, - forward_origin: Optional["MessageOrigin"] = None, - reply_to_story: Optional[Story] = None, - boost_added: Optional[ChatBoostAdded] = None, - sender_boost_count: Optional[int] = None, - business_connection_id: Optional[str] = None, - sender_business_bot: Optional[User] = None, - is_from_offline: Optional[bool] = None, - chat_background_set: Optional[ChatBackground] = None, - effect_id: Optional[str] = None, - show_caption_above_media: Optional[bool] = None, - paid_media: Optional[PaidMediaInfo] = None, - refunded_payment: Optional[RefundedPayment] = None, + from_user: User | None = None, + reply_to_message: "Message | None" = None, + edit_date: dtm.datetime | None = None, + text: str | None = None, + entities: Sequence["MessageEntity"] | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, + audio: Audio | None = None, + document: Document | None = None, + game: Game | None = None, + photo: Sequence[PhotoSize] | None = None, + sticker: Sticker | None = None, + video: Video | None = None, + voice: Voice | None = None, + video_note: VideoNote | None = None, + new_chat_members: Sequence[User] | None = None, + caption: str | None = None, + contact: "Contact | None" = None, + location: "Location | None" = None, + venue: Venue | None = None, + left_chat_member: User | None = None, + new_chat_title: str | None = None, + new_chat_photo: Sequence[PhotoSize] | None = None, + delete_chat_photo: bool | None = None, + group_chat_created: bool | None = None, + supergroup_chat_created: bool | None = None, + channel_chat_created: bool | None = None, + migrate_to_chat_id: int | None = None, + migrate_from_chat_id: int | None = None, + pinned_message: MaybeInaccessibleMessage | None = None, + invoice: Invoice | None = None, + successful_payment: SuccessfulPayment | None = None, + author_signature: str | None = None, + media_group_id: str | None = None, + connected_website: str | None = None, + animation: Animation | None = None, + passport_data: PassportData | None = None, + poll: Poll | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + dice: Dice | None = None, + via_bot: User | None = None, + proximity_alert_triggered: ProximityAlertTriggered | None = None, + sender_chat: Chat | None = None, + video_chat_started: VideoChatStarted | None = None, + video_chat_ended: VideoChatEnded | None = None, + video_chat_participants_invited: VideoChatParticipantsInvited | None = None, + message_auto_delete_timer_changed: MessageAutoDeleteTimerChanged | None = None, + video_chat_scheduled: VideoChatScheduled | None = None, + is_automatic_forward: bool | None = None, + has_protected_content: bool | None = None, + web_app_data: WebAppData | None = None, + is_topic_message: bool | None = None, + message_thread_id: int | None = None, + forum_topic_created: ForumTopicCreated | None = None, + forum_topic_closed: ForumTopicClosed | None = None, + forum_topic_reopened: ForumTopicReopened | None = None, + forum_topic_edited: ForumTopicEdited | None = None, + general_forum_topic_hidden: GeneralForumTopicHidden | None = None, + general_forum_topic_unhidden: GeneralForumTopicUnhidden | None = None, + write_access_allowed: WriteAccessAllowed | None = None, + has_media_spoiler: bool | None = None, + chat_shared: ChatShared | None = None, + story: Story | None = None, + giveaway: "Giveaway | None" = None, + giveaway_completed: "GiveawayCompleted | None" = None, + giveaway_created: "GiveawayCreated | None" = None, + giveaway_winners: "GiveawayWinners | None" = None, + users_shared: UsersShared | None = None, + link_preview_options: LinkPreviewOptions | None = None, + external_reply: "ExternalReplyInfo | None" = None, + quote: "TextQuote | None" = None, + forward_origin: "MessageOrigin | None" = None, + reply_to_story: Story | None = None, + boost_added: ChatBoostAdded | None = None, + sender_boost_count: int | None = None, + business_connection_id: str | None = None, + sender_business_bot: User | None = None, + is_from_offline: bool | None = None, + chat_background_set: ChatBackground | None = None, + effect_id: str | None = None, + show_caption_above_media: bool | None = None, + paid_media: PaidMediaInfo | None = None, + refunded_payment: RefundedPayment | None = None, + gift: GiftInfo | None = None, + unique_gift: UniqueGiftInfo | None = None, + paid_message_price_changed: PaidMessagePriceChanged | None = None, + paid_star_count: int | None = None, + direct_message_price_changed: DirectMessagePriceChanged | None = None, + checklist: Checklist | None = None, + checklist_tasks_done: ChecklistTasksDone | None = None, + checklist_tasks_added: ChecklistTasksAdded | None = None, + is_paid_post: bool | None = None, + direct_messages_topic: DirectMessagesTopic | None = None, + reply_to_checklist_task_id: int | None = None, + suggested_post_declined: "SuggestedPostDeclined | None" = None, + suggested_post_paid: "SuggestedPostPaid | None" = None, + suggested_post_refunded: "SuggestedPostRefunded | None" = None, + suggested_post_info: "SuggestedPostInfo | None" = None, + suggested_post_approved: "SuggestedPostApproved | None" = None, + suggested_post_approval_failed: "SuggestedPostApprovalFailed | None" = None, + gift_upgrade_sent: GiftInfo | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(chat=chat, message_id=message_id, date=date, api_kwargs=api_kwargs) @@ -1117,100 +1315,124 @@ def __init__( # Required self.message_id: int = message_id # Optionals - self.from_user: Optional[User] = from_user - self.sender_chat: Optional[Chat] = sender_chat + self.from_user: User | None = from_user + self.sender_chat: Chat | None = sender_chat self.date: dtm.datetime = date self.chat: Chat = chat - self.is_automatic_forward: Optional[bool] = is_automatic_forward - self.reply_to_message: Optional[Message] = reply_to_message - self.edit_date: Optional[dtm.datetime] = edit_date - self.has_protected_content: Optional[bool] = has_protected_content - self.text: Optional[str] = text + self.is_automatic_forward: bool | None = is_automatic_forward + self.reply_to_message: Message | None = reply_to_message + self.edit_date: dtm.datetime | None = edit_date + self.has_protected_content: bool | None = has_protected_content + self.text: str | None = text self.entities: tuple[MessageEntity, ...] = parse_sequence_arg(entities) self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.audio: Optional[Audio] = audio - self.game: Optional[Game] = game - self.document: Optional[Document] = document + self.audio: Audio | None = audio + self.game: Game | None = game + self.document: Document | None = document self.photo: tuple[PhotoSize, ...] = parse_sequence_arg(photo) - self.sticker: Optional[Sticker] = sticker - self.video: Optional[Video] = video - self.voice: Optional[Voice] = voice - self.video_note: Optional[VideoNote] = video_note - self.caption: Optional[str] = caption - self.contact: Optional[Contact] = contact - self.location: Optional[Location] = location - self.venue: Optional[Venue] = venue + self.sticker: Sticker | None = sticker + self.video: Video | None = video + self.voice: Voice | None = voice + self.video_note: VideoNote | None = video_note + self.caption: str | None = caption + self.contact: Contact | None = contact + self.location: Location | None = location + self.venue: Venue | None = venue self.new_chat_members: tuple[User, ...] = parse_sequence_arg(new_chat_members) - self.left_chat_member: Optional[User] = left_chat_member - self.new_chat_title: Optional[str] = new_chat_title + self.left_chat_member: User | None = left_chat_member + self.new_chat_title: str | None = new_chat_title self.new_chat_photo: tuple[PhotoSize, ...] = parse_sequence_arg(new_chat_photo) - self.delete_chat_photo: Optional[bool] = bool(delete_chat_photo) - self.group_chat_created: Optional[bool] = bool(group_chat_created) - self.supergroup_chat_created: Optional[bool] = bool(supergroup_chat_created) - self.migrate_to_chat_id: Optional[int] = migrate_to_chat_id - self.migrate_from_chat_id: Optional[int] = migrate_from_chat_id - self.channel_chat_created: Optional[bool] = bool(channel_chat_created) - self.message_auto_delete_timer_changed: Optional[MessageAutoDeleteTimerChanged] = ( + self.delete_chat_photo: bool | None = bool(delete_chat_photo) + self.group_chat_created: bool | None = bool(group_chat_created) + self.supergroup_chat_created: bool | None = bool(supergroup_chat_created) + self.migrate_to_chat_id: int | None = migrate_to_chat_id + self.migrate_from_chat_id: int | None = migrate_from_chat_id + self.channel_chat_created: bool | None = bool(channel_chat_created) + self.message_auto_delete_timer_changed: MessageAutoDeleteTimerChanged | None = ( message_auto_delete_timer_changed ) - self.pinned_message: Optional[MaybeInaccessibleMessage] = pinned_message - self.invoice: Optional[Invoice] = invoice - self.successful_payment: Optional[SuccessfulPayment] = successful_payment - self.connected_website: Optional[str] = connected_website - self.author_signature: Optional[str] = author_signature - self.media_group_id: Optional[str] = media_group_id - self.animation: Optional[Animation] = animation - self.passport_data: Optional[PassportData] = passport_data - self.poll: Optional[Poll] = poll - self.dice: Optional[Dice] = dice - self.via_bot: Optional[User] = via_bot - self.proximity_alert_triggered: Optional[ProximityAlertTriggered] = ( + self.pinned_message: MaybeInaccessibleMessage | None = pinned_message + self.invoice: Invoice | None = invoice + self.successful_payment: SuccessfulPayment | None = successful_payment + self.connected_website: str | None = connected_website + self.author_signature: str | None = author_signature + self.media_group_id: str | None = media_group_id + self.animation: Animation | None = animation + self.passport_data: PassportData | None = passport_data + self.poll: Poll | None = poll + self.dice: Dice | None = dice + self.via_bot: User | None = via_bot + self.proximity_alert_triggered: ProximityAlertTriggered | None = ( proximity_alert_triggered ) - self.video_chat_scheduled: Optional[VideoChatScheduled] = video_chat_scheduled - self.video_chat_started: Optional[VideoChatStarted] = video_chat_started - self.video_chat_ended: Optional[VideoChatEnded] = video_chat_ended - self.video_chat_participants_invited: Optional[VideoChatParticipantsInvited] = ( + self.video_chat_scheduled: VideoChatScheduled | None = video_chat_scheduled + self.video_chat_started: VideoChatStarted | None = video_chat_started + self.video_chat_ended: VideoChatEnded | None = video_chat_ended + self.video_chat_participants_invited: VideoChatParticipantsInvited | None = ( video_chat_participants_invited ) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.web_app_data: Optional[WebAppData] = web_app_data - self.is_topic_message: Optional[bool] = is_topic_message - self.message_thread_id: Optional[int] = message_thread_id - self.forum_topic_created: Optional[ForumTopicCreated] = forum_topic_created - self.forum_topic_closed: Optional[ForumTopicClosed] = forum_topic_closed - self.forum_topic_reopened: Optional[ForumTopicReopened] = forum_topic_reopened - self.forum_topic_edited: Optional[ForumTopicEdited] = forum_topic_edited - self.general_forum_topic_hidden: Optional[GeneralForumTopicHidden] = ( + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.web_app_data: WebAppData | None = web_app_data + self.is_topic_message: bool | None = is_topic_message + self.message_thread_id: int | None = message_thread_id + self.forum_topic_created: ForumTopicCreated | None = forum_topic_created + self.forum_topic_closed: ForumTopicClosed | None = forum_topic_closed + self.forum_topic_reopened: ForumTopicReopened | None = forum_topic_reopened + self.forum_topic_edited: ForumTopicEdited | None = forum_topic_edited + self.general_forum_topic_hidden: GeneralForumTopicHidden | None = ( general_forum_topic_hidden ) - self.general_forum_topic_unhidden: Optional[GeneralForumTopicUnhidden] = ( + self.general_forum_topic_unhidden: GeneralForumTopicUnhidden | None = ( general_forum_topic_unhidden ) - self.write_access_allowed: Optional[WriteAccessAllowed] = write_access_allowed - self.has_media_spoiler: Optional[bool] = has_media_spoiler - self.users_shared: Optional[UsersShared] = users_shared - self.chat_shared: Optional[ChatShared] = chat_shared - self.story: Optional[Story] = story - self.giveaway: Optional[Giveaway] = giveaway - self.giveaway_completed: Optional[GiveawayCompleted] = giveaway_completed - self.giveaway_created: Optional[GiveawayCreated] = giveaway_created - self.giveaway_winners: Optional[GiveawayWinners] = giveaway_winners - self.link_preview_options: Optional[LinkPreviewOptions] = link_preview_options - self.external_reply: Optional[ExternalReplyInfo] = external_reply - self.quote: Optional[TextQuote] = quote - self.forward_origin: Optional[MessageOrigin] = forward_origin - self.reply_to_story: Optional[Story] = reply_to_story - self.boost_added: Optional[ChatBoostAdded] = boost_added - self.sender_boost_count: Optional[int] = sender_boost_count - self.business_connection_id: Optional[str] = business_connection_id - self.sender_business_bot: Optional[User] = sender_business_bot - self.is_from_offline: Optional[bool] = is_from_offline - self.chat_background_set: Optional[ChatBackground] = chat_background_set - self.effect_id: Optional[str] = effect_id - self.show_caption_above_media: Optional[bool] = show_caption_above_media - self.paid_media: Optional[PaidMediaInfo] = paid_media - self.refunded_payment: Optional[RefundedPayment] = refunded_payment + self.write_access_allowed: WriteAccessAllowed | None = write_access_allowed + self.has_media_spoiler: bool | None = has_media_spoiler + self.checklist: Checklist | None = checklist + self.users_shared: UsersShared | None = users_shared + self.chat_shared: ChatShared | None = chat_shared + self.story: Story | None = story + self.giveaway: Giveaway | None = giveaway + self.giveaway_completed: GiveawayCompleted | None = giveaway_completed + self.giveaway_created: GiveawayCreated | None = giveaway_created + self.giveaway_winners: GiveawayWinners | None = giveaway_winners + self.link_preview_options: LinkPreviewOptions | None = link_preview_options + self.external_reply: ExternalReplyInfo | None = external_reply + self.quote: TextQuote | None = quote + self.forward_origin: MessageOrigin | None = forward_origin + self.reply_to_story: Story | None = reply_to_story + self.boost_added: ChatBoostAdded | None = boost_added + self.sender_boost_count: int | None = sender_boost_count + self.business_connection_id: str | None = business_connection_id + self.sender_business_bot: User | None = sender_business_bot + self.is_from_offline: bool | None = is_from_offline + self.chat_background_set: ChatBackground | None = chat_background_set + self.checklist_tasks_done: ChecklistTasksDone | None = checklist_tasks_done + self.checklist_tasks_added: ChecklistTasksAdded | None = checklist_tasks_added + self.effect_id: str | None = effect_id + self.show_caption_above_media: bool | None = show_caption_above_media + self.paid_media: PaidMediaInfo | None = paid_media + self.refunded_payment: RefundedPayment | None = refunded_payment + self.gift: GiftInfo | None = gift + self.unique_gift: UniqueGiftInfo | None = unique_gift + self.paid_message_price_changed: PaidMessagePriceChanged | None = ( + paid_message_price_changed + ) + self.paid_star_count: int | None = paid_star_count + self.direct_message_price_changed: DirectMessagePriceChanged | None = ( + direct_message_price_changed + ) + self.is_paid_post: bool | None = is_paid_post + self.direct_messages_topic: DirectMessagesTopic | None = direct_messages_topic + self.reply_to_checklist_task_id: int | None = reply_to_checklist_task_id + self.suggested_post_declined: SuggestedPostDeclined | None = suggested_post_declined + self.suggested_post_paid: SuggestedPostPaid | None = suggested_post_paid + self.suggested_post_refunded: SuggestedPostRefunded | None = suggested_post_refunded + self.suggested_post_info: SuggestedPostInfo | None = suggested_post_info + self.suggested_post_approved: SuggestedPostApproved | None = suggested_post_approved + self.suggested_post_approval_failed: SuggestedPostApprovalFailed | None = ( + suggested_post_approval_failed + ) + self.gift_upgrade_sent: GiftInfo | None = gift_upgrade_sent self._effective_attachment = DEFAULT_NONE @@ -1231,7 +1453,7 @@ def id(self) -> int: return self.message_id @property - def link(self) -> Optional[str]: + def link(self) -> str | None: """:obj:`str`: Convenience property. If the chat of the message is not a private chat or normal group, returns a t.me link of the message. @@ -1240,8 +1462,8 @@ def link(self) -> Optional[str]: to the corresponding thread view. """ if self.chat.type not in [Chat.PRIVATE, Chat.GROUP]: - # the else block gets rid of leading -100 for supergroups: - to_link = self.chat.username if self.chat.username else f"c/{str(self.chat.id)[4:]}" + # if username doesn't exist, remove the leading -100 for supergroups in link + to_link = self.chat.username or f"c/{str(self.chat.id)[4:]}" baselink = f"https://t.me/{to_link}/{self.message_id}" # adds the thread for topics and replies @@ -1251,112 +1473,182 @@ def link(self) -> Optional[str]: return None @classmethod - def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Message"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Message": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) - data["from_user"] = User.de_json(data.pop("from", None), bot) - data["sender_chat"] = Chat.de_json(data.get("sender_chat"), bot) - data["entities"] = MessageEntity.de_list(data.get("entities"), bot) - data["caption_entities"] = MessageEntity.de_list(data.get("caption_entities"), bot) - data["reply_to_message"] = Message.de_json(data.get("reply_to_message"), bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) + data["sender_chat"] = de_json_optional(data.get("sender_chat"), Chat, bot) + data["entities"] = de_list_optional(data.get("entities"), MessageEntity, bot) + data["caption_entities"] = de_list_optional( + data.get("caption_entities"), MessageEntity, bot + ) + data["reply_to_message"] = de_json_optional(data.get("reply_to_message"), Message, bot) data["edit_date"] = from_timestamp(data.get("edit_date"), tzinfo=loc_tzinfo) - data["audio"] = Audio.de_json(data.get("audio"), bot) - data["document"] = Document.de_json(data.get("document"), bot) - data["animation"] = Animation.de_json(data.get("animation"), bot) - data["game"] = Game.de_json(data.get("game"), bot) - data["photo"] = PhotoSize.de_list(data.get("photo"), bot) - data["sticker"] = Sticker.de_json(data.get("sticker"), bot) - data["story"] = Story.de_json(data.get("story"), bot) - data["video"] = Video.de_json(data.get("video"), bot) - data["voice"] = Voice.de_json(data.get("voice"), bot) - data["video_note"] = VideoNote.de_json(data.get("video_note"), bot) - data["contact"] = Contact.de_json(data.get("contact"), bot) - data["location"] = Location.de_json(data.get("location"), bot) - data["venue"] = Venue.de_json(data.get("venue"), bot) - data["new_chat_members"] = User.de_list(data.get("new_chat_members"), bot) - data["left_chat_member"] = User.de_json(data.get("left_chat_member"), bot) - data["new_chat_photo"] = PhotoSize.de_list(data.get("new_chat_photo"), bot) - data["message_auto_delete_timer_changed"] = MessageAutoDeleteTimerChanged.de_json( - data.get("message_auto_delete_timer_changed"), bot + data["audio"] = de_json_optional(data.get("audio"), Audio, bot) + data["document"] = de_json_optional(data.get("document"), Document, bot) + data["animation"] = de_json_optional(data.get("animation"), Animation, bot) + data["game"] = de_json_optional(data.get("game"), Game, bot) + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + data["story"] = de_json_optional(data.get("story"), Story, bot) + data["video"] = de_json_optional(data.get("video"), Video, bot) + data["voice"] = de_json_optional(data.get("voice"), Voice, bot) + data["video_note"] = de_json_optional(data.get("video_note"), VideoNote, bot) + data["contact"] = de_json_optional(data.get("contact"), Contact, bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) + data["venue"] = de_json_optional(data.get("venue"), Venue, bot) + data["new_chat_members"] = de_list_optional(data.get("new_chat_members"), User, bot) + data["left_chat_member"] = de_json_optional(data.get("left_chat_member"), User, bot) + data["new_chat_photo"] = de_list_optional(data.get("new_chat_photo"), PhotoSize, bot) + data["message_auto_delete_timer_changed"] = de_json_optional( + data.get("message_auto_delete_timer_changed"), MessageAutoDeleteTimerChanged, bot + ) + data["pinned_message"] = de_json_optional( + data.get("pinned_message"), MaybeInaccessibleMessage, bot + ) + data["invoice"] = de_json_optional(data.get("invoice"), Invoice, bot) + data["successful_payment"] = de_json_optional( + data.get("successful_payment"), SuccessfulPayment, bot + ) + data["passport_data"] = de_json_optional(data.get("passport_data"), PassportData, bot) + data["poll"] = de_json_optional(data.get("poll"), Poll, bot) + data["dice"] = de_json_optional(data.get("dice"), Dice, bot) + data["via_bot"] = de_json_optional(data.get("via_bot"), User, bot) + data["proximity_alert_triggered"] = de_json_optional( + data.get("proximity_alert_triggered"), ProximityAlertTriggered, bot + ) + data["reply_markup"] = de_json_optional( + data.get("reply_markup"), InlineKeyboardMarkup, bot + ) + data["video_chat_scheduled"] = de_json_optional( + data.get("video_chat_scheduled"), VideoChatScheduled, bot + ) + data["video_chat_started"] = de_json_optional( + data.get("video_chat_started"), VideoChatStarted, bot + ) + data["video_chat_ended"] = de_json_optional( + data.get("video_chat_ended"), VideoChatEnded, bot + ) + data["video_chat_participants_invited"] = de_json_optional( + data.get("video_chat_participants_invited"), VideoChatParticipantsInvited, bot + ) + data["web_app_data"] = de_json_optional(data.get("web_app_data"), WebAppData, bot) + data["forum_topic_closed"] = de_json_optional( + data.get("forum_topic_closed"), ForumTopicClosed, bot + ) + data["forum_topic_created"] = de_json_optional( + data.get("forum_topic_created"), ForumTopicCreated, bot ) - data["pinned_message"] = MaybeInaccessibleMessage.de_json(data.get("pinned_message"), bot) - data["invoice"] = Invoice.de_json(data.get("invoice"), bot) - data["successful_payment"] = SuccessfulPayment.de_json(data.get("successful_payment"), bot) - data["passport_data"] = PassportData.de_json(data.get("passport_data"), bot) - data["poll"] = Poll.de_json(data.get("poll"), bot) - data["dice"] = Dice.de_json(data.get("dice"), bot) - data["via_bot"] = User.de_json(data.get("via_bot"), bot) - data["proximity_alert_triggered"] = ProximityAlertTriggered.de_json( - data.get("proximity_alert_triggered"), bot + data["forum_topic_reopened"] = de_json_optional( + data.get("forum_topic_reopened"), ForumTopicReopened, bot ) - data["reply_markup"] = InlineKeyboardMarkup.de_json(data.get("reply_markup"), bot) - data["video_chat_scheduled"] = VideoChatScheduled.de_json( - data.get("video_chat_scheduled"), bot + data["forum_topic_edited"] = de_json_optional( + data.get("forum_topic_edited"), ForumTopicEdited, bot ) - data["video_chat_started"] = VideoChatStarted.de_json(data.get("video_chat_started"), bot) - data["video_chat_ended"] = VideoChatEnded.de_json(data.get("video_chat_ended"), bot) - data["video_chat_participants_invited"] = VideoChatParticipantsInvited.de_json( - data.get("video_chat_participants_invited"), bot + data["general_forum_topic_hidden"] = de_json_optional( + data.get("general_forum_topic_hidden"), GeneralForumTopicHidden, bot ) - data["web_app_data"] = WebAppData.de_json(data.get("web_app_data"), bot) - data["forum_topic_closed"] = ForumTopicClosed.de_json(data.get("forum_topic_closed"), bot) - data["forum_topic_created"] = ForumTopicCreated.de_json( - data.get("forum_topic_created"), bot + data["general_forum_topic_unhidden"] = de_json_optional( + data.get("general_forum_topic_unhidden"), GeneralForumTopicUnhidden, bot ) - data["forum_topic_reopened"] = ForumTopicReopened.de_json( - data.get("forum_topic_reopened"), bot + data["write_access_allowed"] = de_json_optional( + data.get("write_access_allowed"), WriteAccessAllowed, bot ) - data["forum_topic_edited"] = ForumTopicEdited.de_json(data.get("forum_topic_edited"), bot) - data["general_forum_topic_hidden"] = GeneralForumTopicHidden.de_json( - data.get("general_forum_topic_hidden"), bot + data["users_shared"] = de_json_optional(data.get("users_shared"), UsersShared, bot) + data["chat_shared"] = de_json_optional(data.get("chat_shared"), ChatShared, bot) + data["chat_background_set"] = de_json_optional( + data.get("chat_background_set"), ChatBackground, bot ) - data["general_forum_topic_unhidden"] = GeneralForumTopicUnhidden.de_json( - data.get("general_forum_topic_unhidden"), bot + data["paid_media"] = de_json_optional(data.get("paid_media"), PaidMediaInfo, bot) + data["refunded_payment"] = de_json_optional( + data.get("refunded_payment"), RefundedPayment, bot ) - data["write_access_allowed"] = WriteAccessAllowed.de_json( - data.get("write_access_allowed"), bot + data["gift"] = de_json_optional(data.get("gift"), GiftInfo, bot) + data["unique_gift"] = de_json_optional(data.get("unique_gift"), UniqueGiftInfo, bot) + data["paid_message_price_changed"] = de_json_optional( + data.get("paid_message_price_changed"), PaidMessagePriceChanged, bot ) - data["users_shared"] = UsersShared.de_json(data.get("users_shared"), bot) - data["chat_shared"] = ChatShared.de_json(data.get("chat_shared"), bot) - data["chat_background_set"] = ChatBackground.de_json(data.get("chat_background_set"), bot) - data["paid_media"] = PaidMediaInfo.de_json(data.get("paid_media"), bot) - data["refunded_payment"] = RefundedPayment.de_json(data.get("refunded_payment"), bot) # Unfortunately, this needs to be here due to cyclic imports - from telegram._giveaway import ( # pylint: disable=import-outside-toplevel + from telegram._giveaway import ( # pylint: disable=C0415 # noqa: PLC0415 Giveaway, GiveawayCompleted, GiveawayCreated, GiveawayWinners, ) - from telegram._messageorigin import ( # pylint: disable=import-outside-toplevel + from telegram._messageorigin import ( # pylint: disable=C0415 # noqa: PLC0415 MessageOrigin, ) - from telegram._reply import ( # pylint: disable=import-outside-toplevel + from telegram._reply import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 ExternalReplyInfo, TextQuote, ) + from telegram._suggestedpost import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + SuggestedPostApprovalFailed, + SuggestedPostApproved, + SuggestedPostDeclined, + SuggestedPostInfo, + SuggestedPostPaid, + SuggestedPostRefunded, + ) - data["giveaway"] = Giveaway.de_json(data.get("giveaway"), bot) - data["giveaway_completed"] = GiveawayCompleted.de_json(data.get("giveaway_completed"), bot) - data["giveaway_created"] = GiveawayCreated.de_json(data.get("giveaway_created"), bot) - data["giveaway_winners"] = GiveawayWinners.de_json(data.get("giveaway_winners"), bot) - data["link_preview_options"] = LinkPreviewOptions.de_json( - data.get("link_preview_options"), bot + data["giveaway"] = de_json_optional(data.get("giveaway"), Giveaway, bot) + data["giveaway_completed"] = de_json_optional( + data.get("giveaway_completed"), GiveawayCompleted, bot + ) + data["giveaway_created"] = de_json_optional( + data.get("giveaway_created"), GiveawayCreated, bot + ) + data["giveaway_winners"] = de_json_optional( + data.get("giveaway_winners"), GiveawayWinners, bot + ) + data["link_preview_options"] = de_json_optional( + data.get("link_preview_options"), LinkPreviewOptions, bot + ) + data["external_reply"] = de_json_optional( + data.get("external_reply"), ExternalReplyInfo, bot + ) + data["quote"] = de_json_optional(data.get("quote"), TextQuote, bot) + data["forward_origin"] = de_json_optional(data.get("forward_origin"), MessageOrigin, bot) + data["reply_to_story"] = de_json_optional(data.get("reply_to_story"), Story, bot) + data["boost_added"] = de_json_optional(data.get("boost_added"), ChatBoostAdded, bot) + data["sender_business_bot"] = de_json_optional(data.get("sender_business_bot"), User, bot) + data["direct_message_price_changed"] = de_json_optional( + data.get("direct_message_price_changed"), DirectMessagePriceChanged, bot + ) + data["checklist"] = de_json_optional(data.get("checklist"), Checklist, bot) + data["checklist_tasks_done"] = de_json_optional( + data.get("checklist_tasks_done"), ChecklistTasksDone, bot + ) + data["checklist_tasks_added"] = de_json_optional( + data.get("checklist_tasks_added"), ChecklistTasksAdded, bot + ) + data["direct_messages_topic"] = de_json_optional( + data.get("direct_messages_topic"), DirectMessagesTopic, bot + ) + data["suggested_post_declined"] = de_json_optional( + data.get("suggested_post_declined"), SuggestedPostDeclined, bot + ) + data["suggested_post_paid"] = de_json_optional( + data.get("suggested_post_paid"), SuggestedPostPaid, bot ) - data["external_reply"] = ExternalReplyInfo.de_json(data.get("external_reply"), bot) - data["quote"] = TextQuote.de_json(data.get("quote"), bot) - data["forward_origin"] = MessageOrigin.de_json(data.get("forward_origin"), bot) - data["reply_to_story"] = Story.de_json(data.get("reply_to_story"), bot) - data["boost_added"] = ChatBoostAdded.de_json(data.get("boost_added"), bot) - data["sender_business_bot"] = User.de_json(data.get("sender_business_bot"), bot) + data["suggested_post_refunded"] = de_json_optional( + data.get("suggested_post_refunded"), SuggestedPostRefunded, bot + ) + data["suggested_post_info"] = de_json_optional( + data.get("suggested_post_info"), SuggestedPostInfo, bot + ) + data["suggested_post_approved"] = de_json_optional( + data.get("suggested_post_approved"), SuggestedPostApproved, bot + ) + data["suggested_post_approval_failed"] = de_json_optional( + data.get("suggested_post_approval_failed"), SuggestedPostApprovalFailed, bot + ) + data["gift_upgrade_sent"] = de_json_optional(data.get("gift_upgrade_sent"), GiftInfo, bot) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility @@ -1380,28 +1672,28 @@ def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optio @property def effective_attachment( self, - ) -> Union[ - Animation, - Audio, - Contact, - Dice, - Document, - Game, - Invoice, - Location, - PassportData, - Sequence[PhotoSize], - PaidMediaInfo, - Poll, - Sticker, - Story, - SuccessfulPayment, - Venue, - Video, - VideoNote, - Voice, - None, - ]: + ) -> ( + Animation + | Audio + | Contact + | Dice + | Document + | Game + | Invoice + | Location + | PassportData + | Sequence[PhotoSize] + | PaidMediaInfo + | Poll + | Sticker + | Story + | SuccessfulPayment + | Venue + | Video + | VideoNote + | Voice + | None + ): """If the message is a user generated content which is not a plain text message, this property is set to this content. It may be one of @@ -1461,32 +1753,35 @@ def effective_attachment( return self._effective_attachment # type: ignore[return-value] - def _quote( - self, quote: Optional[bool], reply_to_message_id: Optional[int] = None - ) -> Optional[ReplyParameters]: + def _do_quote( + self, do_quote: bool | None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE + ) -> ReplyParameters | None: """Modify kwargs for replying with or without quoting.""" - if reply_to_message_id is not None: - return ReplyParameters(reply_to_message_id) - - if quote is not None: - if quote: - return ReplyParameters(self.message_id) + # `Defaults` handling for allow_sending_without_reply is not necessary, as + # `ReplyParameters` have special defaults handling in (ExtBot)._insert_defaults + if do_quote is not None: + if do_quote: + return ReplyParameters( + self.message_id, allow_sending_without_reply=allow_sending_without_reply + ) else: # Unfortunately we need some ExtBot logic here because it's hard to move shortcut # logic into ExtBot if hasattr(self.get_bot(), "defaults") and self.get_bot().defaults: # type: ignore - default_quote = self.get_bot().defaults.quote # type: ignore[attr-defined] + default_quote = self.get_bot().defaults.do_quote # type: ignore[attr-defined] else: default_quote = None if (default_quote is None and self.chat.type != Chat.PRIVATE) or default_quote: - return ReplyParameters(self.message_id) + return ReplyParameters( + self.message_id, allow_sending_without_reply=allow_sending_without_reply + ) return None def compute_quote_position_and_entities( - self, quote: str, index: Optional[int] = None - ) -> tuple[int, Optional[tuple[MessageEntity, ...]]]: + self, quote: str, index: int | None = None + ) -> tuple[int, tuple[MessageEntity, ...] | None]: """ Use this function to compute position and entities of a quote in the message text or caption. Useful for filling the parameters @@ -1562,9 +1857,9 @@ def compute_quote_position_and_entities( def build_reply_arguments( self, - quote: Optional[str] = None, - quote_index: Optional[int] = None, - target_chat_id: Optional[Union[int, str]] = None, + quote: str | None = None, + quote_index: int | None = None, + target_chat_id: int | (str | None) = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, ) -> _ReplyKwargs: @@ -1651,58 +1946,57 @@ def build_reply_arguments( async def _parse_quote_arguments( self, - do_quote: Optional[Union[bool, _ReplyKwargs]], - quote: Optional[bool], - reply_to_message_id: Optional[int], - reply_parameters: Optional["ReplyParameters"], - ) -> tuple[Union[str, int], ReplyParameters]: - if quote and do_quote: - raise ValueError("The arguments `quote` and `do_quote` are mutually exclusive") - + do_quote: bool | _ReplyKwargs | None, + reply_to_message_id: int | None, + reply_parameters: "ReplyParameters | None", + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + ) -> tuple[str | int, ReplyParameters]: + if allow_sending_without_reply is not DEFAULT_NONE and reply_parameters is not None: + raise ValueError( + "`allow_sending_without_reply` and `reply_parameters` are mutually exclusive." + ) if reply_to_message_id is not None and reply_parameters is not None: raise ValueError( "`reply_to_message_id` and `reply_parameters` are mutually exclusive." ) - if quote is not None: - warn( - PTBDeprecationWarning( - "20.8", - "The `quote` parameter is deprecated in favor of the `do_quote` parameter. " - "Please update your code to use `do_quote` instead.", - ), - stacklevel=2, - ) - - effective_do_quote = quote or do_quote - chat_id: Union[str, int] = self.chat_id + chat_id: str | int = self.chat_id # reply_parameters and reply_to_message_id overrule the do_quote parameter if reply_parameters is not None: effective_reply_parameters = reply_parameters elif reply_to_message_id is not None: - effective_reply_parameters = ReplyParameters(message_id=reply_to_message_id) - elif isinstance(effective_do_quote, dict): - effective_reply_parameters = effective_do_quote["reply_parameters"] - chat_id = effective_do_quote["chat_id"] + effective_reply_parameters = ReplyParameters( + message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + ) + elif isinstance(do_quote, dict): + if allow_sending_without_reply is not DEFAULT_NONE: + raise ValueError( + "`allow_sending_without_reply` and `dict`-value input for `do_quote` are " + "mutually exclusive." + ) + + effective_reply_parameters = do_quote["reply_parameters"] + chat_id = do_quote["chat_id"] else: - effective_reply_parameters = self._quote(effective_do_quote) + effective_reply_parameters = self._do_quote(do_quote, allow_sending_without_reply) return chat_id, effective_reply_parameters def _parse_message_thread_id( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: ODVInput[int] = DEFAULT_NONE, - ) -> Optional[int]: + ) -> int | None: # values set by user have the highest priority if not isinstance(message_thread_id, DefaultValue): return message_thread_id # self.message_thread_id can be used for send_*.param.message_thread_id only if the - # thread is a forum topic. It does not work if the thread is a chain of replies to a - # message in a normal group. In that case, self.message_thread_id is just the message_id - # of the first message in the chain. + # thread is a forum topic (in supergroups or private chats). It does not work if the + # thread is a chain of replies to a message in a normal group. In that case, + # self.message_thread_id is just the message_id of the first message in the chain. if not self.is_topic_message: return None @@ -1710,30 +2004,34 @@ def _parse_message_thread_id( # the same chat. return self.message_thread_id if chat_id in {self.chat_id, self.chat.username} else None + def _extract_direct_messages_topic_id(self) -> int | None: + """Return the topic id of the direct messages chat, if it is present.""" + return self.direct_messages_topic.topic_id if self.direct_messages_topic else None + async def reply_text( self, text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "ReplyMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - disable_web_page_preview: Optional[bool] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + disable_web_page_preview: bool | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1741,6 +2039,7 @@ async def reply_text( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -1750,13 +2049,11 @@ async def reply_text( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -1765,7 +2062,7 @@ async def reply_text( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_message( @@ -1777,7 +2074,6 @@ async def reply_text( disable_notification=disable_notification, reply_parameters=effective_reply_parameters, reply_markup=reply_markup, - allow_sending_without_reply=allow_sending_without_reply, entities=entities, protect_content=protect_content, message_thread_id=message_thread_id, @@ -1789,31 +2085,82 @@ async def reply_text( business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_text_draft( + self, + draft_id: int, + text: str, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Sequence["MessageEntity"] | None = None, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.send_message_draft( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message_draft`. + + Note: + |reply_same_thread| + + .. versionadded:: 22.6 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + message_thread_id = self._parse_message_thread_id(self.chat_id, message_thread_id) + return await self.get_bot().send_message_draft( + chat_id=self.chat_id, + draft_id=draft_id, + text=text, + parse_mode=parse_mode, + entities=entities, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, ) async def reply_markdown( self, text: str, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "ReplyMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - disable_web_page_preview: Optional[bool] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + disable_web_page_preview: bool | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1822,6 +2169,7 @@ async def reply_markdown( message_thread_id=update.effective_message.message_thread_id, parse_mode=ParseMode.MARKDOWN, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -1833,17 +2181,15 @@ async def reply_markdown( .. versionchanged:: 21.1 |reply_same_thread| + .. versionchanged:: 22.0 + |quote_removed| + Note: :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by Telegram for backward compatibility. You should use :meth:`reply_markdown_v2` instead. Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| - - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -1851,7 +2197,7 @@ async def reply_markdown( :class:`telegram.Message`: On success, instance representing the message posted. """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_message( @@ -1863,7 +2209,6 @@ async def reply_markdown( disable_notification=disable_notification, reply_parameters=effective_reply_parameters, reply_markup=reply_markup, - allow_sending_without_reply=allow_sending_without_reply, entities=entities, protect_content=protect_content, message_thread_id=message_thread_id, @@ -1875,31 +2220,33 @@ async def reply_markdown( business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def reply_markdown_v2( self, text: str, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "ReplyMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - disable_web_page_preview: Optional[bool] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + disable_web_page_preview: bool | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1907,6 +2254,7 @@ async def reply_markdown_v2( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, parse_mode=ParseMode.MARKDOWN_V2, + direct_messages_topic_id=self.direct_messages_topic.topic_id, business_connection_id=self.business_connection_id, *args, **kwargs, @@ -1919,13 +2267,11 @@ async def reply_markdown_v2( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -1933,7 +2279,7 @@ async def reply_markdown_v2( :class:`telegram.Message`: On success, instance representing the message posted. """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_message( @@ -1945,7 +2291,6 @@ async def reply_markdown_v2( disable_notification=disable_notification, reply_parameters=effective_reply_parameters, reply_markup=reply_markup, - allow_sending_without_reply=allow_sending_without_reply, entities=entities, protect_content=protect_content, message_thread_id=message_thread_id, @@ -1957,31 +2302,33 @@ async def reply_markdown_v2( business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def reply_html( self, text: str, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "ReplyMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - disable_web_page_preview: Optional[bool] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + disable_web_page_preview: bool | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1989,6 +2336,7 @@ async def reply_html( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, parse_mode=ParseMode.HTML, + direct_messages_topic_id=self.direct_messages_topic.topic_id, business_connection_id=self.business_connection_id, *args, **kwargs, @@ -2001,13 +2349,11 @@ async def reply_html( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -2015,7 +2361,7 @@ async def reply_html( :class:`telegram.Message`: On success, instance representing the message posted. """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_message( @@ -2027,7 +2373,6 @@ async def reply_html( disable_notification=disable_notification, reply_parameters=effective_reply_parameters, reply_markup=reply_markup, - allow_sending_without_reply=allow_sending_without_reply, entities=entities, protect_content=protect_content, message_thread_id=message_thread_id, @@ -2039,32 +2384,33 @@ async def reply_html( business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def reply_media_group( self, media: Sequence[ - Union["InputMediaAudio", "InputMediaDocument", "InputMediaPhoto", "InputMediaVideo"] + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - caption: Optional[str] = None, + api_kwargs: JSONDict | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, ) -> tuple["Message", ...]: """Shortcut for:: @@ -2072,6 +2418,7 @@ async def reply_media_group( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -2081,13 +2428,11 @@ async def reply_media_group( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -2098,7 +2443,7 @@ async def reply_media_group( :class:`telegram.error.TelegramError` """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_media_group( @@ -2111,7 +2456,6 @@ async def reply_media_group( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, caption=caption, @@ -2120,34 +2464,35 @@ async def reply_media_group( business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), ) async def reply_photo( self, - photo: Union[FileInput, "PhotoSize"], - caption: Optional[str] = None, + photo: "FileInput | PhotoSize", + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - has_spoiler: Optional[bool] = None, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, + has_spoiler: bool | None = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2155,6 +2500,7 @@ async def reply_photo( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -2164,13 +2510,11 @@ async def reply_photo( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -2179,7 +2523,7 @@ async def reply_photo( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_photo( @@ -2190,7 +2534,6 @@ async def reply_photo( reply_parameters=effective_reply_parameters, reply_markup=reply_markup, parse_mode=parse_mode, - allow_sending_without_reply=allow_sending_without_reply, caption_entities=caption_entities, filename=filename, protect_content=protect_content, @@ -2205,36 +2548,38 @@ async def reply_photo( message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def reply_audio( self, - audio: Union[FileInput, "Audio"], - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - caption: Optional[str] = None, + audio: "FileInput | Audio", + duration: TimePeriod | None = None, + performer: str | None = None, + title: str | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2242,6 +2587,7 @@ async def reply_audio( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -2251,13 +2597,11 @@ async def reply_audio( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -2266,7 +2610,7 @@ async def reply_audio( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_audio( @@ -2280,7 +2624,6 @@ async def reply_audio( reply_parameters=effective_reply_parameters, reply_markup=reply_markup, parse_mode=parse_mode, - allow_sending_without_reply=allow_sending_without_reply, caption_entities=caption_entities, filename=filename, protect_content=protect_content, @@ -2294,34 +2637,36 @@ async def reply_audio( business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def reply_document( self, - document: Union[FileInput, "Document"], - caption: Optional[str] = None, + document: "FileInput | Document", + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_content_type_detection: Optional[bool] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + disable_content_type_detection: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2329,6 +2674,7 @@ async def reply_document( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -2338,13 +2684,11 @@ async def reply_document( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -2353,7 +2697,7 @@ async def reply_document( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_document( @@ -2371,7 +2715,6 @@ async def reply_document( parse_mode=parse_mode, api_kwargs=api_kwargs, disable_content_type_detection=disable_content_type_detection, - allow_sending_without_reply=allow_sending_without_reply, caption_entities=caption_entities, protect_content=protect_content, message_thread_id=message_thread_id, @@ -2379,38 +2722,40 @@ async def reply_document( business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def reply_animation( self, - animation: Union[FileInput, "Animation"], - duration: Optional[int] = None, - width: Optional[int] = None, - height: Optional[int] = None, - caption: Optional[str] = None, + animation: "FileInput | Animation", + duration: TimePeriod | None = None, + width: int | None = None, + height: int | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "ReplyMarkup | None" = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2418,6 +2763,7 @@ async def reply_animation( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -2427,13 +2773,11 @@ async def reply_animation( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -2442,7 +2786,7 @@ async def reply_animation( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_animation( @@ -2461,7 +2805,6 @@ async def reply_animation( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, caption_entities=caption_entities, filename=filename, protect_content=protect_content, @@ -2472,29 +2815,31 @@ async def reply_animation( message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def reply_sticker( self, - sticker: Union[FileInput, "Sticker"], + sticker: "FileInput | Sticker", disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - emoji: Optional[str] = None, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + emoji: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2502,6 +2847,7 @@ async def reply_sticker( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -2511,13 +2857,11 @@ async def reply_sticker( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -2526,7 +2870,7 @@ async def reply_sticker( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_sticker( @@ -2540,46 +2884,49 @@ async def reply_sticker( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, emoji=emoji, business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def reply_video( self, - video: Union[FileInput, "Video"], - duration: Optional[int] = None, - caption: Optional[str] = None, + video: "FileInput | Video", + duration: TimePeriod | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - width: Optional[int] = None, - height: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + width: int | None = None, + height: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - supports_streaming: Optional[bool] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + supports_streaming: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2587,6 +2934,7 @@ async def reply_video( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -2596,13 +2944,11 @@ async def reply_video( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -2611,7 +2957,7 @@ async def reply_video( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_video( @@ -2631,43 +2977,46 @@ async def reply_video( parse_mode=parse_mode, supports_streaming=supports_streaming, api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, caption_entities=caption_entities, filename=filename, protect_content=protect_content, message_thread_id=message_thread_id, has_spoiler=has_spoiler, thumbnail=thumbnail, + cover=cover, + start_timestamp=start_timestamp, business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def reply_video_note( self, - video_note: Union[FileInput, "VideoNote"], - duration: Optional[int] = None, - length: Optional[int] = None, + video_note: "FileInput | VideoNote", + duration: TimePeriod | None = None, + length: int | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2675,6 +3024,7 @@ async def reply_video_note( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -2684,13 +3034,11 @@ async def reply_video_note( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -2699,7 +3047,7 @@ async def reply_video_note( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_video_note( @@ -2715,7 +3063,6 @@ async def reply_video_note( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, filename=filename, protect_content=protect_content, message_thread_id=message_thread_id, @@ -2723,33 +3070,35 @@ async def reply_video_note( business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def reply_voice( self, - voice: Union[FileInput, "Voice"], - duration: Optional[int] = None, - caption: Optional[str] = None, + voice: "FileInput | Voice", + duration: TimePeriod | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2757,6 +3106,7 @@ async def reply_voice( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -2766,13 +3116,11 @@ async def reply_voice( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -2781,7 +3129,7 @@ async def reply_voice( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_voice( @@ -2798,7 +3146,6 @@ async def reply_voice( pool_timeout=pool_timeout, parse_mode=parse_mode, api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, caption_entities=caption_entities, filename=filename, protect_content=protect_content, @@ -2806,34 +3153,36 @@ async def reply_voice( business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def reply_location( self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, + latitude: float | None = None, + longitude: float | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - live_period: Optional[int] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + live_period: TimePeriod | None = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - location: Optional[Location] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + location: "Location | None" = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2841,6 +3190,7 @@ async def reply_location( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -2850,13 +3200,11 @@ async def reply_location( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -2865,7 +3213,7 @@ async def reply_location( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_location( @@ -2885,42 +3233,43 @@ async def reply_location( horizontal_accuracy=horizontal_accuracy, heading=heading, proximity_alert_radius=proximity_alert_radius, - allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def reply_venue( self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - title: Optional[str] = None, - address: Optional[str] = None, - foursquare_id: Optional[str] = None, + latitude: float | None = None, + longitude: float | None = None, + title: str | None = None, + address: str | None = None, + foursquare_id: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - venue: Optional[Venue] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + venue: "Venue | None" = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2928,6 +3277,7 @@ async def reply_venue( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -2937,13 +3287,11 @@ async def reply_venue( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -2952,7 +3300,7 @@ async def reply_venue( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_venue( @@ -2974,38 +3322,39 @@ async def reply_venue( api_kwargs=api_kwargs, google_place_id=google_place_id, google_place_type=google_place_type, - allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def reply_contact( self, - phone_number: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, + phone_number: str | None = None, + first_name: str | None = None, + last_name: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - vcard: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + vcard: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - contact: Optional[Contact] = None, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + contact: "Contact | None" = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -3013,6 +3362,7 @@ async def reply_contact( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -3022,13 +3372,11 @@ async def reply_contact( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -3037,7 +3385,7 @@ async def reply_contact( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_contact( @@ -3055,47 +3403,47 @@ async def reply_contact( contact=contact, vcard=vcard, api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), allow_paid_broadcast=allow_paid_broadcast, + suggested_post_parameters=suggested_post_parameters, ) async def reply_poll( self, question: str, - options: Sequence[Union[str, "InputPollOption"]], - is_anonymous: Optional[bool] = None, - type: Optional[str] = None, # pylint: disable=redefined-builtin - allows_multiple_answers: Optional[bool] = None, - correct_option_id: Optional[CorrectOptionID] = None, - is_closed: Optional[bool] = None, + options: Sequence["str | InputPollOption"], + is_anonymous: bool | None = None, + type: str | None = None, # pylint: disable=redefined-builtin + allows_multiple_answers: bool | None = None, + correct_option_id: CorrectOptionID | None = None, + is_closed: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - explanation: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + explanation: str | None = None, explanation_parse_mode: ODVInput[str] = DEFAULT_NONE, - open_period: Optional[int] = None, - close_date: Optional[Union[int, dtm.datetime]] = None, - explanation_entities: Optional[Sequence["MessageEntity"]] = None, + open_period: TimePeriod | None = None, + close_date: int | (dtm.datetime | None) = None, + explanation_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, + reply_parameters: "ReplyParameters | None" = None, question_parse_mode: ODVInput[str] = DEFAULT_NONE, - question_entities: Optional[Sequence["MessageEntity"]] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + question_entities: Sequence["MessageEntity"] | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -3112,13 +3460,11 @@ async def reply_poll( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -3127,7 +3473,7 @@ async def reply_poll( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_poll( @@ -3151,7 +3497,6 @@ async def reply_poll( open_period=open_period, close_date=close_date, api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, explanation_entities=explanation_entities, protect_content=protect_content, message_thread_id=message_thread_id, @@ -3165,23 +3510,23 @@ async def reply_poll( async def reply_dice( self, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - emoji: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + emoji: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -3189,6 +3534,7 @@ async def reply_dice( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -3198,13 +3544,11 @@ async def reply_dice( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -3213,7 +3557,7 @@ async def reply_dice( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_dice( @@ -3227,12 +3571,70 @@ async def reply_dice( pool_timeout=pool_timeout, emoji=emoji, api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, business_connection_id=self.business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_checklist( + self, + checklist: InputChecklist, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_effect_id: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_checklist( + business_connection_id=self.business_connection_id, + chat_id=update.effective_message.chat_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_checklist`. + + .. versionadded:: 22.3 + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + return await self.get_bot().send_checklist( + business_connection_id=self.business_connection_id, + chat_id=chat_id, # type: ignore[arg-type] + checklist=checklist, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_effect_id=message_effect_id, ) async def reply_chat_action( @@ -3244,7 +3646,7 @@ async def reply_chat_action( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3283,22 +3685,21 @@ async def reply_game( self, game_short_name: str, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -3315,13 +3716,11 @@ async def reply_game( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -3332,7 +3731,7 @@ async def reply_game( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_game( @@ -3346,7 +3745,6 @@ async def reply_game( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, business_connection_id=self.business_connection_id, @@ -3359,47 +3757,48 @@ async def reply_invoice( title: str, description: str, payload: str, - provider_token: Optional[str], currency: str, prices: Sequence["LabeledPrice"], - start_parameter: Optional[str] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - is_flexible: Optional[bool] = None, + provider_token: str | None = None, + start_parameter: str | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + is_flexible: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - provider_data: Optional[Union[str, object]] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + provider_data: str | (object | None) = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: await bot.send_invoice( update.effective_message.chat_id, message_thread_id=update.effective_message.message_thread_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs, ) @@ -3409,6 +3808,9 @@ async def reply_invoice( .. versionchanged:: 21.1 |reply_same_thread| + .. versionchanged:: 22.0 + |quote_removed| + Warning: As of API 5.2 :paramref:`start_parameter ` is an optional argument and therefore the @@ -3422,12 +3824,7 @@ async def reply_invoice( :paramref:`start_parameter ` is optional. Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| - - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -3436,7 +3833,7 @@ async def reply_invoice( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_invoice( @@ -3468,33 +3865,38 @@ async def reply_invoice( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, max_tip_amount=max_tip_amount, suggested_tip_amounts=suggested_tip_amounts, protect_content=protect_content, message_thread_id=message_thread_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, ) async def forward( self, - chat_id: Union[int, str], + chat_id: int | str, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: await bot.forward_message( from_chat_id=update.effective_message.chat_id, message_id=update.effective_message.message_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs ) @@ -3517,37 +3919,44 @@ async def forward( chat_id=chat_id, from_chat_id=self.chat_id, message_id=self.message_id, + video_start_timestamp=video_start_timestamp, disable_notification=disable_notification, protect_content=protect_content, message_thread_id=message_thread_id, + suggested_post_parameters=suggested_post_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + message_effect_id=message_effect_id, ) async def copy( self, - chat_id: Union[int, str], - caption: Optional[str] = None, + chat_id: int | str, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - show_caption_above_media: Optional[bool] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "MessageId": """Shortcut for:: @@ -3555,6 +3964,7 @@ async def copy( chat_id=chat_id, from_chat_id=update.effective_message.chat_id, message_id=update.effective_message.message_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs ) @@ -3570,6 +3980,7 @@ async def copy( from_chat_id=self.chat_id, message_id=self.message_id, caption=caption, + video_start_timestamp=video_start_timestamp, parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, @@ -3586,32 +3997,37 @@ async def copy( message_thread_id=message_thread_id, show_caption_above_media=show_caption_above_media, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) async def reply_copy( self, - from_chat_id: Union[str, int], + from_chat_id: str | int, message_id: int, - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: ODVInput[int] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - show_caption_above_media: Optional[bool] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "MessageId": """Shortcut for:: @@ -3619,6 +4035,7 @@ async def reply_copy( chat_id=message.chat.id, message_thread_id=update.effective_message.message_thread_id, message_id=message_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs ) @@ -3628,14 +4045,11 @@ async def reply_copy( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. versionadded:: 13.1 - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. .. versionadded:: 20.8 @@ -3644,7 +4058,7 @@ async def reply_copy( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().copy_message( @@ -3652,11 +4066,11 @@ async def reply_copy( from_chat_id=from_chat_id, message_id=message_id, caption=caption, + video_start_timestamp=video_start_timestamp, parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, reply_parameters=effective_reply_parameters, - allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -3667,37 +4081,44 @@ async def reply_copy( message_thread_id=message_thread_id, show_caption_above_media=show_caption_above_media, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) async def reply_paid_media( self, star_count: int, media: Sequence["InputPaidMedia"], - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - reply_markup: Optional[ReplyMarkup] = None, - payload: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + payload: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_thread_id: ODVInput[int] = DEFAULT_NONE, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - do_quote: Optional[Union[bool, _ReplyKwargs]] = None, + do_quote: bool | (_ReplyKwargs | None) = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: await bot.send_paid_media( chat_id=message.chat.id, + message_thread_id=update.effective_message.message_thread_id, business_connection_id=message.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, *args, **kwargs ) @@ -3708,15 +4129,15 @@ async def reply_paid_media( Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| - Mutually exclusive with :paramref:`quote`. Returns: :class:`telegram.Message`: On success, the sent message is returned. """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, None, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_paid_media( chat_id=chat_id, caption=caption, @@ -3728,7 +4149,6 @@ async def reply_paid_media( caption_entities=caption_entities, disable_notification=disable_notification, reply_parameters=effective_reply_parameters, - allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -3738,23 +4158,26 @@ async def reply_paid_media( protect_content=protect_content, show_caption_above_media=show_caption_above_media, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + message_thread_id=message_thread_id, ) async def edit_text( self, text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, *, - disable_web_page_preview: Optional[bool] = None, + disable_web_page_preview: bool | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for:: await bot.edit_message_text( @@ -3799,18 +4222,18 @@ async def edit_text( async def edit_caption( self, - caption: Optional[str] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + caption: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - show_caption_above_media: Optional[bool] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for:: await bot.edit_message_caption( @@ -3853,17 +4276,64 @@ async def edit_caption( business_connection_id=self.business_connection_id, ) + async def edit_checklist( + self, + checklist: InputChecklist, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.edit_message_checklist( + business_connection_id=message.business_connection_id, + chat_id=message.chat_id, + message_id=message.message_id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.edit_message_checklist`. + + .. versionadded:: 22.3 + + Note: + You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family + of methods) or channel posts, if the bot is an admin in that channel. However, this + behaviour is undocumented and might be changed by Telegram. + + Returns: + :class:`telegram.Message`: On success, the edited Message is returned. + + """ + return await self.get_bot().edit_message_checklist( + business_connection_id=self.business_connection_id, + chat_id=self.chat_id, + message_id=self.message_id, + checklist=checklist, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def edit_media( self, media: "InputMedia", - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for:: await bot.edit_message_media( @@ -3905,14 +4375,14 @@ async def edit_media( async def edit_reply_markup( self, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for:: await bot.edit_message_reply_markup( @@ -3952,21 +4422,21 @@ async def edit_reply_markup( async def edit_live_location( self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, - live_period: Optional[int] = None, + latitude: float | None = None, + longitude: float | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, + live_period: TimePeriod | None = None, *, - location: Optional[Location] = None, + location: "Location | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for:: await bot.edit_message_live_location( @@ -4013,14 +4483,14 @@ async def edit_live_location( async def stop_live_location( self, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for:: await bot.stop_message_live_location( @@ -4062,15 +4532,15 @@ async def set_game_score( self, user_id: int, score: int, - force: Optional[bool] = None, - disable_edit_message: Optional[bool] = None, + force: bool | None = None, + disable_edit_message: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for:: await bot.set_game_score( @@ -4111,7 +4581,7 @@ async def get_game_high_scores( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple["GameHighScore", ...]: """Shortcut for:: @@ -4149,20 +4619,45 @@ async def delete( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: - """Shortcut for:: + """Shortcut for either:: await bot.delete_message( chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs ) - For the documentation of the arguments, please see :meth:`telegram.Bot.delete_message`. + or:: + + await bot.delete_business_messages( + business_connection_id=self.business_connection_id, + message_ids=[self.message_id], + *args, + **kwargs, + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.delete_message` and :meth:`telegram.Bot.delete_business_messages`. + + .. versionchanged:: 22.4 + Calls either :meth:`telegram.Bot.delete_message` + or :meth:`telegram.Bot.delete_business_messages` based + on :attr:`business_connection_id`. Returns: :obj:`bool`: On success, :obj:`True` is returned. """ + if self.business_connection_id: + return await self.get_bot().delete_business_messages( + business_connection_id=self.business_connection_id, + message_ids=[self.message_id], + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) return await self.get_bot().delete_message( chat_id=self.chat_id, message_id=self.message_id, @@ -4175,13 +4670,13 @@ async def delete( async def stop_poll( self, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Poll: """Shortcut for:: @@ -4222,7 +4717,7 @@ async def pin( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -4262,7 +4757,7 @@ async def unpin( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -4296,14 +4791,14 @@ async def unpin( async def edit_forum_topic( self, - name: Optional[str] = None, - icon_custom_emoji_id: Optional[str] = None, + name: str | None = None, + icon_custom_emoji_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -4339,7 +4834,7 @@ async def close_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -4373,7 +4868,7 @@ async def reopen_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -4407,7 +4902,7 @@ async def delete_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -4441,7 +4936,7 @@ async def unpin_all_forum_topic_messages( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -4470,16 +4965,14 @@ async def unpin_all_forum_topic_messages( async def set_reaction( self, - reaction: Optional[ - Union[Sequence["ReactionType"], "ReactionType", Sequence[str], str] - ] = None, - is_big: Optional[bool] = None, + reaction: "Sequence[ReactionType] | ReactionType | Sequence[str] | str | None" = None, + is_big: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -4506,6 +4999,117 @@ async def set_reaction( api_kwargs=api_kwargs, ) + async def read_business_message( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.read_business_message( + chat_id=message.chat_id, + message_id=message.message_id, + business_connection_id=message.business_connection_id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.read_business_message`. + + .. versionadded:: 22.1 + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + """ + return await self.get_bot().read_business_message( + chat_id=self.chat_id, + message_id=self.message_id, + business_connection_id=self.business_connection_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def approve_suggested_post( + self, + send_date: int | dtm.datetime | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.approve_suggested_post( + chat_id=message.chat_id, + message_id=message.message_id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.approve_suggested_post`. + + .. versionadded:: 22.4 + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + """ + return await self.get_bot().approve_suggested_post( + chat_id=self.chat_id, + message_id=self.message_id, + send_date=send_date, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def decline_suggested_post( + self, + comment: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.decline_suggested_post( + chat_id=message.chat_id, + message_id=message.message_id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.decline_suggested_post`. + + .. versionadded:: 22.4 + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + """ + return await self.get_bot().decline_suggested_post( + chat_id=self.chat_id, + message_id=self.message_id, + comment=comment, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + def parse_entity(self, entity: MessageEntity) -> str: """Returns the text from a given :class:`telegram.MessageEntity`. @@ -4554,7 +5158,7 @@ def parse_caption_entity(self, entity: MessageEntity) -> str: return parse_message_entity(self.caption, entity) - def parse_entities(self, types: Optional[list[str]] = None) -> dict[MessageEntity, str]: + def parse_entities(self, types: list[str | None] | None = None) -> dict[MessageEntity, str]: """ Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. It contains entities from this message filtered by their @@ -4580,7 +5184,7 @@ def parse_entities(self, types: Optional[list[str]] = None) -> dict[MessageEntit return parse_message_entities(self.text, self.entities, types=types) def parse_caption_entities( - self, types: Optional[list[str]] = None + self, types: list[str | None] | None = None ) -> dict[MessageEntity, str]: """ Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. @@ -4609,11 +5213,11 @@ def parse_caption_entities( @classmethod def _parse_html( cls, - message_text: Optional[str], + message_text: str | None, entities: dict[MessageEntity, str], urled: bool = False, offset: int = 0, - ) -> Optional[str]: + ) -> str | None: if message_text is None: return None @@ -4798,12 +5402,12 @@ def caption_html_urled(self) -> str: @classmethod def _parse_markdown( cls, - message_text: Optional[str], + message_text: str | None, entities: dict[MessageEntity, str], urled: bool = False, version: MarkdownVersion = 1, offset: int = 0, - ) -> Optional[str]: + ) -> str | None: if version == 1: for entity_type in ( MessageEntity.EXPANDABLE_BLOCKQUOTE, diff --git a/telegram/_messageautodeletetimerchanged.py b/src/telegram/_messageautodeletetimerchanged.py similarity index 57% rename from telegram/_messageautodeletetimerchanged.py rename to src/telegram/_messageautodeletetimerchanged.py index 1653c050d59..9a6e0a42852 100644 --- a/telegram/_messageautodeletetimerchanged.py +++ b/src/telegram/_messageautodeletetimerchanged.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,10 +20,12 @@ deletion. """ -from typing import Optional +import datetime as dtm from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class MessageAutoDeleteTimerChanged(TelegramObject): @@ -35,26 +37,38 @@ class MessageAutoDeleteTimerChanged(TelegramObject): .. versionadded:: 13.4 Args: - message_auto_delete_time (:obj:`int`): New auto-delete time for messages in the - chat. + message_auto_delete_time (:obj:`int` | :class:`datetime.timedelta`): New auto-delete time + for messages in the chat. + + .. versionchanged:: v22.2 + |time-period-input| Attributes: - message_auto_delete_time (:obj:`int`): New auto-delete time for messages in the - chat. + message_auto_delete_time (:obj:`int` | :class:`datetime.timedelta`): New auto-delete time + for messages in the chat. + + .. deprecated:: v22.2 + |time-period-int-deprecated| """ - __slots__ = ("message_auto_delete_time",) + __slots__ = ("_message_auto_delete_time",) def __init__( self, - message_auto_delete_time: int, + message_auto_delete_time: TimePeriod, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.message_auto_delete_time: int = message_auto_delete_time + self._message_auto_delete_time: dtm.timedelta = to_timedelta(message_auto_delete_time) self._id_attrs = (self.message_auto_delete_time,) self._freeze() + + @property + def message_auto_delete_time(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._message_auto_delete_time, attribute="message_auto_delete_time" + ) diff --git a/telegram/_messageentity.py b/src/telegram/_messageentity.py similarity index 95% rename from telegram/_messageentity.py rename to src/telegram/_messageentity.py index 852c496bf92..37fb597a4cc 100644 --- a/telegram/_messageentity.py +++ b/src/telegram/_messageentity.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -21,12 +21,13 @@ import copy import itertools from collections.abc import Sequence -from typing import TYPE_CHECKING, Final, Optional, Union +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._telegramobject import TelegramObject from telegram._user import User from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.strings import TextEncoding from telegram._utils.types import JSONDict @@ -114,12 +115,12 @@ def __init__( type: str, # pylint: disable=redefined-builtin offset: int, length: int, - url: Optional[str] = None, - user: Optional[User] = None, - language: Optional[str] = None, - custom_emoji_id: Optional[str] = None, + url: str | None = None, + user: User | None = None, + language: str | None = None, + custom_emoji_id: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -127,26 +128,21 @@ def __init__( self.offset: int = offset self.length: int = length # Optionals - self.url: Optional[str] = url - self.user: Optional[User] = user - self.language: Optional[str] = language - self.custom_emoji_id: Optional[str] = custom_emoji_id + self.url: str | None = url + self.user: User | None = user + self.language: str | None = language + self.custom_emoji_id: str | None = custom_emoji_id self._id_attrs = (self.type, self.offset, self.length) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["MessageEntity"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "MessageEntity": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["user"] = User.de_json(data.get("user"), bot) + data["user"] = de_json_optional(data.get("user"), User, bot) return super().de_json(data=data, bot=bot) @@ -224,7 +220,7 @@ def adjust_message_entities_to_utf_16(text: str, entities: _SEM) -> _SEM: return out @staticmethod - def shift_entities(by: Union[str, int], entities: _SEM) -> _SEM: + def shift_entities(by: str | int, entities: _SEM) -> _SEM: """Utility functionality for shifting the offset of entities by a given amount. Examples: @@ -289,7 +285,7 @@ def shift_entities(by: Union[str, int], entities: _SEM) -> _SEM: @classmethod def concatenate( cls, - *args: Union[tuple[str, _SEM], tuple[str, _SEM, bool]], + *args: tuple[str, _SEM] | tuple[str, _SEM, bool], ) -> tuple[str, _SEM]: """Utility functionality for concatenating two text along with their formatting entities. diff --git a/telegram/_messageid.py b/src/telegram/_messageid.py similarity index 94% rename from telegram/_messageid.py rename to src/telegram/_messageid.py index ac550fc3f45..70420936b8b 100644 --- a/telegram/_messageid.py +++ b/src/telegram/_messageid.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents an instance of a Telegram MessageId.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -45,7 +43,7 @@ class MessageId(TelegramObject): __slots__ = ("message_id",) - def __init__(self, message_id: int, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, message_id: int, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.message_id: int = message_id diff --git a/telegram/_messageorigin.py b/src/telegram/_messageorigin.py similarity index 90% rename from telegram/_messageorigin.py rename to src/telegram/_messageorigin.py index 0982d6501aa..767f5a4a0af 100644 --- a/telegram/_messageorigin.py +++ b/src/telegram/_messageorigin.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,16 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram MessageOigin.""" + import datetime as dtm -from typing import TYPE_CHECKING, Final, Optional +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._chat import Chat from telegram._telegramobject import TelegramObject from telegram._user import User from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict @@ -80,7 +82,7 @@ def __init__( type: str, # pylint: disable=W0622 date: dtm.datetime, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required by all subclasses @@ -94,17 +96,12 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["MessageOrigin"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "MessageOrigin": """Converts JSON data to the appropriate :class:`MessageOrigin` object, i.e. takes care of selecting the correct subclass. """ data = cls._parse_data(data) - if not data: - return None - _class_mapping: dict[str, type[MessageOrigin]] = { cls.USER: MessageOriginUser, cls.HIDDEN_USER: MessageOriginHiddenUser, @@ -118,13 +115,13 @@ def de_json( data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo) if "sender_user" in data: - data["sender_user"] = User.de_json(data.get("sender_user"), bot) + data["sender_user"] = de_json_optional(data.get("sender_user"), User, bot) if "sender_chat" in data: - data["sender_chat"] = Chat.de_json(data.get("sender_chat"), bot) + data["sender_chat"] = de_json_optional(data.get("sender_chat"), Chat, bot) if "chat" in data: - data["chat"] = Chat.de_json(data.get("chat"), bot) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) return super().de_json(data=data, bot=bot) @@ -155,7 +152,7 @@ def __init__( date: dtm.datetime, sender_user: User, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(type=self.USER, date=date, api_kwargs=api_kwargs) @@ -189,7 +186,7 @@ def __init__( date: dtm.datetime, sender_user_name: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(type=self.HIDDEN_USER, date=date, api_kwargs=api_kwargs) @@ -229,15 +226,15 @@ def __init__( self, date: dtm.datetime, sender_chat: Chat, - author_signature: Optional[str] = None, + author_signature: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(type=self.CHAT, date=date, api_kwargs=api_kwargs) with self._unfrozen(): self.sender_chat: Chat = sender_chat - self.author_signature: Optional[str] = author_signature + self.author_signature: str | None = author_signature class MessageOriginChannel(MessageOrigin): @@ -274,13 +271,13 @@ def __init__( date: dtm.datetime, chat: Chat, message_id: int, - author_signature: Optional[str] = None, + author_signature: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(type=self.CHANNEL, date=date, api_kwargs=api_kwargs) with self._unfrozen(): self.chat: Chat = chat self.message_id: int = message_id - self.author_signature: Optional[str] = author_signature + self.author_signature: str | None = author_signature diff --git a/telegram/_messagereactionupdated.py b/src/telegram/_messagereactionupdated.py similarity index 84% rename from telegram/_messagereactionupdated.py rename to src/telegram/_messagereactionupdated.py index 105362af9e5..d5e5471954f 100644 --- a/telegram/_messagereactionupdated.py +++ b/src/telegram/_messagereactionupdated.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,15 +17,16 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram MessageReaction Update.""" + import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._chat import Chat from telegram._reaction import ReactionCount, ReactionType from telegram._telegramobject import TelegramObject from telegram._user import User -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict @@ -73,7 +74,7 @@ def __init__( date: dtm.datetime, reactions: Sequence[ReactionCount], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -86,21 +87,16 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["MessageReactionCountUpdated"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "MessageReactionCountUpdated": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo) - data["chat"] = Chat.de_json(data.get("chat"), bot) - data["reactions"] = ReactionCount.de_list(data.get("reactions"), bot) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["reactions"] = de_list_optional(data.get("reactions"), ReactionCount, bot) return super().de_json(data=data, bot=bot) @@ -160,10 +156,10 @@ def __init__( date: dtm.datetime, old_reaction: Sequence[ReactionType], new_reaction: Sequence[ReactionType], - user: Optional[User] = None, - actor_chat: Optional[Chat] = None, + user: User | None = None, + actor_chat: Chat | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -174,8 +170,8 @@ def __init__( self.new_reaction: tuple[ReactionType, ...] = parse_sequence_arg(new_reaction) # Optional - self.user: Optional[User] = user - self.actor_chat: Optional[Chat] = actor_chat + self.user: User | None = user + self.actor_chat: Chat | None = actor_chat self._id_attrs = ( self.chat, @@ -187,23 +183,18 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["MessageReactionUpdated"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "MessageReactionUpdated": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo) - data["chat"] = Chat.de_json(data.get("chat"), bot) - data["old_reaction"] = ReactionType.de_list(data.get("old_reaction"), bot) - data["new_reaction"] = ReactionType.de_list(data.get("new_reaction"), bot) - data["user"] = User.de_json(data.get("user"), bot) - data["actor_chat"] = Chat.de_json(data.get("actor_chat"), bot) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["old_reaction"] = de_list_optional(data.get("old_reaction"), ReactionType, bot) + data["new_reaction"] = de_list_optional(data.get("new_reaction"), ReactionType, bot) + data["user"] = de_json_optional(data.get("user"), User, bot) + data["actor_chat"] = de_json_optional(data.get("actor_chat"), Chat, bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_ownedgift.py b/src/telegram/_ownedgift.py new file mode 100644 index 00000000000..37ae8f693a6 --- /dev/null +++ b/src/telegram/_ownedgift.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains objects that represent owned gifts.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final + +from telegram import constants +from telegram._gifts import Gift +from telegram._messageentity import MessageEntity +from telegram._telegramobject import TelegramObject +from telegram._uniquegift import UniqueGift +from telegram._user import User +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.entities import parse_message_entities, parse_message_entity +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class OwnedGift(TelegramObject): + """This object describes a gift received and owned by a user or a chat. Currently, it + can be one of: + + * :class:`telegram.OwnedGiftRegular` + * :class:`telegram.OwnedGiftUnique` + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`type` is equal. + + .. versionadded:: 22.1 + + Args: + type (:obj:`str`): Type of the owned gift. + + Attributes: + type (:obj:`str`): Type of the owned gift. + """ + + __slots__ = ("type",) + + REGULAR: Final[str] = constants.OwnedGiftType.REGULAR + """:const:`telegram.constants.OwnedGiftType.REGULAR`""" + UNIQUE: Final[str] = constants.OwnedGiftType.UNIQUE + """:const:`telegram.constants.OwnedGiftType.UNIQUE`""" + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.OwnedGiftType, type, type) + + self._id_attrs = (self.type,) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "OwnedGift": + """Converts JSON data to the appropriate :class:`OwnedGift` object, i.e. takes + care of selecting the correct subclass. + + Args: + data (dict[:obj:`str`, ...]): The JSON data. + bot (:class:`telegram.Bot`, optional): The bot associated with this object. + + Returns: + The Telegram object. + + """ + data = cls._parse_data(data) + + _class_mapping: dict[str, type[OwnedGift]] = { + cls.REGULAR: OwnedGiftRegular, + cls.UNIQUE: OwnedGiftUnique, + } + + if cls is OwnedGift and data.get("type") in _class_mapping: + return _class_mapping[data.pop("type")].de_json(data=data, bot=bot) + + return super().de_json(data=data, bot=bot) + + +class OwnedGifts(TelegramObject): + """Contains the list of gifts received and owned by a user or a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`total_count` and :attr:`gifts` are equal. + + .. versionadded:: 22.1 + + Args: + total_count (:obj:`int`): The total number of gifts owned by the user or the chat. + gifts (Sequence[:class:`telegram.OwnedGift`]): The list of gifts. + next_offset (:obj:`str`, optional): Offset for the next request. If empty, + then there are no more results. + + Attributes: + total_count (:obj:`int`): The total number of gifts owned by the user or the chat. + gifts (Sequence[:class:`telegram.OwnedGift`]): The list of gifts. + next_offset (:obj:`str`): Optional. Offset for the next request. If empty, + then there are no more results. + """ + + __slots__ = ( + "gifts", + "next_offset", + "total_count", + ) + + def __init__( + self, + total_count: int, + gifts: Sequence[OwnedGift], + next_offset: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.total_count: int = total_count + self.gifts: tuple[OwnedGift, ...] = parse_sequence_arg(gifts) + self.next_offset: str | None = next_offset + + self._id_attrs = (self.total_count, self.gifts) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "OwnedGifts": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["gifts"] = de_list_optional(data.get("gifts"), OwnedGift, bot) + return super().de_json(data=data, bot=bot) + + +class OwnedGiftRegular(OwnedGift): + """Describes a regular gift owned by a user or a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`gift` and :attr:`send_date` are equal. + + .. versionadded:: 22.1 + + Args: + gift (:class:`telegram.Gift`): Information about the regular gift. + owned_gift_id (:obj:`str`, optional): Unique identifier of the gift for the bot; for + gifts received on behalf of business accounts only. + sender_user (:class:`telegram.User`, optional): Sender of the gift if it is a known user. + send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. + |datetime_localization|. + text (:obj:`str`, optional): Text of the message that was added to the gift. + entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities that + appear in the text. + is_private (:obj:`bool`, optional): :obj:`True`, if the sender and gift text are shown + only to the gift receiver; otherwise, everyone will be able to see them. + is_saved (:obj:`bool`, optional): :obj:`True`, if the gift is displayed on the account's + profile page; for gifts received on behalf of business accounts only. + can_be_upgraded (:obj:`bool`, optional): :obj:`True`, if the gift can be upgraded to a + unique gift; for gifts received on behalf of business accounts only. + was_refunded (:obj:`bool`, optional): :obj:`True`, if the gift was refunded and isn't + available anymore. + convert_star_count (:obj:`int`, optional): Number of Telegram Stars that can be + claimed by the receiver instead of the gift; omitted if the gift cannot be converted + to Telegram Stars; for gifts received on behalf of business accounts only. + prepaid_upgrade_star_count (:obj:`int`, optional): Number of Telegram Stars that were + paid for the ability to upgrade the gift. + is_upgrade_separate (:obj:`bool`, optional): :obj:`True`, if the gift's upgrade was + purchased after the gift was sent; for gifts received on behalf of business accounts + + .. versionadded:: 22.6 + unique_gift_number (:obj:`int`, optional): Unique number reserved for this gift when + upgraded. See the number field in :class:`~telegram.UniqueGift` + + ... versionadded:: 22.6 + + Attributes: + type (:obj:`str`): Type of the gift, always :attr:`~telegram.OwnedGift.REGULAR`. + gift (:class:`telegram.Gift`): Information about the regular gift. + owned_gift_id (:obj:`str`): Optional. Unique identifier of the gift for the bot; for + gifts received on behalf of business accounts only. + sender_user (:class:`telegram.User`): Optional. Sender of the gift if it is a known user. + send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. + |datetime_localization|. + text (:obj:`str`): Optional. Text of the message that was added to the gift. + entities (Sequence[:class:`telegram.MessageEntity`]): Optional. Special entities that + appear in the text. + is_private (:obj:`bool`): Optional. :obj:`True`, if the sender and gift text are shown + only to the gift receiver; otherwise, everyone will be able to see them. + is_saved (:obj:`bool`): Optional. :obj:`True`, if the gift is displayed on the account's + profile page; for gifts received on behalf of business accounts only. + can_be_upgraded (:obj:`bool`): Optional. :obj:`True`, if the gift can be upgraded to a + unique gift; for gifts received on behalf of business accounts only. + was_refunded (:obj:`bool`): Optional. :obj:`True`, if the gift was refunded and isn't + available anymore. + convert_star_count (:obj:`int`): Optional. Number of Telegram Stars that can be + claimed by the receiver instead of the gift; omitted if the gift cannot be converted + to Telegram Stars; for gifts received on behalf of business accounts only. + prepaid_upgrade_star_count (:obj:`int`): Optional. Number of Telegram Stars that were + paid for the ability to upgrade the gift. + is_upgrade_separate (:obj:`bool`): Optional. :obj:`True`, if the gift's upgrade was + purchased after the gift was sent; for gifts received on behalf of business accounts + + .. versionadded:: 22.6 + unique_gift_number (:obj:`int`): Optional. Unique number reserved for this gift when + upgraded. See the number field in :class:`~telegram.UniqueGift` + + ... versionadded:: 22.6 + + """ + + __slots__ = ( + "can_be_upgraded", + "convert_star_count", + "entities", + "gift", + "is_private", + "is_saved", + "is_upgrade_separate", + "owned_gift_id", + "prepaid_upgrade_star_count", + "send_date", + "sender_user", + "text", + "unique_gift_number", + "was_refunded", + ) + + def __init__( + self, + gift: Gift, + send_date: dtm.datetime, + owned_gift_id: str | None = None, + sender_user: User | None = None, + text: str | None = None, + entities: Sequence[MessageEntity] | None = None, + is_private: bool | None = None, + is_saved: bool | None = None, + can_be_upgraded: bool | None = None, + was_refunded: bool | None = None, + convert_star_count: int | None = None, + prepaid_upgrade_star_count: int | None = None, + is_upgrade_separate: bool | None = None, + unique_gift_number: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=OwnedGift.REGULAR, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.gift: Gift = gift + self.send_date: dtm.datetime = send_date + self.owned_gift_id: str | None = owned_gift_id + self.sender_user: User | None = sender_user + self.text: str | None = text + self.entities: tuple[MessageEntity, ...] = parse_sequence_arg(entities) + self.is_private: bool | None = is_private + self.is_saved: bool | None = is_saved + self.can_be_upgraded: bool | None = can_be_upgraded + self.was_refunded: bool | None = was_refunded + self.convert_star_count: int | None = convert_star_count + self.prepaid_upgrade_star_count: int | None = prepaid_upgrade_star_count + self.is_upgrade_separate: bool | None = is_upgrade_separate + self.unique_gift_number: int | None = unique_gift_number + + self._id_attrs = (self.type, self.gift, self.send_date) + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "OwnedGiftRegular": + """See :meth:`telegram.OwnedGift.de_json`.""" + data = cls._parse_data(data) + + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["send_date"] = from_timestamp(data.get("send_date"), tzinfo=loc_tzinfo) + data["sender_user"] = de_json_optional(data.get("sender_user"), User, bot) + data["gift"] = de_json_optional(data.get("gift"), Gift, bot) + data["entities"] = de_list_optional(data.get("entities"), MessageEntity, bot) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`text` + from a given :class:`telegram.MessageEntity` of :attr:`entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``OwnedGiftRegular.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`entities`. + + Returns: + :obj:`str`: The text of the given entity. + + Raises: + RuntimeError: If the owned gift has no text. + + """ + if not self.text: + raise RuntimeError("This OwnedGiftRegular has no 'text'.") + + return parse_message_entity(self.text, entity) + + def parse_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this owned gift's text filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + + Raises: + RuntimeError: If the owned gift has no text. + + """ + if not self.text: + raise RuntimeError("This OwnedGiftRegular has no 'text'.") + + return parse_message_entities(self.text, self.entities, types) + + +class OwnedGiftUnique(OwnedGift): + """ + Describes a unique gift received and owned by a user or a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`gift` and :attr:`send_date` are equal. + + .. versionadded:: 22.1 + + Args: + gift (:class:`telegram.UniqueGift`): Information about the unique gift. + owned_gift_id (:obj:`str`, optional): Unique identifier of the received gift for the + bot; for gifts received on behalf of business accounts only. + sender_user (:class:`telegram.User`, optional): Sender of the gift if it is a known user. + send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. + |datetime_localization| + is_saved (:obj:`bool`, optional): :obj:`True`, if the gift is displayed on the account's + profile page; for gifts received on behalf of business accounts only. + can_be_transferred (:obj:`bool`, optional): :obj:`True`, if the gift can be transferred to + another owner; for gifts received on behalf of business accounts only. + transfer_star_count (:obj:`int`, optional): Number of Telegram Stars that must be paid + to transfer the gift; omitted if the bot cannot transfer the gift. + next_transfer_date (:obj:`datetime.datetime`, optional): Date when the gift can be + transferred. If it's in the past, then the gift can be transferred now. + |datetime_localization| + .. versionadded:: 22.3 + + Attributes: + type (:obj:`str`): Type of the owned gift, always :tg-const:`~telegram.OwnedGift.UNIQUE`. + gift (:class:`telegram.UniqueGift`): Information about the unique gift. + owned_gift_id (:obj:`str`): Optional. Unique identifier of the received gift for the + bot; for gifts received on behalf of business accounts only. + sender_user (:class:`telegram.User`): Optional. Sender of the gift if it is a known user. + send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. + |datetime_localization| + is_saved (:obj:`bool`): Optional. :obj:`True`, if the gift is displayed on the account's + profile page; for gifts received on behalf of business accounts only. + can_be_transferred (:obj:`bool`): Optional. :obj:`True`, if the gift can be transferred to + another owner; for gifts received on behalf of business accounts only. + transfer_star_count (:obj:`int`): Optional. Number of Telegram Stars that must be paid + to transfer the gift; omitted if the bot cannot transfer the gift. + next_transfer_date (:obj:`datetime.datetime`): Optional. Date when the gift can be + transferred. If it's in the past, then the gift can be transferred now. + |datetime_localization| + .. versionadded:: 22.3 + """ + + __slots__ = ( + "can_be_transferred", + "gift", + "is_saved", + "next_transfer_date", + "owned_gift_id", + "send_date", + "sender_user", + "transfer_star_count", + ) + + def __init__( + self, + gift: UniqueGift, + send_date: dtm.datetime, + owned_gift_id: str | None = None, + sender_user: User | None = None, + is_saved: bool | None = None, + can_be_transferred: bool | None = None, + transfer_star_count: int | None = None, + next_transfer_date: dtm.datetime | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=OwnedGift.UNIQUE, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.gift: UniqueGift = gift + self.send_date: dtm.datetime = send_date + self.owned_gift_id: str | None = owned_gift_id + self.sender_user: User | None = sender_user + self.is_saved: bool | None = is_saved + self.can_be_transferred: bool | None = can_be_transferred + self.transfer_star_count: int | None = transfer_star_count + self.next_transfer_date: dtm.datetime | None = next_transfer_date + + self._id_attrs = (self.type, self.gift, self.send_date) + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "OwnedGiftUnique": + """See :meth:`telegram.OwnedGift.de_json`.""" + data = cls._parse_data(data) + + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["send_date"] = from_timestamp(data.get("send_date"), tzinfo=loc_tzinfo) + data["sender_user"] = de_json_optional(data.get("sender_user"), User, bot) + data["gift"] = de_json_optional(data.get("gift"), UniqueGift, bot) + data["next_transfer_date"] = from_timestamp( + data.get("next_transfer_date"), tzinfo=loc_tzinfo + ) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] diff --git a/telegram/_paidmedia.py b/src/telegram/_paidmedia.py similarity index 78% rename from telegram/_paidmedia.py rename to src/telegram/_paidmedia.py index b649c7ec320..67af46710a5 100644 --- a/telegram/_paidmedia.py +++ b/src/telegram/_paidmedia.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,9 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains objects that represent paid media in Telegram.""" +import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Final, Optional +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._files.photosize import PhotoSize @@ -27,8 +28,14 @@ from telegram._telegramobject import TelegramObject from telegram._user import User from telegram._utils import enum -from telegram._utils.argumentparsing import parse_sequence_arg -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_sequence_arg, + to_timedelta, +) +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod if TYPE_CHECKING: from telegram import Bot @@ -66,7 +73,7 @@ def __init__( self, type: str, # pylint: disable=redefined-builtin *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) self.type: str = enum.get_member(constants.PaidMediaType, type, type) @@ -75,9 +82,7 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["PaidMedia"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMedia": """Converts JSON data to the appropriate :class:`PaidMedia` object, i.e. takes care of selecting the correct subclass. @@ -91,12 +96,6 @@ def de_json( """ data = cls._parse_data(data) - if data is None: - return None - - if not data and cls is PaidMedia: - return None - _class_mapping: dict[str, type[PaidMedia]] = { cls.PREVIEW: PaidMediaPreview, cls.PHOTO: PaidMediaPhoto, @@ -106,6 +105,9 @@ def de_json( if cls is PaidMedia and data.get("type") in _class_mapping: return _class_mapping[data.pop("type")].de_json(data=data, bot=bot) + if "duration" in data: + data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None + return super().de_json(data=data, bot=bot) @@ -118,37 +120,53 @@ class PaidMediaPreview(PaidMedia): .. versionadded:: 21.4 + .. versionchanged:: v22.2 + As part of the migration to representing time periods using ``datetime.timedelta``, + equality comparison now considers integer durations and equivalent timedeltas as equal. + Args: type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.PREVIEW`. width (:obj:`int`, optional): Media width as defined by the sender. height (:obj:`int`, optional): Media height as defined by the sender. - duration (:obj:`int`, optional): Duration of the media in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of the media in + seconds as defined by the sender. + + .. versionchanged:: v22.2 + |time-period-input| Attributes: type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.PREVIEW`. width (:obj:`int`): Optional. Media width as defined by the sender. height (:obj:`int`): Optional. Media height as defined by the sender. - duration (:obj:`int`): Optional. Duration of the media in seconds as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Duration of the media in + seconds as defined by the sender. + + .. deprecated:: v22.2 + |time-period-int-deprecated| """ - __slots__ = ("duration", "height", "width") + __slots__ = ("_duration", "height", "width") def __init__( self, - width: Optional[int] = None, - height: Optional[int] = None, - duration: Optional[int] = None, + width: int | None = None, + height: int | None = None, + duration: TimePeriod | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(type=PaidMedia.PREVIEW, api_kwargs=api_kwargs) with self._unfrozen(): - self.width: Optional[int] = width - self.height: Optional[int] = height - self.duration: Optional[int] = duration + self.width: int | None = width + self.height: int | None = height + self._duration: dtm.timedelta | None = to_timedelta(duration) - self._id_attrs = (self.type, self.width, self.height, self.duration) + self._id_attrs = (self.type, self.width, self.height, self._duration) + + @property + def duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._duration, attribute="duration") class PaidMediaPhoto(PaidMedia): @@ -175,7 +193,7 @@ def __init__( self, photo: Sequence["PhotoSize"], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(type=PaidMedia.PHOTO, api_kwargs=api_kwargs) @@ -185,15 +203,10 @@ def __init__( self._id_attrs = (self.type, self.photo) @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["PaidMediaPhoto"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMediaPhoto": data = cls._parse_data(data) - if not data: - return None - - data["photo"] = PhotoSize.de_list(data.get("photo"), bot=bot) + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) return super().de_json(data=data, bot=bot) # type: ignore[return-value] @@ -221,7 +234,7 @@ def __init__( self, video: Video, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(type=PaidMedia.VIDEO, api_kwargs=api_kwargs) @@ -231,15 +244,10 @@ def __init__( self._id_attrs = (self.type, self.video) @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["PaidMediaVideo"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMediaVideo": data = cls._parse_data(data) - if not data: - return None - - data["video"] = Video.de_json(data.get("video"), bot=bot) + data["video"] = de_json_optional(data.get("video"), Video, bot) return super().de_json(data=data, bot=bot) # type: ignore[return-value] @@ -270,7 +278,7 @@ def __init__( star_count: int, paid_media: Sequence[PaidMedia], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) self.star_count: int = star_count @@ -280,15 +288,10 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["PaidMediaInfo"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMediaInfo": data = cls._parse_data(data) - if not data: - return None - - data["paid_media"] = PaidMedia.de_list(data.get("paid_media"), bot=bot) + data["paid_media"] = de_list_optional(data.get("paid_media"), PaidMedia, bot) return super().de_json(data=data, bot=bot) @@ -319,7 +322,7 @@ def __init__( from_user: "User", paid_media_payload: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) self.from_user: User = from_user @@ -329,13 +332,8 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["PaidMediaPurchased"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMediaPurchased": data = cls._parse_data(data) - if not data: - return None - data["from_user"] = User.de_json(data=data.pop("from"), bot=bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_paidmessagepricechanged.py b/src/telegram/_paidmessagepricechanged.py new file mode 100644 index 00000000000..d5fa78692ee --- /dev/null +++ b/src/telegram/_paidmessagepricechanged.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that describes a price change of a paid message.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class PaidMessagePriceChanged(TelegramObject): + """Describes a service message about a change in the price of paid messages within a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`paid_message_star_count` is equal. + + .. versionadded:: 22.1 + + Args: + paid_message_star_count (:obj:`int`): The new number of Telegram Stars that must be paid by + non-administrator users of the supergroup chat for each sent message + + Attributes: + paid_message_star_count (:obj:`int`): The new number of Telegram Stars that must be paid by + non-administrator users of the supergroup chat for each sent message + """ + + __slots__ = ("paid_message_star_count",) + + def __init__( + self, + paid_message_star_count: int, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.paid_message_star_count: int = paid_message_star_count + + self._id_attrs = (self.paid_message_star_count,) + self._freeze() diff --git a/telegram/_payment/__init__.py b/src/telegram/_passport/__init__.py similarity index 100% rename from telegram/_payment/__init__.py rename to src/telegram/_passport/__init__.py diff --git a/telegram/_passport/credentials.py b/src/telegram/_passport/credentials.py similarity index 79% rename from telegram/_passport/credentials.py rename to src/telegram/_passport/credentials.py index 8384fde76be..d5a16443980 100644 --- a/telegram/_passport/credentials.py +++ b/src/telegram/_passport/credentials.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,7 +20,7 @@ import json from base64 import b64decode from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional, no_type_check +from typing import TYPE_CHECKING, no_type_check try: from cryptography.hazmat.backends import default_backend @@ -39,7 +39,7 @@ CRYPTO_INSTALLED = False from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.strings import TextEncoding from telegram._utils.types import JSONDict from telegram.error import PassportDecryptionError @@ -145,7 +145,7 @@ def __init__( hash: str, secret: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -155,8 +155,8 @@ def __init__( self._id_attrs = (self.data, self.hash, self.secret) - self._decrypted_secret: Optional[bytes] = None - self._decrypted_data: Optional[Credentials] = None + self._decrypted_secret: bytes | None = None + self._decrypted_data: Credentials | None = None self._freeze() @@ -207,7 +207,7 @@ def decrypted_data(self) -> "Credentials": decrypt_json(self.decrypted_secret, b64decode(self.hash), b64decode(self.data)), self.get_bot(), ) - return self._decrypted_data # type: ignore[return-value] + return self._decrypted_data class Credentials(TelegramObject): @@ -224,7 +224,7 @@ def __init__( secure_data: "SecureData", nonce: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -234,16 +234,11 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["Credentials"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Credentials": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["secure_data"] = SecureData.de_json(data.get("secure_data"), bot=bot) + data["secure_data"] = de_json_optional(data.get("secure_data"), SecureData, bot) return super().de_json(data=data, bot=bot) @@ -314,62 +309,59 @@ class SecureData(TelegramObject): def __init__( self, - personal_details: Optional["SecureValue"] = None, - passport: Optional["SecureValue"] = None, - internal_passport: Optional["SecureValue"] = None, - driver_license: Optional["SecureValue"] = None, - identity_card: Optional["SecureValue"] = None, - address: Optional["SecureValue"] = None, - utility_bill: Optional["SecureValue"] = None, - bank_statement: Optional["SecureValue"] = None, - rental_agreement: Optional["SecureValue"] = None, - passport_registration: Optional["SecureValue"] = None, - temporary_registration: Optional["SecureValue"] = None, + personal_details: "SecureValue | None" = None, + passport: "SecureValue | None" = None, + internal_passport: "SecureValue | None" = None, + driver_license: "SecureValue | None" = None, + identity_card: "SecureValue | None" = None, + address: "SecureValue | None" = None, + utility_bill: "SecureValue | None" = None, + bank_statement: "SecureValue | None" = None, + rental_agreement: "SecureValue | None" = None, + passport_registration: "SecureValue | None" = None, + temporary_registration: "SecureValue | None" = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Optionals - self.temporary_registration: Optional[SecureValue] = temporary_registration - self.passport_registration: Optional[SecureValue] = passport_registration - self.rental_agreement: Optional[SecureValue] = rental_agreement - self.bank_statement: Optional[SecureValue] = bank_statement - self.utility_bill: Optional[SecureValue] = utility_bill - self.address: Optional[SecureValue] = address - self.identity_card: Optional[SecureValue] = identity_card - self.driver_license: Optional[SecureValue] = driver_license - self.internal_passport: Optional[SecureValue] = internal_passport - self.passport: Optional[SecureValue] = passport - self.personal_details: Optional[SecureValue] = personal_details + self.temporary_registration: SecureValue | None = temporary_registration + self.passport_registration: SecureValue | None = passport_registration + self.rental_agreement: SecureValue | None = rental_agreement + self.bank_statement: SecureValue | None = bank_statement + self.utility_bill: SecureValue | None = utility_bill + self.address: SecureValue | None = address + self.identity_card: SecureValue | None = identity_card + self.driver_license: SecureValue | None = driver_license + self.internal_passport: SecureValue | None = internal_passport + self.passport: SecureValue | None = passport + self.personal_details: SecureValue | None = personal_details self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["SecureData"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "SecureData": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["temporary_registration"] = SecureValue.de_json( - data.get("temporary_registration"), bot=bot + data["temporary_registration"] = de_json_optional( + data.get("temporary_registration"), SecureValue, bot ) - data["passport_registration"] = SecureValue.de_json( - data.get("passport_registration"), bot=bot + data["passport_registration"] = de_json_optional( + data.get("passport_registration"), SecureValue, bot ) - data["rental_agreement"] = SecureValue.de_json(data.get("rental_agreement"), bot=bot) - data["bank_statement"] = SecureValue.de_json(data.get("bank_statement"), bot=bot) - data["utility_bill"] = SecureValue.de_json(data.get("utility_bill"), bot=bot) - data["address"] = SecureValue.de_json(data.get("address"), bot=bot) - data["identity_card"] = SecureValue.de_json(data.get("identity_card"), bot=bot) - data["driver_license"] = SecureValue.de_json(data.get("driver_license"), bot=bot) - data["internal_passport"] = SecureValue.de_json(data.get("internal_passport"), bot=bot) - data["passport"] = SecureValue.de_json(data.get("passport"), bot=bot) - data["personal_details"] = SecureValue.de_json(data.get("personal_details"), bot=bot) + data["rental_agreement"] = de_json_optional(data.get("rental_agreement"), SecureValue, bot) + data["bank_statement"] = de_json_optional(data.get("bank_statement"), SecureValue, bot) + data["utility_bill"] = de_json_optional(data.get("utility_bill"), SecureValue, bot) + data["address"] = de_json_optional(data.get("address"), SecureValue, bot) + data["identity_card"] = de_json_optional(data.get("identity_card"), SecureValue, bot) + data["driver_license"] = de_json_optional(data.get("driver_license"), SecureValue, bot) + data["internal_passport"] = de_json_optional( + data.get("internal_passport"), SecureValue, bot + ) + data["passport"] = de_json_optional(data.get("passport"), SecureValue, bot) + data["personal_details"] = de_json_optional(data.get("personal_details"), SecureValue, bot) return super().de_json(data=data, bot=bot) @@ -434,41 +426,36 @@ class SecureValue(TelegramObject): def __init__( self, - data: Optional["DataCredentials"] = None, - front_side: Optional["FileCredentials"] = None, - reverse_side: Optional["FileCredentials"] = None, - selfie: Optional["FileCredentials"] = None, - files: Optional[Sequence["FileCredentials"]] = None, - translation: Optional[Sequence["FileCredentials"]] = None, + data: "DataCredentials | None" = None, + front_side: "FileCredentials | None" = None, + reverse_side: "FileCredentials | None" = None, + selfie: "FileCredentials | None" = None, + files: Sequence["FileCredentials"] | None = None, + translation: Sequence["FileCredentials"] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.data: Optional[DataCredentials] = data - self.front_side: Optional[FileCredentials] = front_side - self.reverse_side: Optional[FileCredentials] = reverse_side - self.selfie: Optional[FileCredentials] = selfie + self.data: DataCredentials | None = data + self.front_side: FileCredentials | None = front_side + self.reverse_side: FileCredentials | None = reverse_side + self.selfie: FileCredentials | None = selfie self.files: tuple[FileCredentials, ...] = parse_sequence_arg(files) self.translation: tuple[FileCredentials, ...] = parse_sequence_arg(translation) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["SecureValue"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "SecureValue": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["data"] = DataCredentials.de_json(data.get("data"), bot=bot) - data["front_side"] = FileCredentials.de_json(data.get("front_side"), bot=bot) - data["reverse_side"] = FileCredentials.de_json(data.get("reverse_side"), bot=bot) - data["selfie"] = FileCredentials.de_json(data.get("selfie"), bot=bot) - data["files"] = FileCredentials.de_list(data.get("files"), bot=bot) - data["translation"] = FileCredentials.de_list(data.get("translation"), bot=bot) + data["data"] = de_json_optional(data.get("data"), DataCredentials, bot) + data["front_side"] = de_json_optional(data.get("front_side"), FileCredentials, bot) + data["reverse_side"] = de_json_optional(data.get("reverse_side"), FileCredentials, bot) + data["selfie"] = de_json_optional(data.get("selfie"), FileCredentials, bot) + data["files"] = de_list_optional(data.get("files"), FileCredentials, bot) + data["translation"] = de_list_optional(data.get("translation"), FileCredentials, bot) return super().de_json(data=data, bot=bot) @@ -483,7 +470,7 @@ def __init__( hash: str, secret: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) with self._unfrozen(): @@ -511,7 +498,7 @@ class DataCredentials(_CredentialsBase): __slots__ = () - def __init__(self, data_hash: str, secret: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, data_hash: str, secret: str, *, api_kwargs: JSONDict | None = None): super().__init__(hash=data_hash, secret=secret, api_kwargs=api_kwargs) self._freeze() @@ -532,6 +519,6 @@ class FileCredentials(_CredentialsBase): __slots__ = () - def __init__(self, file_hash: str, secret: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, file_hash: str, secret: str, *, api_kwargs: JSONDict | None = None): super().__init__(hash=file_hash, secret=secret, api_kwargs=api_kwargs) self._freeze() diff --git a/telegram/_passport/data.py b/src/telegram/_passport/data.py similarity index 90% rename from telegram/_passport/data.py rename to src/telegram/_passport/data.py index 5cbd5c74c87..57261496a8e 100644 --- a/telegram/_passport/data.py +++ b/src/telegram/_passport/data.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. # pylint: disable=missing-module-docstring -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -81,25 +80,25 @@ def __init__( gender: str, country_code: str, residence_country_code: str, - first_name_native: Optional[str] = None, - last_name_native: Optional[str] = None, - middle_name: Optional[str] = None, - middle_name_native: Optional[str] = None, + first_name_native: str | None = None, + last_name_native: str | None = None, + middle_name: str | None = None, + middle_name_native: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.first_name: str = first_name self.last_name: str = last_name - self.middle_name: Optional[str] = middle_name + self.middle_name: str | None = middle_name self.birth_date: str = birth_date self.gender: str = gender self.country_code: str = country_code self.residence_country_code: str = residence_country_code - self.first_name_native: Optional[str] = first_name_native - self.last_name_native: Optional[str] = last_name_native - self.middle_name_native: Optional[str] = middle_name_native + self.first_name_native: str | None = first_name_native + self.last_name_native: str | None = last_name_native + self.middle_name_native: str | None = middle_name_native self._freeze() @@ -143,7 +142,7 @@ def __init__( country_code: str, post_code: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -177,7 +176,7 @@ def __init__( document_no: str, expiry_date: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.document_no: str = document_no diff --git a/telegram/_passport/encryptedpassportelement.py b/src/telegram/_passport/encryptedpassportelement.py similarity index 80% rename from telegram/_passport/encryptedpassportelement.py rename to src/telegram/_passport/encryptedpassportelement.py index aa646df5839..8b4bdfbd77e 100644 --- a/telegram/_passport/encryptedpassportelement.py +++ b/src/telegram/_passport/encryptedpassportelement.py @@ -1,7 +1,6 @@ #!/usr/bin/env python -# flake8: noqa: E501 # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,15 +16,22 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram EncryptedPassportElement.""" + from base64 import b64decode from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING from telegram._passport.credentials import decrypt_json from telegram._passport.data import IdDocumentData, PersonalDetails, ResidentialAddress from telegram._passport.passportfile import PassportFile from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import ( + de_json_decrypted_optional, + de_json_optional, + de_list_decrypted_optional, + de_list_optional, + parse_sequence_arg, +) from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -150,33 +156,29 @@ def __init__( self, type: str, # pylint: disable=redefined-builtin hash: str, # pylint: disable=redefined-builtin - data: Optional[Union[PersonalDetails, IdDocumentData, ResidentialAddress]] = None, - phone_number: Optional[str] = None, - email: Optional[str] = None, - files: Optional[Sequence[PassportFile]] = None, - front_side: Optional[PassportFile] = None, - reverse_side: Optional[PassportFile] = None, - selfie: Optional[PassportFile] = None, - translation: Optional[Sequence[PassportFile]] = None, - # TODO: Remove the credentials argument in 22.0 or later - credentials: Optional[ # pylint: disable=unused-argument # noqa: ARG002 - "Credentials" - ] = None, + data: PersonalDetails | IdDocumentData | ResidentialAddress | None = None, + phone_number: str | None = None, + email: str | None = None, + files: Sequence[PassportFile] | None = None, + front_side: PassportFile | None = None, + reverse_side: PassportFile | None = None, + selfie: PassportFile | None = None, + translation: Sequence[PassportFile] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.type: str = type # Optionals - self.data: Optional[Union[PersonalDetails, IdDocumentData, ResidentialAddress]] = data - self.phone_number: Optional[str] = phone_number - self.email: Optional[str] = email + self.data: PersonalDetails | IdDocumentData | ResidentialAddress | None = data + self.phone_number: str | None = phone_number + self.email: str | None = email self.files: tuple[PassportFile, ...] = parse_sequence_arg(files) - self.front_side: Optional[PassportFile] = front_side - self.reverse_side: Optional[PassportFile] = reverse_side - self.selfie: Optional[PassportFile] = selfie + self.front_side: PassportFile | None = front_side + self.reverse_side: PassportFile | None = reverse_side + self.selfie: PassportFile | None = selfie self.translation: tuple[PassportFile, ...] = parse_sequence_arg(translation) self.hash: str = hash @@ -194,27 +196,22 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["EncryptedPassportElement"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "EncryptedPassportElement": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["files"] = PassportFile.de_list(data.get("files"), bot) or None - data["front_side"] = PassportFile.de_json(data.get("front_side"), bot) - data["reverse_side"] = PassportFile.de_json(data.get("reverse_side"), bot) - data["selfie"] = PassportFile.de_json(data.get("selfie"), bot) - data["translation"] = PassportFile.de_list(data.get("translation"), bot) or None + data["files"] = de_list_optional(data.get("files"), PassportFile, bot) or None + data["front_side"] = de_json_optional(data.get("front_side"), PassportFile, bot) + data["reverse_side"] = de_json_optional(data.get("reverse_side"), PassportFile, bot) + data["selfie"] = de_json_optional(data.get("selfie"), PassportFile, bot) + data["translation"] = de_list_optional(data.get("translation"), PassportFile, bot) or None return super().de_json(data=data, bot=bot) @classmethod def de_json_decrypted( - cls, data: Optional[JSONDict], bot: Optional["Bot"], credentials: "Credentials" - ) -> Optional["EncryptedPassportElement"]: + cls, data: JSONDict, bot: "Bot | None", credentials: "Credentials" + ) -> "EncryptedPassportElement": """Variant of :meth:`telegram.TelegramObject.de_json` that also takes into account passport credentials. @@ -234,8 +231,6 @@ def de_json_decrypted( :class:`telegram.EncryptedPassportElement`: """ - if not data: - return None if data["type"] not in ("phone_number", "email"): secure_data = getattr(credentials.secure_data, data["type"]) @@ -261,20 +256,21 @@ def de_json_decrypted( data["data"] = ResidentialAddress.de_json(data["data"], bot=bot) data["files"] = ( - PassportFile.de_list_decrypted(data.get("files"), bot, secure_data.files) or None + de_list_decrypted_optional(data.get("files"), PassportFile, bot, secure_data.files) + or None ) - data["front_side"] = PassportFile.de_json_decrypted( - data.get("front_side"), bot, secure_data.front_side + data["front_side"] = de_json_decrypted_optional( + data.get("front_side"), PassportFile, bot, secure_data.front_side ) - data["reverse_side"] = PassportFile.de_json_decrypted( - data.get("reverse_side"), bot, secure_data.reverse_side + data["reverse_side"] = de_json_decrypted_optional( + data.get("reverse_side"), PassportFile, bot, secure_data.reverse_side ) - data["selfie"] = PassportFile.de_json_decrypted( - data.get("selfie"), bot, secure_data.selfie + data["selfie"] = de_json_decrypted_optional( + data.get("selfie"), PassportFile, bot, secure_data.selfie ) data["translation"] = ( - PassportFile.de_list_decrypted( - data.get("translation"), bot, secure_data.translation + de_list_decrypted_optional( + data.get("translation"), PassportFile, bot, secure_data.translation ) or None ) diff --git a/telegram/_passport/passportdata.py b/src/telegram/_passport/passportdata.py similarity index 88% rename from telegram/_passport/passportdata.py rename to src/telegram/_passport/passportdata.py index 200a45899dc..deae7196f3a 100644 --- a/telegram/_passport/passportdata.py +++ b/src/telegram/_passport/passportdata.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,14 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Contains information about Telegram Passport data shared with the bot by the user.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._passport.credentials import EncryptedCredentials from telegram._passport.encryptedpassportelement import EncryptedPassportElement from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -69,30 +70,25 @@ def __init__( data: Sequence[EncryptedPassportElement], credentials: EncryptedCredentials, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.data: tuple[EncryptedPassportElement, ...] = parse_sequence_arg(data) self.credentials: EncryptedCredentials = credentials - self._decrypted_data: Optional[tuple[EncryptedPassportElement]] = None + self._decrypted_data: tuple[EncryptedPassportElement] | None = None self._id_attrs = tuple([x.type for x in data] + [credentials.hash]) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["PassportData"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PassportData": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["data"] = EncryptedPassportElement.de_list(data.get("data"), bot) - data["credentials"] = EncryptedCredentials.de_json(data.get("credentials"), bot) + data["data"] = de_list_optional(data.get("data"), EncryptedPassportElement, bot) + data["credentials"] = de_json_optional(data.get("credentials"), EncryptedCredentials, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_passport/passportelementerrors.py b/src/telegram/_passport/passportelementerrors.py similarity index 86% rename from telegram/_passport/passportelementerrors.py rename to src/telegram/_passport/passportelementerrors.py index d5a74462833..bba589ee2cb 100644 --- a/telegram/_passport/passportelementerrors.py +++ b/src/telegram/_passport/passportelementerrors.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,12 +19,11 @@ # pylint: disable=redefined-builtin """This module contains the classes that represent Telegram PassportElementError.""" -from typing import Optional +from collections.abc import Sequence from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import parse_sequence_arg from telegram._utils.types import JSONDict -from telegram._utils.warnings import warn -from telegram.warnings import PTBDeprecationWarning class PassportElementError(TelegramObject): @@ -51,7 +50,7 @@ class PassportElementError(TelegramObject): __slots__ = ("message", "source", "type") def __init__( - self, source: str, type: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, source: str, type: str, message: str, *, api_kwargs: JSONDict | None = None ): super().__init__(api_kwargs=api_kwargs) # Required @@ -100,7 +99,7 @@ def __init__( data_hash: str, message: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__("data", type, message, api_kwargs=api_kwargs) @@ -145,7 +144,7 @@ class PassportElementErrorFile(PassportElementError): __slots__ = ("file_hash",) def __init__( - self, type: str, file_hash: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, type: str, file_hash: str, message: str, *, api_kwargs: JSONDict | None = None ): # Required super().__init__("file", type, message, api_kwargs=api_kwargs) @@ -168,56 +167,40 @@ class PassportElementErrorFiles(PassportElementError): type (:obj:`str`): The section of the user's Telegram Passport which has the issue, one of ``"utility_bill"``, ``"bank_statement"``, ``"rental_agreement"``, ``"passport_registration"``, ``"temporary_registration"``. - file_hashes (list[:obj:`str`]): List of base64-encoded file hashes. + file_hashes (Sequence[:obj:`str`]): List of base64-encoded file hashes. + + .. versionchanged:: 22.0 + |sequenceargs| message (:obj:`str`): Error message. Attributes: type (:obj:`str`): The section of the user's Telegram Passport which has the issue, one of ``"utility_bill"``, ``"bank_statement"``, ``"rental_agreement"``, ``"passport_registration"``, ``"temporary_registration"``. + file_hashes (tuple[:obj:`str`]): List of base64-encoded file hashes. + + .. versionchanged:: 22.0 + |tupleclassattrs| message (:obj:`str`): Error message. """ - __slots__ = ("_file_hashes",) + __slots__ = ("file_hashes",) def __init__( self, type: str, - file_hashes: list[str], + file_hashes: Sequence[str], message: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__("files", type, message, api_kwargs=api_kwargs) with self._unfrozen(): - self._file_hashes: list[str] = file_hashes - - self._id_attrs = (self.source, self.type, self.message, *tuple(file_hashes)) - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict` for details.""" - data = super().to_dict(recursive) - data["file_hashes"] = self._file_hashes - return data - - @property - def file_hashes(self) -> list[str]: - """List of base64-encoded file hashes. - - .. deprecated:: 20.6 - This attribute will return a tuple instead of a list in future major versions. - """ - warn( - PTBDeprecationWarning( - "20.6", - "The attribute `file_hashes` will return a tuple instead of a list in future major" - " versions.", - ), - stacklevel=2, - ) - return self._file_hashes + self.file_hashes: tuple[str, ...] = parse_sequence_arg(file_hashes) + + self._id_attrs = (self.source, self.type, self.message, self.file_hashes) class PassportElementErrorFrontSide(PassportElementError): @@ -248,7 +231,7 @@ class PassportElementErrorFrontSide(PassportElementError): __slots__ = ("file_hash",) def __init__( - self, type: str, file_hash: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, type: str, file_hash: str, message: str, *, api_kwargs: JSONDict | None = None ): # Required super().__init__("front_side", type, message, api_kwargs=api_kwargs) @@ -286,7 +269,7 @@ class PassportElementErrorReverseSide(PassportElementError): __slots__ = ("file_hash",) def __init__( - self, type: str, file_hash: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, type: str, file_hash: str, message: str, *, api_kwargs: JSONDict | None = None ): # Required super().__init__("reverse_side", type, message, api_kwargs=api_kwargs) @@ -322,7 +305,7 @@ class PassportElementErrorSelfie(PassportElementError): __slots__ = ("file_hash",) def __init__( - self, type: str, file_hash: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, type: str, file_hash: str, message: str, *, api_kwargs: JSONDict | None = None ): # Required super().__init__("selfie", type, message, api_kwargs=api_kwargs) @@ -362,7 +345,7 @@ class PassportElementErrorTranslationFile(PassportElementError): __slots__ = ("file_hash",) def __init__( - self, type: str, file_hash: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, type: str, file_hash: str, message: str, *, api_kwargs: JSONDict | None = None ): # Required super().__init__("translation_file", type, message, api_kwargs=api_kwargs) @@ -386,7 +369,10 @@ class PassportElementErrorTranslationFiles(PassportElementError): one of ``"passport"``, ``"driver_license"``, ``"identity_card"``, ``"internal_passport"``, ``"utility_bill"``, ``"bank_statement"``, ``"rental_agreement"``, ``"passport_registration"``, ``"temporary_registration"``. - file_hashes (list[:obj:`str`]): List of base64-encoded file hashes. + file_hashes (Sequence[:obj:`str`]): List of base64-encoded file hashes. + + .. versionchanged:: 22.0 + |sequenceargs| message (:obj:`str`): Error message. Attributes: @@ -394,50 +380,30 @@ class PassportElementErrorTranslationFiles(PassportElementError): one of ``"passport"``, ``"driver_license"``, ``"identity_card"``, ``"internal_passport"``, ``"utility_bill"``, ``"bank_statement"``, ``"rental_agreement"``, ``"passport_registration"``, ``"temporary_registration"``. + file_hashes (tuple[:obj:`str`]): List of base64-encoded file hashes. + + .. versionchanged:: 22.0 + |tupleclassattrs| message (:obj:`str`): Error message. """ - __slots__ = ("_file_hashes",) + __slots__ = ("file_hashes",) def __init__( self, type: str, - file_hashes: list[str], + file_hashes: Sequence[str], message: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__("translation_files", type, message, api_kwargs=api_kwargs) with self._unfrozen(): - self._file_hashes: list[str] = file_hashes - - self._id_attrs = (self.source, self.type, self.message, *tuple(file_hashes)) - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict` for details.""" - data = super().to_dict(recursive) - data["file_hashes"] = self._file_hashes - return data - - @property - def file_hashes(self) -> list[str]: - """List of base64-encoded file hashes. - - .. deprecated:: 20.6 - This attribute will return a tuple instead of a list in future major versions. - """ - warn( - PTBDeprecationWarning( - "20.6", - "The attribute `file_hashes` will return a tuple instead of a list in future major" - " versions. See the stability policy:" - " https://docs.python-telegram-bot.org/en/stable/stability_policy.html", - ), - stacklevel=2, - ) - return self._file_hashes + self.file_hashes: tuple[str, ...] = parse_sequence_arg(file_hashes) + + self._id_attrs = (self.source, self.type, self.message, self.file_hashes) class PassportElementErrorUnspecified(PassportElementError): @@ -464,7 +430,7 @@ class PassportElementErrorUnspecified(PassportElementError): __slots__ = ("element_hash",) def __init__( - self, type: str, element_hash: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, type: str, element_hash: str, message: str, *, api_kwargs: JSONDict | None = None ): # Required super().__init__("unspecified", type, message, api_kwargs=api_kwargs) diff --git a/telegram/_passport/passportfile.py b/src/telegram/_passport/passportfile.py similarity index 77% rename from telegram/_passport/passportfile.py rename to src/telegram/_passport/passportfile.py index c9516e7c5c3..724fb51ed1c 100644 --- a/telegram/_passport/passportfile.py +++ b/src/telegram/_passport/passportfile.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,13 +18,13 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Encrypted PassportFile.""" -from typing import TYPE_CHECKING, Optional +import datetime as dtm +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput -from telegram._utils.warnings import warn -from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import Bot, File, FileCredentials @@ -45,11 +45,11 @@ class PassportFile(TelegramObject): is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. file_size (:obj:`int`): File size in bytes. - file_date (:obj:`int`): Unix time when the file was uploaded. + file_date (:class:`datetime.datetime`): Time when the file was uploaded. - .. deprecated:: 20.6 - This argument will only accept a datetime instead of an integer in future - major versions. + .. versionchanged:: 22.0 + Accepts only :class:`datetime.datetime` instead of :obj:`int`. + |datetime_localization| Attributes: file_id (:obj:`str`): Identifier for this file, which can be used to download @@ -58,11 +58,16 @@ class PassportFile(TelegramObject): is supposed to be the same over time and for different bots. Can't be used to download or reuse the file. file_size (:obj:`int`): File size in bytes. + file_date (:class:`datetime.datetime`): Time when the file was uploaded. + + .. versionchanged:: 22.0 + Returns :class:`datetime.datetime` instead of :obj:`int`. + |datetime_localization| """ __slots__ = ( "_credentials", - "_file_date", + "file_date", "file_id", "file_size", "file_unique_id", @@ -72,11 +77,11 @@ def __init__( self, file_id: str, file_unique_id: str, - file_date: int, + file_date: dtm.datetime, file_size: int, - credentials: Optional["FileCredentials"] = None, + credentials: "FileCredentials | None" = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -84,42 +89,30 @@ def __init__( self.file_id: str = file_id self.file_unique_id: str = file_unique_id self.file_size: int = file_size - self._file_date: int = file_date + self.file_date: dtm.datetime = file_date # Optionals - self._credentials: Optional[FileCredentials] = credentials + self._credentials: FileCredentials | None = credentials self._id_attrs = (self.file_unique_id,) self._freeze() - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict` for details.""" - data = super().to_dict(recursive) - data["file_date"] = self._file_date - return data + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PassportFile": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) - @property - def file_date(self) -> int: - """:obj:`int`: Unix time when the file was uploaded. + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["file_date"] = from_timestamp(data.get("file_date"), tzinfo=loc_tzinfo) - .. deprecated:: 20.6 - This attribute will return a datetime instead of a integer in future major versions. - """ - warn( - PTBDeprecationWarning( - "20.6", - "The attribute `file_date` will return a datetime instead of an integer in future" - " major versions.", - ), - stacklevel=2, - ) - return self._file_date + return super().de_json(data=data, bot=bot) @classmethod def de_json_decrypted( - cls, data: Optional[JSONDict], bot: Optional["Bot"], credentials: "FileCredentials" - ) -> Optional["PassportFile"]: + cls, data: JSONDict, bot: "Bot | None", credentials: "FileCredentials" + ) -> "PassportFile": """Variant of :meth:`telegram.TelegramObject.de_json` that also takes into account passport credentials. @@ -141,9 +134,6 @@ def de_json_decrypted( """ data = cls._parse_data(data) - if not data: - return None - data["credentials"] = credentials return super().de_json(data=data, bot=bot) @@ -151,10 +141,10 @@ def de_json_decrypted( @classmethod def de_list_decrypted( cls, - data: Optional[list[JSONDict]], - bot: Optional["Bot"], + data: list[JSONDict], + bot: "Bot | None", credentials: list["FileCredentials"], - ) -> tuple[Optional["PassportFile"], ...]: + ) -> tuple["PassportFile", ...]: """Variant of :meth:`telegram.TelegramObject.de_list` that also takes into account passport credentials. @@ -179,16 +169,12 @@ def de_list_decrypted( tuple[:class:`telegram.PassportFile`]: """ - if not data: - return () - return tuple( obj for obj in ( cls.de_json_decrypted(passport_file, bot, credentials[i]) for i, passport_file in enumerate(data) ) - if obj is not None ) async def get_file( @@ -198,7 +184,7 @@ async def get_file( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "File": """ Wrapper over :meth:`telegram.Bot.get_file`. Will automatically assign the correct diff --git a/telegram/_payment/stars/__init__.py b/src/telegram/_payment/__init__.py similarity index 100% rename from telegram/_payment/stars/__init__.py rename to src/telegram/_payment/__init__.py diff --git a/telegram/_payment/invoice.py b/src/telegram/_payment/invoice.py similarity index 97% rename from telegram/_payment/invoice.py rename to src/telegram/_payment/invoice.py index b6ec5d9c440..be7d7df173a 100644 --- a/telegram/_payment/invoice.py +++ b/src/telegram/_payment/invoice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Invoice.""" -from typing import Final, Optional +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -78,7 +78,7 @@ def __init__( currency: str, total_amount: int, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.title: str = title diff --git a/telegram/_payment/labeledprice.py b/src/telegram/_payment/labeledprice.py similarity index 94% rename from telegram/_payment/labeledprice.py rename to src/telegram/_payment/labeledprice.py index 3aa7091fe03..a9526d4f069 100644 --- a/telegram/_payment/labeledprice.py +++ b/src/telegram/_payment/labeledprice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram LabeledPrice.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -55,7 +53,7 @@ class LabeledPrice(TelegramObject): __slots__ = ("amount", "label") - def __init__(self, label: str, amount: int, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, label: str, amount: int, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.label: str = label self.amount: int = amount diff --git a/telegram/_payment/orderinfo.py b/src/telegram/_payment/orderinfo.py similarity index 75% rename from telegram/_payment/orderinfo.py rename to src/telegram/_payment/orderinfo.py index ddd7aef1600..c3f8a798d80 100644 --- a/telegram/_payment/orderinfo.py +++ b/src/telegram/_payment/orderinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,10 +18,11 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram OrderInfo.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._payment.shippingaddress import ShippingAddress from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -53,33 +54,30 @@ class OrderInfo(TelegramObject): def __init__( self, - name: Optional[str] = None, - phone_number: Optional[str] = None, - email: Optional[str] = None, - shipping_address: Optional[ShippingAddress] = None, + name: str | None = None, + phone_number: str | None = None, + email: str | None = None, + shipping_address: ShippingAddress | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.name: Optional[str] = name - self.phone_number: Optional[str] = phone_number - self.email: Optional[str] = email - self.shipping_address: Optional[ShippingAddress] = shipping_address + self.name: str | None = name + self.phone_number: str | None = phone_number + self.email: str | None = email + self.shipping_address: ShippingAddress | None = shipping_address self._id_attrs = (self.name, self.phone_number, self.email, self.shipping_address) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["OrderInfo"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "OrderInfo": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return cls() - - data["shipping_address"] = ShippingAddress.de_json(data.get("shipping_address"), bot) + data["shipping_address"] = de_json_optional( + data.get("shipping_address"), ShippingAddress, bot + ) return super().de_json(data=data, bot=bot) diff --git a/telegram/_payment/precheckoutquery.py b/src/telegram/_payment/precheckoutquery.py similarity index 87% rename from telegram/_payment/precheckoutquery.py rename to src/telegram/_payment/precheckoutquery.py index 766f2a4b26c..94acae703cb 100644 --- a/telegram/_payment/precheckoutquery.py +++ b/src/telegram/_payment/precheckoutquery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,11 +18,12 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram PreCheckoutQuery.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._payment.orderinfo import OrderInfo from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput @@ -91,10 +92,10 @@ def __init__( currency: str, total_amount: int, invoice_payload: str, - shipping_option_id: Optional[str] = None, - order_info: Optional[OrderInfo] = None, + shipping_option_id: str | None = None, + order_info: OrderInfo | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.id: str = id @@ -102,38 +103,33 @@ def __init__( self.currency: str = currency self.total_amount: int = total_amount self.invoice_payload: str = invoice_payload - self.shipping_option_id: Optional[str] = shipping_option_id - self.order_info: Optional[OrderInfo] = order_info + self.shipping_option_id: str | None = shipping_option_id + self.order_info: OrderInfo | None = order_info self._id_attrs = (self.id,) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["PreCheckoutQuery"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PreCheckoutQuery": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["from_user"] = User.de_json(data.pop("from", None), bot) - data["order_info"] = OrderInfo.de_json(data.get("order_info"), bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) + data["order_info"] = de_json_optional(data.get("order_info"), OrderInfo, bot) return super().de_json(data=data, bot=bot) async def answer( self, ok: bool, - error_message: Optional[str] = None, + error_message: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: diff --git a/telegram/_payment/refundedpayment.py b/src/telegram/_payment/refundedpayment.py similarity index 94% rename from telegram/_payment/refundedpayment.py rename to src/telegram/_payment/refundedpayment.py index 46ef4e3faff..f29fe0face6 100644 --- a/telegram/_payment/refundedpayment.py +++ b/src/telegram/_payment/refundedpayment.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram RefundedPayment.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -76,9 +74,9 @@ def __init__( total_amount: int, invoice_payload: str, telegram_payment_charge_id: str, - provider_payment_charge_id: Optional[str] = None, + provider_payment_charge_id: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.currency: str = currency @@ -86,7 +84,7 @@ def __init__( self.invoice_payload: str = invoice_payload self.telegram_payment_charge_id: str = telegram_payment_charge_id # Optional - self.provider_payment_charge_id: Optional[str] = provider_payment_charge_id + self.provider_payment_charge_id: str | None = provider_payment_charge_id self._id_attrs = (self.telegram_payment_charge_id,) diff --git a/telegram/_payment/shippingaddress.py b/src/telegram/_payment/shippingaddress.py similarity index 96% rename from telegram/_payment/shippingaddress.py rename to src/telegram/_payment/shippingaddress.py index ed97e02972b..22e2f0c2860 100644 --- a/telegram/_payment/shippingaddress.py +++ b/src/telegram/_payment/shippingaddress.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ShippingAddress.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -67,7 +65,7 @@ def __init__( street_line2: str, post_code: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.country_code: str = country_code diff --git a/telegram/_payment/shippingoption.py b/src/telegram/_payment/shippingoption.py similarity index 95% rename from telegram/_payment/shippingoption.py rename to src/telegram/_payment/shippingoption.py index 341dbbe6c51..0b0a987a013 100644 --- a/telegram/_payment/shippingoption.py +++ b/src/telegram/_payment/shippingoption.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ShippingOption.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._utils.argumentparsing import parse_sequence_arg @@ -63,7 +64,7 @@ def __init__( title: str, prices: Sequence["LabeledPrice"], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) diff --git a/telegram/_payment/shippingquery.py b/src/telegram/_payment/shippingquery.py similarity index 86% rename from telegram/_payment/shippingquery.py rename to src/telegram/_payment/shippingquery.py index fadf88c0847..2b3f92b3a16 100644 --- a/telegram/_payment/shippingquery.py +++ b/src/telegram/_payment/shippingquery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,11 +19,12 @@ """This module contains an object that represents a Telegram ShippingQuery.""" from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._payment.shippingaddress import ShippingAddress from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput @@ -65,7 +66,7 @@ def __init__( invoice_payload: str, shipping_address: ShippingAddress, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.id: str = id @@ -78,31 +79,28 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ShippingQuery"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ShippingQuery": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["from_user"] = User.de_json(data.pop("from", None), bot) - data["shipping_address"] = ShippingAddress.de_json(data.get("shipping_address"), bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) + data["shipping_address"] = de_json_optional( + data.get("shipping_address"), ShippingAddress, bot + ) return super().de_json(data=data, bot=bot) async def answer( self, ok: bool, - shipping_options: Optional[Sequence["ShippingOption"]] = None, - error_message: Optional[str] = None, + shipping_options: Sequence["ShippingOption"] | None = None, + error_message: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: diff --git a/telegram/_utils/__init__.py b/src/telegram/_payment/stars/__init__.py similarity index 100% rename from telegram/_utils/__init__.py rename to src/telegram/_payment/stars/__init__.py diff --git a/telegram/_payment/stars/affiliateinfo.py b/src/telegram/_payment/stars/affiliateinfo.py similarity index 75% rename from telegram/_payment/stars/affiliateinfo.py rename to src/telegram/_payment/stars/affiliateinfo.py index 9026853de7a..5b178f74483 100644 --- a/telegram/_payment/stars/affiliateinfo.py +++ b/src/telegram/_payment/stars/affiliateinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,13 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes for Telegram Stars affiliates.""" -from typing import TYPE_CHECKING, Optional + +from typing import TYPE_CHECKING from telegram._chat import Chat from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -47,10 +49,10 @@ class AffiliateInfo(TelegramObject): amount (:obj:`int`): Integer amount of Telegram Stars received by the affiliate from the transaction, rounded to 0; can be negative for refunds nanostar_amount (:obj:`int`, optional): The number of - :tg-const:`~telegram.constants.StarTransactions.NANOSTAR_VALUE` shares of Telegram + :tg-const:`~telegram.constants.Nanostar.VALUE` shares of Telegram Stars received by the affiliate; from - :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MIN_AMOUNT` to - :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT`; + :tg-const:`~telegram.constants.NanostarLimit.MIN_AMOUNT` to + :tg-const:`~telegram.constants.NanostarLimit.MAX_AMOUNT`; can be negative for refunds Attributes: @@ -63,10 +65,10 @@ class AffiliateInfo(TelegramObject): amount (:obj:`int`): Integer amount of Telegram Stars received by the affiliate from the transaction, rounded to 0; can be negative for refunds nanostar_amount (:obj:`int`): Optional. The number of - :tg-const:`~telegram.constants.StarTransactions.NANOSTAR_VALUE` shares of Telegram + :tg-const:`~telegram.constants.Nanostar.VALUE` shares of Telegram Stars received by the affiliate; from - :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MIN_AMOUNT` to - :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT`; + :tg-const:`~telegram.constants.NanostarLimit.MIN_AMOUNT` to + :tg-const:`~telegram.constants.NanostarLimit.MAX_AMOUNT`; can be negative for refunds """ @@ -82,18 +84,18 @@ def __init__( self, commission_per_mille: int, amount: int, - affiliate_user: Optional["User"] = None, - affiliate_chat: Optional["Chat"] = None, - nanostar_amount: Optional[int] = None, + affiliate_user: "User | None" = None, + affiliate_chat: "Chat | None" = None, + nanostar_amount: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) - self.affiliate_user: Optional[User] = affiliate_user - self.affiliate_chat: Optional[Chat] = affiliate_chat + self.affiliate_user: User | None = affiliate_user + self.affiliate_chat: Chat | None = affiliate_chat self.commission_per_mille: int = commission_per_mille self.amount: int = amount - self.nanostar_amount: Optional[int] = nanostar_amount + self.nanostar_amount: int | None = nanostar_amount self._id_attrs = ( self.affiliate_user, @@ -105,16 +107,11 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["AffiliateInfo"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "AffiliateInfo": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["affiliate_user"] = User.de_json(data.get("affiliate_user"), bot) - data["affiliate_chat"] = Chat.de_json(data.get("affiliate_chat"), bot) + data["affiliate_user"] = de_json_optional(data.get("affiliate_user"), User, bot) + data["affiliate_chat"] = de_json_optional(data.get("affiliate_chat"), Chat, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_payment/stars/revenuewithdrawalstate.py b/src/telegram/_payment/stars/revenuewithdrawalstate.py similarity index 88% rename from telegram/_payment/stars/revenuewithdrawalstate.py rename to src/telegram/_payment/stars/revenuewithdrawalstate.py index be75ae4c6c0..beb0e7de74f 100644 --- a/telegram/_payment/stars/revenuewithdrawalstate.py +++ b/src/telegram/_payment/stars/revenuewithdrawalstate.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,9 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. # pylint: disable=redefined-builtin """This module contains the classes for Telegram Stars Revenue Withdrawals.""" + import datetime as dtm -from typing import TYPE_CHECKING, Final, Optional +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -60,7 +61,7 @@ class RevenueWithdrawalState(TelegramObject): FAILED: Final[str] = constants.RevenueWithdrawalStateType.FAILED """:const:`telegram.constants.RevenueWithdrawalStateType.FAILED`""" - def __init__(self, type: str, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, type: str, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(api_kwargs=api_kwargs) self.type: str = enum.get_member(constants.RevenueWithdrawalStateType, type, type) @@ -68,9 +69,7 @@ def __init__(self, type: str, *, api_kwargs: Optional[JSONDict] = None) -> None: self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["RevenueWithdrawalState"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "RevenueWithdrawalState": """Converts JSON data to the appropriate :class:`RevenueWithdrawalState` object, i.e. takes care of selecting the correct subclass. @@ -84,9 +83,6 @@ def de_json( """ data = cls._parse_data(data) - if (cls is RevenueWithdrawalState and not data) or data is None: - return None - _class_mapping: dict[str, type[RevenueWithdrawalState]] = { cls.PENDING: RevenueWithdrawalStatePending, cls.SUCCEEDED: RevenueWithdrawalStateSucceeded, @@ -111,7 +107,7 @@ class RevenueWithdrawalStatePending(RevenueWithdrawalState): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(type=RevenueWithdrawalState.PENDING, api_kwargs=api_kwargs) self._freeze() @@ -142,7 +138,7 @@ def __init__( date: dtm.datetime, url: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(type=RevenueWithdrawalState.SUCCEEDED, api_kwargs=api_kwargs) @@ -156,14 +152,11 @@ def __init__( @classmethod def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["RevenueWithdrawalStateSucceeded"]: + cls, data: JSONDict, bot: "Bot | None" = None + ) -> "RevenueWithdrawalStateSucceeded": """See :meth:`telegram.RevenueWithdrawalState.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) data["date"] = from_timestamp(data.get("date", None), tzinfo=loc_tzinfo) @@ -183,6 +176,6 @@ class RevenueWithdrawalStateFailed(RevenueWithdrawalState): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(type=RevenueWithdrawalState.FAILED, api_kwargs=api_kwargs) self._freeze() diff --git a/src/telegram/_payment/stars/staramount.py b/src/telegram/_payment/stars/staramount.py new file mode 100644 index 00000000000..38765d8c8a9 --- /dev/null +++ b/src/telegram/_payment/stars/staramount.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram StarAmount.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class StarAmount(TelegramObject): + """Describes an amount of Telegram Stars. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`amount` and :attr:`nanostar_amount` are equal. + + Args: + amount (:obj:`int`): Integer amount of Telegram Stars, rounded to ``0``; can be negative. + nanostar_amount (:obj:`int`, optional): The number of + :tg-const:`telegram.constants.Nanostar.VALUE` shares of Telegram + Stars; from :tg-const:`telegram.constants.NanostarLimit.MIN_AMOUNT` + to :tg-const:`telegram.constants.NanostarLimit.MAX_AMOUNT`; can be + negative if and only if :attr:`amount` is non-positive. + + Attributes: + amount (:obj:`int`): Integer amount of Telegram Stars, rounded to ``0``; can be negative. + nanostar_amount (:obj:`int`): Optional. The number of + :tg-const:`telegram.constants.Nanostar.VALUE` shares of Telegram + Stars; from :tg-const:`telegram.constants.NanostarLimit.MIN_AMOUNT` + to :tg-const:`telegram.constants.NanostarLimit.MAX_AMOUNT`; can be + negative if and only if :attr:`amount` is non-positive. + + """ + + __slots__ = ("amount", "nanostar_amount") + + def __init__( + self, + amount: int, + nanostar_amount: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.amount: int = amount + self.nanostar_amount: int | None = nanostar_amount + + self._id_attrs = (self.amount, self.nanostar_amount) + + self._freeze() diff --git a/telegram/_payment/stars/startransactions.py b/src/telegram/_payment/stars/startransactions.py similarity index 78% rename from telegram/_payment/stars/startransactions.py rename to src/telegram/_payment/stars/startransactions.py index f53a0d1a660..b8502fd8fd7 100644 --- a/telegram/_payment/stars/startransactions.py +++ b/src/telegram/_payment/stars/startransactions.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -21,10 +21,10 @@ import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict @@ -36,6 +36,9 @@ class StarTransaction(TelegramObject): """Describes a Telegram Star transaction. + Note that if the buyer initiates a chargeback with the payment provider from whom they + acquired Stars (e.g., Apple, Google) following this transaction, the refunded Stars will be + deducted from the bot's balance. This is outside of Telegram's control. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`id`, :attr:`source`, and :attr:`receiver` are equal. @@ -49,9 +52,9 @@ class StarTransaction(TelegramObject): successful incoming payments from users. amount (:obj:`int`): Integer amount of Telegram Stars transferred by the transaction. nanostar_amount (:obj:`int`, optional): The number of - :tg-const:`~telegram.constants.StarTransactions.NANOSTAR_VALUE` shares of Telegram + :tg-const:`~telegram.constants.Nanostar.VALUE` shares of Telegram Stars transferred by the transaction; from 0 to - :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT` + :tg-const:`~telegram.constants.NanostarLimit.MAX_AMOUNT` .. versionadded:: 21.9 date (:obj:`datetime.datetime`): Date the transaction was created as a datetime object. @@ -69,9 +72,9 @@ class StarTransaction(TelegramObject): successful incoming payments from users. amount (:obj:`int`): Integer amount of Telegram Stars transferred by the transaction. nanostar_amount (:obj:`int`): Optional. The number of - :tg-const:`~telegram.constants.StarTransactions.NANOSTAR_VALUE` shares of Telegram + :tg-const:`~telegram.constants.Nanostar.VALUE` shares of Telegram Stars transferred by the transaction; from 0 to - :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT` + :tg-const:`~telegram.constants.NanostarLimit.MAX_AMOUNT` .. versionadded:: 21.9 date (:obj:`datetime.datetime`): Date the transaction was created as a datetime object. @@ -90,19 +93,19 @@ def __init__( id: str, amount: int, date: dtm.datetime, - source: Optional[TransactionPartner] = None, - receiver: Optional[TransactionPartner] = None, - nanostar_amount: Optional[int] = None, + source: TransactionPartner | None = None, + receiver: TransactionPartner | None = None, + nanostar_amount: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) self.id: str = id self.amount: int = amount self.date: dtm.datetime = date - self.source: Optional[TransactionPartner] = source - self.receiver: Optional[TransactionPartner] = receiver - self.nanostar_amount: Optional[int] = nanostar_amount + self.source: TransactionPartner | None = source + self.receiver: TransactionPartner | None = receiver + self.nanostar_amount: int | None = nanostar_amount self._id_attrs = ( self.id, @@ -112,21 +115,16 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["StarTransaction"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "StarTransaction": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) data["date"] = from_timestamp(data.get("date", None), tzinfo=loc_tzinfo) - data["source"] = TransactionPartner.de_json(data.get("source"), bot) - data["receiver"] = TransactionPartner.de_json(data.get("receiver"), bot) + data["source"] = de_json_optional(data.get("source"), TransactionPartner, bot) + data["receiver"] = de_json_optional(data.get("receiver"), TransactionPartner, bot) return super().de_json(data=data, bot=bot) @@ -150,7 +148,7 @@ class StarTransactions(TelegramObject): __slots__ = ("transactions",) def __init__( - self, transactions: Sequence[StarTransaction], *, api_kwargs: Optional[JSONDict] = None + self, transactions: Sequence[StarTransaction], *, api_kwargs: JSONDict | None = None ): super().__init__(api_kwargs=api_kwargs) self.transactions: tuple[StarTransaction, ...] = parse_sequence_arg(transactions) @@ -159,14 +157,9 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["StarTransactions"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "StarTransactions": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if data is None: - return None - - data["transactions"] = StarTransaction.de_list(data.get("transactions"), bot) + data["transactions"] = de_list_optional(data.get("transactions"), StarTransaction, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_payment/stars/transactionpartner.py b/src/telegram/_payment/stars/transactionpartner.py similarity index 54% rename from telegram/_payment/stars/transactionpartner.py rename to src/telegram/_payment/stars/transactionpartner.py index 7c82bf0c0f3..09d96c159da 100644 --- a/telegram/_payment/stars/transactionpartner.py +++ b/src/telegram/_payment/stars/transactionpartner.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,23 +18,31 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. # pylint: disable=redefined-builtin """This module contains the classes for Telegram Stars transaction partners.""" -import datetime as dtm + from collections.abc import Sequence -from typing import TYPE_CHECKING, Final, Optional +from typing import TYPE_CHECKING, Final from telegram import constants +from telegram._chat import Chat from telegram._gifts import Gift from telegram._paidmedia import PaidMedia from telegram._telegramobject import TelegramObject from telegram._user import User from telegram._utils import enum -from telegram._utils.argumentparsing import parse_sequence_arg -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_sequence_arg, + to_timedelta, +) +from telegram._utils.types import JSONDict, TimePeriod from .affiliateinfo import AffiliateInfo from .revenuewithdrawalstate import RevenueWithdrawalState if TYPE_CHECKING: + import datetime as dtm + from telegram import Bot @@ -43,6 +51,7 @@ class TransactionPartner(TelegramObject): transactions. Currently, it can be one of: * :class:`TransactionPartnerUser` + * :class:`TransactionPartnerChat` * :class:`TransactionPartnerAffiliateProgram` * :class:`TransactionPartnerFragment` * :class:`TransactionPartnerTelegramAds` @@ -54,6 +63,9 @@ class TransactionPartner(TelegramObject): .. versionadded:: 21.4 + .. versionchanged:: 21.11 + Added :class:`TransactionPartnerChat` + Args: type (:obj:`str`): The type of the transaction partner. @@ -68,6 +80,11 @@ class TransactionPartner(TelegramObject): .. versionadded:: 21.9 """ + CHAT: Final[str] = constants.TransactionPartnerType.CHAT + """:const:`telegram.constants.TransactionPartnerType.CHAT` + + .. versionadded:: 21.11 + """ FRAGMENT: Final[str] = constants.TransactionPartnerType.FRAGMENT """:const:`telegram.constants.TransactionPartnerType.FRAGMENT`""" OTHER: Final[str] = constants.TransactionPartnerType.OTHER @@ -79,7 +96,7 @@ class TransactionPartner(TelegramObject): USER: Final[str] = constants.TransactionPartnerType.USER """:const:`telegram.constants.TransactionPartnerType.USER`""" - def __init__(self, type: str, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, type: str, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(api_kwargs=api_kwargs) self.type: str = enum.get_member(constants.TransactionPartnerType, type, type) @@ -87,9 +104,7 @@ def __init__(self, type: str, *, api_kwargs: Optional[JSONDict] = None) -> None: self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["TransactionPartner"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "TransactionPartner": """Converts JSON data to the appropriate :class:`TransactionPartner` object, i.e. takes care of selecting the correct subclass. @@ -103,11 +118,9 @@ def de_json( """ data = cls._parse_data(data) - if (cls is TransactionPartner and not data) or data is None: - return None - _class_mapping: dict[str, type[TransactionPartner]] = { cls.AFFILIATE_PROGRAM: TransactionPartnerAffiliateProgram, + cls.CHAT: TransactionPartnerChat, cls.FRAGMENT: TransactionPartnerFragment, cls.USER: TransactionPartnerUser, cls.TELEGRAM_ADS: TransactionPartnerTelegramAds, @@ -150,14 +163,14 @@ class TransactionPartnerAffiliateProgram(TransactionPartner): def __init__( self, commission_per_mille: int, - sponsor_user: Optional["User"] = None, + sponsor_user: "User | None" = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(type=TransactionPartner.AFFILIATE_PROGRAM, api_kwargs=api_kwargs) with self._unfrozen(): - self.sponsor_user: Optional[User] = sponsor_user + self.sponsor_user: User | None = sponsor_user self.commission_per_mille: int = commission_per_mille self._id_attrs = ( self.type, @@ -166,15 +179,66 @@ def __init__( @classmethod def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["TransactionPartnerAffiliateProgram"]: + cls, data: JSONDict, bot: "Bot | None" = None + ) -> "TransactionPartnerAffiliateProgram": """See :meth:`telegram.TransactionPartner.de_json`.""" data = cls._parse_data(data) - if not data: - return None + data["sponsor_user"] = de_json_optional(data.get("sponsor_user"), User, bot) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + +class TransactionPartnerChat(TransactionPartner): + """Describes a transaction with a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`chat` are equal. - data["sponsor_user"] = User.de_json(data.get("sponsor_user"), bot) + .. versionadded:: 21.11 + + Args: + chat (:class:`telegram.Chat`): Information about the chat. + gift (:class:`telegram.Gift`, optional): The gift sent to the chat by the bot. + + Attributes: + type (:obj:`str`): The type of the transaction partner, + always :tg-const:`telegram.TransactionPartner.CHAT`. + chat (:class:`telegram.Chat`): Information about the chat. + gift (:class:`telegram.Gift`): Optional. The gift sent to the user by the bot. + + """ + + __slots__ = ( + "chat", + "gift", + ) + + def __init__( + self, + chat: Chat, + gift: Gift | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=TransactionPartner.CHAT, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.chat: Chat = chat + self.gift: Gift | None = gift + + self._id_attrs = ( + self.type, + self.chat, + ) + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "TransactionPartnerChat": + """See :meth:`telegram.TransactionPartner.de_json`.""" + data = cls._parse_data(data) + + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["gift"] = de_json_optional(data.get("gift"), Gift, bot) return super().de_json(data=data, bot=bot) # type: ignore[return-value] @@ -199,27 +263,22 @@ class TransactionPartnerFragment(TransactionPartner): def __init__( self, - withdrawal_state: Optional["RevenueWithdrawalState"] = None, + withdrawal_state: "RevenueWithdrawalState | None" = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(type=TransactionPartner.FRAGMENT, api_kwargs=api_kwargs) with self._unfrozen(): - self.withdrawal_state: Optional[RevenueWithdrawalState] = withdrawal_state + self.withdrawal_state: RevenueWithdrawalState | None = withdrawal_state @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["TransactionPartnerFragment"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "TransactionPartnerFragment": """See :meth:`telegram.TransactionPartner.de_json`.""" data = cls._parse_data(data) - if data is None: - return None - - data["withdrawal_state"] = RevenueWithdrawalState.de_json( - data.get("withdrawal_state"), bot + data["withdrawal_state"] = de_json_optional( + data.get("withdrawal_state"), RevenueWithdrawalState, bot ) return super().de_json(data=data, bot=bot) # type: ignore[return-value] @@ -229,55 +288,118 @@ class TransactionPartnerUser(TransactionPartner): """Describes a transaction with a user. Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`user` are equal. + considered equal, if their :attr:`user` and :attr:`transaction_type` are equal. .. versionadded:: 21.4 + .. versionchanged:: 22.1 + Equality comparison now includes the new required argument :paramref:`transaction_type`, + introduced in Bot API 9.0. + Args: + transaction_type (:obj:`str`): Type of the transaction, currently one of + :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` for payments via + invoices, :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` + for payments for paid media, + :tg-const:`telegram.constants.TransactionPartnerUser.GIFT_PURCHASE` for gifts sent by + the bot, :tg-const:`telegram.constants.TransactionPartnerUser.PREMIUM_PURCHASE` + for Telegram Premium subscriptions gifted by the bot, + :tg-const:`telegram.constants.TransactionPartnerUser.BUSINESS_ACCOUNT_TRANSFER` for + direct transfers from managed business accounts. + + .. versionadded:: 22.1 user (:class:`telegram.User`): Information about the user. affiliate (:class:`telegram.AffiliateInfo`, optional): Information about the affiliate that - received a commission via this transaction + received a commission via this transaction. Can be available only for + :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` + and :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` + transactions. .. versionadded:: 21.9 - invoice_payload (:obj:`str`, optional): Bot-specified invoice payload. - subscription_period (:class:`datetime.timedelta`, optional): The duration of the paid - subscription + invoice_payload (:obj:`str`, optional): Bot-specified invoice payload. Can be available + only for :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` + transactions. + subscription_period (:obj:`int` | :class:`datetime.timedelta`, optional): The duration of + the paid subscription. Can be available only for + :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` transactions. .. versionadded:: 21.8 + + .. versionchanged:: v22.2 + Accepts :obj:`int` objects as well as :class:`datetime.timedelta`. paid_media (Sequence[:class:`telegram.PaidMedia`], optional): Information about the paid - media bought by the user. + media bought by the user. for + :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` + transactions only. .. versionadded:: 21.5 - paid_media_payload (:obj:`str`, optional): Bot-specified paid media payload. + paid_media_payload (:obj:`str`, optional): Bot-specified paid media payload. Can be + available only for + :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` transactions. .. versionadded:: 21.6 - gift (:class:`telegram.Gift`, optional): The gift sent to the user by the bot + gift (:class:`telegram.Gift`, optional): The gift sent to the user by the bot; for + :tg-const:`telegram.constants.TransactionPartnerUser.GIFT_PURCHASE` transactions only. .. versionadded:: 21.8 + premium_subscription_duration (:obj:`int`, optional): Number of months the gifted Telegram + Premium subscription will be active for; for + :tg-const:`telegram.constants.TransactionPartnerUser.PREMIUM_PURCHASE` + transactions only. + + .. versionadded:: 22.1 Attributes: type (:obj:`str`): The type of the transaction partner, always :tg-const:`telegram.TransactionPartner.USER`. + transaction_type (:obj:`str`): Type of the transaction, currently one of + :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` for payments via + invoices, :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` + for payments for paid media, + :tg-const:`telegram.constants.TransactionPartnerUser.GIFT_PURCHASE` for gifts sent by + the bot, :tg-const:`telegram.constants.TransactionPartnerUser.PREMIUM_PURCHASE` + for Telegram Premium subscriptions gifted by the bot, + :tg-const:`telegram.constants.TransactionPartnerUser.BUSINESS_ACCOUNT_TRANSFER` for + direct transfers from managed business accounts. + + .. versionadded:: 22.1 user (:class:`telegram.User`): Information about the user. affiliate (:class:`telegram.AffiliateInfo`): Optional. Information about the affiliate that - received a commission via this transaction + received a commission via this transaction. Can be available only for + :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` + and :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` + transactions. .. versionadded:: 21.9 - invoice_payload (:obj:`str`): Optional. Bot-specified invoice payload. + invoice_payload (:obj:`str`): Optional. Bot-specified invoice payload. Can be available + only for :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` + transactions. subscription_period (:class:`datetime.timedelta`): Optional. The duration of the paid - subscription + subscription. Can be available only for + :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` transactions. .. versionadded:: 21.8 paid_media (tuple[:class:`telegram.PaidMedia`]): Optional. Information about the paid - media bought by the user. + media bought by the user. for + :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` + transactions only. .. versionadded:: 21.5 - paid_media_payload (:obj:`str`): Optional. Bot-specified paid media payload. + paid_media_payload (:obj:`str`): Optional. Bot-specified paid media payload. Can be + available only for + :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` transactions. .. versionadded:: 21.6 - gift (:class:`telegram.Gift`): Optional. The gift sent to the user by the bot + gift (:class:`telegram.Gift`): Optional. The gift sent to the user by the bot; for + :tg-const:`telegram.constants.TransactionPartnerUser.GIFT_PURCHASE` transactions only. .. versionadded:: 21.8 + premium_subscription_duration (:obj:`int`): Optional. Number of months the gifted Telegram + Premium subscription will be active for; for + :tg-const:`telegram.constants.TransactionPartnerUser.PREMIUM_PURCHASE` + transactions only. + + .. versionadded:: 22.1 """ @@ -287,57 +409,54 @@ class TransactionPartnerUser(TransactionPartner): "invoice_payload", "paid_media", "paid_media_payload", + "premium_subscription_duration", "subscription_period", + "transaction_type", "user", ) def __init__( self, + transaction_type: str, user: "User", - invoice_payload: Optional[str] = None, - paid_media: Optional[Sequence[PaidMedia]] = None, - paid_media_payload: Optional[str] = None, - subscription_period: Optional[dtm.timedelta] = None, - gift: Optional[Gift] = None, - affiliate: Optional[AffiliateInfo] = None, + invoice_payload: str | None = None, + paid_media: Sequence[PaidMedia] | None = None, + paid_media_payload: str | None = None, + subscription_period: TimePeriod | None = None, + gift: Gift | None = None, + affiliate: AffiliateInfo | None = None, + premium_subscription_duration: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(type=TransactionPartner.USER, api_kwargs=api_kwargs) with self._unfrozen(): self.user: User = user - self.affiliate: Optional[AffiliateInfo] = affiliate - self.invoice_payload: Optional[str] = invoice_payload - self.paid_media: Optional[tuple[PaidMedia, ...]] = parse_sequence_arg(paid_media) - self.paid_media_payload: Optional[str] = paid_media_payload - self.subscription_period: Optional[dtm.timedelta] = subscription_period - self.gift: Optional[Gift] = gift + self.affiliate: AffiliateInfo | None = affiliate + self.invoice_payload: str | None = invoice_payload + self.paid_media: tuple[PaidMedia, ...] | None = parse_sequence_arg(paid_media) + self.paid_media_payload: str | None = paid_media_payload + self.subscription_period: dtm.timedelta | None = to_timedelta(subscription_period) + self.gift: Gift | None = gift + self.premium_subscription_duration: int | None = premium_subscription_duration + self.transaction_type: str = transaction_type self._id_attrs = ( self.type, self.user, + self.transaction_type, ) @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["TransactionPartnerUser"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "TransactionPartnerUser": """See :meth:`telegram.TransactionPartner.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["user"] = User.de_json(data.get("user"), bot) - data["affiliate"] = AffiliateInfo.de_json(data.get("affiliate"), bot) - data["paid_media"] = PaidMedia.de_list(data.get("paid_media"), bot=bot) - data["subscription_period"] = ( - dtm.timedelta(seconds=sp) - if (sp := data.get("subscription_period")) is not None - else None - ) - data["gift"] = Gift.de_json(data.get("gift"), bot) + data["user"] = de_json_optional(data.get("user"), User, bot) + data["affiliate"] = de_json_optional(data.get("affiliate"), AffiliateInfo, bot) + data["paid_media"] = de_list_optional(data.get("paid_media"), PaidMedia, bot) + data["gift"] = de_json_optional(data.get("gift"), Gift, bot) return super().de_json(data=data, bot=bot) # type: ignore[return-value] @@ -354,7 +473,7 @@ class TransactionPartnerOther(TransactionPartner): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(type=TransactionPartner.OTHER, api_kwargs=api_kwargs) self._freeze() @@ -371,7 +490,7 @@ class TransactionPartnerTelegramAds(TransactionPartner): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(type=TransactionPartner.TELEGRAM_ADS, api_kwargs=api_kwargs) self._freeze() @@ -398,7 +517,7 @@ class TransactionPartnerTelegramApi(TransactionPartner): __slots__ = ("request_count",) - def __init__(self, request_count: int, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, request_count: int, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(type=TransactionPartner.TELEGRAM_API, api_kwargs=api_kwargs) with self._unfrozen(): self.request_count: int = request_count diff --git a/telegram/_payment/successfulpayment.py b/src/telegram/_payment/successfulpayment.py similarity index 84% rename from telegram/_payment/successfulpayment.py rename to src/telegram/_payment/successfulpayment.py index 0642e302c21..7fb284814c6 100644 --- a/telegram/_payment/successfulpayment.py +++ b/src/telegram/_payment/successfulpayment.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,10 +19,11 @@ """This module contains an object that represents a Telegram SuccessfulPayment.""" import datetime as dtm -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._payment.orderinfo import OrderInfo from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict @@ -32,6 +33,9 @@ class SuccessfulPayment(TelegramObject): """This object contains basic information about a successful payment. + Note that if the buyer initiates a chargeback with the relevant payment provider following + this transaction, the funds may be debited from your balance. This is outside of + Telegram's control. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`telegram_payment_charge_id` and @@ -113,41 +117,36 @@ def __init__( invoice_payload: str, telegram_payment_charge_id: str, provider_payment_charge_id: str, - shipping_option_id: Optional[str] = None, - order_info: Optional[OrderInfo] = None, - subscription_expiration_date: Optional[dtm.datetime] = None, - is_recurring: Optional[bool] = None, - is_first_recurring: Optional[bool] = None, + shipping_option_id: str | None = None, + order_info: OrderInfo | None = None, + subscription_expiration_date: dtm.datetime | None = None, + is_recurring: bool | None = None, + is_first_recurring: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.currency: str = currency self.total_amount: int = total_amount self.invoice_payload: str = invoice_payload - self.shipping_option_id: Optional[str] = shipping_option_id - self.order_info: Optional[OrderInfo] = order_info + self.shipping_option_id: str | None = shipping_option_id + self.order_info: OrderInfo | None = order_info self.telegram_payment_charge_id: str = telegram_payment_charge_id self.provider_payment_charge_id: str = provider_payment_charge_id - self.subscription_expiration_date: Optional[dtm.datetime] = subscription_expiration_date - self.is_recurring: Optional[bool] = is_recurring - self.is_first_recurring: Optional[bool] = is_first_recurring + self.subscription_expiration_date: dtm.datetime | None = subscription_expiration_date + self.is_recurring: bool | None = is_recurring + self.is_first_recurring: bool | None = is_first_recurring self._id_attrs = (self.telegram_payment_charge_id, self.provider_payment_charge_id) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["SuccessfulPayment"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "SuccessfulPayment": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["order_info"] = OrderInfo.de_json(data.get("order_info"), bot) + data["order_info"] = de_json_optional(data.get("order_info"), OrderInfo, bot) # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) diff --git a/telegram/_poll.py b/src/telegram/_poll.py similarity index 87% rename from telegram/_poll.py rename to src/telegram/_poll.py index f26ac8476e6..744edd22eff 100644 --- a/telegram/_poll.py +++ b/src/telegram/_poll.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,9 +17,10 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Poll.""" + import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Final, Optional +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._chat import Chat @@ -27,11 +28,20 @@ from telegram._telegramobject import TelegramObject from telegram._user import User from telegram._utils import enum -from telegram._utils.argumentparsing import parse_sequence_arg -from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_sequence_arg, + to_timedelta, +) +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_timedelta_value, +) from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.entities import parse_message_entities, parse_message_entity -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod if TYPE_CHECKING: from telegram import Bot @@ -77,9 +87,9 @@ def __init__( self, text: str, text_parse_mode: ODVInput[str] = DEFAULT_NONE, - text_entities: Optional[Sequence[MessageEntity]] = None, + text_entities: Sequence[MessageEntity] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.text: str = text @@ -91,16 +101,11 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["InputPollOption"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InputPollOption": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["text_entities"] = MessageEntity.de_list(data.get("text_entities"), bot) + data["text_entities"] = de_list_optional(data.get("text_entities"), MessageEntity, bot) return super().de_json(data=data, bot=bot) @@ -143,9 +148,9 @@ def __init__( self, text: str, voter_count: int, - text_entities: Optional[Sequence[MessageEntity]] = None, + text_entities: Sequence[MessageEntity] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.text: str = text @@ -157,16 +162,11 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["PollOption"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PollOption": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["text_entities"] = MessageEntity.de_list(data.get("text_entities"), bot) + data["text_entities"] = de_list_optional(data.get("text_entities"), MessageEntity, bot) return super().de_json(data=data, bot=bot) @@ -190,7 +190,7 @@ def parse_entity(self, entity: MessageEntity) -> str: """ return parse_message_entity(self.text, entity) - def parse_entities(self, types: Optional[list[str]] = None) -> dict[MessageEntity, str]: + def parse_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: """ Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. It contains entities from this polls question filtered by their ``type`` attribute as @@ -285,16 +285,16 @@ def __init__( self, poll_id: str, option_ids: Sequence[int], - user: Optional[User] = None, - voter_chat: Optional[Chat] = None, + user: User | None = None, + voter_chat: Chat | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.poll_id: str = poll_id - self.voter_chat: Optional[Chat] = voter_chat + self.voter_chat: Chat | None = voter_chat self.option_ids: tuple[int, ...] = parse_sequence_arg(option_ids) - self.user: Optional[User] = user + self.user: User | None = user self._id_attrs = ( self.poll_id, @@ -306,17 +306,12 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["PollAnswer"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PollAnswer": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["user"] = User.de_json(data.get("user"), bot) - data["voter_chat"] = Chat.de_json(data.get("voter_chat"), bot) + data["user"] = de_json_optional(data.get("user"), User, bot) + data["voter_chat"] = de_json_optional(data.get("voter_chat"), Chat, bot) return super().de_json(data=data, bot=bot) @@ -358,8 +353,11 @@ class Poll(TelegramObject): * This attribute is now always a (possibly empty) list and never :obj:`None`. * |sequenceclassargs| - open_period (:obj:`int`, optional): Amount of time in seconds the poll will be active - after creation. + open_period (:obj:`int` | :class:`datetime.timedelta`, optional): Amount of time in seconds + the poll will be active after creation. + + .. versionchanged:: v22.2 + |time-period-input| close_date (:obj:`datetime.datetime`, optional): Point in time (Unix timestamp) when the poll will be automatically closed. Converted to :obj:`datetime.datetime`. @@ -399,8 +397,11 @@ class Poll(TelegramObject): .. versionchanged:: 20.0 This attribute is now always a (possibly empty) list and never :obj:`None`. - open_period (:obj:`int`): Optional. Amount of time in seconds the poll will be active - after creation. + open_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Amount of time in seconds + the poll will be active after creation. + + .. deprecated:: v22.2 + |time-period-int-deprecated| close_date (:obj:`datetime.datetime`): Optional. Point in time when the poll will be automatically closed. @@ -416,6 +417,7 @@ class Poll(TelegramObject): """ __slots__ = ( + "_open_period", "allows_multiple_answers", "close_date", "correct_option_id", @@ -424,7 +426,6 @@ class Poll(TelegramObject): "id", "is_anonymous", "is_closed", - "open_period", "options", "question", "question_entities", @@ -442,14 +443,14 @@ def __init__( is_anonymous: bool, type: str, # pylint: disable=redefined-builtin allows_multiple_answers: bool, - correct_option_id: Optional[int] = None, - explanation: Optional[str] = None, - explanation_entities: Optional[Sequence[MessageEntity]] = None, - open_period: Optional[int] = None, - close_date: Optional[dtm.datetime] = None, - question_entities: Optional[Sequence[MessageEntity]] = None, + correct_option_id: int | None = None, + explanation: str | None = None, + explanation_entities: Sequence[MessageEntity] | None = None, + open_period: TimePeriod | None = None, + close_date: dtm.datetime | None = None, + question_entities: Sequence[MessageEntity] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.id: str = id @@ -460,34 +461,39 @@ def __init__( self.is_anonymous: bool = is_anonymous self.type: str = enum.get_member(constants.PollType, type, type) self.allows_multiple_answers: bool = allows_multiple_answers - self.correct_option_id: Optional[int] = correct_option_id - self.explanation: Optional[str] = explanation + self.correct_option_id: int | None = correct_option_id + self.explanation: str | None = explanation self.explanation_entities: tuple[MessageEntity, ...] = parse_sequence_arg( explanation_entities ) - self.open_period: Optional[int] = open_period - self.close_date: Optional[dtm.datetime] = close_date + self._open_period: dtm.timedelta | None = to_timedelta(open_period) + self.close_date: dtm.datetime | None = close_date self.question_entities: tuple[MessageEntity, ...] = parse_sequence_arg(question_entities) self._id_attrs = (self.id,) self._freeze() + @property + def open_period(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._open_period, attribute="open_period") + @classmethod - def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Poll"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Poll": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) - data["options"] = [PollOption.de_json(option, bot) for option in data["options"]] - data["explanation_entities"] = MessageEntity.de_list(data.get("explanation_entities"), bot) + data["options"] = de_list_optional(data.get("options"), PollOption, bot) + data["explanation_entities"] = de_list_optional( + data.get("explanation_entities"), MessageEntity, bot + ) data["close_date"] = from_timestamp(data.get("close_date"), tzinfo=loc_tzinfo) - data["question_entities"] = MessageEntity.de_list(data.get("question_entities"), bot) + data["question_entities"] = de_list_optional( + data.get("question_entities"), MessageEntity, bot + ) return super().de_json(data=data, bot=bot) @@ -517,7 +523,7 @@ def parse_explanation_entity(self, entity: MessageEntity) -> str: return parse_message_entity(self.explanation, entity) def parse_explanation_entities( - self, types: Optional[list[str]] = None + self, types: list[str] | None = None ) -> dict[MessageEntity, str]: """ Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. @@ -567,9 +573,7 @@ def parse_question_entity(self, entity: MessageEntity) -> str: """ return parse_message_entity(self.question, entity) - def parse_question_entities( - self, types: Optional[list[str]] = None - ) -> dict[MessageEntity, str]: + def parse_question_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: """ Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. It contains entities from this polls question filtered by their ``type`` attribute as diff --git a/telegram/_proximityalerttriggered.py b/src/telegram/_proximityalerttriggered.py similarity index 85% rename from telegram/_proximityalerttriggered.py rename to src/telegram/_proximityalerttriggered.py index b4ea6a26b5e..8f07d9aede4 100644 --- a/telegram/_proximityalerttriggered.py +++ b/src/telegram/_proximityalerttriggered.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,12 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Proximity Alert.""" -from typing import TYPE_CHECKING, Optional + +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -55,7 +57,7 @@ def __init__( watcher: User, distance: int, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.traveler: User = traveler @@ -67,16 +69,11 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ProximityAlertTriggered"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ProximityAlertTriggered": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["traveler"] = User.de_json(data.get("traveler"), bot) - data["watcher"] = User.de_json(data.get("watcher"), bot) + data["traveler"] = de_json_optional(data.get("traveler"), User, bot) + data["watcher"] = de_json_optional(data.get("watcher"), User, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_reaction.py b/src/telegram/_reaction.py similarity index 87% rename from telegram/_reaction.py rename to src/telegram/_reaction.py index 7d76b82efbf..2dbcd462e72 100644 --- a/telegram/_reaction.py +++ b/src/telegram/_reaction.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,13 +16,15 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +# pylint: disable=redefined-builtin """This module contains objects that represents a Telegram ReactionType.""" -from typing import TYPE_CHECKING, Final, Literal, Optional, Union +from typing import TYPE_CHECKING, Final, Literal from telegram import constants from telegram._telegramobject import TelegramObject from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -64,11 +66,9 @@ class ReactionType(TelegramObject): def __init__( self, - type: Union[ # pylint: disable=redefined-builtin - Literal["emoji", "custom_emoji", "paid"], constants.ReactionType - ], + type: Literal["emoji", "custom_emoji", "paid"] | constants.ReactionType, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required by all subclasses @@ -77,18 +77,10 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ReactionType"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ReactionType": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if data is None: - return None - - if not data and cls is ReactionType: - return None - _class_mapping: dict[str, type[ReactionType]] = { cls.EMOJI: ReactionTypeEmoji, cls.CUSTOM_EMOJI: ReactionTypeCustomEmoji, @@ -127,7 +119,7 @@ def __init__( self, emoji: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(type=ReactionType.EMOJI, api_kwargs=api_kwargs) @@ -161,7 +153,7 @@ def __init__( self, custom_emoji_id: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(type=ReactionType.CUSTOM_EMOJI, api_kwargs=api_kwargs) @@ -183,7 +175,7 @@ class ReactionTypePaid(ReactionType): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(type=ReactionType.PAID, api_kwargs=api_kwargs) self._freeze() @@ -216,7 +208,7 @@ def __init__( type: ReactionType, # pylint: disable=redefined-builtin total_count: int, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -230,15 +222,10 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ReactionCount"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ReactionCount": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["type"] = ReactionType.de_json(data.get("type"), bot) + data["type"] = de_json_optional(data.get("type"), ReactionType, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_reply.py b/src/telegram/_reply.py similarity index 70% rename from telegram/_reply.py rename to src/telegram/_reply.py index ba04b892ca6..367d5aad7a0 100644 --- a/telegram/_reply.py +++ b/src/telegram/_reply.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,12 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This modules contains objects that represents Telegram Replies""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING from telegram._chat import Chat +from telegram._checklists import Checklist from telegram._dice import Dice from telegram._files.animation import Animation from telegram._files.audio import Audio @@ -43,7 +45,7 @@ from telegram._poll import Poll from telegram._story import Story from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput @@ -89,6 +91,9 @@ class ExternalReplyInfo(TelegramObject): the file. has_media_spoiler (:obj:`bool`, optional): :obj:`True`, if the message media is covered by a spoiler animation. + checklist (:class:`telegram.Checklist`, optional): Message is a checklist. + + .. versionadded:: 22.3 contact (:class:`telegram.Contact`, optional): Message is a shared contact, information about the contact. dice (:class:`telegram.Dice`, optional): Message is a dice with random value. @@ -138,6 +143,9 @@ class ExternalReplyInfo(TelegramObject): the file. has_media_spoiler (:obj:`bool`): Optional. :obj:`True`, if the message media is covered by a spoiler animation. + checklist (:class:`telegram.Checklist`): Optional. Message is a checklist. + + .. versionadded:: 22.3 contact (:class:`telegram.Contact`): Optional. Message is a shared contact, information about the contact. dice (:class:`telegram.Dice`): Optional. Message is a dice with random value. @@ -164,6 +172,7 @@ class ExternalReplyInfo(TelegramObject): "animation", "audio", "chat", + "checklist", "contact", "dice", "document", @@ -190,97 +199,97 @@ class ExternalReplyInfo(TelegramObject): def __init__( self, origin: MessageOrigin, - chat: Optional[Chat] = None, - message_id: Optional[int] = None, - link_preview_options: Optional[LinkPreviewOptions] = None, - animation: Optional[Animation] = None, - audio: Optional[Audio] = None, - document: Optional[Document] = None, - photo: Optional[Sequence[PhotoSize]] = None, - sticker: Optional[Sticker] = None, - story: Optional[Story] = None, - video: Optional[Video] = None, - video_note: Optional[VideoNote] = None, - voice: Optional[Voice] = None, - has_media_spoiler: Optional[bool] = None, - contact: Optional[Contact] = None, - dice: Optional[Dice] = None, - game: Optional[Game] = None, - giveaway: Optional[Giveaway] = None, - giveaway_winners: Optional[GiveawayWinners] = None, - invoice: Optional[Invoice] = None, - location: Optional[Location] = None, - poll: Optional[Poll] = None, - venue: Optional[Venue] = None, - paid_media: Optional[PaidMediaInfo] = None, + chat: Chat | None = None, + message_id: int | None = None, + link_preview_options: LinkPreviewOptions | None = None, + animation: Animation | None = None, + audio: Audio | None = None, + document: Document | None = None, + photo: Sequence[PhotoSize] | None = None, + sticker: Sticker | None = None, + story: Story | None = None, + video: Video | None = None, + video_note: VideoNote | None = None, + voice: Voice | None = None, + has_media_spoiler: bool | None = None, + contact: "Contact | None" = None, + dice: Dice | None = None, + game: Game | None = None, + giveaway: Giveaway | None = None, + giveaway_winners: GiveawayWinners | None = None, + invoice: Invoice | None = None, + location: "Location | None" = None, + poll: Poll | None = None, + venue: Venue | None = None, + paid_media: PaidMediaInfo | None = None, + checklist: Checklist | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.origin: MessageOrigin = origin - self.chat: Optional[Chat] = chat - self.message_id: Optional[int] = message_id - self.link_preview_options: Optional[LinkPreviewOptions] = link_preview_options - self.animation: Optional[Animation] = animation - self.audio: Optional[Audio] = audio - self.document: Optional[Document] = document - self.photo: Optional[tuple[PhotoSize, ...]] = parse_sequence_arg(photo) - self.sticker: Optional[Sticker] = sticker - self.story: Optional[Story] = story - self.video: Optional[Video] = video - self.video_note: Optional[VideoNote] = video_note - self.voice: Optional[Voice] = voice - self.has_media_spoiler: Optional[bool] = has_media_spoiler - self.contact: Optional[Contact] = contact - self.dice: Optional[Dice] = dice - self.game: Optional[Game] = game - self.giveaway: Optional[Giveaway] = giveaway - self.giveaway_winners: Optional[GiveawayWinners] = giveaway_winners - self.invoice: Optional[Invoice] = invoice - self.location: Optional[Location] = location - self.poll: Optional[Poll] = poll - self.venue: Optional[Venue] = venue - self.paid_media: Optional[PaidMediaInfo] = paid_media + self.chat: Chat | None = chat + self.message_id: int | None = message_id + self.link_preview_options: LinkPreviewOptions | None = link_preview_options + self.animation: Animation | None = animation + self.audio: Audio | None = audio + self.document: Document | None = document + self.photo: tuple[PhotoSize, ...] | None = parse_sequence_arg(photo) + self.sticker: Sticker | None = sticker + self.story: Story | None = story + self.video: Video | None = video + self.video_note: VideoNote | None = video_note + self.voice: Voice | None = voice + self.has_media_spoiler: bool | None = has_media_spoiler + self.checklist: Checklist | None = checklist + self.contact: Contact | None = contact + self.dice: Dice | None = dice + self.game: Game | None = game + self.giveaway: Giveaway | None = giveaway + self.giveaway_winners: GiveawayWinners | None = giveaway_winners + self.invoice: Invoice | None = invoice + self.location: Location | None = location + self.poll: Poll | None = poll + self.venue: Venue | None = venue + self.paid_media: PaidMediaInfo | None = paid_media self._id_attrs = (self.origin,) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ExternalReplyInfo"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ExternalReplyInfo": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if data is None: - return None - - data["origin"] = MessageOrigin.de_json(data.get("origin"), bot) - data["chat"] = Chat.de_json(data.get("chat"), bot) - data["link_preview_options"] = LinkPreviewOptions.de_json( - data.get("link_preview_options"), bot + data["origin"] = de_json_optional(data.get("origin"), MessageOrigin, bot) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["link_preview_options"] = de_json_optional( + data.get("link_preview_options"), LinkPreviewOptions, bot + ) + data["animation"] = de_json_optional(data.get("animation"), Animation, bot) + data["audio"] = de_json_optional(data.get("audio"), Audio, bot) + data["document"] = de_json_optional(data.get("document"), Document, bot) + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + data["story"] = de_json_optional(data.get("story"), Story, bot) + data["video"] = de_json_optional(data.get("video"), Video, bot) + data["video_note"] = de_json_optional(data.get("video_note"), VideoNote, bot) + data["voice"] = de_json_optional(data.get("voice"), Voice, bot) + data["contact"] = de_json_optional(data.get("contact"), Contact, bot) + data["dice"] = de_json_optional(data.get("dice"), Dice, bot) + data["game"] = de_json_optional(data.get("game"), Game, bot) + data["giveaway"] = de_json_optional(data.get("giveaway"), Giveaway, bot) + data["giveaway_winners"] = de_json_optional( + data.get("giveaway_winners"), GiveawayWinners, bot ) - data["animation"] = Animation.de_json(data.get("animation"), bot) - data["audio"] = Audio.de_json(data.get("audio"), bot) - data["document"] = Document.de_json(data.get("document"), bot) - data["photo"] = tuple(PhotoSize.de_list(data.get("photo"), bot)) - data["sticker"] = Sticker.de_json(data.get("sticker"), bot) - data["story"] = Story.de_json(data.get("story"), bot) - data["video"] = Video.de_json(data.get("video"), bot) - data["video_note"] = VideoNote.de_json(data.get("video_note"), bot) - data["voice"] = Voice.de_json(data.get("voice"), bot) - data["contact"] = Contact.de_json(data.get("contact"), bot) - data["dice"] = Dice.de_json(data.get("dice"), bot) - data["game"] = Game.de_json(data.get("game"), bot) - data["giveaway"] = Giveaway.de_json(data.get("giveaway"), bot) - data["giveaway_winners"] = GiveawayWinners.de_json(data.get("giveaway_winners"), bot) - data["invoice"] = Invoice.de_json(data.get("invoice"), bot) - data["location"] = Location.de_json(data.get("location"), bot) - data["poll"] = Poll.de_json(data.get("poll"), bot) - data["venue"] = Venue.de_json(data.get("venue"), bot) - data["paid_media"] = PaidMediaInfo.de_json(data.get("paid_media"), bot) + data["invoice"] = de_json_optional(data.get("invoice"), Invoice, bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) + data["poll"] = de_json_optional(data.get("poll"), Poll, bot) + data["venue"] = de_json_optional(data.get("venue"), Venue, bot) + data["paid_media"] = de_json_optional(data.get("paid_media"), PaidMediaInfo, bot) + data["checklist"] = de_json_optional(data.get("checklist"), Checklist, bot) return super().de_json(data=data, bot=bot) @@ -330,17 +339,17 @@ def __init__( self, text: str, position: int, - entities: Optional[Sequence[MessageEntity]] = None, - is_manual: Optional[bool] = None, + entities: Sequence[MessageEntity] | None = None, + is_manual: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.text: str = text self.position: int = position - self.entities: Optional[tuple[MessageEntity, ...]] = parse_sequence_arg(entities) - self.is_manual: Optional[bool] = is_manual + self.entities: tuple[MessageEntity, ...] | None = parse_sequence_arg(entities) + self.is_manual: bool | None = is_manual self._id_attrs = ( self.text, @@ -350,16 +359,11 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["TextQuote"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "TextQuote": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if data is None: - return None - - data["entities"] = tuple(MessageEntity.de_list(data.get("entities"), bot)) + data["entities"] = de_list_optional(data.get("entities"), MessageEntity, bot) return super().de_json(data=data, bot=bot) @@ -373,12 +377,19 @@ class ReplyParameters(TelegramObject): .. versionadded:: 20.8 + .. versionchanged:: 22.5 + The :paramref:`checklist_task_id` parameter has been moved to the last position to + maintain backward compatibility with versions prior to 22.4. + This resolves a breaking change accidentally introduced in version 22.4. See the changelog + for version 22.5 for more information. + Args: message_id (:obj:`int`): Identifier of the message that will be replied to in the current chat, or in the chat :paramref:`chat_id` if it is specified. chat_id (:obj:`int` | :obj:`str`, optional): If the message to be replied to is from a different chat, |chat_id_channel| - Not supported for messages sent on behalf of a business account. + Not supported for messages sent on behalf of a business account and messages from + channel direct messages chats. allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| Can be used only for replies in the same chat and forum topic. quote (:obj:`str`, optional): Quoted part of the message to be replied to; 0-1024 @@ -395,13 +406,18 @@ class ReplyParameters(TelegramObject): :paramref:`quote_parse_mode`. quote_position (:obj:`int`, optional): Position of the quote in the original message in UTF-16 code units. + checklist_task_id (:obj:`int`, optional): Identifier of the specific checklist task to be + replied to. + + .. versionadded:: 22.4 Attributes: message_id (:obj:`int`): Identifier of the message that will be replied to in the current chat, or in the chat :paramref:`chat_id` if it is specified. chat_id (:obj:`int` | :obj:`str`): Optional. If the message to be replied to is from a different chat, |chat_id_channel| - Not supported for messages sent on behalf of a business account. + Not supported for messages sent on behalf of a business account and messages from + channel direct messages chats. allow_sending_without_reply (:obj:`bool`): Optional. |allow_sending_without_reply| Can be used only for replies in the same chat and forum topic. quote (:obj:`str`): Optional. Quoted part of the message to be replied to; 0-1024 @@ -417,11 +433,16 @@ class ReplyParameters(TelegramObject): :paramref:`quote_parse_mode`. quote_position (:obj:`int`): Optional. Position of the quote in the original message in UTF-16 code units. + checklist_task_id (:obj:`int`): Optional. Identifier of the specific checklist task to be + replied to. + + .. versionadded:: 22.4 """ __slots__ = ( "allow_sending_without_reply", "chat_id", + "checklist_task_id", "message_id", "quote", "quote_entities", @@ -432,41 +453,38 @@ class ReplyParameters(TelegramObject): def __init__( self, message_id: int, - chat_id: Optional[Union[int, str]] = None, + chat_id: int | str | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[str] = None, + quote: str | None = None, quote_parse_mode: ODVInput[str] = DEFAULT_NONE, - quote_entities: Optional[Sequence[MessageEntity]] = None, - quote_position: Optional[int] = None, + quote_entities: Sequence[MessageEntity] | None = None, + quote_position: int | None = None, + checklist_task_id: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.message_id: int = message_id - self.chat_id: Optional[Union[int, str]] = chat_id + self.chat_id: int | str | None = chat_id self.allow_sending_without_reply: ODVInput[bool] = allow_sending_without_reply - self.quote: Optional[str] = quote + self.quote: str | None = quote self.quote_parse_mode: ODVInput[str] = quote_parse_mode - self.quote_entities: Optional[tuple[MessageEntity, ...]] = parse_sequence_arg( - quote_entities - ) - self.quote_position: Optional[int] = quote_position + self.quote_entities: tuple[MessageEntity, ...] | None = parse_sequence_arg(quote_entities) + self.quote_position: int | None = quote_position + self.checklist_task_id: int | None = checklist_task_id self._id_attrs = (self.message_id,) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ReplyParameters"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ReplyParameters": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if data is None: - return None - - data["quote_entities"] = tuple(MessageEntity.de_list(data.get("quote_entities"), bot)) + data["quote_entities"] = tuple( + de_list_optional(data.get("quote_entities"), MessageEntity, bot) + ) return super().de_json(data=data, bot=bot) diff --git a/telegram/_replykeyboardmarkup.py b/src/telegram/_replykeyboardmarkup.py similarity index 92% rename from telegram/_replykeyboardmarkup.py rename to src/telegram/_replykeyboardmarkup.py index 3928f82aa96..99ac0de3b00 100644 --- a/telegram/_replykeyboardmarkup.py +++ b/src/telegram/_replykeyboardmarkup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,7 +19,7 @@ """This module contains an object that represents a Telegram ReplyKeyboardMarkup.""" from collections.abc import Sequence -from typing import Final, Optional, Union +from typing import Final from telegram import constants from telegram._keyboardbutton import KeyboardButton @@ -132,14 +132,14 @@ class ReplyKeyboardMarkup(TelegramObject): def __init__( self, - keyboard: Sequence[Sequence[Union[str, KeyboardButton]]], - resize_keyboard: Optional[bool] = None, - one_time_keyboard: Optional[bool] = None, - selective: Optional[bool] = None, - input_field_placeholder: Optional[str] = None, - is_persistent: Optional[bool] = None, + keyboard: Sequence[Sequence[str | KeyboardButton]], + resize_keyboard: bool | None = None, + one_time_keyboard: bool | None = None, + selective: bool | None = None, + input_field_placeholder: str | None = None, + is_persistent: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) if not check_keyboard_type(keyboard): @@ -155,11 +155,11 @@ def __init__( ) # Optionals - self.resize_keyboard: Optional[bool] = resize_keyboard - self.one_time_keyboard: Optional[bool] = one_time_keyboard - self.selective: Optional[bool] = selective - self.input_field_placeholder: Optional[str] = input_field_placeholder - self.is_persistent: Optional[bool] = is_persistent + self.resize_keyboard: bool | None = resize_keyboard + self.one_time_keyboard: bool | None = one_time_keyboard + self.selective: bool | None = selective + self.input_field_placeholder: str | None = input_field_placeholder + self.is_persistent: bool | None = is_persistent self._id_attrs = (self.keyboard,) @@ -168,12 +168,12 @@ def __init__( @classmethod def from_button( cls, - button: Union[KeyboardButton, str], + button: KeyboardButton | str, resize_keyboard: bool = False, one_time_keyboard: bool = False, selective: bool = False, - input_field_placeholder: Optional[str] = None, - is_persistent: Optional[bool] = None, + input_field_placeholder: str | None = None, + is_persistent: bool | None = None, **kwargs: object, ) -> "ReplyKeyboardMarkup": """Shortcut for:: @@ -226,12 +226,12 @@ def from_button( @classmethod def from_row( cls, - button_row: Sequence[Union[str, KeyboardButton]], + button_row: Sequence[str | KeyboardButton], resize_keyboard: bool = False, one_time_keyboard: bool = False, selective: bool = False, - input_field_placeholder: Optional[str] = None, - is_persistent: Optional[bool] = None, + input_field_placeholder: str | None = None, + is_persistent: bool | None = None, **kwargs: object, ) -> "ReplyKeyboardMarkup": """Shortcut for:: @@ -288,12 +288,12 @@ def from_row( @classmethod def from_column( cls, - button_column: Sequence[Union[str, KeyboardButton]], + button_column: Sequence[str | KeyboardButton], resize_keyboard: bool = False, one_time_keyboard: bool = False, selective: bool = False, - input_field_placeholder: Optional[str] = None, - is_persistent: Optional[bool] = None, + input_field_placeholder: str | None = None, + is_persistent: bool | None = None, **kwargs: object, ) -> "ReplyKeyboardMarkup": """Shortcut for:: diff --git a/telegram/_replykeyboardremove.py b/src/telegram/_replykeyboardremove.py similarity index 93% rename from telegram/_replykeyboardremove.py rename to src/telegram/_replykeyboardremove.py index 808bee20b6b..05a5839595a 100644 --- a/telegram/_replykeyboardremove.py +++ b/src/telegram/_replykeyboardremove.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ReplyKeyboardRemove.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -63,11 +62,11 @@ class ReplyKeyboardRemove(TelegramObject): __slots__ = ("remove_keyboard", "selective") - def __init__(self, selective: Optional[bool] = None, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, selective: bool | None = None, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) # Required self.remove_keyboard: bool = True # Optionals - self.selective: Optional[bool] = selective + self.selective: bool | None = selective self._freeze() diff --git a/telegram/_sentwebappmessage.py b/src/telegram/_sentwebappmessage.py similarity index 90% rename from telegram/_sentwebappmessage.py rename to src/telegram/_sentwebappmessage.py index 492f440d003..1d26e231b9e 100644 --- a/telegram/_sentwebappmessage.py +++ b/src/telegram/_sentwebappmessage.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Sent Web App Message.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -45,11 +44,11 @@ class SentWebAppMessage(TelegramObject): __slots__ = ("inline_message_id",) def __init__( - self, inline_message_id: Optional[str] = None, *, api_kwargs: Optional[JSONDict] = None + self, inline_message_id: str | None = None, *, api_kwargs: JSONDict | None = None ): super().__init__(api_kwargs=api_kwargs) # Optionals - self.inline_message_id: Optional[str] = inline_message_id + self.inline_message_id: str | None = inline_message_id self._id_attrs = (self.inline_message_id,) diff --git a/telegram/_shared.py b/src/telegram/_shared.py similarity index 77% rename from telegram/_shared.py rename to src/telegram/_shared.py index 5ba54ea99e2..2de07529c62 100644 --- a/telegram/_shared.py +++ b/src/telegram/_shared.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,15 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains two objects used for request chats/users service messages.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._files.photosize import PhotoSize from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg from telegram._utils.types import JSONDict +from telegram._utils.usernames import get_full_name, get_link, get_name if TYPE_CHECKING: from telegram._bot import Bot @@ -73,7 +75,7 @@ def __init__( request_id: int, users: Sequence["SharedUser"], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.request_id: int = request_id @@ -84,16 +86,11 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["UsersShared"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UsersShared": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["users"] = SharedUser.de_list(data.get("users"), bot) + data["users"] = de_list_optional(data.get("users"), SharedUser, bot) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility @@ -157,36 +154,40 @@ def __init__( self, request_id: int, chat_id: int, - title: Optional[str] = None, - username: Optional[str] = None, - photo: Optional[Sequence[PhotoSize]] = None, + title: str | None = None, + username: str | None = None, + photo: Sequence[PhotoSize] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.request_id: int = request_id self.chat_id: int = chat_id - self.title: Optional[str] = title - self.username: Optional[str] = username - self.photo: Optional[tuple[PhotoSize, ...]] = parse_sequence_arg(photo) + self.title: str | None = title + self.username: str | None = username + self.photo: tuple[PhotoSize, ...] | None = parse_sequence_arg(photo) self._id_attrs = (self.request_id, self.chat_id) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["ChatShared"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatShared": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["photo"] = PhotoSize.de_list(data.get("photo"), bot) + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) return super().de_json(data=data, bot=bot) + @property + def link(self) -> str | None: + """:obj:`str`: Convenience property. If :attr:`username` is available, returns a t.me link + of the chat. + + .. versionadded:: 22.4 + """ + return get_link(self) + class SharedUser(TelegramObject): """ @@ -236,33 +237,55 @@ class SharedUser(TelegramObject): def __init__( self, user_id: int, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - username: Optional[str] = None, - photo: Optional[Sequence[PhotoSize]] = None, + first_name: str | None = None, + last_name: str | None = None, + username: str | None = None, + photo: Sequence[PhotoSize] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.user_id: int = user_id - self.first_name: Optional[str] = first_name - self.last_name: Optional[str] = last_name - self.username: Optional[str] = username - self.photo: Optional[tuple[PhotoSize, ...]] = parse_sequence_arg(photo) + self.first_name: str | None = first_name + self.last_name: str | None = last_name + self.username: str | None = username + self.photo: tuple[PhotoSize, ...] | None = parse_sequence_arg(photo) self._id_attrs = (self.user_id,) self._freeze() + @property + def name(self) -> str | None: + """:obj:`str`: Convenience property. If available, returns the user's :attr:`username` + prefixed with "@". If :attr:`username` is not available, returns :attr:`full_name`. + + .. versionadded:: 22.4 + """ + return get_name(self) + + @property + def full_name(self) -> str | None: + """:obj:`str`: Convenience property. If :attr:`first_name` is not :obj:`None`, gives + :attr:`first_name` followed by (if available) :attr:`last_name`. + + .. versionadded:: 22.4 + """ + return get_full_name(self) + + @property + def link(self) -> str | None: + """:obj:`str`: Convenience property. If :attr:`username` is available, returns a t.me link + of the user. + + .. versionadded:: 22.4 + """ + return get_link(self) + @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["SharedUser"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "SharedUser": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["photo"] = PhotoSize.de_list(data.get("photo"), bot) + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_story.py b/src/telegram/_story.py similarity index 56% rename from telegram/_story.py rename to src/telegram/_story.py index 06832e08b87..680e7206230 100644 --- a/telegram/_story.py +++ b/src/telegram/_story.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,11 +18,12 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object related to a Telegram Story.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._chat import Chat from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.types import JSONDict, ODVInput, TimePeriod if TYPE_CHECKING: from telegram import Bot @@ -60,7 +61,7 @@ def __init__( chat: Chat, id: int, # pylint: disable=redefined-builtin *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) self.chat: Chat = chat @@ -71,12 +72,52 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Story"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Story": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - data["chat"] = Chat.de_json(data.get("chat", {}), bot) return super().de_json(data=data, bot=bot) + + async def repost( + self, + business_connection_id: str, + active_period: TimePeriod, + post_to_chat_page: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Story": + """Shortcut for:: + + await bot.repost_story( + from_chat_id=story.chat.id, + from_story_id=story.id, + *args, **kwargs + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.repost_story`. + + .. versionadded:: 22.6 + + Returns: + :class:`Story`: On success, :class:`Story` is returned. + + """ + return await self.get_bot().repost_story( + business_connection_id=business_connection_id, + from_chat_id=self.chat.id, + from_story_id=self.id, + active_period=active_period, + post_to_chat_page=post_to_chat_page, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) diff --git a/src/telegram/_storyarea.py b/src/telegram/_storyarea.py new file mode 100644 index 00000000000..27d8f168cf2 --- /dev/null +++ b/src/telegram/_storyarea.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains objects that represent story areas.""" + +from typing import Final + +from telegram import constants +from telegram._reaction import ReactionType +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.types import JSONDict + + +class StoryAreaPosition(TelegramObject): + """Describes the position of a clickable area within a story. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if all of their attributes are equal. + + .. versionadded:: 22.1 + + Args: + x_percentage (:obj:`float`): The abscissa of the area's center, as a percentage of the + media width. + y_percentage (:obj:`float`): The ordinate of the area's center, as a percentage of the + media height. + width_percentage (:obj:`float`): The width of the area's rectangle, as a percentage of the + media width. + height_percentage (:obj:`float`): The height of the area's rectangle, as a percentage of + the media height. + rotation_angle (:obj:`float`): The clockwise rotation angle of the rectangle, in degrees; + 0-:tg-const:`~telegram.constants.StoryAreaPositionLimit.MAX_ROTATION_ANGLE`. + corner_radius_percentage (:obj:`float`): The radius of the rectangle corner rounding, as a + percentage of the media width. + + Attributes: + x_percentage (:obj:`float`): The abscissa of the area's center, as a percentage of the + media width. + y_percentage (:obj:`float`): The ordinate of the area's center, as a percentage of the + media height. + width_percentage (:obj:`float`): The width of the area's rectangle, as a percentage of the + media width. + height_percentage (:obj:`float`): The height of the area's rectangle, as a percentage of + the media height. + rotation_angle (:obj:`float`): The clockwise rotation angle of the rectangle, in degrees; + 0-:tg-const:`~telegram.constants.StoryAreaPositionLimit.MAX_ROTATION_ANGLE`. + corner_radius_percentage (:obj:`float`): The radius of the rectangle corner rounding, as a + percentage of the media width. + + """ + + __slots__ = ( + "corner_radius_percentage", + "height_percentage", + "rotation_angle", + "width_percentage", + "x_percentage", + "y_percentage", + ) + + def __init__( + self, + x_percentage: float, + y_percentage: float, + width_percentage: float, + height_percentage: float, + rotation_angle: float, + corner_radius_percentage: float, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.x_percentage: float = x_percentage + self.y_percentage: float = y_percentage + self.width_percentage: float = width_percentage + self.height_percentage: float = height_percentage + self.rotation_angle: float = rotation_angle + self.corner_radius_percentage: float = corner_radius_percentage + + self._id_attrs = ( + self.x_percentage, + self.y_percentage, + self.width_percentage, + self.height_percentage, + self.rotation_angle, + self.corner_radius_percentage, + ) + self._freeze() + + +class LocationAddress(TelegramObject): + """Describes the physical address of a location. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`country_code`, :attr:`state`, :attr:`city` and :attr:`street` + are equal. + + .. versionadded:: 22.1 + + Args: + country_code (:obj:`str`): The two-letter ``ISO 3166-1 alpha-2`` country code of the + country where the location is located. + state (:obj:`str`, optional): State of the location. + city (:obj:`str`, optional): City of the location. + street (:obj:`str`, optional): Street address of the location. + + Attributes: + country_code (:obj:`str`): The two-letter ``ISO 3166-1 alpha-2`` country code of the + country where the location is located. + state (:obj:`str`): Optional. State of the location. + city (:obj:`str`): Optional. City of the location. + street (:obj:`str`): Optional. Street address of the location. + + """ + + __slots__ = ("city", "country_code", "state", "street") + + def __init__( + self, + country_code: str, + state: str | None = None, + city: str | None = None, + street: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.country_code: str = country_code + self.state: str | None = state + self.city: str | None = city + self.street: str | None = street + + self._id_attrs = (self.country_code, self.state, self.city, self.street) + self._freeze() + + +class StoryAreaType(TelegramObject): + """Describes the type of a clickable area on a story. Currently, it can be one of: + + * :class:`telegram.StoryAreaTypeLocation` + * :class:`telegram.StoryAreaTypeSuggestedReaction` + * :class:`telegram.StoryAreaTypeLink` + * :class:`telegram.StoryAreaTypeWeather` + * :class:`telegram.StoryAreaTypeUniqueGift` + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`type` is equal. + + .. versionadded:: 22.1 + + Args: + type (:obj:`str`): Type of the area. + + Attributes: + type (:obj:`str`): Type of the area. + + """ + + __slots__ = ("type",) + + LOCATION: Final[str] = constants.StoryAreaTypeType.LOCATION + """:const:`telegram.constants.StoryAreaTypeType.LOCATION`""" + SUGGESTED_REACTION: Final[str] = constants.StoryAreaTypeType.SUGGESTED_REACTION + """:const:`telegram.constants.StoryAreaTypeType.SUGGESTED_REACTION`""" + LINK: Final[str] = constants.StoryAreaTypeType.LINK + """:const:`telegram.constants.StoryAreaTypeType.LINK`""" + WEATHER: Final[str] = constants.StoryAreaTypeType.WEATHER + """:const:`telegram.constants.StoryAreaTypeType.WEATHER`""" + UNIQUE_GIFT: Final[str] = constants.StoryAreaTypeType.UNIQUE_GIFT + """:const:`telegram.constants.StoryAreaTypeType.UNIQUE_GIFT`""" + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.StoryAreaTypeType, type, type) + + self._id_attrs = (self.type,) + self._freeze() + + +class StoryAreaTypeLocation(StoryAreaType): + """Describes a story area pointing to a location. Currently, a story can have up to + :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_LOCATION_AREAS` location areas. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`latitude` and :attr:`longitude` are equal. + + .. versionadded:: 22.1 + + Args: + latitude (:obj:`float`): Location latitude in degrees. + longitude (:obj:`float`): Location longitude in degrees. + address (:class:`telegram.LocationAddress`, optional): Address of the location. + + Attributes: + type (:obj:`str`): Type of the area, always :attr:`~telegram.StoryAreaType.LOCATION`. + latitude (:obj:`float`): Location latitude in degrees. + longitude (:obj:`float`): Location longitude in degrees. + address (:class:`telegram.LocationAddress`): Optional. Address of the location. + + """ + + __slots__ = ("address", "latitude", "longitude") + + def __init__( + self, + latitude: float, + longitude: float, + address: LocationAddress | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=StoryAreaType.LOCATION, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.latitude: float = latitude + self.longitude: float = longitude + self.address: LocationAddress | None = address + + self._id_attrs = (self.type, self.latitude, self.longitude) + + +class StoryAreaTypeSuggestedReaction(StoryAreaType): + """ + Describes a story area pointing to a suggested reaction. Currently, a story can have up to + :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_SUGGESTED_REACTION_AREAS` + suggested reaction areas. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`reaction_type`, :attr:`is_dark` and :attr:`is_flipped` + are equal. + + .. versionadded:: 22.1 + + Args: + reaction_type (:class:`ReactionType`): Type of the reaction. + is_dark (:obj:`bool`, optional): Pass :obj:`True` if the reaction area has a dark + background. + is_flipped (:obj:`bool`, optional): Pass :obj:`True` if reaction area corner is flipped. + + Attributes: + type (:obj:`str`): Type of the area, always + :tg-const:`~telegram.StoryAreaType.SUGGESTED_REACTION`. + reaction_type (:class:`ReactionType`): Type of the reaction. + is_dark (:obj:`bool`): Optional. Pass :obj:`True` if the reaction area has a dark + background. + is_flipped (:obj:`bool`): Optional. Pass :obj:`True` if reaction area corner is flipped. + + """ + + __slots__ = ("is_dark", "is_flipped", "reaction_type") + + def __init__( + self, + reaction_type: ReactionType, + is_dark: bool | None = None, + is_flipped: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=StoryAreaType.SUGGESTED_REACTION, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.reaction_type: ReactionType = reaction_type + self.is_dark: bool | None = is_dark + self.is_flipped: bool | None = is_flipped + + self._id_attrs = (self.type, self.reaction_type, self.is_dark, self.is_flipped) + + +class StoryAreaTypeLink(StoryAreaType): + """Describes a story area pointing to an ``HTTP`` or ``tg://`` link. Currently, a story can + have up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_LINK_AREAS` link areas. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`url` is equal. + + .. versionadded:: 22.1 + + Args: + url (:obj:`str`): ``HTTP`` or ``tg://`` URL to be opened when the area is clicked. + + Attributes: + type (:obj:`str`): Type of the area, always :attr:`~telegram.StoryAreaType.LINK`. + url (:obj:`str`): ``HTTP`` or ``tg://`` URL to be opened when the area is clicked. + + """ + + __slots__ = ("url",) + + def __init__( + self, + url: str, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=StoryAreaType.LINK, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.url: str = url + + self._id_attrs = (self.type, self.url) + + +class StoryAreaTypeWeather(StoryAreaType): + """ + Describes a story area containing weather information. Currently, a story can have up to + :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_WEATHER_AREAS` weather areas. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`temperature`, :attr:`emoji` and + :attr:`background_color` are equal. + + .. versionadded:: 22.1 + + Args: + temperature (:obj:`float`): Temperature, in degree Celsius. + emoji (:obj:`str`): Emoji representing the weather. + background_color (:obj:`int`): A color of the area background in the ``ARGB`` format. + + Attributes: + type (:obj:`str`): Type of the area, always + :tg-const:`~telegram.StoryAreaType.WEATHER`. + temperature (:obj:`float`): Temperature, in degree Celsius. + emoji (:obj:`str`): Emoji representing the weather. + background_color (:obj:`int`): A color of the area background in the ``ARGB`` format. + + """ + + __slots__ = ("background_color", "emoji", "temperature") + + def __init__( + self, + temperature: float, + emoji: str, + background_color: int, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=StoryAreaType.WEATHER, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.temperature: float = temperature + self.emoji: str = emoji + self.background_color: int = background_color + + self._id_attrs = (self.type, self.temperature, self.emoji, self.background_color) + + +class StoryAreaTypeUniqueGift(StoryAreaType): + """ + Describes a story area pointing to a unique gift. Currently, a story can have at most + :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_UNIQUE_GIFT_AREAS` unique gift area. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`name` is equal. + + .. versionadded:: 22.1 + + Args: + name (:obj:`str`): Unique name of the gift. + + Attributes: + type (:obj:`str`): Type of the area, always + :tg-const:`~telegram.StoryAreaType.UNIQUE_GIFT`. + name (:obj:`str`): Unique name of the gift. + + """ + + __slots__ = ("name",) + + def __init__( + self, + name: str, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=StoryAreaType.UNIQUE_GIFT, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.name: str = name + + self._id_attrs = (self.type, self.name) + + +class StoryArea(TelegramObject): + """Describes a clickable area on a story media. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`position` and :attr:`type` are equal. + + .. versionadded:: 22.1 + + Args: + position (:class:`telegram.StoryAreaPosition`): Position of the area. + type (:class:`telegram.StoryAreaType`): Type of the area. + + Attributes: + position (:class:`telegram.StoryAreaPosition`): Position of the area. + type (:class:`telegram.StoryAreaType`): Type of the area. + + """ + + __slots__ = ("position", "type") + + def __init__( + self, + position: StoryAreaPosition, + type: StoryAreaType, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.position: StoryAreaPosition = position + self.type: StoryAreaType = type + self._id_attrs = (self.position, self.type) + + self._freeze() diff --git a/src/telegram/_suggestedpost.py b/src/telegram/_suggestedpost.py new file mode 100644 index 00000000000..b820706685a --- /dev/null +++ b/src/telegram/_suggestedpost.py @@ -0,0 +1,573 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains objects related to Telegram suggested posts.""" + +import datetime as dtm +from typing import TYPE_CHECKING, Final, Optional + +from telegram import constants +from telegram._message import Message +from telegram._payment.stars.staramount import StarAmount +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class SuggestedPostPrice(TelegramObject): + """ + Desribes the price of a suggested post. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`currency` and :attr:`amount` are equal. + + .. versionadded:: 22.4 + + Args: + currency (:obj:`str`): + Currency in which the post will be paid. Currently, must be one of ``“XTR”`` for + Telegram Stars or ``“TON”`` for toncoins. + amount (:obj:`int`): + The amount of the currency that will be paid for the post in the smallest units of the + currency, i.e. Telegram Stars or nanotoncoins. Currently, price in Telegram Stars must + be between :tg-const:`telegram.constants.SuggestedPost.MIN_PRICE_STARS` + and :tg-const:`telegram.constants.SuggestedPost.MAX_PRICE_STARS`, and price in + nanotoncoins must be between + :tg-const:`telegram.constants.SuggestedPost.MIN_PRICE_NANOTONCOINS` + and :tg-const:`telegram.constants.SuggestedPost.MAX_PRICE_NANOTONCOINS`. + + Attributes: + currency (:obj:`str`): + Currency in which the post will be paid. Currently, must be one of ``“XTR”`` for + Telegram Stars or ``“TON”`` for toncoins. + amount (:obj:`int`): + The amount of the currency that will be paid for the post in the smallest units of the + currency, i.e. Telegram Stars or nanotoncoins. Currently, price in Telegram Stars must + be between :tg-const:`telegram.constants.SuggestedPost.MIN_PRICE_STARS` + and :tg-const:`telegram.constants.SuggestedPost.MAX_PRICE_STARS`, and price in + nanotoncoins must be between + :tg-const:`telegram.constants.SuggestedPost.MIN_PRICE_NANOTONCOINS` + and :tg-const:`telegram.constants.SuggestedPost.MAX_PRICE_NANOTONCOINS`. + """ + + __slots__ = ("amount", "currency") + + def __init__( + self, + currency: str, + amount: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.currency: str = currency + self.amount: int = amount + + self._id_attrs = (self.currency, self.amount) + + self._freeze() + + +class SuggestedPostParameters(TelegramObject): + """ + Contains parameters of a post that is being suggested by the bot. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`price` and :attr:`send_date` are equal. + + .. versionadded:: 22.4 + + Args: + price (:class:`telegram.SuggestedPostPrice`, optional): + Proposed price for the post. If the field is omitted, then the post is unpaid. + send_date (:class:`datetime.datetime`, optional): + Proposed send date of the post. If specified, then the date + must be between :tg-const:`telegram.constants.SuggestedPost.MIN_SEND_DATE` + second and :tg-const:`telegram.constants.SuggestedPost.MAX_SEND_DATE` seconds (30 days) + in the future. If the field is omitted, then the post can be published at any time + within :tg-const:`telegram.constants.SuggestedPost.MAX_SEND_DATE` seconds (30 days) at + the sole discretion of the user who approves it. + |datetime_localization| + + Attributes: + price (:class:`telegram.SuggestedPostPrice`): + Optional. Proposed price for the post. If the field is omitted, then the post + is unpaid. + send_date (:class:`datetime.datetime`): + Optional. Proposed send date of the post. If specified, then the date + must be between :tg-const:`telegram.constants.SuggestedPost.MIN_SEND_DATE` + second and :tg-const:`telegram.constants.SuggestedPost.MAX_SEND_DATE` seconds (30 days) + in the future. If the field is omitted, then the post can be published at any time + within :tg-const:`telegram.constants.SuggestedPost.MAX_SEND_DATE` seconds (30 days) at + the sole discretion of the user who approves it. + |datetime_localization| + + """ + + __slots__ = ("price", "send_date") + + def __init__( + self, + price: SuggestedPostPrice | None = None, + send_date: dtm.datetime | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.price: SuggestedPostPrice | None = price + self.send_date: dtm.datetime | None = send_date + + self._id_attrs = (self.price, self.send_date) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostParameters": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["price"] = de_json_optional(data.get("price"), SuggestedPostPrice, bot) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["send_date"] = from_timestamp(data.get("send_date"), tzinfo=loc_tzinfo) + + return super().de_json(data=data, bot=bot) + + +class SuggestedPostInfo(TelegramObject): + """ + Contains information about a suggested post. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`state` and :attr:`price` are equal. + + .. versionadded:: 22.4 + + Args: + state (:obj:`str`): + State of the suggested post. Currently, it can be one of + :tg-const:`~telegram.constants.SuggestedPostInfoState.PENDING`, + :tg-const:`~telegram.constants.SuggestedPostInfoState.APPROVED`, + :tg-const:`~telegram.constants.SuggestedPostInfoState.DECLINED`. + price (:obj:`SuggestedPostPrice`, optional): + Proposed price of the post. If the field is omitted, then the post is unpaid. + send_date (:class:`datetime.datetime`, optional): + Proposed send date of the post. If the field is omitted, then the post can be published + at any time within 30 days at the sole discretion of the user or administrator who + approves it. + |datetime_localization| + + Attributes: + state (:obj:`str`): + State of the suggested post. Currently, it can be one of + :tg-const:`~telegram.constants.SuggestedPostInfoState.PENDING`, + :tg-const:`~telegram.constants.SuggestedPostInfoState.APPROVED`, + :tg-const:`~telegram.constants.SuggestedPostInfoState.DECLINED`. + price (:obj:`SuggestedPostPrice`): + Optional. Proposed price of the post. If the field is omitted, then the post is unpaid. + send_date (:class:`datetime.datetime`): + Optional. Proposed send date of the post. If the field is omitted, then the post can be + published at any time within 30 days at the sole discretion of the user or + administrator who approves it. + |datetime_localization| + + """ + + __slots__ = ("price", "send_date", "state") + + PENDING: Final[str] = constants.SuggestedPostInfoState.PENDING + """:const:`telegram.constants.SuggestedPostInfoState.PENDING`""" + APPROVED: Final[str] = constants.SuggestedPostInfoState.APPROVED + """:const:`telegram.constants.SuggestedPostInfoState.APPROVED`""" + DECLINED: Final[str] = constants.SuggestedPostInfoState.DECLINED + """:const:`telegram.constants.SuggestedPostInfoState.DECLINED`""" + + def __init__( + self, + state: str, + price: SuggestedPostPrice | None = None, + send_date: dtm.datetime | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.state: str = enum.get_member(constants.SuggestedPostInfoState, state, state) + # Optionals + self.price: SuggestedPostPrice | None = price + self.send_date: dtm.datetime | None = send_date + + self._id_attrs = (self.state, self.price) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostInfo": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["price"] = de_json_optional(data.get("price"), SuggestedPostPrice, bot) + data["send_date"] = from_timestamp(data.get("send_date"), tzinfo=loc_tzinfo) + + return super().de_json(data=data, bot=bot) + + +class SuggestedPostDeclined(TelegramObject): + """ + Describes a service message about the rejection of a suggested post. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`suggested_post_message` and :attr:`comment` are equal. + + .. versionadded:: 22.4 + + Args: + suggested_post_message (:class:`telegram.Message`, optional): + Message containing the suggested post. Note that the :class:`~telegram.Message` object + in this field will not contain the :attr:`~telegram.Message.reply_to_message` field + even if it itself is a reply. + comment (:obj:`str`, optional): + Comment with which the post was declined. + + Attributes: + suggested_post_message (:class:`telegram.Message`): + Optional. Message containing the suggested post. Note that the + :class:`~telegram.Message` object in this field will not contain + the :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + comment (:obj:`str`): + Optional. Comment with which the post was declined. + + """ + + __slots__ = ("comment", "suggested_post_message") + + def __init__( + self, + suggested_post_message: Message | None = None, + comment: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.suggested_post_message: Message | None = suggested_post_message + self.comment: str | None = comment + + self._id_attrs = (self.suggested_post_message, self.comment) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostDeclined": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["suggested_post_message"] = de_json_optional( + data.get("suggested_post_message"), Message, bot + ) + + return super().de_json(data=data, bot=bot) + + +class SuggestedPostPaid(TelegramObject): + """ + Describes a service message about a successful payment for a suggested post. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if all of their attributes are equal. + + .. versionadded:: 22.4 + + Args: + suggested_post_message (:class:`telegram.Message`, optional): + Message containing the suggested post. Note that the :class:`~telegram.Message` object + in this field will not contain the :attr:`~telegram.Message.reply_to_message` field + even if it itself is a reply. + currency (:obj:`str`): + Currency in which the payment was made. Currently, one of ``“XTR”`` for Telegram Stars + or ``“TON”`` for toncoins. + amount (:obj:`int`, optional): + The amount of the currency that was received by the channel in nanotoncoins; for + payments in toncoins only. + star_amount (:class:`telegram.StarAmount`, optional): + The amount of Telegram Stars that was received by the channel; for payments in Telegram + Stars only. + + + Attributes: + suggested_post_message (:class:`telegram.Message`): + Optional. Message containing the suggested post. Note that the + :class:`~telegram.Message` object in this field will not contain + the :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + currency (:obj:`str`): + Currency in which the payment was made. Currently, one of ``“XTR”`` for Telegram Stars + or ``“TON”`` for toncoins. + amount (:obj:`int`): + Optional. The amount of the currency that was received by the channel in nanotoncoins; + for payments in toncoins only. + star_amount (:class:`telegram.StarAmount`): + Optional. The amount of Telegram Stars that was received by the channel; for payments + in Telegram Stars only. + + """ + + __slots__ = ("amount", "currency", "star_amount", "suggested_post_message") + + def __init__( + self, + currency: str, + suggested_post_message: Message | None = None, + amount: int | None = None, + star_amount: StarAmount | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.currency: str = currency + # Optionals + self.suggested_post_message: Message | None = suggested_post_message + self.amount: int | None = amount + self.star_amount: StarAmount | None = star_amount + + self._id_attrs = ( + self.currency, + self.suggested_post_message, + self.amount, + self.star_amount, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostPaid": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["suggested_post_message"] = de_json_optional( + data.get("suggested_post_message"), Message, bot + ) + data["star_amount"] = de_json_optional(data.get("star_amount"), StarAmount, bot) + + return super().de_json(data=data, bot=bot) + + +class SuggestedPostRefunded(TelegramObject): + """ + Describes a service message about a payment refund for a suggested post. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`suggested_post_message` and :attr:`reason` are equal. + + .. versionadded:: 22.4 + + Args: + suggested_post_message (:class:`telegram.Message`, optional): + Message containing the suggested post. Note that the :class:`~telegram.Message` object + in this field will not contain the :attr:`~telegram.Message.reply_to_message` field + even if it itself is a reply. + reason (:obj:`str`): + Reason for the refund. Currently, + one of :tg-const:`telegram.constants.SuggestedPostRefunded.POST_DELETED` if the post + was deleted within 24 hours of being posted or removed from scheduled messages without + being posted, or :tg-const:`telegram.constants.SuggestedPostRefunded.PAYMENT_REFUNDED` + if the payer refunded their payment. + + Attributes: + suggested_post_message (:class:`telegram.Message`): + Optional. Message containing the suggested post. Note that the + :class:`~telegram.Message` object in this field will not contain + the :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + reason (:obj:`str`): + Reason for the refund. Currently, + one of :tg-const:`telegram.constants.SuggestedPostRefunded.POST_DELETED` if the post + was deleted within 24 hours of being posted or removed from scheduled messages without + being posted, or :tg-const:`telegram.constants.SuggestedPostRefunded.PAYMENT_REFUNDED` + if the payer refunded their payment. + + """ + + __slots__ = ("reason", "suggested_post_message") + + def __init__( + self, + reason: str, + suggested_post_message: Message | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.reason: str = reason + # Optionals + self.suggested_post_message: Message | None = suggested_post_message + + self._id_attrs = (self.reason, self.suggested_post_message) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostRefunded": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["suggested_post_message"] = de_json_optional( + data.get("suggested_post_message"), Message, bot + ) + + return super().de_json(data=data, bot=bot) + + +class SuggestedPostApproved(TelegramObject): + """ + Describes a service message about the approval of a suggested post. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if all of their attributes are equal. + + .. versionadded:: 22.4 + + Args: + suggested_post_message (:class:`telegram.Message`, optional): + Message containing the suggested post. Note that the :class:`~telegram.Message` object + in this field will not contain the :attr:`~telegram.Message.reply_to_message` field + even if it itself is a reply. + price (:obj:`SuggestedPostPrice`, optional): + Amount paid for the post. + send_date (:class:`datetime.datetime`): + Date when the post will be published. + |datetime_localization| + + Attributes: + suggested_post_message (:class:`telegram.Message`): + Optional. Message containing the suggested post. Note that the + :class:`~telegram.Message` object in this field will not contain + the :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + price (:obj:`SuggestedPostPrice`): + Optional. Amount paid for the post. + send_date (:class:`datetime.datetime`): + Date when the post will be published. + |datetime_localization| + + """ + + __slots__ = ("price", "send_date", "suggested_post_message") + + def __init__( + self, + send_date: dtm.datetime, + suggested_post_message: Message | None = None, + price: SuggestedPostPrice | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.send_date: dtm.datetime = send_date + # Optionals + self.suggested_post_message: Message | None = suggested_post_message + self.price: SuggestedPostPrice | None = price + + self._id_attrs = (self.send_date, self.suggested_post_message, self.price) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostApproved": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["send_date"] = from_timestamp(data.get("send_date"), tzinfo=loc_tzinfo) + data["price"] = de_json_optional(data.get("price"), SuggestedPostPrice, bot) + data["suggested_post_message"] = de_json_optional( + data.get("suggested_post_message"), Message, bot + ) + + return super().de_json(data=data, bot=bot) + + +class SuggestedPostApprovalFailed(TelegramObject): + """ + Describes a service message about the failed approval of a suggested post. Currently, only + caused by insufficient user funds at the time of approval. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`suggested_post_message` and :attr:`price` are equal. + + .. versionadded:: 22.4 + + Args: + suggested_post_message (:class:`telegram.Message`, optional): + Message containing the suggested post. Note that the :class:`~telegram.Message` object + in this field will not contain the :attr:`~telegram.Message.reply_to_message` field + even if it itself is a reply. + price (:obj:`SuggestedPostPrice`): + Expected price of the post. + + Attributes: + suggested_post_message (:class:`telegram.Message`): + Optional. Message containing the suggested post. Note that the + :class:`~telegram.Message` object in this field will not contain + the :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + price (:obj:`SuggestedPostPrice`): + Expected price of the post. + + """ + + __slots__ = ("price", "suggested_post_message") + + def __init__( + self, + price: SuggestedPostPrice, + suggested_post_message: Message | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.price: SuggestedPostPrice = price + # Optionals + self.suggested_post_message: Message | None = suggested_post_message + + self._id_attrs = (self.price, self.suggested_post_message) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostApprovalFailed": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["price"] = de_json_optional(data.get("price"), SuggestedPostPrice, bot) + data["suggested_post_message"] = de_json_optional( + data.get("suggested_post_message"), Message, bot + ) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_switchinlinequerychosenchat.py b/src/telegram/_switchinlinequerychosenchat.py similarity index 84% rename from telegram/_switchinlinequerychosenchat.py rename to src/telegram/_switchinlinequerychosenchat.py index 7fca5a9f728..30e721321e3 100644 --- a/telegram/_switchinlinequerychosenchat.py +++ b/src/telegram/_switchinlinequerychosenchat.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,7 +16,6 @@ # # You should have received a copy of the GNU Lesser Public License """This module contains a class that represents a Telegram SwitchInlineQueryChosenChat.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -73,21 +72,21 @@ class SwitchInlineQueryChosenChat(TelegramObject): def __init__( self, - query: Optional[str] = None, - allow_user_chats: Optional[bool] = None, - allow_bot_chats: Optional[bool] = None, - allow_group_chats: Optional[bool] = None, - allow_channel_chats: Optional[bool] = None, + query: str | None = None, + allow_user_chats: bool | None = None, + allow_bot_chats: bool | None = None, + allow_group_chats: bool | None = None, + allow_channel_chats: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Optional - self.query: Optional[str] = query - self.allow_user_chats: Optional[bool] = allow_user_chats - self.allow_bot_chats: Optional[bool] = allow_bot_chats - self.allow_group_chats: Optional[bool] = allow_group_chats - self.allow_channel_chats: Optional[bool] = allow_channel_chats + self.query: str | None = query + self.allow_user_chats: bool | None = allow_user_chats + self.allow_bot_chats: bool | None = allow_bot_chats + self.allow_group_chats: bool | None = allow_group_chats + self.allow_channel_chats: bool | None = allow_channel_chats self._id_attrs = ( self.query, diff --git a/telegram/_telegramobject.py b/src/telegram/_telegramobject.py similarity index 86% rename from telegram/_telegramobject.py rename to src/telegram/_telegramobject.py index baa739e410e..4098c75c37e 100644 --- a/telegram/_telegramobject.py +++ b/src/telegram/_telegramobject.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Base class for Telegram Objects.""" + import contextlib import datetime as dtm import inspect @@ -26,7 +27,7 @@ from copy import deepcopy from itertools import chain from types import MappingProxyType -from typing import TYPE_CHECKING, Any, ClassVar, Optional, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast from telegram._utils.datetime import to_timestamp from telegram._utils.defaultvalue import DefaultValue @@ -37,6 +38,7 @@ from telegram import Bot Tele_co = TypeVar("Tele_co", bound="TelegramObject", covariant=True) +Tele = TypeVar("Tele", bound="TelegramObject") class TelegramObject: @@ -84,16 +86,16 @@ class TelegramObject: # Used to check if __INIT_PARAMS has been set for the current class. Unfortunately, we can't # just check if `__INIT_PARAMS is None`, since subclasses use the parent class' __INIT_PARAMS # unless it's overridden - __INIT_PARAMS_CHECK: Optional[type["TelegramObject"]] = None + __INIT_PARAMS_CHECK: type["TelegramObject"] | None = None - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: # Setting _frozen to `False` here means that classes without arguments still need to # implement __init__. However, with `True` would mean increased usage of # `with self._unfrozen()` in the `__init__` of subclasses and we have fewer empty # classes than classes with arguments. self._frozen: bool = False self._id_attrs: tuple[object, ...] = () - self._bot: Optional[Bot] = None + self._bot: Bot | None = None # We don't do anything with api_kwargs here - see docstring of _apply_api_kwargs self.api_kwargs: Mapping[str, Any] = MappingProxyType(api_kwargs or {}) @@ -206,8 +208,7 @@ def __repr__(self) -> str: if ( as_dict[k] is not None and not ( - isinstance(as_dict[k], Sized) - and len(as_dict[k]) == 0 # type: ignore[arg-type] + isinstance(as_dict[k], Sized) and len(as_dict[k]) == 0 # type: ignore[arg-type] ) ) ) @@ -248,7 +249,7 @@ def __getitem__(self, item: str) -> object: f"`{item}`." ) from exc - def __getstate__(self) -> dict[str, Union[str, object]]: + def __getstate__(self) -> dict[str, str | object]: """ Overrides :meth:`object.__getstate__` to customize the pickling process of objects of this type. @@ -259,7 +260,7 @@ def __getstate__(self) -> dict[str, Union[str, object]]: state (dict[:obj:`str`, :obj:`object`]): The state of the object. """ out = self._get_attrs( - include_private=True, recursive=False, remove_bot=True, convert_default_vault=False + include_private=True, recursive=False, remove_bot=True, convert_default_value=False ) # MappingProxyType is not pickable, so we convert it to a dict and revert in # __setstate__ @@ -290,7 +291,7 @@ def __setstate__(self, state: dict[str, object]) -> None: self._bot = None # get api_kwargs first because we may need to add entries to it (see try-except below) - api_kwargs = cast(dict[str, object], state.pop("api_kwargs", {})) + api_kwargs = cast("dict[str, object]", state.pop("api_kwargs", {})) # get _frozen before the loop to avoid setting it to True in the loop frozen = state.pop("_frozen", False) @@ -377,23 +378,20 @@ def __deepcopy__(self: Tele_co, memodict: dict[int, object]) -> Tele_co: return result @staticmethod - def _parse_data(data: Optional[JSONDict]) -> Optional[JSONDict]: + def _parse_data(data: JSONDict) -> JSONDict: """Should be called by subclasses that override de_json to ensure that the input is not altered. Whoever calls de_json might still want to use the original input for something else. """ - return None if data is None else data.copy() + return data.copy() @classmethod def _de_json( cls: type[Tele_co], - data: Optional[JSONDict], - bot: Optional["Bot"], - api_kwargs: Optional[JSONDict] = None, - ) -> Optional[Tele_co]: - if data is None: - return None - + data: JSONDict, + bot: "Bot | None", + api_kwargs: JSONDict | None = None, + ) -> Tele_co: # try-except is significantly faster in case we already have a correct argument set try: obj = cls(**data, api_kwargs=api_kwargs) @@ -417,9 +415,7 @@ def _de_json( return obj @classmethod - def de_json( - cls: type[Tele_co], data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional[Tele_co]: + def de_json(cls: type[Tele_co], data: JSONDict, bot: "Bot | None" = None) -> Tele_co: """Converts JSON data to a Telegram object. Args: @@ -438,7 +434,7 @@ def de_json( @classmethod def de_list( - cls: type[Tele_co], data: Optional[list[JSONDict]], bot: Optional["Bot"] = None + cls: type[Tele_co], data: list[JSONDict], bot: "Bot | None" = None ) -> tuple[Tele_co, ...]: """Converts a list of JSON objects to a tuple of Telegram objects. @@ -459,13 +455,10 @@ def de_list( A tuple of Telegram objects. """ - if not data: - return () - - return tuple(obj for obj in (cls.de_json(d, bot) for d in data) if obj is not None) + return tuple(cls.de_json(d, bot) for d in data) @contextmanager - def _unfrozen(self: Tele_co) -> Iterator[Tele_co]: + def _unfrozen(self: Tele) -> Iterator[Tele]: """Context manager to temporarily unfreeze the object. For internal use only. Note: @@ -507,6 +500,12 @@ def _apply_api_kwargs(self, api_kwargs: JSONDict) -> None: elif getattr(self, key, True) is None: setattr(self, key, api_kwargs.pop(key)) + def _is_deprecated_attr(self, attr: str) -> bool: + """Checks whether `attr` is in the list of deprecated time period attributes.""" + return ( + class_name := self.__class__.__name__ + ) in _TIME_PERIOD_DEPRECATIONS and attr in _TIME_PERIOD_DEPRECATIONS[class_name] + def _get_attrs_names(self, include_private: bool) -> Iterator[str]: """ Returns the names of the attributes of this object. This is used to determine which @@ -529,15 +528,20 @@ def _get_attrs_names(self, include_private: bool) -> Iterator[str]: if include_private: return all_attrs - return (attr for attr in all_attrs if not attr.startswith("_")) + return ( + attr + for attr in all_attrs + # Include deprecated private attributes, which are exposed via properties + if not attr.startswith("_") or self._is_deprecated_attr(attr) + ) def _get_attrs( self, include_private: bool = False, recursive: bool = False, remove_bot: bool = False, - convert_default_vault: bool = True, - ) -> dict[str, Union[str, object]]: + convert_default_value: bool = True, + ) -> dict[str, str | object]: """This method is used for obtaining the attributes of the object. Args: @@ -545,7 +549,7 @@ def _get_attrs( recursive (:obj:`bool`): If :obj:`True`, will convert any ``TelegramObjects`` (if found) in the attributes to a dictionary. Else, preserves it as an object itself. remove_bot (:obj:`bool`): Whether the bot should be included in the result. - convert_default_vault (:obj:`bool`): Whether :class:`telegram.DefaultValue` should be + convert_default_value (:obj:`bool`): Whether :class:`telegram.DefaultValue` should be converted to its true value. This is necessary when converting to a dictionary for end users since DefaultValue is used in some classes that work with `tg.ext.defaults` (like `LinkPreviewOptions`) @@ -558,7 +562,7 @@ def _get_attrs( for key in self._get_attrs_names(include_private=include_private): value = ( DefaultValue.get_value(getattr(self, key, None)) - if convert_default_vault + if convert_default_value else getattr(self, key, None) ) @@ -611,8 +615,9 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # datetimes to timestamps. This mostly eliminates the need for subclasses to override # `to_dict` pop_keys: set[str] = set() + timedelta_dict: dict = {} for key, value in out.items(): - if isinstance(value, (tuple, list)): + if isinstance(value, tuple | list): if not value: # not popping directly to avoid changing the dict size during iteration pop_keys.add(key) @@ -623,7 +628,7 @@ def to_dict(self, recursive: bool = True) -> JSONDict: if hasattr(item, "to_dict"): val.append(item.to_dict(recursive=recursive)) # This branch is useful for e.g. tuple[tuple[PhotoSize|KeyboardButton]] - elif isinstance(item, (tuple, list)): + elif isinstance(item, tuple | list): val.append( [ i.to_dict(recursive=recursive) if hasattr(i, "to_dict") else i @@ -637,11 +642,25 @@ def to_dict(self, recursive: bool = True) -> JSONDict: elif isinstance(value, dtm.datetime): out[key] = to_timestamp(value) elif isinstance(value, dtm.timedelta): - out[key] = value.total_seconds() + # Converting to int here is neccassry in some cases where Bot API returns + # 'BadRquest' when expecting integers (e.g. InputMediaVideo.duration). + # Other times, floats are accepted but the Bot API handles ints just as well + # (e.g. InputStoryContentVideo.duration). + # Not updating `out` directly to avoid changing the dict size during iteration + timedelta_dict[key.removeprefix("_")] = ( + int(seconds) if (seconds := value.total_seconds()).is_integer() else seconds + ) + # This will sometimes add non-deprecated timedelta attributes to pop_keys. + # We'll restore them shortly. + pop_keys.add(key) for key in pop_keys: out.pop(key) + # `out.update` must to be called *after* we pop deprecated time period attributes + # this ensures that we restore attributes that were already using datetime.timdelta + out.update(timedelta_dict) + # Effectively "unpack" api_kwargs into `out`: out.update(out.pop("api_kwargs", {})) # type: ignore[call-overload] return out @@ -662,7 +681,7 @@ def get_bot(self) -> "Bot": ) return self._bot - def set_bot(self, bot: Optional["Bot"]) -> None: + def set_bot(self, bot: "Bot | None") -> None: """Sets the :class:`telegram.Bot` instance associated with this object. .. seealso:: :meth:`get_bot` @@ -673,3 +692,31 @@ def set_bot(self, bot: Optional["Bot"]) -> None: bot (:class:`telegram.Bot` | :obj:`None`): The bot instance. """ self._bot = bot + + +# We use str keys to avoid importing which causes circular dependencies +_TIME_PERIOD_DEPRECATIONS: dict[str, tuple[str, ...]] = { + "ChatFullInfo": ("_message_auto_delete_time", "_slow_mode_delay"), + "Animation": ("_duration",), + "Audio": ("_duration",), + "Video": ("_duration", "_start_timestamp"), + "VideoNote": ("_duration",), + "Voice": ("_duration",), + "PaidMediaPreview": ("_duration",), + "VideoChatEnded": ("_duration",), + "InputMediaVideo": ("_duration",), + "InputMediaAnimation": ("_duration",), + "InputMediaAudio": ("_duration",), + "InputPaidMediaVideo": ("_duration",), + "InlineQueryResultGif": ("_gif_duration",), + "InlineQueryResultMpeg4Gif": ("_mpeg4_duration",), + "InlineQueryResultVideo": ("_video_duration",), + "InlineQueryResultAudio": ("_audio_duration",), + "InlineQueryResultVoice": ("_voice_duration",), + "InlineQueryResultLocation": ("_live_period",), + "Poll": ("_open_period",), + "Location": ("_live_period",), + "MessageAutoDeleteTimerChanged": ("_message_auto_delete_time",), + "ChatInviteLink": ("_subscription_period",), + "InputLocationMessageContent": ("_live_period",), +} diff --git a/src/telegram/_uniquegift.py b/src/telegram/_uniquegift.py new file mode 100644 index 00000000000..b6b92b654ce --- /dev/null +++ b/src/telegram/_uniquegift.py @@ -0,0 +1,614 @@ +#!/usr/bin/env python +# +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/] +"""This module contains classes related to unique gifs.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final + +from telegram import constants +from telegram._chat import Chat +from telegram._files.sticker import Sticker +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional, parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class UniqueGiftColors(TelegramObject): + """This object contains information about the color scheme for a user's name, message replies + and link previews based on a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`model_custom_emoji_id`, :attr:`symbol_custom_emoji_id`, + :attr:`light_theme_main_color`, :attr:`light_theme_other_colors`, + :attr:`dark_theme_main_color`, and :attr:`dark_theme_other_colors` are equal. + + .. versionadded:: 22.6 + + Args: + model_custom_emoji_id (:obj:`str`): Custom emoji identifier of the unique gift's model. + symbol_custom_emoji_id (:obj:`str`): Custom emoji identifier of the unique gift's symbol. + light_theme_main_color (:obj:`int`): Main color used in light themes; RGB format. + light_theme_other_colors (Sequence[:obj:`int`]): List of 1-3 additional colors used in + light themes; RGB format. |sequenceclassargs| + dark_theme_main_color (:obj:`int`): Main color used in dark themes; RGB format. + dark_theme_other_colors (Sequence[:obj:`int`]): List of 1-3 additional colors used in dark + themes; RGB format. |sequenceclassargs| + + Attributes: + model_custom_emoji_id (:obj:`str`): Custom emoji identifier of the unique gift's model. + symbol_custom_emoji_id (:obj:`str`): Custom emoji identifier of the unique gift's symbol. + light_theme_main_color (:obj:`int`): Main color used in light themes; RGB format. + light_theme_other_colors (Tuple[:obj:`int`]): Tuple of 1-3 additional colors used in + light themes; RGB format. + dark_theme_main_color (:obj:`int`): Main color used in dark themes; RGB format. + dark_theme_other_colors (Tuple[:obj:`int`]): Tuple of 1-3 additional colors used in dark + themes; RGB format. + """ + + __slots__ = ( + "dark_theme_main_color", + "dark_theme_other_colors", + "light_theme_main_color", + "light_theme_other_colors", + "model_custom_emoji_id", + "symbol_custom_emoji_id", + ) + + def __init__( + self, + model_custom_emoji_id: str, + symbol_custom_emoji_id: str, + light_theme_main_color: int, + light_theme_other_colors: Sequence[int], + dark_theme_main_color: int, + dark_theme_other_colors: Sequence[int], + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.model_custom_emoji_id: str = model_custom_emoji_id + self.symbol_custom_emoji_id: str = symbol_custom_emoji_id + self.light_theme_main_color: int = light_theme_main_color + self.light_theme_other_colors: tuple[int, ...] = parse_sequence_arg( + light_theme_other_colors + ) + self.dark_theme_main_color: int = dark_theme_main_color + self.dark_theme_other_colors: tuple[int, ...] = parse_sequence_arg(dark_theme_other_colors) + + self._id_attrs = ( + self.model_custom_emoji_id, + self.symbol_custom_emoji_id, + self.light_theme_main_color, + self.light_theme_other_colors, + self.dark_theme_main_color, + self.dark_theme_other_colors, + ) + + self._freeze() + + +class UniqueGiftModel(TelegramObject): + """This object describes the model of a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`name`, :attr:`sticker` and :attr:`rarity_per_mille` are equal. + + .. versionadded:: 22.1 + + Args: + name (:obj:`str`): Name of the model. + sticker (:class:`telegram.Sticker`): The sticker that represents the unique gift. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this + model for every ``1000`` gifts upgraded. + + Attributes: + name (:obj:`str`): Name of the model. + sticker (:class:`telegram.Sticker`): The sticker that represents the unique gift. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this + model for every ``1000`` gifts upgraded. + + """ + + __slots__ = ( + "name", + "rarity_per_mille", + "sticker", + ) + + def __init__( + self, + name: str, + sticker: Sticker, + rarity_per_mille: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.name: str = name + self.sticker: Sticker = sticker + self.rarity_per_mille: int = rarity_per_mille + + self._id_attrs = (self.name, self.sticker, self.rarity_per_mille) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UniqueGiftModel": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + + return super().de_json(data=data, bot=bot) + + +class UniqueGiftSymbol(TelegramObject): + """This object describes the symbol shown on the pattern of a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`name`, :attr:`sticker` and :attr:`rarity_per_mille` are equal. + + .. versionadded:: 22.1 + + Args: + name (:obj:`str`): Name of the symbol. + sticker (:class:`telegram.Sticker`): The sticker that represents the unique gift. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this + model for every ``1000`` gifts upgraded. + + Attributes: + name (:obj:`str`): Name of the symbol. + sticker (:class:`telegram.Sticker`): The sticker that represents the unique gift. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this + model for every ``1000`` gifts upgraded. + + """ + + __slots__ = ( + "name", + "rarity_per_mille", + "sticker", + ) + + def __init__( + self, + name: str, + sticker: Sticker, + rarity_per_mille: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.name: str = name + self.sticker: Sticker = sticker + self.rarity_per_mille: int = rarity_per_mille + + self._id_attrs = (self.name, self.sticker, self.rarity_per_mille) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UniqueGiftSymbol": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + + return super().de_json(data=data, bot=bot) + + +class UniqueGiftBackdropColors(TelegramObject): + """This object describes the colors of the backdrop of a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`center_color`, :attr:`edge_color`, :attr:`symbol_color`, + and :attr:`text_color` are equal. + + .. versionadded:: 22.1 + + Args: + center_color (:obj:`int`): The color in the center of the backdrop in RGB format. + edge_color (:obj:`int`): The color on the edges of the backdrop in RGB format. + symbol_color (:obj:`int`): The color to be applied to the symbol in RGB format. + text_color (:obj:`int`): The color for the text on the backdrop in RGB format. + + Attributes: + center_color (:obj:`int`): The color in the center of the backdrop in RGB format. + edge_color (:obj:`int`): The color on the edges of the backdrop in RGB format. + symbol_color (:obj:`int`): The color to be applied to the symbol in RGB format. + text_color (:obj:`int`): The color for the text on the backdrop in RGB format. + + """ + + __slots__ = ( + "center_color", + "edge_color", + "symbol_color", + "text_color", + ) + + def __init__( + self, + center_color: int, + edge_color: int, + symbol_color: int, + text_color: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.center_color: int = center_color + self.edge_color: int = edge_color + self.symbol_color: int = symbol_color + self.text_color: int = text_color + + self._id_attrs = (self.center_color, self.edge_color, self.symbol_color, self.text_color) + + self._freeze() + + +class UniqueGiftBackdrop(TelegramObject): + """This object describes the backdrop of a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`name`, :attr:`colors`, and :attr:`rarity_per_mille` are equal. + + .. versionadded:: 22.1 + + Args: + name (:obj:`str`): Name of the backdrop. + colors (:class:`telegram.UniqueGiftBackdropColors`): Colors of the backdrop. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this backdrop + for every ``1000`` gifts upgraded. + + Attributes: + name (:obj:`str`): Name of the backdrop. + colors (:class:`telegram.UniqueGiftBackdropColors`): Colors of the backdrop. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this backdrop + for every ``1000`` gifts upgraded. + + """ + + __slots__ = ( + "colors", + "name", + "rarity_per_mille", + ) + + def __init__( + self, + name: str, + colors: UniqueGiftBackdropColors, + rarity_per_mille: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.name: str = name + self.colors: UniqueGiftBackdropColors = colors + self.rarity_per_mille: int = rarity_per_mille + + self._id_attrs = (self.name, self.colors, self.rarity_per_mille) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UniqueGiftBackdrop": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["colors"] = de_json_optional(data.get("colors"), UniqueGiftBackdropColors, bot) + + return super().de_json(data=data, bot=bot) + + +class UniqueGift(TelegramObject): + """This object describes a unique gift that was upgraded from a regular gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`base_name`, :attr:`name`, :attr:`number`, :class:`model`, + :attr:`symbol`, and :attr:`backdrop` are equal. + + .. versionadded:: 22.1 + + .. versionchanged:: NEXT.VERSION + :attr:`gift_id` is now a positional argument. + + Args: + gift_id (:obj:`str`): Identifier of the regular gift from which the gift was upgraded. + + .. versionadded:: 22.6 + base_name (:obj:`str`): Human-readable name of the regular gift from which this unique + gift was upgraded. + name (:obj:`str`): Unique name of the gift. This name can be used + in ``https://t.me/nft/...`` links and story areas. + number (:obj:`int`): Unique number of the upgraded gift among gifts upgraded from the + same regular gift. + model (:class:`UniqueGiftModel`): Model of the gift. + symbol (:class:`UniqueGiftSymbol`): Symbol of the gift. + backdrop (:class:`UniqueGiftBackdrop`): Backdrop of the gift. + publisher_chat (:class:`telegram.Chat`, optional): Information about the chat that + published the gift. + + .. versionadded:: 22.4 + is_premium (:obj:`bool`, optional): :obj:`True`, if the original regular gift was + exclusively purchaseable by Telegram Premium subscribers. + + .. versionadded:: 22.6 + is_from_blockchain (:obj:`bool`, optional): :obj:`True`, if the gift is assigned from the + TON blockchain and can't be resold or transferred in Telegram. + + .. versionadded:: 22.6 + colors (:class:`telegram.UniqueGiftColors`, optional): The color scheme that can be used + by the gift's owner for the chat's name, replies to messages and link previews; for + business account gifts and gifts that are currently on sale only. + + .. versionadded:: 22.6 + + Attributes: + gift_id (:obj:`str`): Identifier of the regular gift from which the gift was upgraded. + + .. versionadded:: 22.6 + base_name (:obj:`str`): Human-readable name of the regular gift from which this unique + gift was upgraded. + name (:obj:`str`): Unique name of the gift. This name can be used + in ``https://t.me/nft/...`` links and story areas. + number (:obj:`int`): Unique number of the upgraded gift among gifts upgraded from the + same regular gift. + model (:class:`telegram.UniqueGiftModel`): Model of the gift. + symbol (:class:`telegram.UniqueGiftSymbol`): Symbol of the gift. + backdrop (:class:`telegram.UniqueGiftBackdrop`): Backdrop of the gift. + publisher_chat (:class:`telegram.Chat`): Optional. Information about the chat that + published the gift. + + .. versionadded:: 22.4 + is_premium (:obj:`bool`): Optional. :obj:`True`, if the original regular gift was + exclusively purchaseable by Telegram Premium subscribers. + + .. versionadded:: 22.6 + is_from_blockchain (:obj:`bool`): Optional. :obj:`True`, if the gift is assigned from the + TON blockchain and can't be resold or transferred in Telegram. + + .. versionadded:: 22.6 + colors (:class:`telegram.UniqueGiftColors`): Optional. The color scheme that can be used + by the gift's owner for the chat's name, replies to messages and link previews; for + business account gifts and gifts that are currently on sale only. + + .. versionadded:: 22.6 + + """ + + __slots__ = ( + "backdrop", + "base_name", + "colors", + "gift_id", + "is_from_blockchain", + "is_premium", + "model", + "name", + "number", + "publisher_chat", + "symbol", + ) + + def __init__( + self, + gift_id: str, + base_name: str, + name: str, + number: int, + model: UniqueGiftModel, + symbol: UniqueGiftSymbol, + backdrop: UniqueGiftBackdrop, + publisher_chat: Chat | None = None, + is_from_blockchain: bool | None = None, + is_premium: bool | None = None, + colors: UniqueGiftColors | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.gift_id: str = gift_id + self.base_name: str = base_name + self.name: str = name + self.number: int = number + self.model: UniqueGiftModel = model + self.symbol: UniqueGiftSymbol = symbol + self.backdrop: UniqueGiftBackdrop = backdrop + self.publisher_chat: Chat | None = publisher_chat + self.is_from_blockchain: bool | None = is_from_blockchain + self.is_premium: bool | None = is_premium + self.colors: UniqueGiftColors | None = colors + + self._id_attrs = ( + self.base_name, + self.name, + self.number, + self.model, + self.symbol, + self.backdrop, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UniqueGift": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["model"] = de_json_optional(data.get("model"), UniqueGiftModel, bot) + data["symbol"] = de_json_optional(data.get("symbol"), UniqueGiftSymbol, bot) + data["backdrop"] = de_json_optional(data.get("backdrop"), UniqueGiftBackdrop, bot) + data["publisher_chat"] = de_json_optional(data.get("publisher_chat"), Chat, bot) + data["colors"] = de_json_optional(data.get("colors"), UniqueGiftColors, bot) + + return super().de_json(data=data, bot=bot) + + +class UniqueGiftInfo(TelegramObject): + """Describes a service message about a unique gift that was sent or received. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`gift`, and :attr:`origin` are equal. + + .. versionadded:: 22.1 + + .. versionremoved:: NEXT.VERSION + Removed argument and attribute ``last_resale_star_count`` deprecated since Bot API 9.3. + + Args: + gift (:class:`UniqueGift`): Information about the gift. + origin (:obj:`str`): Origin of the gift. Currently, either :attr:`UPGRADE` for gifts + upgraded from regular gifts, :attr:`TRANSFER` for gifts transferred from other users + or channels, :attr:`RESALE` for gifts bought from other users, + :attr:`GIFTED_UPGRADE` for upgrades purchased after the gift was sent, or :attr:`OFFER` + for gifts bought or sold through gift purchase offers + + .. versionchanged:: 22.3 + The :attr:`RESALE` origin was added. + .. versionchanged:: 22.6 + Bot API 9.3 added the :attr:`GIFTED_UPGRADE` and :attr:`OFFER` origins. + owned_gift_id (:obj:`str`, optional) Unique identifier of the received gift for the + bot; only present for gifts received on behalf of business accounts. + transfer_star_count (:obj:`int`, optional): Number of Telegram Stars that must be paid + to transfer the gift; omitted if the bot cannot transfer the gift. + last_resale_currency (:obj:`str`, optional): For gifts bought from other users, the + currency in which the payment for the gift was done. Currently, one of ``XTR`` for + Telegram Stars or ``TON`` for toncoins. + + .. versionadded:: 22.6 + last_resale_amount (:obj:`int`, optional): For gifts bought from other users, the price + paid for the gift in either Telegram Stars or nanotoncoins. + + .. versionadded:: 22.6 + next_transfer_date (:obj:`datetime.datetime`, optional): Date when the gift can be + transferred. If it's in the past, then the gift can be transferred now. + |datetime_localization| + + .. versionadded:: 22.3 + + Attributes: + gift (:class:`UniqueGift`): Information about the gift. + origin (:obj:`str`): Origin of the gift. Currently, either :attr:`UPGRADE` for gifts + upgraded from regular gifts, :attr:`TRANSFER` for gifts transferred from other users + or channels, :attr:`RESALE` for gifts bought from other users, + :attr:`GIFTED_UPGRADE` for upgrades purchased after the gift was sent, or :attr:`OFFER` + for gifts bought or sold through gift purchase offers + + .. versionchanged:: 22.3 + The :attr:`RESALE` origin was added. + .. versionchanged:: 22.6 + Bot API 9.3 added the :attr:`GIFTED_UPGRADE` and :attr:`OFFER` origins. + owned_gift_id (:obj:`str`) Optional. Unique identifier of the received gift for the + bot; only present for gifts received on behalf of business accounts. + transfer_star_count (:obj:`int`): Optional. Number of Telegram Stars that must be paid + to transfer the gift; omitted if the bot cannot transfer the gift. + last_resale_currency (:obj:`str`): Optional. For gifts bought from other users, the + currency in which the payment for the gift was done. Currently, one of ``XTR`` for + Telegram Stars or ``TON`` for toncoins. + + .. versionadded:: 22.6 + last_resale_amount (:obj:`int`): Optional. For gifts bought from other users, the price + paid for the gift in either Telegram Stars or nanotoncoins. + + .. versionadded:: 22.6 + next_transfer_date (:obj:`datetime.datetime`): Optional. Date when the gift can be + transferred. If it's in the past, then the gift can be transferred now. + |datetime_localization| + + .. versionadded:: 22.3 + """ + + GIFTED_UPGRADE: Final[str] = constants.UniqueGiftInfoOrigin.GIFTED_UPGRADE + """:const:`telegram.constants.UniqueGiftInfoOrigin.GIFTED_UPGRADE` + + .. versionadded:: 22.6 + """ + OFFER: Final[str] = constants.UniqueGiftInfoOrigin.OFFER + """:const:`telegram.constants.UniqueGiftInfoOrigin.OFFER` + + .. versionadded:: 22.6 + """ + RESALE: Final[str] = constants.UniqueGiftInfoOrigin.RESALE + """:const:`telegram.constants.UniqueGiftInfoOrigin.RESALE` + + .. versionadded:: 22.3 + """ + TRANSFER: Final[str] = constants.UniqueGiftInfoOrigin.TRANSFER + """:const:`telegram.constants.UniqueGiftInfoOrigin.TRANSFER`""" + UPGRADE: Final[str] = constants.UniqueGiftInfoOrigin.UPGRADE + """:const:`telegram.constants.UniqueGiftInfoOrigin.UPGRADE`""" + + __slots__ = ( + "gift", + "last_resale_amount", + "last_resale_currency", + "next_transfer_date", + "origin", + "owned_gift_id", + "transfer_star_count", + ) + + def __init__( + self, + gift: UniqueGift, + origin: str, + owned_gift_id: str | None = None, + transfer_star_count: int | None = None, + next_transfer_date: dtm.datetime | None = None, + last_resale_currency: str | None = None, + last_resale_amount: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.gift: UniqueGift = gift + self.origin: str = enum.get_member(constants.UniqueGiftInfoOrigin, origin, origin) + # Optional + self.owned_gift_id: str | None = owned_gift_id + self.transfer_star_count: int | None = transfer_star_count + self.next_transfer_date: dtm.datetime | None = next_transfer_date + self.last_resale_currency: str | None = last_resale_currency + self.last_resale_amount: int | None = last_resale_amount + + self._id_attrs = (self.gift, self.origin) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UniqueGiftInfo": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["gift"] = de_json_optional(data.get("gift"), UniqueGift, bot) + data["next_transfer_date"] = from_timestamp( + data.get("next_transfer_date"), tzinfo=loc_tzinfo + ) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_update.py b/src/telegram/_update.py similarity index 82% rename from telegram/_update.py rename to src/telegram/_update.py index 34f8fe2749a..9e018e4cb0f 100644 --- a/telegram/_update.py +++ b/src/telegram/_update.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Update.""" -from typing import TYPE_CHECKING, Final, Optional, Union +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._business import BusinessConnection, BusinessMessagesDeleted @@ -35,6 +35,7 @@ from telegram._payment.shippingquery import ShippingQuery from telegram._poll import Poll, PollAnswer from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict from telegram._utils.warnings import warn @@ -410,73 +411,71 @@ class Update(TelegramObject): def __init__( self, update_id: int, - message: Optional[Message] = None, - edited_message: Optional[Message] = None, - channel_post: Optional[Message] = None, - edited_channel_post: Optional[Message] = None, - inline_query: Optional[InlineQuery] = None, - chosen_inline_result: Optional[ChosenInlineResult] = None, - callback_query: Optional[CallbackQuery] = None, - shipping_query: Optional[ShippingQuery] = None, - pre_checkout_query: Optional[PreCheckoutQuery] = None, - poll: Optional[Poll] = None, - poll_answer: Optional[PollAnswer] = None, - my_chat_member: Optional[ChatMemberUpdated] = None, - chat_member: Optional[ChatMemberUpdated] = None, - chat_join_request: Optional[ChatJoinRequest] = None, - chat_boost: Optional[ChatBoostUpdated] = None, - removed_chat_boost: Optional[ChatBoostRemoved] = None, - message_reaction: Optional[MessageReactionUpdated] = None, - message_reaction_count: Optional[MessageReactionCountUpdated] = None, - business_connection: Optional[BusinessConnection] = None, - business_message: Optional[Message] = None, - edited_business_message: Optional[Message] = None, - deleted_business_messages: Optional[BusinessMessagesDeleted] = None, - purchased_paid_media: Optional[PaidMediaPurchased] = None, + message: Message | None = None, + edited_message: Message | None = None, + channel_post: Message | None = None, + edited_channel_post: Message | None = None, + inline_query: InlineQuery | None = None, + chosen_inline_result: ChosenInlineResult | None = None, + callback_query: CallbackQuery | None = None, + shipping_query: ShippingQuery | None = None, + pre_checkout_query: PreCheckoutQuery | None = None, + poll: Poll | None = None, + poll_answer: PollAnswer | None = None, + my_chat_member: ChatMemberUpdated | None = None, + chat_member: ChatMemberUpdated | None = None, + chat_join_request: ChatJoinRequest | None = None, + chat_boost: ChatBoostUpdated | None = None, + removed_chat_boost: ChatBoostRemoved | None = None, + message_reaction: MessageReactionUpdated | None = None, + message_reaction_count: MessageReactionCountUpdated | None = None, + business_connection: BusinessConnection | None = None, + business_message: Message | None = None, + edited_business_message: Message | None = None, + deleted_business_messages: BusinessMessagesDeleted | None = None, + purchased_paid_media: PaidMediaPurchased | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.update_id: int = update_id # Optionals - self.message: Optional[Message] = message - self.edited_message: Optional[Message] = edited_message - self.inline_query: Optional[InlineQuery] = inline_query - self.chosen_inline_result: Optional[ChosenInlineResult] = chosen_inline_result - self.callback_query: Optional[CallbackQuery] = callback_query - self.shipping_query: Optional[ShippingQuery] = shipping_query - self.pre_checkout_query: Optional[PreCheckoutQuery] = pre_checkout_query - self.channel_post: Optional[Message] = channel_post - self.edited_channel_post: Optional[Message] = edited_channel_post - self.poll: Optional[Poll] = poll - self.poll_answer: Optional[PollAnswer] = poll_answer - self.my_chat_member: Optional[ChatMemberUpdated] = my_chat_member - self.chat_member: Optional[ChatMemberUpdated] = chat_member - self.chat_join_request: Optional[ChatJoinRequest] = chat_join_request - self.chat_boost: Optional[ChatBoostUpdated] = chat_boost - self.removed_chat_boost: Optional[ChatBoostRemoved] = removed_chat_boost - self.message_reaction: Optional[MessageReactionUpdated] = message_reaction - self.message_reaction_count: Optional[MessageReactionCountUpdated] = message_reaction_count - self.business_connection: Optional[BusinessConnection] = business_connection - self.business_message: Optional[Message] = business_message - self.edited_business_message: Optional[Message] = edited_business_message - self.deleted_business_messages: Optional[BusinessMessagesDeleted] = ( - deleted_business_messages - ) - self.purchased_paid_media: Optional[PaidMediaPurchased] = purchased_paid_media - - self._effective_user: Optional[User] = None - self._effective_sender: Optional[Union[User, Chat]] = None - self._effective_chat: Optional[Chat] = None - self._effective_message: Optional[Message] = None + self.message: Message | None = message + self.edited_message: Message | None = edited_message + self.inline_query: InlineQuery | None = inline_query + self.chosen_inline_result: ChosenInlineResult | None = chosen_inline_result + self.callback_query: CallbackQuery | None = callback_query + self.shipping_query: ShippingQuery | None = shipping_query + self.pre_checkout_query: PreCheckoutQuery | None = pre_checkout_query + self.channel_post: Message | None = channel_post + self.edited_channel_post: Message | None = edited_channel_post + self.poll: Poll | None = poll + self.poll_answer: PollAnswer | None = poll_answer + self.my_chat_member: ChatMemberUpdated | None = my_chat_member + self.chat_member: ChatMemberUpdated | None = chat_member + self.chat_join_request: ChatJoinRequest | None = chat_join_request + self.chat_boost: ChatBoostUpdated | None = chat_boost + self.removed_chat_boost: ChatBoostRemoved | None = removed_chat_boost + self.message_reaction: MessageReactionUpdated | None = message_reaction + self.message_reaction_count: MessageReactionCountUpdated | None = message_reaction_count + self.business_connection: BusinessConnection | None = business_connection + self.business_message: Message | None = business_message + self.edited_business_message: Message | None = edited_business_message + self.deleted_business_messages: BusinessMessagesDeleted | None = deleted_business_messages + self.purchased_paid_media: PaidMediaPurchased | None = purchased_paid_media + + self._effective_user: User | None = None + self._effective_sender: User | Chat | None = None + self._effective_chat: Chat | None = None + self._effective_message: Message | None = None self._id_attrs = (self.update_id,) self._freeze() @property - def effective_user(self) -> Optional["User"]: + def effective_user(self) -> "User | None": """ :class:`telegram.User`: The user that sent this update, no matter what kind of update this is. If no user is associated with this update, this gives :obj:`None`. This is the case @@ -562,7 +561,7 @@ def effective_user(self) -> Optional["User"]: return user @property - def effective_sender(self) -> Optional[Union["User", "Chat"]]: + def effective_sender(self) -> "User | Chat | None": """ :class:`telegram.User` or :class:`telegram.Chat`: The user or chat that sent this update, no matter what kind of update this is. @@ -595,7 +594,7 @@ def effective_sender(self) -> Optional[Union["User", "Chat"]]: if self._effective_sender: return self._effective_sender - sender: Optional[Union[User, Chat]] = None + sender: User | Chat | None = None if message := ( self.message @@ -620,7 +619,7 @@ def effective_sender(self) -> Optional[Union["User", "Chat"]]: return sender @property - def effective_chat(self) -> Optional["Chat"]: + def effective_chat(self) -> "Chat | None": """ :class:`telegram.Chat`: The chat that this update was sent in, no matter what kind of update this is. @@ -693,7 +692,7 @@ def effective_chat(self) -> Optional["Chat"]: return chat @property - def effective_message(self) -> Optional[Message]: + def effective_message(self) -> Message | None: """ :class:`telegram.Message`: The message included in this update, no matter what kind of update this is. More precisely, this will be the message contained in :attr:`message`, @@ -716,7 +715,7 @@ def effective_message(self) -> Optional[Message]: if self._effective_message: return self._effective_message - message: Optional[Message] = None + message: Message | None = None if self.message: message = self.message @@ -757,47 +756,56 @@ def effective_message(self) -> Optional[Message]: return message @classmethod - def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Update"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Update": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["message"] = Message.de_json(data.get("message"), bot) - data["edited_message"] = Message.de_json(data.get("edited_message"), bot) - data["inline_query"] = InlineQuery.de_json(data.get("inline_query"), bot) - data["chosen_inline_result"] = ChosenInlineResult.de_json( - data.get("chosen_inline_result"), bot + data["message"] = de_json_optional(data.get("message"), Message, bot) + data["edited_message"] = de_json_optional(data.get("edited_message"), Message, bot) + data["inline_query"] = de_json_optional(data.get("inline_query"), InlineQuery, bot) + data["chosen_inline_result"] = de_json_optional( + data.get("chosen_inline_result"), ChosenInlineResult, bot + ) + data["callback_query"] = de_json_optional(data.get("callback_query"), CallbackQuery, bot) + data["shipping_query"] = de_json_optional(data.get("shipping_query"), ShippingQuery, bot) + data["pre_checkout_query"] = de_json_optional( + data.get("pre_checkout_query"), PreCheckoutQuery, bot + ) + data["channel_post"] = de_json_optional(data.get("channel_post"), Message, bot) + data["edited_channel_post"] = de_json_optional( + data.get("edited_channel_post"), Message, bot + ) + data["poll"] = de_json_optional(data.get("poll"), Poll, bot) + data["poll_answer"] = de_json_optional(data.get("poll_answer"), PollAnswer, bot) + data["my_chat_member"] = de_json_optional( + data.get("my_chat_member"), ChatMemberUpdated, bot + ) + data["chat_member"] = de_json_optional(data.get("chat_member"), ChatMemberUpdated, bot) + data["chat_join_request"] = de_json_optional( + data.get("chat_join_request"), ChatJoinRequest, bot + ) + data["chat_boost"] = de_json_optional(data.get("chat_boost"), ChatBoostUpdated, bot) + data["removed_chat_boost"] = de_json_optional( + data.get("removed_chat_boost"), ChatBoostRemoved, bot + ) + data["message_reaction"] = de_json_optional( + data.get("message_reaction"), MessageReactionUpdated, bot ) - data["callback_query"] = CallbackQuery.de_json(data.get("callback_query"), bot) - data["shipping_query"] = ShippingQuery.de_json(data.get("shipping_query"), bot) - data["pre_checkout_query"] = PreCheckoutQuery.de_json(data.get("pre_checkout_query"), bot) - data["channel_post"] = Message.de_json(data.get("channel_post"), bot) - data["edited_channel_post"] = Message.de_json(data.get("edited_channel_post"), bot) - data["poll"] = Poll.de_json(data.get("poll"), bot) - data["poll_answer"] = PollAnswer.de_json(data.get("poll_answer"), bot) - data["my_chat_member"] = ChatMemberUpdated.de_json(data.get("my_chat_member"), bot) - data["chat_member"] = ChatMemberUpdated.de_json(data.get("chat_member"), bot) - data["chat_join_request"] = ChatJoinRequest.de_json(data.get("chat_join_request"), bot) - data["chat_boost"] = ChatBoostUpdated.de_json(data.get("chat_boost"), bot) - data["removed_chat_boost"] = ChatBoostRemoved.de_json(data.get("removed_chat_boost"), bot) - data["message_reaction"] = MessageReactionUpdated.de_json( - data.get("message_reaction"), bot + data["message_reaction_count"] = de_json_optional( + data.get("message_reaction_count"), MessageReactionCountUpdated, bot ) - data["message_reaction_count"] = MessageReactionCountUpdated.de_json( - data.get("message_reaction_count"), bot + data["business_connection"] = de_json_optional( + data.get("business_connection"), BusinessConnection, bot ) - data["business_connection"] = BusinessConnection.de_json( - data.get("business_connection"), bot + data["business_message"] = de_json_optional(data.get("business_message"), Message, bot) + data["edited_business_message"] = de_json_optional( + data.get("edited_business_message"), Message, bot ) - data["business_message"] = Message.de_json(data.get("business_message"), bot) - data["edited_business_message"] = Message.de_json(data.get("edited_business_message"), bot) - data["deleted_business_messages"] = BusinessMessagesDeleted.de_json( - data.get("deleted_business_messages"), bot + data["deleted_business_messages"] = de_json_optional( + data.get("deleted_business_messages"), BusinessMessagesDeleted, bot ) - data["purchased_paid_media"] = PaidMediaPurchased.de_json( - data.get("purchased_paid_media"), bot + data["purchased_paid_media"] = de_json_optional( + data.get("purchased_paid_media"), PaidMediaPurchased, bot ) return super().de_json(data=data, bot=bot) diff --git a/telegram/_user.py b/src/telegram/_user.py similarity index 71% rename from telegram/_user.py rename to src/telegram/_user.py index 0e9ce1f2b5c..284f6d17d2b 100644 --- a/telegram/_user.py +++ b/src/telegram/_user.py @@ -2,7 +2,7 @@ # pylint: disable=redefined-builtin # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,15 +18,22 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram User.""" + import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardbutton import InlineKeyboardButton from telegram._menubutton import MenuButton from telegram._telegramobject import TelegramObject from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import CorrectOptionID, FileInput, JSONDict, ODVInput, ReplyMarkup +from telegram._utils.types import ( + CorrectOptionID, + JSONDict, + ODVInput, + TimePeriod, +) +from telegram._utils.usernames import get_full_name, get_link, get_name from telegram.helpers import mention_html as helpers_mention_html from telegram.helpers import mention_markdown as helpers_mention_markdown @@ -49,9 +56,12 @@ Message, MessageEntity, MessageId, + OwnedGifts, PhotoSize, ReplyParameters, Sticker, + Story, + SuggestedPostParameters, UserChatBoosts, UserProfilePhotos, Venue, @@ -59,6 +69,7 @@ VideoNote, Voice, ) + from telegram._utils.types import FileInput, ReplyMarkup class User(TelegramObject): @@ -103,6 +114,10 @@ class User(TelegramObject): Returned only in :meth:`telegram.Bot.get_me`. .. versionadded:: 21.5 + has_topics_enabled (:obj:`bool`, optional): :obj:`True`, if the bot has forum topic mode + enabled in private chats. Returned only in :meth:`telegram.Bot.get_me`. + + .. versionadded:: 22.6 Attributes: id (:obj:`int`): Unique identifier for this user or bot. @@ -134,6 +149,10 @@ class User(TelegramObject): Returned only in :meth:`telegram.Bot.get_me`. .. versionadded:: 21.5 + has_topics_enabled (:obj:`bool`): Optional. :obj:`True`, if the bot has forum topic mode + enabled in private chats. Returned only in :meth:`telegram.Bot.get_me`. + + .. versionadded:: 22.6 .. |user_chat_id_note| replace:: This shortcuts build on the assumption that :attr:`User.id` coincides with the :attr:`Chat.id` of the private chat with the user. This has been the @@ -147,6 +166,7 @@ class User(TelegramObject): "can_read_all_group_messages", "first_name", "has_main_web_app", + "has_topics_enabled", "id", "is_bot", "is_premium", @@ -161,18 +181,19 @@ def __init__( id: int, first_name: str, is_bot: bool, - last_name: Optional[str] = None, - username: Optional[str] = None, - language_code: Optional[str] = None, - can_join_groups: Optional[bool] = None, - can_read_all_group_messages: Optional[bool] = None, - supports_inline_queries: Optional[bool] = None, - is_premium: Optional[bool] = None, - added_to_attachment_menu: Optional[bool] = None, - can_connect_to_business: Optional[bool] = None, - has_main_web_app: Optional[bool] = None, + last_name: str | None = None, + username: str | None = None, + language_code: str | None = None, + can_join_groups: bool | None = None, + can_read_all_group_messages: bool | None = None, + supports_inline_queries: bool | None = None, + is_premium: bool | None = None, + added_to_attachment_menu: bool | None = None, + can_connect_to_business: bool | None = None, + has_main_web_app: bool | None = None, + has_topics_enabled: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -180,16 +201,17 @@ def __init__( self.first_name: str = first_name self.is_bot: bool = is_bot # Optionals - self.last_name: Optional[str] = last_name - self.username: Optional[str] = username - self.language_code: Optional[str] = language_code - self.can_join_groups: Optional[bool] = can_join_groups - self.can_read_all_group_messages: Optional[bool] = can_read_all_group_messages - self.supports_inline_queries: Optional[bool] = supports_inline_queries - self.is_premium: Optional[bool] = is_premium - self.added_to_attachment_menu: Optional[bool] = added_to_attachment_menu - self.can_connect_to_business: Optional[bool] = can_connect_to_business - self.has_main_web_app: Optional[bool] = has_main_web_app + self.last_name: str | None = last_name + self.username: str | None = username + self.language_code: str | None = language_code + self.can_join_groups: bool | None = can_join_groups + self.can_read_all_group_messages: bool | None = can_read_all_group_messages + self.supports_inline_queries: bool | None = supports_inline_queries + self.is_premium: bool | None = is_premium + self.added_to_attachment_menu: bool | None = added_to_attachment_menu + self.can_connect_to_business: bool | None = can_connect_to_business + self.has_main_web_app: bool | None = has_main_web_app + self.has_topics_enabled: bool | None = has_topics_enabled self._id_attrs = (self.id,) @@ -200,39 +222,33 @@ def name(self) -> str: """:obj:`str`: Convenience property. If available, returns the user's :attr:`username` prefixed with "@". If :attr:`username` is not available, returns :attr:`full_name`. """ - if self.username: - return f"@{self.username}" - return self.full_name + return get_name(self) @property def full_name(self) -> str: """:obj:`str`: Convenience property. The user's :attr:`first_name`, followed by (if available) :attr:`last_name`. """ - if self.last_name: - return f"{self.first_name} {self.last_name}" - return self.first_name + return get_full_name(self) @property - def link(self) -> Optional[str]: + def link(self) -> str | None: """:obj:`str`: Convenience property. If :attr:`username` is available, returns a t.me link of the user. """ - if self.username: - return f"https://t.me/{self.username}" - return None + return get_link(self) async def get_profile_photos( self, - offset: Optional[int] = None, - limit: Optional[int] = None, + offset: int | None = None, + limit: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Optional["UserProfilePhotos"]: + api_kwargs: JSONDict | None = None, + ) -> "UserProfilePhotos": """Shortcut for:: await bot.get_user_profile_photos(update.effective_user.id, *args, **kwargs) @@ -240,6 +256,9 @@ async def get_profile_photos( For the documentation of the arguments, please see :meth:`telegram.Bot.get_user_profile_photos`. + Returns: + :class:`telegram.UserProfilePhotos` + """ return await self.get_bot().get_user_profile_photos( user_id=self.id, @@ -252,7 +271,7 @@ async def get_profile_photos( api_kwargs=api_kwargs, ) - def mention_markdown(self, name: Optional[str] = None) -> str: + def mention_markdown(self, name: str | None = None) -> str: """ Note: :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by @@ -270,7 +289,7 @@ def mention_markdown(self, name: Optional[str] = None) -> str: return helpers_mention_markdown(self.id, name) return helpers_mention_markdown(self.id, self.full_name) - def mention_markdown_v2(self, name: Optional[str] = None) -> str: + def mention_markdown_v2(self, name: str | None = None) -> str: """ Args: name (:obj:`str`): The name used as a link for the user. Defaults to :attr:`full_name`. @@ -283,7 +302,7 @@ def mention_markdown_v2(self, name: Optional[str] = None) -> str: return helpers_mention_markdown(self.id, name, version=2) return helpers_mention_markdown(self.id, self.full_name, version=2) - def mention_html(self, name: Optional[str] = None) -> str: + def mention_html(self, name: str | None = None) -> str: """ Args: name (:obj:`str`): The name used as a link for the user. Defaults to :attr:`full_name`. @@ -296,7 +315,7 @@ def mention_html(self, name: Optional[str] = None) -> str: return helpers_mention_html(self.id, name) return helpers_mention_html(self.id, self.full_name) - def mention_button(self, name: Optional[str] = None) -> InlineKeyboardButton: + def mention_button(self, name: str | None = None) -> InlineKeyboardButton: """Shortcut for:: InlineKeyboardButton(text=name, url=f"tg://user?id={update.effective_user.id}") @@ -315,13 +334,13 @@ async def pin_message( self, message_id: int, disable_notification: ODVInput[bool] = DEFAULT_NONE, - business_connection_id: Optional[str] = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -350,14 +369,14 @@ async def pin_message( async def unpin_message( self, - message_id: Optional[int] = None, - business_connection_id: Optional[str] = None, + message_id: int | None = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -390,7 +409,7 @@ async def unpin_all_messages( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -420,24 +439,26 @@ async def send_message( text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "ReplyMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, - disable_web_page_preview: Optional[bool] = None, + reply_to_message_id: int | None = None, + disable_web_page_preview: bool | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -474,6 +495,51 @@ async def send_message( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_message_draft( + self, + draft_id: int, + text: str, + message_thread_id: int | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Sequence["MessageEntity"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.send_message_draft(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message_draft`. + + Note: + |user_chat_id_note| + + .. versionadded:: 22.6 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().send_message_draft( + chat_id=self.id, + draft_id=draft_id, + text=text, + message_thread_id=message_thread_id, + parse_mode=parse_mode, + entities=entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, ) async def delete_message( @@ -484,7 +550,7 @@ async def delete_message( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -516,7 +582,7 @@ async def delete_messages( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -542,29 +608,31 @@ async def delete_messages( async def send_photo( self, - photo: Union[FileInput, "PhotoSize"], - caption: Optional[str] = None, + photo: "FileInput | PhotoSize", + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -603,31 +671,34 @@ async def send_photo( message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_media_group( self, media: Sequence[ - Union["InputMediaAudio", "InputMediaDocument", "InputMediaPhoto", "InputMediaVideo"] + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - caption: Optional[str] = None, + api_kwargs: JSONDict | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, ) -> tuple["Message", ...]: """Shortcut for:: @@ -663,35 +734,38 @@ async def send_media_group( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, ) async def send_audio( self, - audio: Union[FileInput, "Audio"], - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - caption: Optional[str] = None, + audio: "FileInput | Audio", + duration: TimePeriod | None = None, + performer: str | None = None, + title: str | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -732,19 +806,21 @@ async def send_audio( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_chat_action( self, action: str, - message_thread_id: Optional[int] = None, - business_connection_id: Optional[str] = None, + message_thread_id: int | None = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -776,27 +852,29 @@ async def send_chat_action( async def send_contact( self, - phone_number: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, + phone_number: str | None = None, + first_name: str | None = None, + last_name: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - vcard: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + vcard: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - contact: Optional["Contact"] = None, + contact: "Contact | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -833,27 +911,31 @@ async def send_contact( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_dice( self, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - emoji: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + emoji: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -886,33 +968,37 @@ async def send_dice( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_document( self, - document: Union[FileInput, "Document"], - caption: Optional[str] = None, + document: "FileInput | Document", + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_content_type_detection: Optional[bool] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + disable_content_type_detection: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -951,27 +1037,29 @@ async def send_document( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_game( self, game_short_name: str, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1011,39 +1099,41 @@ async def send_invoice( title: str, description: str, payload: str, - provider_token: Optional[str], currency: str, prices: Sequence["LabeledPrice"], - start_parameter: Optional[str] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - is_flexible: Optional[bool] = None, + provider_token: str | None = None, + start_parameter: str | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + is_flexible: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - provider_data: Optional[Union[str, object]] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + provider_data: str | object | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1105,33 +1195,37 @@ async def send_invoice( message_thread_id=message_thread_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_location( self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, + latitude: float | None = None, + longitude: float | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - live_period: Optional[int] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + live_period: TimePeriod | None = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - location: Optional["Location"] = None, + location: "Location | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1170,37 +1264,41 @@ async def send_location( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_animation( self, - animation: Union[FileInput, "Animation"], - duration: Optional[int] = None, - width: Optional[int] = None, - height: Optional[int] = None, - caption: Optional[str] = None, + animation: "FileInput | Animation", + duration: TimePeriod | None = None, + width: int | None = None, + height: int | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "ReplyMarkup | None" = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1243,28 +1341,32 @@ async def send_animation( message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_sticker( self, - sticker: Union[FileInput, "Sticker"], + sticker: "FileInput | Sticker", disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - emoji: Optional[str] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + emoji: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1298,38 +1400,44 @@ async def send_sticker( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_video( self, - video: Union[FileInput, "Video"], - duration: Optional[int] = None, - caption: Optional[str] = None, + video: "FileInput | Video", + duration: TimePeriod | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - width: Optional[int] = None, - height: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + width: int | None = None, + height: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - supports_streaming: Optional[bool] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + supports_streaming: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1362,6 +1470,8 @@ async def send_video( parse_mode=parse_mode, supports_streaming=supports_streaming, thumbnail=thumbnail, + cover=cover, + start_timestamp=start_timestamp, api_kwargs=api_kwargs, allow_sending_without_reply=allow_sending_without_reply, caption_entities=caption_entities, @@ -1373,35 +1483,39 @@ async def send_video( message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_venue( self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - title: Optional[str] = None, - address: Optional[str] = None, - foursquare_id: Optional[str] = None, + latitude: float | None = None, + longitude: float | None = None, + title: str | None = None, + address: str | None = None, + foursquare_id: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - venue: Optional["Venue"] = None, + venue: "Venue | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1442,31 +1556,35 @@ async def send_venue( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_video_note( self, - video_note: Union[FileInput, "VideoNote"], - duration: Optional[int] = None, - length: Optional[int] = None, + video_note: "FileInput | VideoNote", + duration: TimePeriod | None = None, + length: int | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1503,32 +1621,36 @@ async def send_video_note( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_voice( self, - voice: Union[FileInput, "Voice"], - duration: Optional[int] = None, - caption: Optional[str] = None, + voice: "FileInput | Voice", + duration: TimePeriod | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1566,40 +1688,42 @@ async def send_voice( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_poll( self, question: str, - options: Sequence[Union[str, "InputPollOption"]], - is_anonymous: Optional[bool] = None, - type: Optional[str] = None, - allows_multiple_answers: Optional[bool] = None, - correct_option_id: Optional[CorrectOptionID] = None, - is_closed: Optional[bool] = None, + options: Sequence["str | InputPollOption"], + is_anonymous: bool | None = None, + type: str | None = None, + allows_multiple_answers: bool | None = None, + correct_option_id: CorrectOptionID | None = None, + is_closed: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - explanation: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + explanation: str | None = None, explanation_parse_mode: ODVInput[str] = DEFAULT_NONE, - open_period: Optional[int] = None, - close_date: Optional[Union[int, dtm.datetime]] = None, - explanation_entities: Optional[Sequence["MessageEntity"]] = None, + open_period: TimePeriod | None = None, + close_date: int | dtm.datetime | None = None, + explanation_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, question_parse_mode: ODVInput[str] = DEFAULT_NONE, - question_entities: Optional[Sequence["MessageEntity"]] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + question_entities: Sequence["MessageEntity"] | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1649,21 +1773,21 @@ async def send_poll( async def send_gift( self, - gift_id: Union[str, "Gift"], - text: Optional[str] = None, + gift_id: "str | Gift", + text: str | None = None, text_parse_mode: ODVInput[str] = DEFAULT_NONE, - text_entities: Optional[Sequence["MessageEntity"]] = None, - pay_for_upgrade: Optional[bool] = None, + text_entities: Sequence["MessageEntity"] | None = None, + pay_for_upgrade: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: - await bot.send_gift( user_id=update.effective_user.id, *args, **kwargs ) + await bot.send_gift(user_id=update.effective_user.id, *args, **kwargs ) For the documentation of the arguments, please see :meth:`telegram.Bot.send_gift`. @@ -1673,6 +1797,7 @@ async def send_gift( :obj:`bool`: On success, :obj:`True` is returned. """ return await self.get_bot().send_gift( + chat_id=None, user_id=self.id, gift_id=gift_id, text=text, @@ -1686,28 +1811,72 @@ async def send_gift( api_kwargs=api_kwargs, ) + async def gift_premium_subscription( + self, + month_count: int, + star_count: int, + text: str | None = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence["MessageEntity"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.gift_premium_subscription(user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.gift_premium_subscription`. + + .. versionadded:: 22.1 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().gift_premium_subscription( + user_id=self.id, + month_count=month_count, + star_count=star_count, + text=text, + text_parse_mode=text_parse_mode, + text_entities=text_entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def send_copy( self, - from_chat_id: Union[str, int], + from_chat_id: str | int, message_id: int, - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - show_caption_above_media: Optional[bool] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "MessageId": """Shortcut for:: @@ -1727,6 +1896,7 @@ async def send_copy( from_chat_id=from_chat_id, message_id=message_id, caption=caption, + video_start_timestamp=video_start_timestamp, parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, @@ -1743,30 +1913,37 @@ async def send_copy( message_thread_id=message_thread_id, show_caption_above_media=show_caption_above_media, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) async def copy_message( self, - chat_id: Union[int, str], + chat_id: int | str, message_id: int, - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - show_caption_above_media: Optional[bool] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "MessageId": """Shortcut for:: @@ -1786,6 +1963,7 @@ async def copy_message( chat_id=chat_id, message_id=message_id, caption=caption, + video_start_timestamp=video_start_timestamp, parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, @@ -1802,22 +1980,26 @@ async def copy_message( message_thread_id=message_thread_id, show_caption_above_media=show_caption_above_media, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) async def send_copies( self, - from_chat_id: Union[str, int], + from_chat_id: str | int, message_ids: Sequence[int], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - remove_caption: Optional[bool] = None, + message_thread_id: int | None = None, + remove_caption: bool | None = None, + direct_messages_topic_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple["MessageId", ...]: """Shortcut for:: @@ -1847,22 +2029,24 @@ async def send_copies( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, ) async def copy_messages( self, - chat_id: Union[str, int], + chat_id: str | int, message_ids: Sequence[int], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - remove_caption: Optional[bool] = None, + message_thread_id: int | None = None, + remove_caption: bool | None = None, + direct_messages_topic_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple["MessageId", ...]: """Shortcut for:: @@ -1892,21 +2076,26 @@ async def copy_messages( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, ) async def forward_from( self, - from_chat_id: Union[str, int], + from_chat_id: str | int, message_id: int, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1926,6 +2115,7 @@ async def forward_from( chat_id=self.id, from_chat_id=from_chat_id, message_id=message_id, + video_start_timestamp=video_start_timestamp, disable_notification=disable_notification, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1934,21 +2124,28 @@ async def forward_from( api_kwargs=api_kwargs, protect_content=protect_content, message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) async def forward_to( self, - chat_id: Union[int, str], + chat_id: int | str, message_id: int, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1969,6 +2166,7 @@ async def forward_to( from_chat_id=self.id, chat_id=chat_id, message_id=message_id, + video_start_timestamp=video_start_timestamp, disable_notification=disable_notification, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1977,21 +2175,25 @@ async def forward_to( api_kwargs=api_kwargs, protect_content=protect_content, message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) async def forward_messages_from( self, - from_chat_id: Union[str, int], + from_chat_id: str | int, message_ids: Sequence[int], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple["MessageId", ...]: """Shortcut for:: @@ -2020,21 +2222,23 @@ async def forward_messages_from( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, ) async def forward_messages_to( self, - chat_id: Union[int, str], + chat_id: int | str, message_ids: Sequence[int], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple["MessageId", ...]: """Shortcut for:: @@ -2063,17 +2267,18 @@ async def forward_messages_to( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, ) async def approve_join_request( self, - chat_id: Union[int, str], + chat_id: int | str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2103,13 +2308,13 @@ async def approve_join_request( async def decline_join_request( self, - chat_id: Union[int, str], + chat_id: int | str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2139,13 +2344,13 @@ async def decline_join_request( async def set_menu_button( self, - menu_button: Optional[MenuButton] = None, + menu_button: MenuButton | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2181,7 +2386,7 @@ async def get_menu_button( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> MenuButton: """Shortcut for:: @@ -2211,13 +2416,13 @@ async def get_menu_button( async def get_chat_boosts( self, - chat_id: Union[int, str], + chat_id: int | str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "UserChatBoosts": """Shortcut for:: @@ -2249,7 +2454,7 @@ async def refund_star_payment( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2275,13 +2480,13 @@ async def refund_star_payment( async def verify( self, - custom_description: Optional[str] = None, + custom_description: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2312,7 +2517,7 @@ async def remove_verification( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2334,3 +2539,92 @@ async def remove_verification( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) + + async def repost_story( + self, + business_connection_id: str, + from_story_id: int, + active_period: TimePeriod, + post_to_chat_page: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Story": + """Shortcut for:: + + await bot.repost_story( + from_chat_id=update.effective_user.id, + *args, **kwargs + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.repost_story`. + + .. versionadded:: 22.6 + + Returns: + :class:`Story`: On success, :class:`Story` is returned. + + """ + return await self.get_bot().repost_story( + business_connection_id=business_connection_id, + from_chat_id=self.id, + from_story_id=from_story_id, + active_period=active_period, + post_to_chat_page=post_to_chat_page, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_gifts( + self, + exclude_unlimited: bool | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "OwnedGifts": + """Shortcut for:: + + await bot.get_user_gifts(user_id=update.effective_user.id) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_user_gifts`. + + .. versionadded:: 22.6 + + Returns: + :class:`telegram.OwnedGifts`: On success, returns the gifts owned by the user. + """ + return await self.get_bot().get_user_gifts( + user_id=self.id, + exclude_unlimited=exclude_unlimited, + exclude_limited_upgradable=exclude_limited_upgradable, + exclude_limited_non_upgradable=exclude_limited_non_upgradable, + exclude_from_blockchain=exclude_from_blockchain, + exclude_unique=exclude_unique, + sort_by_price=sort_by_price, + offset=offset, + limit=limit, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) diff --git a/telegram/_userprofilephotos.py b/src/telegram/_userprofilephotos.py similarity index 90% rename from telegram/_userprofilephotos.py rename to src/telegram/_userprofilephotos.py index cc812d537ba..59cebae1835 100644 --- a/telegram/_userprofilephotos.py +++ b/src/telegram/_userprofilephotos.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram UserProfilePhotos.""" + from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._files.photosize import PhotoSize from telegram._telegramobject import TelegramObject @@ -59,7 +60,7 @@ def __init__( total_count: int, photos: Sequence[Sequence[PhotoSize]], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -71,15 +72,10 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["UserProfilePhotos"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UserProfilePhotos": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - data["photos"] = [PhotoSize.de_list(photo, bot) for photo in data["photos"]] return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_userrating.py b/src/telegram/_userrating.py new file mode 100644 index 00000000000..f8045eed9ec --- /dev/null +++ b/src/telegram/_userrating.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram user rating.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class UserRating(TelegramObject): + """ + This object describes the rating of a user based on their Telegram Star spendings. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`level` and :attr:`rating` are equal. + + .. versionadded:: 22.6 + + Args: + level (:obj:`int`): Current level of the user, indicating their reliability when purchasing + digital goods and services. A higher level suggests a more trustworthy customer; a + negative level is likely reason for concern. + rating (:obj:`int`): Numerical value of the user's rating; the higher the rating, the + better + current_level_rating (:obj:`int`): The rating value required to get the current level + next_level_rating (:obj:`int`, optional): The rating value required to get to the next + level; omitted if the maximum level was reached + + Attributes: + level (:obj:`int`): Current level of the user, indicating their reliability when purchasing + digital goods and services. A higher level suggests a more trustworthy customer; a + negative level is likely reason for concern. + rating (:obj:`int`): Numerical value of the user's rating; the higher the rating, the + better + current_level_rating (:obj:`int`): The rating value required to get the current level + next_level_rating (:obj:`int`): Optional. The rating value required to get to the next + level; omitted if the maximum level was reached + + """ + + __slots__ = ("current_level_rating", "level", "next_level_rating", "rating") + + def __init__( + self, + level: int, + rating: int, + current_level_rating: int, + next_level_rating: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.level: int = level + self.rating: int = rating + self.current_level_rating: int = current_level_rating + self.next_level_rating: int | None = next_level_rating + + self._id_attrs = (self.level, self.rating) + + self._freeze() diff --git a/telegram/ext/_handlers/__init__.py b/src/telegram/_utils/__init__.py similarity index 100% rename from telegram/ext/_handlers/__init__.py rename to src/telegram/_utils/__init__.py diff --git a/src/telegram/_utils/argumentparsing.py b/src/telegram/_utils/argumentparsing.py new file mode 100644 index 00000000000..240b43ea6dd --- /dev/null +++ b/src/telegram/_utils/argumentparsing.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains helper functions related to parsing arguments for classes and methods. + +Warning: + Contents of this module are intended to be used internally by the library and *not* by the + user. Changes to this module are not considered breaking changes and may not be documented in + the changelog. +""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Protocol, TypeVar, overload + +from telegram._linkpreviewoptions import LinkPreviewOptions +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict, ODVInput + +if TYPE_CHECKING: + from typing import type_check_only + + from telegram import Bot, FileCredentials + +T = TypeVar("T") + + +def parse_sequence_arg(arg: Sequence[T] | None) -> tuple[T, ...]: + """Parses an optional sequence into a tuple + + Args: + arg (:obj:`Sequence`): The sequence to parse. + + Returns: + :obj:`Tuple`: The sequence converted to a tuple or an empty tuple. + """ + return tuple(arg) if arg else () + + +@overload +def to_timedelta(arg: None) -> None: ... + + +@overload +def to_timedelta( + arg: ( + int | float | dtm.timedelta # noqa: PYI041 (be more explicit about `int` and `float` args) + ), +) -> dtm.timedelta: ... + + +def to_timedelta(arg: int | float | dtm.timedelta | None) -> dtm.timedelta | None: # noqa: PYI041 + """Parses an optional time period in seconds into a timedelta + + Args: + arg (:obj:`int` | :class:`datetime.timedelta`, optional): The time period to parse. + + Returns: + :obj:`timedelta`: The time period converted to a timedelta object or :obj:`None`. + """ + if arg is None: + return None + if isinstance(arg, int | float): + return dtm.timedelta(seconds=arg) + return arg + + +def parse_lpo_and_dwpp( + disable_web_page_preview: bool | None, link_preview_options: ODVInput[LinkPreviewOptions] +) -> ODVInput[LinkPreviewOptions]: + """Wrapper around warn_about_deprecated_arg_return_new_arg. Takes care of converting + disable_web_page_preview to LinkPreviewOptions. + """ + if disable_web_page_preview and link_preview_options: + raise ValueError( + "Parameters `disable_web_page_preview` and `link_preview_options` are mutually " + "exclusive." + ) + + if disable_web_page_preview is not None: + link_preview_options = LinkPreviewOptions(is_disabled=disable_web_page_preview) + + return link_preview_options + + +Tele_co = TypeVar("Tele_co", bound=TelegramObject, covariant=True) +TeleCrypto_co = TypeVar("TeleCrypto_co", bound="HasDecryptMethod", covariant=True) + +if TYPE_CHECKING: + + @type_check_only + class HasDecryptMethod(Protocol): + __slots__ = () + + @classmethod + def de_json_decrypted( + cls: type[TeleCrypto_co], + data: JSONDict, + bot: "Bot | None", + credentials: list["FileCredentials"], + ) -> TeleCrypto_co: ... + + @classmethod + def de_list_decrypted( + cls: type[TeleCrypto_co], + data: list[JSONDict], + bot: "Bot | None", + credentials: list["FileCredentials"], + ) -> tuple[TeleCrypto_co, ...]: ... + + +def de_json_optional( + data: JSONDict | None, cls: type[Tele_co], bot: "Bot | None" +) -> Tele_co | None: + """Wrapper around TO.de_json that returns None if data is None.""" + if data is None: + return None + + return cls.de_json(data, bot) + + +def de_json_decrypted_optional( + data: JSONDict | None, + cls: type[TeleCrypto_co], + bot: "Bot | None", + credentials: list["FileCredentials"], +) -> TeleCrypto_co | None: + """Wrapper around TO.de_json_decrypted that returns None if data is None.""" + if data is None: + return None + + return cls.de_json_decrypted(data, bot, credentials) + + +def de_list_optional( + data: list[JSONDict] | None, cls: type[Tele_co], bot: "Bot | None" +) -> tuple[Tele_co, ...]: + """Wrapper around TO.de_list that returns an empty list if data is None.""" + if data is None: + return () + + return cls.de_list(data, bot) + + +def de_list_decrypted_optional( + data: list[JSONDict] | None, + cls: type[TeleCrypto_co], + bot: "Bot | None", + credentials: list["FileCredentials"], +) -> tuple[TeleCrypto_co, ...]: + """Wrapper around TO.de_list_decrypted that returns an empty list if data is None.""" + if data is None: + return () + + return cls.de_list_decrypted(data, bot, credentials) diff --git a/telegram/_utils/datetime.py b/src/telegram/_utils/datetime.py similarity index 75% rename from telegram/_utils/datetime.py rename to src/telegram/_utils/datetime.py index 8e6ebdda1b4..a68faa39908 100644 --- a/telegram/_utils/datetime.py +++ b/src/telegram/_utils/datetime.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -27,10 +27,16 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + import contextlib import datetime as dtm +import os import time -from typing import TYPE_CHECKING, Optional, Union +import zoneinfo +from typing import TYPE_CHECKING + +from telegram._utils.warnings import warn +from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import Bot @@ -58,9 +64,9 @@ def localize(datetime: dtm.datetime, tzinfo: dtm.tzinfo) -> dtm.datetime: def to_float_timestamp( - time_object: Union[float, dtm.timedelta, dtm.datetime, dtm.time], - reference_timestamp: Optional[float] = None, - tzinfo: Optional[dtm.tzinfo] = None, + time_object: float | dtm.timedelta | dtm.datetime | dtm.time, + reference_timestamp: float | None = None, + tzinfo: dtm.tzinfo | None = None, ) -> float: """ Converts a given time object to a float POSIX timestamp. @@ -122,7 +128,7 @@ def to_float_timestamp( if isinstance(time_object, dtm.timedelta): return reference_timestamp + time_object.total_seconds() - if isinstance(time_object, (int, float)): + if isinstance(time_object, int | float): return reference_timestamp + time_object if tzinfo is None: @@ -160,10 +166,10 @@ def to_float_timestamp( def to_timestamp( - dt_obj: Union[float, dtm.timedelta, dtm.datetime, dtm.time, None], - reference_timestamp: Optional[float] = None, - tzinfo: Optional[dtm.tzinfo] = None, -) -> Optional[int]: + dt_obj: float | dtm.timedelta | dtm.datetime | dtm.time | None, + reference_timestamp: float | None = None, + tzinfo: dtm.tzinfo | None = None, +) -> int | None: """ Wrapper over :func:`to_float_timestamp` which returns an integer (the float value truncated down to the nearest integer). @@ -178,9 +184,9 @@ def to_timestamp( def from_timestamp( - unixtime: Optional[int], - tzinfo: Optional[dtm.tzinfo] = None, -) -> Optional[dtm.datetime]: + unixtime: int | None, + tzinfo: dtm.tzinfo | None = None, +) -> dtm.datetime | None: """ Converts an (integer) unix timestamp to a timezone aware datetime object. :obj:`None` s are left alone (i.e. ``from_timestamp(None)`` is :obj:`None`). @@ -201,7 +207,7 @@ def from_timestamp( return dtm.datetime.fromtimestamp(unixtime, tz=UTC if tzinfo is None else tzinfo) -def extract_tzinfo_from_defaults(bot: Optional["Bot"]) -> Union[dtm.tzinfo, None]: +def extract_tzinfo_from_defaults(bot: "Bot | None") -> dtm.tzinfo | None: """ Extracts the timezone info from the default values of the bot. If the bot has no default values, :obj:`None` is returned. @@ -224,3 +230,60 @@ def _datetime_to_float_timestamp(dt_obj: dtm.datetime) -> float: if dt_obj.tzinfo is None: dt_obj = dt_obj.replace(tzinfo=dtm.timezone.utc) return dt_obj.timestamp() + + +def get_zone_info(tz: str) -> zoneinfo.ZoneInfo: + """Wrapper around the `ZoneInfo` constructor with slightly more helpful error message + in case tzdata is not installed. + """ + try: + return zoneinfo.ZoneInfo(tz) + except zoneinfo.ZoneInfoNotFoundError as err: + raise zoneinfo.ZoneInfoNotFoundError( + f"No time zone found with key {tz}. " + "Make sure to use a valid time zone name and " + f"correctly install the tzdata (https://pypi.org/project/tzdata/) package if " + "your system does not provide the time zone data." + ) from err + + +def get_timedelta_value(value: dtm.timedelta | None, attribute: str) -> int | dtm.timedelta | None: + """ + Convert a `datetime.timedelta` to seconds or return it as-is, based on environment config. + + This utility is part of the migration process from integer-based time representations + to using `datetime.timedelta`. The behavior is controlled by the `PTB_TIMEDELTA` + environment variable. + + Note: + When `PTB_TIMEDELTA` is not enabled, the function will issue a deprecation warning. + + Args: + value (:obj:`datetime.timedelta`): The timedelta value to process. + attribute (:obj:`str`): The name of the attribute at the caller scope, used for + warning messages. + + Returns: + - :obj:`None` if :paramref:`value` is None. + - :obj:`datetime.timedelta` if `PTB_TIMEDELTA=true` or ``PTB_TIMEDELTA=1``. + - :obj:`int` if the total seconds is a whole number. + - float: otherwise. + """ + if value is None: + return None + if os.getenv("PTB_TIMEDELTA", "false").lower().strip() in ["true", "1"]: + return value + warn( + PTBDeprecationWarning( + "v22.2", + f"In a future major version attribute `{attribute}` will be of type" + " `datetime.timedelta`. You can opt-in early by setting `PTB_TIMEDELTA=true`" + " or ``PTB_TIMEDELTA=1`` as an environment variable.", + ), + stacklevel=2, + ) + return ( + int(seconds) # type: ignore[return-value] + if (seconds := value.total_seconds()).is_integer() + else seconds + ) diff --git a/telegram/_utils/defaultvalue.py b/src/telegram/_utils/defaultvalue.py similarity index 96% rename from telegram/_utils/defaultvalue.py rename to src/telegram/_utils/defaultvalue.py index f9374c54af7..cdb83cc0a0b 100644 --- a/telegram/_utils/defaultvalue.py +++ b/src/telegram/_utils/defaultvalue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -27,7 +27,8 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ -from typing import Generic, TypeVar, Union, overload + +from typing import Generic, TypeVar, overload DVType = TypeVar("DVType", bound=object) # pylint: disable=invalid-name OT = TypeVar("OT", bound=object) @@ -105,7 +106,7 @@ def get_value(obj: "DefaultValue[OT]") -> OT: ... def get_value(obj: OT) -> OT: ... @staticmethod - def get_value(obj: Union[OT, "DefaultValue[OT]"]) -> OT: + def get_value(obj: "OT | DefaultValue[OT]") -> OT: """Shortcut for:: return obj.value if isinstance(obj, DefaultValue) else obj diff --git a/telegram/_utils/entities.py b/src/telegram/_utils/entities.py similarity index 95% rename from telegram/_utils/entities.py rename to src/telegram/_utils/entities.py index 7ca3eff20fb..31cc4995329 100644 --- a/telegram/_utils/entities.py +++ b/src/telegram/_utils/entities.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,8 +23,8 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + from collections.abc import Sequence -from typing import Optional from telegram._messageentity import MessageEntity from telegram._utils.strings import TextEncoding @@ -47,7 +47,7 @@ def parse_message_entity(text: str, entity: MessageEntity) -> str: def parse_message_entities( - text: str, entities: Sequence[MessageEntity], types: Optional[Sequence[str]] = None + text: str, entities: Sequence[MessageEntity], types: Sequence[str] | None = None ) -> dict[MessageEntity, str]: """ Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. diff --git a/telegram/_utils/enum.py b/src/telegram/_utils/enum.py similarity index 97% rename from telegram/_utils/enum.py rename to src/telegram/_utils/enum.py index 58362870f7e..a8a77959224 100644 --- a/telegram/_utils/enum.py +++ b/src/telegram/_utils/enum.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,16 +23,17 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + import enum as _enum import sys -from typing import TypeVar, Union +from typing import TypeVar _A = TypeVar("_A") _B = TypeVar("_B") _Enum = TypeVar("_Enum", bound=_enum.Enum) -def get_member(enum_cls: type[_Enum], value: _A, default: _B) -> Union[_Enum, _A, _B]: +def get_member(enum_cls: type[_Enum], value: _A, default: _B) -> _Enum | _A | _B: """Tries to call ``enum_cls(value)`` to convert the value into an enumeration member. If that fails, the ``default`` is returned. """ diff --git a/telegram/_utils/files.py b/src/telegram/_utils/files.py similarity index 83% rename from telegram/_utils/files.py rename to src/telegram/_utils/files.py index 8bce30d64c5..e1637ef1988 100644 --- a/telegram/_utils/files.py +++ b/src/telegram/_utils/files.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -29,18 +29,20 @@ """ from pathlib import Path -from typing import IO, TYPE_CHECKING, Any, Optional, TypeVar, Union, cast, overload +from typing import IO, TYPE_CHECKING, TypeVar, cast, overload from telegram._utils.types import FileInput, FilePathInput if TYPE_CHECKING: + from typing import Any + from telegram import InputFile, TelegramObject -_T = TypeVar("_T", bound=Union[bytes, "InputFile", str, Path, None]) +_T = TypeVar("_T", bound="bytes | InputFile | str | Path | None") @overload -def load_file(obj: IO[bytes]) -> tuple[Optional[str], bytes]: ... +def load_file(obj: IO[bytes]) -> tuple[str | None, bytes]: ... @overload @@ -48,8 +50,8 @@ def load_file(obj: _T) -> tuple[None, _T]: ... def load_file( - obj: Optional[FileInput], -) -> tuple[Optional[str], Union[bytes, "InputFile", str, Path, None]]: + obj: "FileInput | None", +) -> tuple[str | None, "bytes | InputFile | str | Path | None"]: """If the input is a file handle, read the data and name and return it. Otherwise, return the input unchanged. """ @@ -59,14 +61,14 @@ def load_file( try: contents = obj.read() # type: ignore[union-attr] except AttributeError: - return None, cast(Union[bytes, "InputFile", str, Path], obj) + return None, cast("bytes | InputFile | str | Path", obj) filename = guess_file_name(obj) return filename, contents -def guess_file_name(obj: FileInput) -> Optional[str]: +def guess_file_name(obj: FileInput) -> str | None: """If the input is a file handle, read name and return it. Otherwise, return the input unchanged. """ @@ -76,7 +78,7 @@ def guess_file_name(obj: FileInput) -> Optional[str]: return None -def is_local_file(obj: Optional[FilePathInput]) -> bool: +def is_local_file(obj: FilePathInput | None) -> bool: """ Checks if a given string is a file on local system. @@ -94,12 +96,12 @@ def is_local_file(obj: Optional[FilePathInput]) -> bool: def parse_file_input( # pylint: disable=too-many-return-statements - file_input: Union[FileInput, "TelegramObject"], - tg_type: Optional[type["TelegramObject"]] = None, - filename: Optional[str] = None, + file_input: "FileInput | TelegramObject", + tg_type: type["TelegramObject"] | None = None, + filename: str | None = None, attach: bool = False, local_mode: bool = False, -) -> Union[str, "InputFile", Any]: +) -> "str | InputFile | Any": """ Parses input for sending files: @@ -134,24 +136,25 @@ def parse_file_input( # pylint: disable=too-many-return-statements :attr:`file_input`, in case it's no valid file input. """ # Importing on file-level yields cyclic Import Errors - from telegram import InputFile # pylint: disable=import-outside-toplevel + from telegram import InputFile # pylint: disable=import-outside-toplevel # noqa: PLC0415 if isinstance(file_input, str) and file_input.startswith("file://"): if not local_mode: raise ValueError("Specified file input is a file URI, but local mode is not enabled.") return file_input - if isinstance(file_input, (str, Path)): + if isinstance(file_input, str | Path): if is_local_file(file_input): path = Path(file_input) if local_mode: return path.absolute().as_uri() - return InputFile(path.open(mode="rb"), filename=filename, attach=attach) + with path.open(mode="rb") as file_handle: + return InputFile(file_handle, filename=filename, attach=attach) return file_input if isinstance(file_input, bytes): return InputFile(file_input, filename=filename, attach=attach) if hasattr(file_input, "read"): - return InputFile(cast(IO, file_input), filename=filename, attach=attach) + return InputFile(cast("IO", file_input), filename=filename, attach=attach) if tg_type and isinstance(file_input, tg_type): return file_input.file_id # type: ignore[attr-defined] return file_input diff --git a/telegram/_utils/logging.py b/src/telegram/_utils/logging.py similarity index 92% rename from telegram/_utils/logging.py rename to src/telegram/_utils/logging.py index 0bd778b8bd7..47913461028 100644 --- a/telegram/_utils/logging.py +++ b/src/telegram/_utils/logging.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,11 +23,11 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + import logging -from typing import Optional -def get_logger(file_name: str, class_name: Optional[str] = None) -> logging.Logger: +def get_logger(file_name: str, class_name: str | None = None) -> logging.Logger: """Returns a logger with an appropriate name. Use as follows:: diff --git a/telegram/_utils/markup.py b/src/telegram/_utils/markup.py similarity index 94% rename from telegram/_utils/markup.py rename to src/telegram/_utils/markup.py index eed70b3bacd..d2ff0c6a8dd 100644 --- a/telegram/_utils/markup.py +++ b/src/telegram/_utils/markup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -27,6 +27,7 @@ class ``telegram.ReplyMarkup``. user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + from collections.abc import Sequence @@ -37,11 +38,11 @@ def check_keyboard_type(keyboard: object) -> bool: # string and bytes may actually be used for ReplyKeyboardMarkup in which case each button # would contain a single character. But that use case should be discouraged and we don't # allow it here. - if not isinstance(keyboard, Sequence) or isinstance(keyboard, (str, bytes)): + if not isinstance(keyboard, Sequence) or isinstance(keyboard, str | bytes): return False for row in keyboard: - if not isinstance(row, Sequence) or isinstance(row, (str, bytes)): + if not isinstance(row, Sequence) or isinstance(row, str | bytes): return False for inner in row: if isinstance(inner, Sequence) and not isinstance(inner, str): diff --git a/telegram/_utils/repr.py b/src/telegram/_utils/repr.py similarity index 98% rename from telegram/_utils/repr.py rename to src/telegram/_utils/repr.py index 38d9834e3bb..3aa78dd51a2 100644 --- a/telegram/_utils/repr.py +++ b/src/telegram/_utils/repr.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,6 +23,7 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + from typing import Any diff --git a/telegram/_utils/strings.py b/src/telegram/_utils/strings.py similarity index 98% rename from telegram/_utils/strings.py rename to src/telegram/_utils/strings.py index 76a8bcd380a..5066db58ab7 100644 --- a/telegram/_utils/strings.py +++ b/src/telegram/_utils/strings.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/telegram/_utils/types.py b/src/telegram/_utils/types.py similarity index 59% rename from telegram/_utils/types.py rename to src/telegram/_utils/types.py index e1bf77d34ea..1b29e2e8de6 100644 --- a/telegram/_utils/types.py +++ b/src/telegram/_utils/types.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,9 +23,13 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ -from collections.abc import Collection + +import datetime as dtm +from collections.abc import Callable, Collection from pathlib import Path -from typing import IO, TYPE_CHECKING, Any, Literal, Optional, TypeVar, Union +from typing import IO, TYPE_CHECKING, Any, Literal, TypeAlias, TypeVar, Union + +from telegram._utils.defaultvalue import DefaultValue if TYPE_CHECKING: from telegram import ( @@ -35,36 +39,41 @@ ReplyKeyboardMarkup, ReplyKeyboardRemove, ) - from telegram._utils.defaultvalue import DefaultValue -FileLike = Union[IO[bytes], "InputFile"] +# We guarantee that InputFile will be defined at runtime, so we can use a string here and ignore +# ruff. +# See https://github.com/python-telegram-bot/python-telegram-bot/pull/4827#issuecomment-2973060875 +# on why we're doing this workaround. +# TODO: Use `type` syntax when we drop support for Python 3.11. +FileLike: TypeAlias = IO[bytes] | "InputFile" # noqa: TC010 """Either a bytes-stream (e.g. open file handler) or a :class:`telegram.InputFile`.""" -FilePathInput = Union[str, Path] +FilePathInput: TypeAlias = str | Path """A filepath either as string or as :obj:`pathlib.Path` object.""" -FileInput = Union[FilePathInput, FileLike, bytes, str] +FileInput: TypeAlias = FilePathInput | FileLike | bytes | str """Valid input for passing files to Telegram. Either a file id as string, a file like object, a local file path as string, :class:`pathlib.Path` or the file contents as :obj:`bytes`.""" -JSONDict = dict[str, Any] +JSONDict: TypeAlias = dict[str, Any] """Dictionary containing response from Telegram or data to send to the API.""" DVValueType = TypeVar("DVValueType") # pylint: disable=invalid-name -DVType = Union[DVValueType, "DefaultValue[DVValueType]"] -"""Generic type for a variable which can be either `type` or `DefaultVaule[type]`.""" -ODVInput = Optional[Union["DefaultValue[DVValueType]", DVValueType, "DefaultValue[None]"]] +DVType: TypeAlias = DVValueType | DefaultValue[DVValueType] +"""Generic type for a variable which can be either `type` or `DefaultValue[type]`.""" +ODVInput: TypeAlias = DefaultValue[DVValueType] | DVValueType | DefaultValue[None] | None """Generic type for bot method parameters which can have defaults. ``ODVInput[type]`` is the same -as ``Optional[Union[DefaultValue[type], type, DefaultValue[None]]``.""" -DVInput = Union["DefaultValue[DVValueType]", DVValueType, "DefaultValue[None]"] +as ``Union[DefaultValue[type], type, DefaultValue[None], None]``.""" +DVInput: TypeAlias = DefaultValue[DVValueType] | DVValueType | DefaultValue[None] """Generic type for bot method parameters which can have defaults. ``DVInput[type]`` is the same as ``Union[DefaultValue[type], type, DefaultValue[None]]``.""" RT = TypeVar("RT") -SCT = Union[RT, Collection[RT]] # pylint: disable=invalid-name +SCT: TypeAlias = RT | Collection[RT] # pylint: disable=invalid-name """Single instance or collection of instances.""" -ReplyMarkup = Union[ +# See comment above on why we're stuck using a Union here. +ReplyMarkup: TypeAlias = Union[ "InlineKeyboardMarkup", "ReplyKeyboardMarkup", "ReplyKeyboardRemove", "ForceReply" ] """Type alias for reply markup objects. @@ -72,22 +81,24 @@ .. versionadded:: 20.0 """ -FieldTuple = tuple[str, Union[bytes, IO[bytes]], str] +FieldTuple: TypeAlias = tuple[str, bytes | IO[bytes], str] """Alias for return type of `InputFile.field_tuple`.""" -UploadFileDict = dict[str, FieldTuple] +UploadFileDict: TypeAlias = dict[str, FieldTuple] """Dictionary containing file data to be uploaded to the API.""" -HTTPVersion = Literal["1.1", "2.0", "2"] +HTTPVersion: TypeAlias = Literal["1.1", "2.0", "2"] """Allowed HTTP versions. .. versionadded:: 20.4""" -CorrectOptionID = Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] +CorrectOptionID: TypeAlias = Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # pylint: disable=invalid-name -MarkdownVersion = Literal[1, 2] +MarkdownVersion: TypeAlias = Literal[1, 2] -SocketOpt = Union[ - tuple[int, int, int], - tuple[int, int, Union[bytes, bytearray]], - tuple[int, int, None, int], -] +SocketOpt: TypeAlias = ( + tuple[int, int, int] | tuple[int, int, bytes | bytearray] | tuple[int, int, None, int] +) + +BaseUrl: TypeAlias = str | Callable[[str], str] + +TimePeriod: TypeAlias = int | dtm.timedelta diff --git a/src/telegram/_utils/usernames.py b/src/telegram/_utils/usernames.py new file mode 100644 index 00000000000..5ecb2e4699f --- /dev/null +++ b/src/telegram/_utils/usernames.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""Helper utilities around Telegram Objects first_name, last_name and username. +.. versionadded:: 22.4 + +Warning: + Contents of this module are intended to be used internally by the library and *not* by the + user. Changes to this module are not considered breaking changes and may not be documented in + the changelog. +""" + +from typing import TYPE_CHECKING, Protocol, TypeVar, overload + +TeleUserLike = TypeVar("TeleUserLike", bound="UserLike") +TeleUserLikeOptional = TypeVar("TeleUserLikeOptional", bound="UserLikeOptional") + +if TYPE_CHECKING: + from typing import type_check_only + + @type_check_only + class UserLike(Protocol): + first_name: str + last_name: str | None + username: str | None + + @type_check_only + class UserLikeOptional(Protocol): + first_name: str | None + last_name: str | None + username: str | None + + +@overload +def get_name(userlike: TeleUserLike) -> str: ... +@overload +def get_name(userlike: TeleUserLikeOptional) -> str | None: ... + + +def get_name(userlike: TeleUserLike | TeleUserLikeOptional) -> str | None: + """Returns ``username`` prefixed with "@". If ``username`` is not available, calls + :func:`get_full_name` below`. + """ + if userlike.username: + return f"@{userlike.username}" + return get_full_name(userlike=userlike) + + +@overload +def get_full_name(userlike: TeleUserLike) -> str: ... +@overload +def get_full_name(userlike: TeleUserLikeOptional) -> str | None: ... + + +def get_full_name(userlike: TeleUserLike | TeleUserLikeOptional) -> str | None: + """ + If parameter ``first_name`` is not :obj:`None`, gives + ``first_name`` followed by (if available) `UserLike.last_name`. Otherwise, + :obj:`None` is returned. + """ + if not userlike.first_name: + return None + if userlike.last_name: + return f"{userlike.first_name} {userlike.last_name}" + return userlike.first_name + + +# We isolate these TypeVars to accomodiate telegram objects with ``username`` +# and no ``first_name`` or ``last_name`` (e.g ``ChatShared``) +TeleLinkable = TypeVar("TeleLinkable", bound="Linkable") +TeleLinkableOptional = TypeVar("TeleLinkableOptional", bound="LinkableOptional") + +if TYPE_CHECKING: + + @type_check_only + class Linkable(Protocol): + username: str + + @type_check_only + class LinkableOptional(Protocol): + username: str | None + + +@overload +def get_link(linkable: TeleLinkable) -> str: ... +@overload +def get_link(linkable: TeleLinkableOptional) -> str | None: ... + + +def get_link(linkable: TeleLinkable | TeleLinkableOptional) -> str | None: + """If ``username`` is available, returns a t.me link of the user/chat.""" + if linkable.username: + return f"https://t.me/{linkable.username}" + return None diff --git a/telegram/_utils/warnings.py b/src/telegram/_utils/warnings.py similarity index 95% rename from telegram/_utils/warnings.py rename to src/telegram/_utils/warnings.py index 2aa79db58d1..d8f8a476014 100644 --- a/telegram/_utils/warnings.py +++ b/src/telegram/_utils/warnings.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,14 +25,14 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + import warnings -from typing import Union from telegram.warnings import PTBUserWarning def warn( - message: Union[str, PTBUserWarning], + message: str | PTBUserWarning, category: type[Warning] = PTBUserWarning, stacklevel: int = 0, ) -> None: diff --git a/telegram/_utils/warnings_transition.py b/src/telegram/_utils/warnings_transition.py similarity index 92% rename from telegram/_utils/warnings_transition.py rename to src/telegram/_utils/warnings_transition.py index cd9fecd7562..24475848752 100644 --- a/telegram/_utils/warnings_transition.py +++ b/src/telegram/_utils/warnings_transition.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,7 +23,9 @@ .. versionadded:: 20.2 """ -from typing import Any, Callable, Union + +from collections.abc import Callable +from typing import Any from telegram._utils.warnings import warn from telegram.warnings import PTBDeprecationWarning, PTBUserWarning @@ -35,12 +37,12 @@ def build_deprecation_warning_message( object_type: str, bot_api_version: str, ) -> str: - """Builds a warning message for the transition in API when an object is renamed. + """Builds a warning message for the transition in API when an object is renamed/replaced. Returns a warning message that can be used in `warn` function. """ return ( - f"The {object_type} '{deprecated_name}' was renamed to '{new_name}' in Bot API " + f"The {object_type} '{deprecated_name}' was replaced by '{new_name}' in Bot API " f"{bot_api_version}. We recommend using '{new_name}' instead of " f"'{deprecated_name}'." ) @@ -56,7 +58,7 @@ def warn_about_deprecated_arg_return_new_arg( bot_api_version: str, ptb_version: str, stacklevel: int = 2, - warn_callback: Callable[[Union[str, PTBUserWarning], type[Warning], int], None] = warn, + warn_callback: Callable[[str | PTBUserWarning, type[Warning], int], None] = warn, ) -> Any: """A helper function for the transition in API when argument is renamed. diff --git a/telegram/_version.py b/src/telegram/_version.py similarity index 95% rename from telegram/_version.py rename to src/telegram/_version.py index f514a9b76f6..8568c717809 100644 --- a/telegram/_version.py +++ b/src/telegram/_version.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -51,6 +51,6 @@ def __str__(self) -> str: __version_info__: Final[Version] = Version( - major=21, minor=10, micro=0, releaselevel="final", serial=0 + major=22, minor=6, micro=0, releaselevel="final", serial=0 ) __version__: Final[str] = str(__version_info__) diff --git a/telegram/_videochat.py b/src/telegram/_videochat.py similarity index 74% rename from telegram/_videochat.py rename to src/telegram/_videochat.py index 77da89343a2..b7f75dfc590 100644 --- a/telegram/_videochat.py +++ b/src/telegram/_videochat.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,15 +17,20 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains objects related to Telegram video chats.""" + import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._user import User -from telegram._utils.argumentparsing import parse_sequence_arg -from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_timedelta_value, +) +from telegram._utils.types import JSONDict, TimePeriod if TYPE_CHECKING: from telegram import Bot @@ -43,7 +48,7 @@ class VideoChatStarted(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(api_kwargs=api_kwargs) self._freeze() @@ -62,28 +67,45 @@ class VideoChatEnded(TelegramObject): .. versionchanged:: 20.0 This class was renamed from ``VoiceChatEnded`` in accordance to Bot API 6.0. + .. versionchanged:: v22.2 + As part of the migration to representing time periods using ``datetime.timedelta``, + equality comparison now considers integer durations and equivalent timedeltas as equal. + Args: - duration (:obj:`int`): Voice chat duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Voice chat duration + in seconds. + + .. versionchanged:: v22.2 + |time-period-input| Attributes: - duration (:obj:`int`): Voice chat duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Voice chat duration in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| """ - __slots__ = ("duration",) + __slots__ = ("_duration",) def __init__( self, - duration: int, + duration: TimePeriod, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) - self.duration: int = duration - self._id_attrs = (self.duration,) + self._duration: dtm.timedelta = to_timedelta(duration) + self._id_attrs = (self._duration,) self._freeze() + @property + def duration(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) + class VideoChatParticipantsInvited(TelegramObject): """ @@ -116,7 +138,7 @@ def __init__( self, users: Sequence[User], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) self.users: tuple[User, ...] = parse_sequence_arg(users) @@ -125,15 +147,10 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["VideoChatParticipantsInvited"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "VideoChatParticipantsInvited": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - data["users"] = User.de_list(data.get("users", []), bot) return super().de_json(data=data, bot=bot) @@ -168,7 +185,7 @@ def __init__( self, start_date: dtm.datetime, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) self.start_date: dtm.datetime = start_date @@ -178,18 +195,13 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["VideoChatScheduled"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "VideoChatScheduled": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) - data["start_date"] = from_timestamp(data["start_date"], tzinfo=loc_tzinfo) + data["start_date"] = from_timestamp(data.get("start_date"), tzinfo=loc_tzinfo) return super().de_json(data=data, bot=bot) diff --git a/telegram/_webappdata.py b/src/telegram/_webappdata.py similarity index 96% rename from telegram/_webappdata.py rename to src/telegram/_webappdata.py index 2b1a8fd6bcd..04583cf99f5 100644 --- a/telegram/_webappdata.py +++ b/src/telegram/_webappdata.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram WebAppData.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -53,7 +51,7 @@ class WebAppData(TelegramObject): __slots__ = ("button_text", "data") - def __init__(self, data: str, button_text: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, data: str, button_text: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) # Required self.data: str = data diff --git a/telegram/_webappinfo.py b/src/telegram/_webappinfo.py similarity index 93% rename from telegram/_webappinfo.py rename to src/telegram/_webappinfo.py index 11a6bf3d23a..2d29cef7df5 100644 --- a/telegram/_webappinfo.py +++ b/src/telegram/_webappinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Web App Info.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -49,7 +47,7 @@ class WebAppInfo(TelegramObject): __slots__ = ("url",) - def __init__(self, url: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, url: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) # Required self.url: str = url diff --git a/telegram/_webhookinfo.py b/src/telegram/_webhookinfo.py similarity index 87% rename from telegram/_webhookinfo.py rename to src/telegram/_webhookinfo.py index 5f46e647f2c..94f6f7bf22e 100644 --- a/telegram/_webhookinfo.py +++ b/src/telegram/_webhookinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,7 +20,7 @@ import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._utils.argumentparsing import parse_sequence_arg @@ -126,14 +126,14 @@ def __init__( url: str, has_custom_certificate: bool, pending_update_count: int, - last_error_date: Optional[dtm.datetime] = None, - last_error_message: Optional[str] = None, - max_connections: Optional[int] = None, - allowed_updates: Optional[Sequence[str]] = None, - ip_address: Optional[str] = None, - last_synchronization_error_date: Optional[dtm.datetime] = None, + last_error_date: dtm.datetime | None = None, + last_error_message: str | None = None, + max_connections: int | None = None, + allowed_updates: Sequence[str] | None = None, + ip_address: str | None = None, + last_synchronization_error_date: dtm.datetime | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -142,14 +142,12 @@ def __init__( self.pending_update_count: int = pending_update_count # Optional - self.ip_address: Optional[str] = ip_address - self.last_error_date: Optional[dtm.datetime] = last_error_date - self.last_error_message: Optional[str] = last_error_message - self.max_connections: Optional[int] = max_connections + self.ip_address: str | None = ip_address + self.last_error_date: dtm.datetime | None = last_error_date + self.last_error_message: str | None = last_error_message + self.max_connections: int | None = max_connections self.allowed_updates: tuple[str, ...] = parse_sequence_arg(allowed_updates) - self.last_synchronization_error_date: Optional[dtm.datetime] = ( - last_synchronization_error_date - ) + self.last_synchronization_error_date: dtm.datetime | None = last_synchronization_error_date self._id_attrs = ( self.url, @@ -166,15 +164,10 @@ def __init__( self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["WebhookInfo"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "WebhookInfo": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) diff --git a/telegram/_writeaccessallowed.py b/src/telegram/_writeaccessallowed.py similarity index 88% rename from telegram/_writeaccessallowed.py rename to src/telegram/_writeaccessallowed.py index 07fdd6ba7e4..c0ff1322fc4 100644 --- a/telegram/_writeaccessallowed.py +++ b/src/telegram/_writeaccessallowed.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains objects related to the write access allowed service message.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -72,16 +71,16 @@ class WriteAccessAllowed(TelegramObject): def __init__( self, - web_app_name: Optional[str] = None, - from_request: Optional[bool] = None, - from_attachment_menu: Optional[bool] = None, + web_app_name: str | None = None, + from_request: bool | None = None, + from_attachment_menu: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.web_app_name: Optional[str] = web_app_name - self.from_request: Optional[bool] = from_request - self.from_attachment_menu: Optional[bool] = from_attachment_menu + self.web_app_name: str | None = web_app_name + self.from_request: bool | None = from_request + self.from_attachment_menu: bool | None = from_attachment_menu self._id_attrs = (self.web_app_name,) diff --git a/telegram/constants.py b/src/telegram/constants.py similarity index 82% rename from telegram/constants.py rename to src/telegram/constants.py index c61b3b96aab..82854ef597d 100644 --- a/telegram/constants.py +++ b/src/telegram/constants.py @@ -1,5 +1,5 @@ # python-telegram-bot - a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # by the python-telegram-bot contributors # # This program is free software: you can redistribute it and/or modify @@ -27,6 +27,10 @@ .. versionchanged:: 20.0 * Most of the constants in this module are grouped into enums. + +.. versionremoved:: 22.3 + Removed deprecated class ``StarTransactions``. Please instead use + :attr:`telegram.constants.Nanostar.VALUE`. """ # TODO: Remove this when https://github.com/PyCQA/pylint/issues/6887 is resolved. # pylint: disable=invalid-enum-extension,invalid-slots @@ -46,6 +50,7 @@ "BotDescriptionLimit", "BotNameLimit", "BulkRequestLimit", + "BusinessLimit", "CallbackQueryLimit", "ChatAction", "ChatBoostSources", @@ -72,8 +77,12 @@ "InlineQueryResultLimit", "InlineQueryResultType", "InlineQueryResultsButtonLimit", + "InputChecklistLimit", "InputMediaType", "InputPaidMediaType", + "InputProfilePhotoType", + "InputStoryContentLimit", + "InputStoryContentType", "InvoiceLimit", "KeyboardButtonRequestUsersLimit", "LocationLimit", @@ -85,23 +94,35 @@ "MessageLimit", "MessageOriginType", "MessageType", + "Nanostar", + "NanostarLimit", + "OwnedGiftType", "PaidMediaType", "ParseMode", "PollLimit", "PollType", "PollingLimit", + "PremiumSubscription", "ProfileAccentColor", "ReactionEmoji", "ReactionType", "ReplyLimit", "RevenueWithdrawalStateType", - "StarTransactions", "StarTransactionsLimit", "StickerFormat", "StickerLimit", "StickerSetLimit", "StickerType", + "StoryAreaPositionLimit", + "StoryAreaTypeLimit", + "StoryAreaTypeType", + "StoryLimit", + "SuggestedPost", + "SuggestedPostInfoState", + "SuggestedPostRefunded", "TransactionPartnerType", + "TransactionPartnerUser", + "UniqueGiftInfoOrigin", "UpdateType", "UserProfilePhotosLimit", "VerifyLimit", @@ -111,7 +132,7 @@ import datetime as dtm import sys from enum import Enum -from typing import Final, NamedTuple, Optional +from typing import Final, NamedTuple from telegram._utils.datetime import UTC from telegram._utils.enum import FloatEnum, IntEnum, StringEnum @@ -143,7 +164,7 @@ class _AccentColor(NamedTuple): """ identifier: int - name: Optional[str] = None + name: str | None = None light_colors: tuple[int, ...] = () dark_colors: tuple[int, ...] = () @@ -155,7 +176,7 @@ class _AccentColor(NamedTuple): #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=8, minor=2) +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=9, minor=3) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. @@ -171,6 +192,7 @@ class _AccentColor(NamedTuple): #: :obj:`datetime.datetime`, value of unix 0. #: This date literal is used in :class:`telegram.InaccessibleMessage` +# and :class:`telegram.ChecklistTask`. #: #: .. versionadded:: 20.8 ZERO_DATE: Final[dtm.datetime] = dtm.datetime(1970, 1, 1, tzinfo=UTC) @@ -702,6 +724,73 @@ class BulkRequestLimit(IntEnum): """:obj:`int`: Maximum number of messages required for bulk actions.""" +class BusinessLimit(IntEnum): + """This enum contains limitations related to handling business accounts. The enum members + of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + CHAT_ACTIVITY_TIMEOUT = int(dtm.timedelta(hours=24).total_seconds()) + """:obj:`int`: Time in seconds in which the chat must have been active for. Relevant for + :paramref:`~telegram.Bot.read_business_message.chat_id` + of :meth:`~telegram.Bot.read_business_message` and + :paramref:`~telegram.Bot.transfer_gift.new_owner_chat_id` + of :meth:`~telegram.Bot.transfer_gift`. + """ + MIN_NAME_LENGTH = 1 + """:obj:`int`: Minimum length of the name of a business account. Relevant only for + :paramref:`~telegram.Bot.set_business_account_name.first_name` of + :meth:`telegram.Bot.set_business_account_name`. + """ + MAX_NAME_LENGTH = 64 + """:obj:`int`: Maximum length of the name of a business account. Relevant for the parameters + of :meth:`telegram.Bot.set_business_account_name`. + """ + MAX_USERNAME_LENGTH = 32 + """::obj:`int`: Maximum length of the username of a business account. Relevant for + :paramref:`~telegram.Bot.set_business_account_username.username` of + :meth:`telegram.Bot.set_business_account_username`. + """ + MAX_BIO_LENGTH = 140 + """:obj:`int`: Maximum length of the bio of a business account. Relevant for + :paramref:`~telegram.Bot.set_business_account_bio.bio` of + :meth:`telegram.Bot.set_business_account_bio`. + """ + MIN_GIFT_RESULTS = 1 + """:obj:`int`: Minimum number of gifts to be returned. Relevant for + + * :paramref:`~telegram.Bot.get_business_account_gifts.limit` of + :meth:`telegram.Bot.get_business_account_gifts`. + * :paramref:`~telegram.Bot.get_chat_gifts.limit` of + :meth:`telegram.Bot.get_chat_gifts`. + * :paramref:`~telegram.Bot.get_user_gifts.limit` of + :meth:`telegram.Bot.get_user_gifts`. + """ + MAX_GIFT_RESULTS = 100 + """:obj:`int`: Maximum number of gifts to be returned. Relevant for + + * :paramref:`~telegram.Bot.get_business_account_gifts.limit` of + :meth:`telegram.Bot.get_business_account_gifts`. + * :paramref:`~telegram.Bot.get_chat_gifts.limit` of + :meth:`telegram.Bot.get_chat_gifts`. + * :paramref:`~telegram.Bot.get_user_gifts.limit` of + :meth:`telegram.Bot.get_user_gifts`. + """ + MIN_STAR_COUNT = 1 + """:obj:`int`: Minimum number of Telegram Stars to be transfered. Relevant for + :paramref:`~telegram.Bot.transfer_business_account_stars.star_count` of + :meth:`telegram.Bot.transfer_business_account_stars`. + """ + MAX_STAR_COUNT = 10000 + """:obj:`int`: Maximum number of Telegram Stars to be transfered. Relevant for + :paramref:`~telegram.Bot.transfer_business_account_stars.star_count` of + :meth:`telegram.Bot.transfer_business_account_stars`. + """ + + class CallbackQueryLimit(IntEnum): """This enum contains limitations for :class:`telegram.CallbackQuery`/ :meth:`telegram.Bot.answer_callback_query`. The enum members of this enumeration are instances @@ -887,8 +976,12 @@ class ChatSubscriptionLimit(IntEnum): """:obj:`int`: The number of seconds the subscription will be active.""" MIN_PRICE = 1 """:obj:`int`: Amount of stars a user pays, minimum amount the subscription can be set to.""" - MAX_PRICE = 2500 - """:obj:`int`: Amount of stars a user pays, maximum amount the subscription can be set to.""" + MAX_PRICE = 10000 + """:obj:`int`: Amount of stars a user pays, maximum amount the subscription can be set to. + + .. versionchanged:: 22.1 + Bot API 9.0 changed the value to 10000. + """ class BackgroundTypeLimit(IntEnum): @@ -1236,9 +1329,12 @@ class GiftLimit(IntEnum): __slots__ = () - MAX_TEXT_LENGTH = 255 + MAX_TEXT_LENGTH = 128 """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the :paramref:`~telegram.Bot.send_gift.text` parameter of :meth:`~telegram.Bot.send_gift`. + + .. versionchanged:: 21.11 + Updated Value to 128 based on Bot API 8.3 """ @@ -1330,6 +1426,47 @@ class InlineKeyboardMarkupLimit(IntEnum): """ +class InputChecklistLimit(IntEnum): + """This enum contains limitations for :class:`telegram.InputChecklist`/ + :class:`telegram.InputChecklistTask`. The enum + members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.3 + """ + + __slots__ = () + + MIN_TITLE_LENGTH = 1 + """:obj:`int`: Minimum number of characters in a :obj:`str` passed as + :paramref:`~telegram.InputChecklist.title` parameter of :class:`telegram.InputChecklist` + """ + + MAX_TITLE_LENGTH = 255 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as + :paramref:`~telegram.InputChecklist.title` parameter of :class:`telegram.InputChecklist` + """ + + MIN_TEXT_LENGTH = 1 + """:obj:`int`: Minimum number of characters in a :obj:`str` passed as + :paramref:`~telegram.InputChecklistTask.text` parameter of :class:`telegram.InputChecklistTask` + """ + + MAX_TEXT_LENGTH = 100 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as + :paramref:`~telegram.InputChecklistTask.text` parameter of :class:`telegram.InputChecklistTask` + """ + + MIN_TASK_NUMBER = 1 + """:obj:`int`: Minimum number of tasks passed as :paramref:`~telegram.InputChecklist.tasks` + parameter of :class:`telegram.InputChecklist` + """ + + MAX_TASK_NUMBER = 30 + """:obj:`int`: Maximum number of tasks passed as :paramref:`~telegram.InputChecklistTask.tasks` + parameter of :class:`telegram.InputChecklistTask` + """ + + class InputMediaType(StringEnum): """This enum contains the available types of :class:`telegram.InputMedia`. The enum members of this enumeration are instances of :class:`str` and can be treated as such. @@ -1366,12 +1503,95 @@ class InputPaidMediaType(StringEnum): """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" +class InputProfilePhotoType(StringEnum): + """This enum contains the available types of :class:`telegram.InputProfilePhoto`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + STATIC = "static" + """:obj:`str`: Type of :class:`telegram.InputProfilePhotoStatic`.""" + ANIMATED = "animated" + """:obj:`str`: Type of :class:`telegram.InputProfilePhotoAnimated`.""" + + +class InputStoryContentLimit(StringEnum): + """This enum contains limitations for :class:`telegram.InputStoryContentPhoto`/ + :class:`telegram.InputStoryContentVideo`. The enum members of this enumeration are instances + of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + PHOTOSIZE_UPLOAD = FileSizeLimit.PHOTOSIZE_UPLOAD # (10MB) + """:obj:`int`: Maximum file size of the photo to be passed to + :paramref:`~telegram.InputStoryContentPhoto.photo` parameter of + :class:`telegram.InputStoryContentPhoto` in Bytes. + """ + PHOTO_WIDTH = 1080 + """:obj:`int`: Horizontal resolution of the photo to be passed to + :paramref:`~telegram.InputStoryContentPhoto.photo` parameter of + :class:`telegram.InputStoryContentPhoto`. + """ + PHOTO_HEIGHT = 1920 + """:obj:`int`: Vertical resolution of the video to be passed to + :paramref:`~telegram.InputStoryContentPhoto.photo` parameter of + :class:`telegram.InputStoryContentPhoto`. + """ + VIDEOSIZE_UPLOAD = int(30e6) # (30MB) + """:obj:`int`: Maximum file size of the video to be passed to + :paramref:`~telegram.InputStoryContentVideo.video` parameter of + :class:`telegram.InputStoryContentVideo` in Bytes. + """ + VIDEO_WIDTH = 720 + """:obj:`int`: Horizontal resolution of the video to be passed to + :paramref:`~telegram.InputStoryContentVideo.video` parameter of + :class:`telegram.InputStoryContentVideo`. + """ + VIDEO_HEIGHT = 1080 + """:obj:`int`: Vertical resolution of the video to be passed to + :paramref:`~telegram.InputStoryContentVideo.video` parameter of + :class:`telegram.InputStoryContentVideo`. + """ + MAX_VIDEO_DURATION = int(dtm.timedelta(seconds=60).total_seconds()) + """:obj:`int`: Maximum duration of the video to be passed to + :paramref:`~telegram.InputStoryContentVideo.duration` parameter of + :class:`telegram.InputStoryContentVideo`. + """ + + +class InputStoryContentType(StringEnum): + """This enum contains the available types of :class:`telegram.InputStoryContent`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + PHOTO = "photo" + """:obj:`str`: Type of :class:`telegram.InputStoryContentPhoto`.""" + VIDEO = "video" + """:obj:`str`: Type of :class:`telegram.InputStoryContentVideo`.""" + + class InlineQueryLimit(IntEnum): """This enum contains limitations for :class:`telegram.InlineQuery`/ :meth:`telegram.Bot.answer_inline_query`. The enum members of this enumeration are instances of :class:`int` and can be treated as such. .. versionadded:: 20.0 + + .. versionchanged:: 22.0 + Removed deprecated attributes ``InlineQueryLimit.MIN_SWITCH_PM_TEXT_LENGTH`` and + ``InlineQueryLimit.MAX_SWITCH_PM_TEXT_LENGTH``. Please instead use + :attr:`InlineQueryResultsButtonLimit.MIN_START_PARAMETER_LENGTH` and + :attr:`InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH`. """ __slots__ = () @@ -1386,22 +1606,6 @@ class InlineQueryLimit(IntEnum): MAX_QUERY_LENGTH = 256 """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the :paramref:`~telegram.InlineQuery.query` parameter of :class:`telegram.InlineQuery`.""" - MIN_SWITCH_PM_TEXT_LENGTH = 1 - """:obj:`int`: Minimum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.Bot.answer_inline_query.switch_pm_parameter` parameter of - :meth:`telegram.Bot.answer_inline_query`. - - .. deprecated:: 20.3 - Deprecated in favor of :attr:`InlineQueryResultsButtonLimit.MIN_START_PARAMETER_LENGTH`. - """ - MAX_SWITCH_PM_TEXT_LENGTH = 64 - """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.Bot.answer_inline_query.switch_pm_parameter` parameter of - :meth:`telegram.Bot.answer_inline_query`. - - .. deprecated:: 20.3 - Deprecated in favor of :attr:`InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH`. - """ class InlineQueryResultLimit(IntEnum): @@ -1434,12 +1638,12 @@ class InlineQueryResultsButtonLimit(IntEnum): __slots__ = () - MIN_START_PARAMETER_LENGTH = InlineQueryLimit.MIN_SWITCH_PM_TEXT_LENGTH + MIN_START_PARAMETER_LENGTH = 1 """:obj:`int`: Minimum number of characters in a :obj:`str` passed as the :paramref:`~telegram.InlineQueryResultsButton.start_parameter` parameter of :meth:`telegram.InlineQueryResultsButton`.""" - MAX_START_PARAMETER_LENGTH = InlineQueryLimit.MAX_SWITCH_PM_TEXT_LENGTH + MAX_START_PARAMETER_LENGTH = 64 """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the :paramref:`~telegram.InlineQueryResultsButton.start_parameter` parameter of :meth:`telegram.InlineQueryResultsButton`.""" @@ -1816,6 +2020,8 @@ class MessageLimit(IntEnum): * :paramref:`~telegram.Bot.send_message.text` parameter of :meth:`telegram.Bot.send_message` * :paramref:`~telegram.Bot.edit_message_text.text` parameter of :meth:`telegram.Bot.edit_message_text` + * :paramref:`~telegram.Bot.send_message_draft.text` parameter of + :meth:`telegram.Bot.send_message_draft` """ CAPTION_LENGTH = 1024 """:obj:`int`: Maximum number of characters in a :obj:`str` passed as: @@ -1831,11 +2037,14 @@ class MessageLimit(IntEnum): """ # constants above this line are tested MIN_TEXT_LENGTH = 1 - """:obj:`int`: Minimum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.InputTextMessageContent.message_text` parameter of - :class:`telegram.InputTextMessageContent` and the - :paramref:`~telegram.Bot.edit_message_text.text` parameter of - :meth:`telegram.Bot.edit_message_text`. + """:obj:`int`: Minimum number of characters in a :obj:`str` passed as: + + * :paramref:`~telegram.InputTextMessageContent.message_text` parameter of + :class:`telegram.InputTextMessageContent`. + * :paramref:`~telegram.Bot.edit_message_text.text` parameter of + :meth:`telegram.Bot.edit_message_text`. + * :paramref:`~telegram.Bot.send_message_draft.text` parameter of + :meth:`telegram.Bot.send_message_draft`. """ DEEP_LINK_LENGTH = 64 """:obj:`int`: Maximum number of characters for a deep link.""" @@ -1910,6 +2119,21 @@ class MessageType(StringEnum): .. versionadded:: 21.2 """ + CHECKLIST = "checklist" + """:obj:`str`: Messages with :attr:`telegram.Message.checklist`. + + .. versionadded:: 22.3 + """ + CHECKLIST_TASKS_ADDED = "checklist_tasks_added" + """:obj:`str`: Messages with :attr:`telegram.Message.checklist_tasks_added`. + + .. versionadded:: 22.3 + """ + CHECKLIST_TASKS_DONE = "checklist_tasks_done" + """:obj:`str`: Messages with :attr:`telegram.Message.checklist_tasks_done`. + + .. versionadded:: 22.3 + """ CONNECTED_WEBSITE = "connected_website" """:obj:`str`: Messages with :attr:`telegram.Message.connected_website`.""" CONTACT = "contact" @@ -1918,6 +2142,11 @@ class MessageType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.delete_chat_photo`.""" DICE = "dice" """:obj:`str`: Messages with :attr:`telegram.Message.dice`.""" + DIRECT_MESSAGE_PRICE_CHANGED = "direct_message_price_changed" + """:obj:`str`: Messages with :attr:`telegram.Message.direct_message_price_changed`. + + .. versionadded:: 22.3 + """ DOCUMENT = "document" """:obj:`str`: Messages with :attr:`telegram.Message.document`.""" EFFECT_ID = "effect_id" @@ -1956,6 +2185,16 @@ class MessageType(StringEnum): .. versionadded:: 20.8 """ + GIFT = "gift" + """:obj:`str`: Messages with :attr:`telegram.Message.gift`. + + .. versionadded:: 22.1 + """ + GIFT_UPGRADE_SENT = "gift_upgrade_sent" + """:obj:`str`: Messages with :attr:`telegram.Message.gift_upgrade_sent`. + + .. versionadded:: 22.6 + """ GIVEAWAY = "giveaway" """:obj:`str`: Messages with :attr:`telegram.Message.giveaway`. @@ -1999,6 +2238,41 @@ class MessageType(StringEnum): .. versionadded:: 21.4 """ + PAID_MESSAGE_PRICE_CHANGED = "paid_message_price_changed" + """:obj:`str`: Messages with :attr:`telegram.Message.paid_message_price_changed`. + + .. versionadded:: v22.2 + """ + SUGGESTED_POST_APPROVAL_FAILED = "suggested_post_approval_failed" + """:obj:`str`: Messages with :attr:`telegram.Message.suggested_post_approval_failed`. + + .. versionadded:: 22.4 + """ + SUGGESTED_POST_APPROVED = "suggested_post_approved" + """:obj:`str`: Messages with :attr:`telegram.Message.suggested_post_approved`. + + .. versionadded:: 22.4 + """ + SUGGESTED_POST_DECLINED = "suggested_post_declined" + """:obj:`str`: Messages with :attr:`telegram.Message.suggested_post_declined`. + + .. versionadded:: 22.4 + """ + SUGGESTED_POST_INFO = "suggested_post_info" + """:obj:`str`: Messages with :attr:`telegram.Message.suggested_post_info`. + + .. versionadded:: 22.4 + """ + SUGGESTED_POST_PAID = "suggested_post_paid" + """:obj:`str`: Messages with :attr:`telegram.Message.suggested_post_paid`. + + .. versionadded:: 22.4 + """ + SUGGESTED_POST_REFUNDED = "suggested_post_refunded" + """:obj:`str`: Messages with :attr:`telegram.Message.suggested_post_refunded`. + + .. versionadded:: 22.4 + """ PASSPORT_DATA = "passport_data" """:obj:`str`: Messages with :attr:`telegram.Message.passport_data`.""" PHOTO = "photo" @@ -2039,6 +2313,11 @@ class MessageType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.successful_payment`.""" TEXT = "text" """:obj:`str`: Messages with :attr:`telegram.Message.text`.""" + UNIQUE_GIFT = "unique_gift" + """:obj:`str`: Messages with :attr:`telegram.Message.unique_gift`. + + .. versionadded:: 22.1 + """ USERS_SHARED = "users_shared" """:obj:`str`: Messages with :attr:`telegram.Message.users_shared`. @@ -2072,6 +2351,69 @@ class MessageType(StringEnum): """ +class Nanostar(FloatEnum): + """This enum contains constants for ``nanostar_amount`` parameter of + :class:`telegram.StarAmount`, :class:`telegram.StarTransaction` + and :class:`telegram.AffiliateInfo`. + The enum members of this enumeration are instances of :class:`float` and can be treated as + such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + VALUE = 1 / 1000000000 + """:obj:`float`: The value of one nanostar as used in + :paramref:`telegram.StarTransaction.nanostar_amount` + parameter of :class:`telegram.StarTransaction`, + :paramref:`telegram.StarAmount.nanostar_amount` parameter of :class:`telegram.StarAmount` + and :paramref:`telegram.AffiliateInfo.nanostar_amount` + parameter of :class:`telegram.AffiliateInfo` + """ + + +class NanostarLimit(IntEnum): + """This enum contains limitations for ``nanostar_amount`` parameter of + :class:`telegram.AffiliateInfo`, :class:`telegram.StarTransaction` + and :class:`telegram.StarAmount`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + MIN_AMOUNT = -999999999 + """:obj:`int`: Minimum value allowed for :paramref:`~telegram.AffiliateInfo.nanostar_amount` + parameter of :class:`telegram.AffiliateInfo` + and :paramref:`~telegram.StarAmount.nanostar_amount` + parameter of :class:`telegram.StarAmount`. + """ + MAX_AMOUNT = 999999999 + """:obj:`int`: Maximum value allowed for :paramref:`~telegram.StarTransaction.nanostar_amount` + parameter of :class:`telegram.StarTransaction`, + :paramref:`~telegram.AffiliateInfo.nanostar_amount` parameter of + :class:`telegram.AffiliateInfo` and :paramref:`~telegram.StarAmount.nanostar_amount` + parameter of :class:`telegram.StarAmount`. + """ + + +class OwnedGiftType(StringEnum): + """This enum contains the available types of :class:`telegram.OwnedGift`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + REGULAR = "regular" + """:obj:`str`: a regular owned gift.""" + UNIQUE = "unique" + """:obj:`str`: a unique owned gift.""" + + class PaidMediaType(StringEnum): """ This enum contains the available types of :class:`telegram.PaidMedia`. The enum @@ -2109,6 +2451,58 @@ class PollingLimit(IntEnum): """ +class PremiumSubscription(IntEnum): + """This enum contains limitations for :meth:`~telegram.Bot.gift_premium_subscription`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + MAX_TEXT_LENGTH = 128 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.Bot.gift_premium_subscription.text` + parameter of :meth:`~telegram.Bot.gift_premium_subscription`. + """ + MONTH_COUNT_THREE = 3 + """:obj:`int`: Possible value for + :paramref:`~telegram.Bot.gift_premium_subscription.month_count` parameter + of :meth:`~telegram.Bot.gift_premium_subscription`; number of months the Premium + subscription will be active for. + """ + MONTH_COUNT_SIX = 6 + """:obj:`int`: Possible value for + :paramref:`~telegram.Bot.gift_premium_subscription.month_count` parameter + of :meth:`~telegram.Bot.gift_premium_subscription`; number of months the Premium + subscription will be active for. + """ + MONTH_COUNT_TWELVE = 12 + """:obj:`int`: Possible value for + :paramref:`~telegram.Bot.gift_premium_subscription.month_count` parameter + of :meth:`~telegram.Bot.gift_premium_subscription`; number of months the Premium + subscription will be active for. + """ + STARS_THREE_MONTHS = 1000 + """:obj:`int`: Number of Telegram Stars to pay for a Premium subscription of + :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_THREE` months period. + Relevant for :paramref:`~telegram.Bot.gift_premium_subscription.star_count` parameter + of :meth:`~telegram.Bot.gift_premium_subscription`. + """ + STARS_SIX_MONTHS = 1500 + """:obj:`int`: Number of Telegram Stars to pay for a Premium subscription of + :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_SIX` months period. + Relevant for :paramref:`~telegram.Bot.gift_premium_subscription.star_count` parameter + of :meth:`~telegram.Bot.gift_premium_subscription`. + """ + STARS_TWELVE_MONTHS = 2500 + """:obj:`int`: Number of Telegram Stars to pay for a Premium subscription of + :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_TWELVE` months period. + Relevant for :paramref:`~telegram.Bot.gift_premium_subscription.star_count` parameter + of :meth:`~telegram.Bot.gift_premium_subscription`. + """ + + class ProfileAccentColor(Enum): """This enum contains the available accent colors for :class:`telegram.ChatFullInfo.profile_accent_color_id`. @@ -2462,28 +2856,18 @@ class RevenueWithdrawalStateType(StringEnum): """:obj:`str`: A withdrawal failed and the transaction was refunded.""" -class StarTransactions(FloatEnum): - """This enum contains constants for :class:`telegram.StarTransaction`. - The enum members of this enumeration are instances of :class:`float` and can be treated as - such. - - .. versionadded:: 21.9 - """ - - __slots__ = () - - NANOSTAR_VALUE = 1 / 1000000000 - """:obj:`float`: The value of one nanostar as used in - :attr:`telegram.StarTransaction.nanostar_amount`. - """ - - class StarTransactionsLimit(IntEnum): """This enum contains limitations for :class:`telegram.Bot.get_star_transactions` and :class:`telegram.StarTransaction`. The enum members of this enumeration are instances of :class:`int` and can be treated as such. .. versionadded:: 21.4 + + .. versionremoved:: 22.3 + Removed deprecated attributes ``StarTransactionsLimit.NANOSTAR_MIN_AMOUNT`` + and ``StarTransactionsLimit.NANOSTAR_MAX_AMOUNT``. Please instead use + :attr:`telegram.constants.NanostarLimit.MIN_AMOUNT` + and :attr:`telegram.constants.NanostarLimit.MAX_AMOUNT`. """ __slots__ = () @@ -2496,20 +2880,6 @@ class StarTransactionsLimit(IntEnum): """:obj:`int`: Maximum value allowed for the :paramref:`~telegram.Bot.get_star_transactions.limit` parameter of :meth:`telegram.Bot.get_star_transactions`.""" - NANOSTAR_MIN_AMOUNT = -999999999 - """:obj:`int`: Minimum value allowed for :paramref:`~telegram.AffiliateInfo.nanostar_amount` - parameter of :class:`telegram.AffiliateInfo`. - - .. versionadded:: 21.9 - """ - NANOSTAR_MAX_AMOUNT = 999999999 - """:obj:`int`: Maximum value allowed for :paramref:`~telegram.StarTransaction.nanostar_amount` - parameter of :class:`telegram.StarTransaction` and - :paramref:`~telegram.AffiliateInfo.nanostar_amount` parameter of - :class:`telegram.AffiliateInfo`. - - .. versionadded:: 21.9 - """ class StickerFormat(StringEnum): @@ -2618,13 +2988,13 @@ class StickerSetLimit(IntEnum): :meth:`telegram.Bot.add_sticker_to_set`. """ MAX_STATIC_THUMBNAIL_SIZE = 128 - """:obj:`int`: Maximum size of the thumbnail if it is a **.WEBP** or **.PNG** in kilobytes, + """:obj:`int`: Maximum size of the thumbnail if it is a ``.WEBP`` or ``.PNG`` in kilobytes, as given in :meth:`telegram.Bot.set_sticker_set_thumbnail`.""" MAX_ANIMATED_THUMBNAIL_SIZE = 32 - """:obj:`int`: Maximum size of the thumbnail if it is a **.TGS** or **.WEBM** in kilobytes, + """:obj:`int`: Maximum size of the thumbnail if it is a ``.TGS`` or ``.WEBM`` in kilobytes, as given in :meth:`telegram.Bot.set_sticker_set_thumbnail`.""" STATIC_THUMB_DIMENSIONS = 100 - """:obj:`int`: Exact height and width of the thumbnail if it is a **.WEBP** or **.PNG** in + """:obj:`int`: Exact height and width of the thumbnail if it is a ``.WEBP`` or ``.PNG`` in pixels, as given in :meth:`telegram.Bot.set_sticker_set_thumbnail`.""" @@ -2645,6 +3015,158 @@ class StickerType(StringEnum): """:obj:`str`: Custom emoji sticker.""" +class StoryAreaPositionLimit(IntEnum): + """This enum contains limitations for :class:`telegram.StoryAreaPosition`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + MAX_ROTATION_ANGLE = 360 + """:obj:`int`: Maximum value allowed for: + :paramref:`~telegram.StoryAreaPosition.rotation_angle` parameter of + :class:`telegram.StoryAreaPosition` + """ + + +class StoryAreaTypeLimit(IntEnum): + """This enum contains limitations for subclasses of :class:`telegram.StoryAreaType`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + MAX_LOCATION_AREAS = 10 + """:obj:`int`: Maximum number of location areas that a story can have. + """ + MAX_SUGGESTED_REACTION_AREAS = 5 + """:obj:`int`: Maximum number of suggested reaction areas that a story can have. + """ + MAX_LINK_AREAS = 3 + """:obj:`int`: Maximum number of link areas that a story can have. + """ + MAX_WEATHER_AREAS = 3 + """:obj:`int`: Maximum number of weather areas that a story can have. + """ + MAX_UNIQUE_GIFT_AREAS = 1 + """:obj:`int`: Maximum number of unique gift areas that a story can have. + """ + + +class StoryAreaTypeType(StringEnum): + """This enum contains the available types of :class:`telegram.StoryAreaType`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + LOCATION = "location" + """:obj:`str`: Type of :class:`telegram.StoryAreaTypeLocation`.""" + SUGGESTED_REACTION = "suggested_reaction" + """:obj:`str`: Type of :class:`telegram.StoryAreaTypeSuggestedReaction`.""" + LINK = "link" + """:obj:`str`: Type of :class:`telegram.StoryAreaTypeLink`.""" + WEATHER = "weather" + """:obj:`str`: Type of :class:`telegram.StoryAreaTypeWeather`.""" + UNIQUE_GIFT = "unique_gift" + """:obj:`str`: Type of :class:`telegram.StoryAreaTypeUniqueGift`.""" + + +class StoryLimit(IntEnum): + """This enum contains limitations for :meth:`~telegram.Bot.post_story` and + :meth:`~telegram.Bot.edit_story`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + CAPTION_LENGTH = 2048 + """:obj:`int`: Maximum number of characters in :paramref:`telegram.Bot.post_story.caption` + parameter of :meth:`telegram.Bot.post_story` and :paramref:`telegram.Bot.edit_story.caption` of + :meth:`telegram.Bot.edit_story`. + """ + ACTIVITY_SIX_HOURS = 6 * 3600 + """:obj:`int`: Possible value for :paramref:`~telegram.Bot.post_story.active_period`` parameter + of :meth:`telegram.Bot.post_story`.""" + ACTIVITY_TWELVE_HOURS = 12 * 3600 + """:obj:`int`: Possible value for :paramref:`~telegram.Bot.post_story.active_period`` parameter + of :meth:`telegram.Bot.post_story`.""" + ACTIVITY_ONE_DAY = 86400 + """:obj:`int`: Possible value for :paramref:`~telegram.Bot.post_story.active_period`` parameter + of :meth:`telegram.Bot.post_story`.""" + ACTIVITY_TWO_DAYS = 2 * 86400 + """:obj:`int`: Possible value for :paramref:`~telegram.Bot.post_story.active_period`` parameter + of :meth:`telegram.Bot.post_story`.""" + + +class SuggestedPost(IntEnum): + """This enum contains limitations for :class:`telegram.SuggestedPostPrice`\ +/:class:`telegram.SuggestedPostParameters`/:meth:`telegram.Bot.decline_suggested_post`. The enum + members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.4 + """ + + __slots__ = () + + MIN_PRICE_STARS = 5 + """:obj:`int`: Minimum number of Telegram Stars in + :paramref:`~telegram.SuggestedPostPrice.amount` + parameter of :class:`telegram.SuggestedPostPrice`. + """ + MAX_PRICE_STARS = 100_000 + """:obj:`int`: Maximum number of Telegram Stars in + :paramref:`~telegram.SuggestedPostPrice.amount` + parameter of :class:`telegram.SuggestedPostPrice`. + """ + MIN_PRICE_NANOTONCOINS = 10_000_000 + """:obj:`int`: Minimum number of nanotoncoins in + :paramref:`~telegram.SuggestedPostPrice.amount` + parameter of :class:`telegram.SuggestedPostPrice`. + """ + MAX_PRICE_NANOTONCOINS = 10_000_000_000_000 + """:obj:`int`: Maximum number of nanotoncoins in + :paramref:`~telegram.SuggestedPostPrice.amount` + parameter of :class:`telegram.SuggestedPostPrice`. + """ + MIN_SEND_DATE = 300 + """:obj:`int`: Minimum number of seconds in the future for + the :paramref:`~telegram.SuggestedPostParameters.send_date` parameter of + :class:`telegram.SuggestedPostParameters`.""" + MAX_SEND_DATE = 2_678_400 + """:obj:`int`: Maximum number of seconds in the future for + the :paramref:`~telegram.SuggestedPostParameters.send_date` parameter of + :class:`telegram.SuggestedPostParameters`.""" + MAX_COMMENT_LENGTH = 128 + """:obj:`int`: Maximum number of characters in the + :paramref:`telegram.Bot.decline_suggested_post.comment` parameter. + """ + + +class SuggestedPostRefunded(StringEnum): + """This enum contains available refund reasons for :class:`telegram.SuggestedPostRefunded`. + The enum members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 22.4 + """ + + __slots__ = () + + POST_DELETED = "post_deleted" + """:obj:`str`: The post was deleted within 24 hours of being posted or removed from + scheduled messages without being posted.""" + PAYMENT_REFUNDED = "payment_refunded" + """:obj:`str`: The payer refunded their payment.""" + + class TransactionPartnerType(StringEnum): """This enum contains the available types of :class:`telegram.TransactionPartner`. The enum members of this enumeration are instances of :class:`str` and can be treated as such. @@ -2659,6 +3181,11 @@ class TransactionPartnerType(StringEnum): .. versionadded:: 21.9 """ + CHAT = "chat" + """:obj:`str`: Transaction with a chat. + + .. versionadded:: 21.11 + """ FRAGMENT = "fragment" """:obj:`str`: Withdrawal transaction with Fragment.""" OTHER = "other" @@ -2669,12 +3196,44 @@ class TransactionPartnerType(StringEnum): """:obj:`str`: Transaction with with payment for `paid broadcasting `_. - ..versionadded:: 21.7 + .. versionadded:: 21.7 """ USER = "user" """:obj:`str`: Transaction with a user.""" +class TransactionPartnerUser(StringEnum): + """This enum contains constants for :class:`telegram.TransactionPartnerUser`. + The enum members of this enumeration are instances of :class:`str` and can be treated as + such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + INVOICE_PAYMENT = "invoice_payment" + """:obj:`str`: Possible value for + :paramref:`telegram.TransactionPartnerUser.transaction_type`. + """ + PAID_MEDIA_PAYMENT = "paid_media_payment" + """:obj:`str`: Possible value for + :paramref:`telegram.TransactionPartnerUser.transaction_type`. + """ + GIFT_PURCHASE = "gift_purchase" + """:obj:`str`: Possible value for + :paramref:`telegram.TransactionPartnerUser.transaction_type`. + """ + PREMIUM_PURCHASE = "premium_purchase" + """:obj:`str`: Possible value for + :paramref:`telegram.TransactionPartnerUser.transaction_type`. + """ + BUSINESS_ACCOUNT_TRANSFER = "business_account_transfer" + """:obj:`str`: Possible value for + :paramref:`telegram.TransactionPartnerUser.transaction_type`. + """ + + class ParseMode(StringEnum): """This enum contains the available parse modes. The enum members of this enumeration are instances of :class:`str` and can be treated as such. @@ -2732,10 +3291,13 @@ class PollLimit(IntEnum): to the :paramref:`~telegram.Bot.send_poll.options` parameter of :meth:`telegram.Bot.send_poll`. """ - MAX_OPTION_NUMBER = 10 + MAX_OPTION_NUMBER = 12 """:obj:`int`: Maximum number of strings passed in a :obj:`list` to the :paramref:`~telegram.Bot.send_poll.options` parameter of :meth:`telegram.Bot.send_poll`. + + .. versionchanged:: 22.3 + This value was changed from ``10`` to ``12`` in accordance to Bot API 9.1. """ MAX_EXPLANATION_LENGTH = 200 """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the @@ -2777,6 +3339,36 @@ class PollType(StringEnum): """:obj:`str`: quiz polls.""" +class UniqueGiftInfoOrigin(StringEnum): + """This enum contains the available origins for :class:`telegram.UniqueGiftInfo`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + GIFTED_UPGRADE = "gifted_upgrade" + """:obj:`str` upgrades purchased after the gift was sent + + .. versionadded:: 22.6 + """ + OFFER = "OFFER" + """:obj:`str` gift bought or sold through gift purchase offers + + .. versionadded:: 22.6 + """ + RESALE = "resale" + """:obj:`str` gift bought from other users + + .. versionadded:: 22.3 + """ + TRANSFER = "transfer" + """:obj:`str` gift transfered""" + UPGRADE = "upgrade" + """:obj:`str` gift upgraded""" + + class UpdateType(StringEnum): """This enum contains the available types of :class:`telegram.Update`. The enum members of this enumeration are instances of :class:`str` and can be treated as such. @@ -2948,12 +3540,16 @@ class InvoiceLimit(IntEnum): .. versionadded:: 21.6 """ - MAX_STAR_COUNT = 2500 + MAX_STAR_COUNT = 25000 """:obj:`int`: Maximum amount of starts that must be paid to buy access to a paid media passed as :paramref:`~telegram.Bot.send_paid_media.star_count` parameter of :meth:`telegram.Bot.send_paid_media`. .. versionadded:: 21.6 + .. versionchanged:: 22.1 + Bot API 9.0 changed the value to 10000. + .. versionchanged:: 22.6 + Bot API 9.3 changed the value to 25000. """ SUBSCRIPTION_PERIOD = dtm.timedelta(days=30).total_seconds() """:obj:`int`: The period of time for which the subscription is active before @@ -2962,11 +3558,13 @@ class InvoiceLimit(IntEnum): .. versionadded:: 21.8 """ - SUBSCRIPTION_MAX_PRICE = 2500 + SUBSCRIPTION_MAX_PRICE = 10000 """:obj:`int`: The maximum price of a subscription created wtih :meth:`telegram.Bot.create_invoice_link`. .. versionadded:: 21.9 + .. versionchanged:: 22.1 + Bot API 9.0 changed the value to 10000. """ @@ -3055,6 +3653,25 @@ class ForumTopicLimit(IntEnum): """ +class SuggestedPostInfoState(StringEnum): + """This enum contains the available states of :attr:`telegram.SuggestedPostInfo.state`. + The enum members of this enumeration are instances + of :class:`str` and can be treated as such. + + .. versionadded:: 22.4 + """ + + __slots__ = () + + PENDING = "pending" + """:obj:`str`: Suggested post is pending.""" + APPROVED = "approved" + """:obj:`str`: Suggested post was approved.""" + DECLINED = "declined" + """:obj:`str`: Suggested post was declined. + """ + + class ReactionType(StringEnum): """This enum contains the available types of :class:`telegram.ReactionType`. The enum members of this enumeration are instances of :class:`str` and can be treated as such. diff --git a/telegram/error.py b/src/telegram/error.py similarity index 79% rename from telegram/error.py rename to src/telegram/error.py index 2de0361762d..014bf8631b3 100644 --- a/telegram/error.py +++ b/src/telegram/error.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -22,6 +22,12 @@ Replaced ``Unauthorized`` by :class:`Forbidden`. """ +import datetime as dtm + +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import TimePeriod + __all__ = ( "BadRequest", "ChatMigrated", @@ -36,8 +42,6 @@ "TimedOut", ) -from typing import Optional, Union - class TelegramError(Exception): """ @@ -117,7 +121,7 @@ class InvalidToken(TelegramError): __slots__ = () - def __init__(self, message: Optional[str] = None) -> None: + def __init__(self, message: str | None = None) -> None: super().__init__("Invalid token" if message is None else message) @@ -171,7 +175,7 @@ class TimedOut(NetworkError): __slots__ = () - def __init__(self, message: Optional[str] = None) -> None: + def __init__(self, message: str | None = None) -> None: super().__init__(message or "Timed out") @@ -208,21 +212,42 @@ class RetryAfter(TelegramError): :attr:`retry_after` is now an integer to comply with the Bot API. Args: - retry_after (:obj:`int`): Time in seconds, after which the bot can retry the request. + retry_after (:obj:`int` | :class:`datetime.timedelta`): Time in seconds, after which the + bot can retry the request. + + .. versionchanged:: v22.2 + |time-period-input| Attributes: - retry_after (:obj:`int`): Time in seconds, after which the bot can retry the request. + retry_after (:obj:`int` | :class:`datetime.timedelta`): Time in seconds, after which the + bot can retry the request. + + .. deprecated:: v22.2 + |time-period-int-deprecated| """ - __slots__ = ("retry_after",) + __slots__ = ("_retry_after",) + + def __init__(self, retry_after: TimePeriod): + self._retry_after: dtm.timedelta = to_timedelta(retry_after) + + if isinstance(self.retry_after, int): + super().__init__(f"Flood control exceeded. Retry in {self.retry_after} seconds") + else: + super().__init__(f"Flood control exceeded. Retry in {self.retry_after!s}") - def __init__(self, retry_after: int): - super().__init__(f"Flood control exceeded. Retry in {retry_after} seconds") - self.retry_after: int = retry_after + @property + def retry_after(self) -> int | dtm.timedelta: # noqa: D102 + # Diableing D102 because docstring for `retry_after` is present at the class's level + return get_timedelta_value( # type: ignore[return-value] + self._retry_after, attribute="retry_after" + ) def __reduce__(self) -> tuple[type, tuple[float]]: # type: ignore[override] - return self.__class__, (self.retry_after,) + # Until support for `int` time periods is lifted, leave pickle behaviour the same + # tag: deprecated: v22.2 + return self.__class__, (int(self._retry_after.total_seconds()),) class Conflict(TelegramError): @@ -244,7 +269,7 @@ class PassportDecryptionError(TelegramError): __slots__ = ("_msg",) - def __init__(self, message: Union[str, Exception]): + def __init__(self, message: str | Exception): super().__init__(f"PassportDecryptionError: {message}") self._msg = str(message) diff --git a/telegram/ext/__init__.py b/src/telegram/ext/__init__.py similarity index 99% rename from telegram/ext/__init__.py rename to src/telegram/ext/__init__.py index 7cd6578e6ac..4c7fa9f5a8c 100644 --- a/telegram/ext/__init__.py +++ b/src/telegram/ext/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/telegram/ext/_aioratelimiter.py b/src/telegram/ext/_aioratelimiter.py similarity index 77% rename from telegram/ext/_aioratelimiter.py rename to src/telegram/ext/_aioratelimiter.py index c990a54b923..05e7a153c01 100644 --- a/telegram/ext/_aioratelimiter.py +++ b/src/telegram/ext/_aioratelimiter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,11 +19,11 @@ """This module contains an implementation of the BaseRateLimiter class based on the aiolimiter library. """ + import asyncio import contextlib -import sys -from collections.abc import AsyncIterator, Coroutine -from typing import Any, Callable, Optional, Union +from collections.abc import Callable, Coroutine +from typing import Any try: from aiolimiter import AsyncLimiter @@ -32,6 +32,7 @@ except ImportError: AIO_LIMITER_AVAILABLE = False +from telegram import constants from telegram._utils.logging import get_logger from telegram._utils.types import JSONDict from telegram.error import RetryAfter @@ -40,13 +41,7 @@ # Useful for something like: # async with group_limiter if group else null_context(): # so we don't have to differentiate between "I'm using a context manager" and "I'm not" -if sys.version_info >= (3, 10): - null_context = contextlib.nullcontext # pylint: disable=invalid-name -else: - - @contextlib.asynccontextmanager - async def null_context() -> AsyncIterator[None]: - yield None +null_context = contextlib.nullcontext # pylint: disable=invalid-name _LOGGER = get_logger(__name__, class_name="AIORateLimiter") @@ -86,7 +81,8 @@ class AIORateLimiter(BaseRateLimiter[int]): * A :exc:`~telegram.error.RetryAfter` exception will halt *all* requests for :attr:`~telegram.error.RetryAfter.retry_after` + 0.1 seconds. This may be stricter than necessary in some cases, e.g. the bot may hit a rate limit in one group but might still - be allowed to send messages in another group. + be allowed to send messages in another group or with + :paramref:`~telegram.Bot.send_message.allow_paid_broadcast` set to :obj:`True`. Tip: With `Bot API 7.1 `_ @@ -96,10 +92,10 @@ class AIORateLimiter(BaseRateLimiter[int]): :tg-const:`telegram.constants.FloodLimit.PAID_MESSAGES_PER_SECOND` messages per second by paying a fee in Telegram Stars. - .. caution:: - This class currently doesn't take the - :paramref:`~telegram.Bot.send_message.allow_paid_broadcast` parameter into account. - This means that the rate limiting is applied just like for any other message. + .. versionchanged:: 21.11 + This class automatically takes the + :paramref:`~telegram.Bot.send_message.allow_paid_broadcast` parameter into account and + throttles the requests accordingly. Note: This class is to be understood as minimal effort reference implementation. @@ -114,16 +110,17 @@ class AIORateLimiter(BaseRateLimiter[int]): Args: overall_max_rate (:obj:`float`): The maximum number of requests allowed for the entire bot per :paramref:`overall_time_period`. When set to 0, no rate limiting will be applied. - Defaults to ``30``. + Defaults to :tg-const:`telegram.constants.FloodLimit.MESSAGES_PER_SECOND`. overall_time_period (:obj:`float`): The time period (in seconds) during which the :paramref:`overall_max_rate` is enforced. When set to 0, no rate limiting will be - applied. Defaults to 1. + applied. Defaults to ``1``. group_max_rate (:obj:`float`): The maximum number of requests allowed for requests related to groups and channels per :paramref:`group_time_period`. When set to 0, no rate - limiting will be applied. Defaults to 20. + limiting will be applied. Defaults to + :tg-const:`telegram.constants.FloodLimit.MESSAGES_PER_MINUTE_PER_GROUP`. group_time_period (:obj:`float`): The time period (in seconds) during which the :paramref:`group_max_rate` is enforced. When set to 0, no rate limiting will be - applied. Defaults to 60. + applied. Defaults to ``60``. max_retries (:obj:`int`): The maximum number of retries to be made in case of a :exc:`~telegram.error.RetryAfter` exception. If set to 0, no retries will be made. Defaults to ``0``. @@ -131,6 +128,7 @@ class AIORateLimiter(BaseRateLimiter[int]): """ __slots__ = ( + "_apb_limiter", "_base_limiter", "_group_limiters", "_group_max_rate", @@ -141,9 +139,9 @@ class AIORateLimiter(BaseRateLimiter[int]): def __init__( self, - overall_max_rate: float = 30, + overall_max_rate: float = constants.FloodLimit.MESSAGES_PER_SECOND, overall_time_period: float = 1, - group_max_rate: float = 20, + group_max_rate: float = constants.FloodLimit.MESSAGES_PER_MINUTE_PER_GROUP, group_time_period: float = 60, max_retries: int = 0, ) -> None: @@ -153,7 +151,7 @@ def __init__( '"python-telegram-bot[rate-limiter]"`.' ) if overall_max_rate and overall_time_period: - self._base_limiter: Optional[AsyncLimiter] = AsyncLimiter( + self._base_limiter: AsyncLimiter | None = AsyncLimiter( max_rate=overall_max_rate, time_period=overall_time_period ) else: @@ -166,7 +164,10 @@ def __init__( self._group_max_rate = 0 self._group_time_period = 0 - self._group_limiters: dict[Union[str, int], AsyncLimiter] = {} + self._group_limiters: dict[str | int, AsyncLimiter] = {} + self._apb_limiter: AsyncLimiter = AsyncLimiter( + max_rate=constants.FloodLimit.PAID_MESSAGES_PER_SECOND, time_period=1 + ) self._max_retries: int = max_retries self._retry_after_event = asyncio.Event() self._retry_after_event.set() @@ -177,7 +178,7 @@ async def initialize(self) -> None: async def shutdown(self) -> None: """Does nothing.""" - def _get_group_limiter(self, group_id: Union[str, int, bool]) -> "AsyncLimiter": + def _get_group_limiter(self, group_id: str | int | bool) -> "AsyncLimiter": # Remove limiters that haven't been used for so long that all their capacity is unused # We only do that if we have a lot of limiters lying around to avoid looping on every call # This is a minimal effort approach - a full-fledged cache could use a TTL approach @@ -200,32 +201,41 @@ def _get_group_limiter(self, group_id: Union[str, int, bool]) -> "AsyncLimiter": async def _run_request( self, chat: bool, - group: Union[str, int, bool], - callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, list[JSONDict]]]], + group: str | int | bool, + allow_paid_broadcast: bool, + callback: Callable[..., Coroutine[Any, Any, bool | JSONDict | list[JSONDict]]], args: Any, kwargs: dict[str, Any], - ) -> Union[bool, JSONDict, list[JSONDict]]: - base_context = self._base_limiter if (chat and self._base_limiter) else null_context() - group_context = ( - self._get_group_limiter(group) if group and self._group_max_rate else null_context() - ) - - async with group_context, base_context: + ) -> bool | JSONDict | list[JSONDict]: + async def inner() -> bool | JSONDict | list[JSONDict]: # In case a retry_after was hit, we wait with processing the request await self._retry_after_event.wait() - return await callback(*args, **kwargs) + if allow_paid_broadcast: + async with self._apb_limiter: + return await inner() + else: + base_context = self._base_limiter if (chat and self._base_limiter) else null_context() + group_context = ( + self._get_group_limiter(group) + if group and self._group_max_rate + else null_context() + ) + + async with group_context, base_context: + return await inner() + # mypy doesn't understand that the last run of the for loop raises an exception async def process_request( self, - callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, list[JSONDict]]]], + callback: Callable[..., Coroutine[Any, Any, bool | JSONDict | list[JSONDict]]], args: Any, kwargs: dict[str, Any], endpoint: str, # noqa: ARG002 data: dict[str, Any], - rate_limit_args: Optional[int], - ) -> Union[bool, JSONDict, list[JSONDict]]: + rate_limit_args: int | None, + ) -> bool | JSONDict | list[JSONDict]: """ Processes a request by applying rate limiting. @@ -239,15 +249,16 @@ async def process_request( """ max_retries = rate_limit_args or self._max_retries - group: Union[int, str, bool] = False + group: int | str | bool = False chat: bool = False chat_id = data.get("chat_id") + allow_paid_broadcast = data.get("allow_paid_broadcast", False) if chat_id is not None: chat = True # In case user passes integer chat id as string with contextlib.suppress(ValueError, TypeError): - chat_id = int(chat_id) + chat_id = int(chat_id) # type: ignore[arg-type] if (isinstance(chat_id, int) and chat_id < 0) or isinstance(chat_id, str): # string chat_id only works for channels and supergroups @@ -257,7 +268,12 @@ async def process_request( for i in range(max_retries + 1): try: return await self._run_request( - chat=chat, group=group, callback=callback, args=args, kwargs=kwargs + chat=chat, + group=group, + allow_paid_broadcast=allow_paid_broadcast, + callback=callback, + args=args, + kwargs=kwargs, ) except RetryAfter as exc: if i == max_retries: @@ -266,7 +282,7 @@ async def process_request( ) raise - sleep = exc.retry_after + 0.1 + sleep = exc._retry_after.total_seconds() + 0.1 # pylint: disable=protected-access _LOGGER.info("Rate limit hit. Retrying after %f seconds", sleep) # Make sure we don't allow other requests to be processed self._retry_after_event.clear() diff --git a/telegram/ext/_application.py b/src/telegram/ext/_application.py similarity index 90% rename from telegram/ext/_application.py rename to src/telegram/ext/_application.py index 5815b34a2eb..d4a216e6b27 100644 --- a/telegram/ext/_application.py +++ b/src/telegram/ext/_application.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,17 +20,18 @@ import asyncio import contextlib +import datetime as dtm import inspect import itertools import platform import signal import sys from collections import defaultdict -from collections.abc import Awaitable, Coroutine, Generator, Mapping, Sequence +from collections.abc import Awaitable, Callable, Coroutine, Generator, Mapping, Sequence from copy import deepcopy from pathlib import Path from types import MappingProxyType, TracebackType -from typing import TYPE_CHECKING, Any, Callable, Generic, NoReturn, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, Generic, NoReturn, TypeAlias, TypeVar from telegram._update import Update from telegram._utils.defaultvalue import ( @@ -42,7 +43,7 @@ ) from telegram._utils.logging import get_logger from telegram._utils.repr import build_repr_with_selected_attrs -from telegram._utils.types import SCT, DVType, ODVInput +from telegram._utils.types import SCT, DVType, ODVInput, TimePeriod from telegram._utils.warnings import warn from telegram.error import TelegramError from telegram.ext._basepersistence import BasePersistence @@ -50,6 +51,7 @@ from telegram.ext._extbot import ExtBot from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._updater import Updater +from telegram.ext._utils.networkloop import network_retry_loop from telegram.ext._utils.stack import was_called_by from telegram.ext._utils.trackingdict import TrackingDict from telegram.ext._utils.types import BD, BT, CCT, CD, JQ, RT, UD, ConversationKey, HandlerCallback @@ -75,9 +77,9 @@ if sys.version_info >= (3, 12): _CoroType = Awaitable[RT] else: - _CoroType = Union[Generator["asyncio.Future[object]", None, RT], Awaitable[RT]] + _CoroType: TypeAlias = Generator["asyncio.Future[object]", None, RT] | Awaitable[RT] -_ErrorCoroType = Optional[_CoroType[RT]] +_ErrorCoroType: TypeAlias = _CoroType[RT] | None _LOGGER = get_logger(__name__) @@ -108,14 +110,14 @@ async def conversation_callback(update, context): __slots__ = ("state",) - def __init__(self, state: Optional[object] = None) -> None: + def __init__(self, state: object | None = None) -> None: super().__init__() - self.state: Optional[object] = state + self.state: object | None = state class Application( - Generic[BT, CCT, UD, CD, BD, JQ], contextlib.AbstractAsyncContextManager["Application"], + Generic[BT, CCT, UD, CD, BD, JQ], ): """This class dispatches all kinds of updates to its registered handlers, and is the entry point to a PTB application. @@ -235,7 +237,7 @@ class Application( """ __slots__ = ( - ( # noqa: RUF005 + ( "__create_task_tasks", "__update_fetcher_task", "__update_persistence_event", @@ -270,9 +272,7 @@ class Application( # Allowing '__weakref__' creation here since we need it for the JobQueue # Currently the __weakref__ slot is already created # in the AsyncContextManager base class for pythons < 3.13 - + ("__weakref__",) - if sys.version_info >= (3, 13) - else () + + (("__weakref__",) if sys.version_info >= (3, 13) else ()) ) def __init__( @@ -280,20 +280,20 @@ def __init__( *, bot: BT, update_queue: "asyncio.Queue[object]", - updater: Optional[Updater], + updater: Updater | None, job_queue: JQ, update_processor: "BaseUpdateProcessor", - persistence: Optional[BasePersistence[UD, CD, BD]], + persistence: BasePersistence[UD, CD, BD] | None, context_types: ContextTypes[CCT, UD, CD, BD], - post_init: Optional[ - Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] - ], - post_shutdown: Optional[ - Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] - ], - post_stop: Optional[ - Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] - ], + post_init: ( + Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] | None + ), + post_shutdown: ( + Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] | None + ), + post_stop: ( + Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] | None + ), ): if not was_called_by( inspect.currentframe(), Path(__file__).parent.resolve() / "_applicationbuilder.py" @@ -306,20 +306,20 @@ def __init__( self.bot: BT = bot self.update_queue: asyncio.Queue[object] = update_queue self.context_types: ContextTypes[CCT, UD, CD, BD] = context_types - self.updater: Optional[Updater] = updater + self.updater: Updater | None = updater self.handlers: dict[int, list[BaseHandler[Any, CCT, Any]]] = {} self.error_handlers: dict[ - HandlerCallback[object, CCT, None], Union[bool, DefaultValue[bool]] + HandlerCallback[object, CCT, None], bool | DefaultValue[bool] ] = {} - self.post_init: Optional[ - Callable[[Application[BT, CCT, UD, CD, BD, JQ]], Coroutine[Any, Any, None]] - ] = post_init - self.post_shutdown: Optional[ - Callable[[Application[BT, CCT, UD, CD, BD, JQ]], Coroutine[Any, Any, None]] - ] = post_shutdown - self.post_stop: Optional[ - Callable[[Application[BT, CCT, UD, CD, BD, JQ]], Coroutine[Any, Any, None]] - ] = post_stop + self.post_init: ( + Callable[[Application[BT, CCT, UD, CD, BD, JQ]], Coroutine[Any, Any, None]] | None + ) = post_init + self.post_shutdown: ( + Callable[[Application[BT, CCT, UD, CD, BD, JQ]], Coroutine[Any, Any, None]] | None + ) = post_shutdown + self.post_stop: ( + Callable[[Application[BT, CCT, UD, CD, BD, JQ]], Coroutine[Any, Any, None]] | None + ) = post_stop self._update_processor = update_processor self.bot_data: BD = self.context_types.bot_data() self._user_data: defaultdict[int, UD] = defaultdict(self.context_types.user_data) @@ -328,7 +328,7 @@ def __init__( self.user_data: Mapping[int, UD] = MappingProxyType(self._user_data) self.chat_data: Mapping[int, CD] = MappingProxyType(self._chat_data) - self.persistence: Optional[BasePersistence[UD, CD, BD]] = None + self.persistence: BasePersistence[UD, CD, BD] | None = None if persistence and not isinstance(persistence, BasePersistence): raise TypeError("persistence must be based on telegram.ext.BasePersistence") self.persistence = persistence @@ -349,14 +349,14 @@ def __init__( self._initialized = False self._running = False self._job_queue: JQ = job_queue - self.__update_fetcher_task: Optional[asyncio.Task] = None - self.__update_persistence_task: Optional[asyncio.Task] = None + self.__update_fetcher_task: asyncio.Task | None = None + self.__update_persistence_task: asyncio.Task | None = None self.__update_persistence_event = asyncio.Event() self.__update_persistence_lock = asyncio.Lock() self.__create_task_tasks: set[asyncio.Task] = set() # Used for awaiting tasks upon exit self.__stop_running_marker = asyncio.Event() - async def __aenter__(self: _AppType) -> _AppType: # noqa: PYI019 + async def __aenter__(self: _AppType) -> _AppType: """|async_context_manager| :meth:`initializes ` the App. Returns: @@ -375,9 +375,9 @@ async def __aenter__(self: _AppType) -> _AppType: # noqa: PYI019 async def __aexit__( self, - exc_type: Optional[type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> None: """|async_context_manager| :meth:`shuts down ` the App.""" # Make sure not to return `True` so that exceptions are not suppressed @@ -418,7 +418,7 @@ def concurrent_updates(self) -> int: return self._update_processor.max_concurrent_updates @property - def job_queue(self) -> Optional["JobQueue[CCT]"]: + def job_queue(self) -> "JobQueue[CCT] | None": """ :class:`telegram.ext.JobQueue`: The :class:`JobQueue` used by the :class:`telegram.ext.Application`. @@ -455,7 +455,9 @@ def builder() -> "InitApplicationBuilder": .. versionadded:: 20.0 """ # Unfortunately this needs to be here due to cyclical imports - from telegram.ext import ApplicationBuilder # pylint: disable=import-outside-toplevel + from telegram.ext import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + ApplicationBuilder, + ) return ApplicationBuilder() @@ -498,7 +500,7 @@ async def initialize(self) -> None: # Unfortunately due to circular imports this has to be here # pylint: disable=import-outside-toplevel - from telegram.ext._handlers.conversationhandler import ConversationHandler + from telegram.ext._handlers.conversationhandler import ConversationHandler # noqa: PLC0415 # Initialize the persistent conversation handlers with the stored states for handler in itertools.chain.from_iterable(self.handlers.values()): @@ -740,14 +742,10 @@ def stop_running(self) -> None: def run_polling( self, poll_interval: float = 0.0, - timeout: int = 10, - bootstrap_retries: int = -1, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - allowed_updates: Optional[Sequence[str]] = None, - drop_pending_updates: Optional[bool] = None, + timeout: TimePeriod = dtm.timedelta(seconds=10), + bootstrap_retries: int = 0, + allowed_updates: Sequence[str] | None = None, + drop_pending_updates: bool | None = None, close_loop: bool = True, stop_signals: ODVInput[Sequence[int]] = DEFAULT_NONE, ) -> None: @@ -777,50 +775,33 @@ def run_polling( .. include:: inclusions/application_run_tip.rst + .. versionchanged:: + Removed the deprecated parameters ``read_timeout``, ``write_timeout``, + ``connect_timeout``, and ``pool_timeout``. Use the corresponding methods in + :class:`telegram.ext.ApplicationBuilder` instead. + Args: poll_interval (:obj:`float`, optional): Time to wait between polling updates from Telegram in seconds. Default is ``0.0``. - timeout (:obj:`int`, optional): Passed to - :paramref:`telegram.Bot.get_updates.timeout`. Default is ``10`` seconds. - bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the - :class:`telegram.ext.Updater` will retry on failures on the Telegram server. + timeout (:obj:`int` | :class:`datetime.timedelta`, optional): Passed to + :paramref:`telegram.Bot.get_updates.timeout`. + Default is :obj:`timedelta(seconds=10)`. + + .. versionchanged:: v22.2 + |time-period-input| + bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase + (calling :meth:`initialize` and the boostrapping of + :meth:`telegram.ext.Updater.start_polling`) + will retry on failures on the Telegram server. - * < 0 - retry indefinitely (default) - * 0 - no retries + * < 0 - retry indefinitely + * 0 - no retries (default) * > 0 - retry up to X times - read_timeout (:obj:`float`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.read_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - - .. versionchanged:: 20.7 - Defaults to :attr:`~telegram.request.BaseRequest.DEFAULT_NONE` instead of - ``2``. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_read_timeout`. - write_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.write_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_write_timeout`. - connect_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.connect_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_connect_timeout`. - pool_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.pool_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_pool_timeout`. + .. versionchanged:: 21.11 + The default value will be changed to from ``-1`` to ``0``. Indefinite retries + during bootstrapping are not recommended. + drop_pending_updates (:obj:`bool`, optional): Whether to clean any pending updates on Telegram servers before actually starting to poll. Default is :obj:`False`. allowed_updates (Sequence[:obj:`str`], optional): Passed to @@ -852,16 +833,6 @@ def run_polling( "Application.run_polling is only available if the application has an Updater." ) - if (read_timeout, write_timeout, connect_timeout, pool_timeout) != ((DEFAULT_NONE,) * 4): - warn( - PTBDeprecationWarning( - "20.6", - "Setting timeouts via `Application.run_polling` is deprecated. " - "Please use `ApplicationBuilder.get_updates_*_timeout` instead.", - ), - stacklevel=2, - ) - def error_callback(exc: TelegramError) -> None: self.create_task(self.process_error(error=exc, update=None)) @@ -870,16 +841,13 @@ def error_callback(exc: TelegramError) -> None: poll_interval=poll_interval, timeout=timeout, bootstrap_retries=bootstrap_retries, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, allowed_updates=allowed_updates, drop_pending_updates=drop_pending_updates, error_callback=error_callback, # if there is an error in fetching updates ), - close_loop=close_loop, stop_signals=stop_signals, + bootstrap_retries=bootstrap_retries, + close_loop=close_loop, ) def run_webhook( @@ -887,18 +855,18 @@ def run_webhook( listen: DVType[str] = DEFAULT_IP, port: DVType[int] = DEFAULT_80, url_path: str = "", - cert: Optional[Union[str, Path]] = None, - key: Optional[Union[str, Path]] = None, + cert: str | Path | None = None, + key: str | Path | None = None, bootstrap_retries: int = 0, - webhook_url: Optional[str] = None, - allowed_updates: Optional[Sequence[str]] = None, - drop_pending_updates: Optional[bool] = None, - ip_address: Optional[str] = None, + webhook_url: str | None = None, + allowed_updates: Sequence[str] | None = None, + drop_pending_updates: bool | None = None, + ip_address: str | None = None, max_connections: int = 40, close_loop: bool = True, stop_signals: ODVInput[Sequence[int]] = DEFAULT_NONE, - secret_token: Optional[str] = None, - unix: Optional[Union[str, Path, "socket"]] = None, + secret_token: str | None = None, + unix: "str | Path | socket | None" = None, ) -> None: """Convenience method that takes care of initializing and starting the app, listening for updates from Telegram using :meth:`telegram.ext.Updater.start_webhook` and @@ -948,8 +916,10 @@ def run_webhook( url_path (:obj:`str`, optional): Path inside url. Defaults to `` '' `` cert (:class:`pathlib.Path` | :obj:`str`, optional): Path to the SSL certificate file. key (:class:`pathlib.Path` | :obj:`str`, optional): Path to the SSL key file. - bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the - :class:`telegram.ext.Updater` will retry on failures on the Telegram server. + bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase + (calling :meth:`initialize` and the boostrapping of + :meth:`telegram.ext.Updater.start_polling`) + will retry on failures on the Telegram server. * < 0 - retry indefinitely * 0 - no retries (default) @@ -1035,20 +1005,35 @@ def run_webhook( secret_token=secret_token, unix=unix, ), - close_loop=close_loop, stop_signals=stop_signals, + bootstrap_retries=bootstrap_retries, + close_loop=close_loop, + ) + + async def _bootstrap_initialize(self, max_retries: int) -> None: + await network_retry_loop( + action_cb=self.initialize, + description="Bootstrap Initialize Application", + max_retries=max_retries, + interval=1, ) def __run( self, updater_coroutine: Coroutine, stop_signals: ODVInput[Sequence[int]], + bootstrap_retries: int, close_loop: bool = True, ) -> None: - # Calling get_event_loop() should still be okay even in py3.10+ as long as there is a - # running event loop or we are in the main thread, which are the intended use cases. - # See the docs of get_event_loop() and get_running_loop() for more info - loop = asyncio.get_event_loop() + # Try to get the running event loop first, and if there isn't one, create a new one. + # This handles the Python 3.14+ behavior where get_event_loop() raises RuntimeError + # when there's no current event loop in the main thread. + try: + loop = asyncio.get_event_loop() + except RuntimeError: + # No running event loop, create and set a new one + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) if stop_signals is DEFAULT_NONE and platform.system() != "Windows": stop_signals = (signal.SIGINT, signal.SIGTERM, signal.SIGABRT) @@ -1066,7 +1051,7 @@ def __run( ) try: - loop.run_until_complete(self.initialize()) + loop.run_until_complete(self._bootstrap_initialize(max_retries=bootstrap_retries)) if self.post_init: loop.run_until_complete(self.post_init(self)) if self.__stop_running_marker.is_set(): @@ -1101,9 +1086,9 @@ def __run( def create_task( self, coroutine: _CoroType[RT], - update: Optional[object] = None, + update: object | None = None, *, - name: Optional[str] = None, + name: str | None = None, ) -> "asyncio.Task[RT]": """Thin wrapper around :func:`asyncio.create_task` that handles exceptions raised by the :paramref:`coroutine` with :meth:`process_error`. @@ -1141,9 +1126,9 @@ def create_task( def __create_task( self, coroutine: _CoroType[RT], - update: Optional[object] = None, + update: object | None = None, is_error_handler: bool = False, - name: Optional[str] = None, + name: str | None = None, ) -> "asyncio.Task[RT]": # Unfortunately, we can't know if `coroutine` runs one of the error handler functions # but by passing `is_error_handler=True` from `process_error`, we can make sure that we @@ -1178,7 +1163,7 @@ def __create_task_done_callback(self, task: asyncio.Task) -> None: async def __create_task_callback( self, coroutine: _CoroType[RT], - update: Optional[object] = None, + update: object | None = None, is_error_handler: bool = False, ) -> RT: try: @@ -1283,8 +1268,14 @@ async def process_update(self, update: object) -> None: context = None any_blocking = False # Flag which is set to True if any handler specifies block=True - for handlers in self.handlers.values(): + # We copy the lists to avoid issues with concurrent modification of the + # handlers (groups or handlers in groups) while iterating over it via add/remove_handler. + # Currently considered implementation detail as described in docstrings of + # add/remove_handler + # do *not* use `copy.deepcopy` here, as we don't want to deepcopy the handlers themselves + for handlers in [v.copy() for v in self.handlers.values()]: try: + # no copy needed b/c we copy above for handler in handlers: check = handler.check_update(update) # Should the handler handle this update? if check is None or check is False: @@ -1316,8 +1307,7 @@ async def process_update(self, update: object) -> None: coroutine, update=update, name=( - f"Application:{self.bot.id}:process_update_non_blocking" - f":{handler}" + f"Application:{self.bot.id}:process_update_non_blocking:{handler}" ), ) else: @@ -1370,6 +1360,14 @@ def add_handler(self, handler: BaseHandler[Any, CCT, Any], group: int = DEFAULT_ might lead to race conditions and undesired behavior. In particular, current conversation states may be overridden by the loaded data. + Hint: + This method currently has no influence on calls to :meth:`process_update` that are + already in progress. + + .. warning:: + This behavior should currently be considered an implementation detail and not as + guaranteed behavior. + Args: handler (:class:`telegram.ext.BaseHandler`): A BaseHandler instance. group (:obj:`int`, optional): The group identifier. Default is ``0``. @@ -1377,7 +1375,7 @@ def add_handler(self, handler: BaseHandler[Any, CCT, Any], group: int = DEFAULT_ """ # Unfortunately due to circular imports this has to be here # pylint: disable=import-outside-toplevel - from telegram.ext._handlers.conversationhandler import ConversationHandler + from telegram.ext._handlers.conversationhandler import ConversationHandler # noqa: PLC0415 if not isinstance(handler, BaseHandler): raise TypeError(f"handler is not an instance of {BaseHandler.__name__}") @@ -1409,11 +1407,10 @@ def add_handler(self, handler: BaseHandler[Any, CCT, Any], group: int = DEFAULT_ def add_handlers( self, - handlers: Union[ - Sequence[BaseHandler[Any, CCT, Any]], - dict[int, Sequence[BaseHandler[Any, CCT, Any]]], - ], - group: Union[int, DefaultValue[int]] = _DEFAULT_0, + handlers: ( + Sequence[BaseHandler[Any, CCT, Any]] | dict[int, Sequence[BaseHandler[Any, CCT, Any]]] + ), + group: int | DefaultValue[int] = _DEFAULT_0, ) -> None: """Registers multiple handlers at once. The order of the handlers in the passed sequence(s) matters. See :meth:`add_handler` for details. @@ -1471,6 +1468,14 @@ def remove_handler( ) -> None: """Remove a handler from the specified group. + Hint: + This method currently has no influence on calls to :meth:`process_update` that are + already in progress. + + .. warning:: + This behavior should currently be considered an implementation detail and not as + guaranteed behavior. + Args: handler (:class:`telegram.ext.BaseHandler`): A :class:`telegram.ext.BaseHandler` instance. @@ -1522,9 +1527,9 @@ def drop_user_data(self, user_id: int) -> None: def migrate_chat_data( self, - message: Optional["Message"] = None, - old_chat_id: Optional[int] = None, - new_chat_id: Optional[int] = None, + message: "Message | None" = None, + old_chat_id: int | None = None, + new_chat_id: int | None = None, ) -> None: """Moves the contents of :attr:`chat_data` at key :paramref:`old_chat_id` to the key :paramref:`new_chat_id`. Also marks the entries to be updated accordingly in the next run @@ -1588,7 +1593,7 @@ def migrate_chat_data( # old_chat_id is marked for deletion by drop_chat_data above def _mark_for_persistence_update( - self, *, update: Optional[object] = None, job: Optional["Job"] = None + self, *, update: object | None = None, job: "Job | None" = None ) -> None: if isinstance(update, Update): if update.effective_chat: @@ -1603,7 +1608,7 @@ def _mark_for_persistence_update( self._user_ids_to_be_updated_in_persistence.add(job.user_id) def mark_data_for_update_persistence( - self, chat_ids: Optional[SCT[int]] = None, user_ids: Optional[SCT[int]] = None + self, chat_ids: SCT[int] | None = None, user_ids: SCT[int] | None = None ) -> None: """Mark entries of :attr:`chat_data` and :attr:`user_data` to be updated on the next run of :meth:`update_persistence`. @@ -1735,7 +1740,7 @@ async def __update_persistence(self) -> None: # Unfortunately due to circular imports this has to be here # pylint: disable=import-outside-toplevel - from telegram.ext._handlers.conversationhandler import PendingState + from telegram.ext._handlers.conversationhandler import PendingState # noqa: PLC0415 for name, (key, new_state) in itertools.chain.from_iterable( zip(itertools.repeat(name), states_dict.pop_accessed_write_items()) @@ -1801,13 +1806,21 @@ def add_error_handler( Examples: :any:`Errorhandler Bot ` + Hint: + This method currently has no influence on calls to :meth:`process_error` that are + already in progress. + + .. warning:: + This behavior should currently be considered an implementation detail and not as + guaranteed behavior. + .. seealso:: :wiki:`Exceptions, Warnings and Logging ` Args: callback (:term:`coroutine function`): The callback function for this error handler. Will be called when an error is raised. Callback signature:: - async def callback(update: Optional[object], context: CallbackContext) + async def callback(update: object | None, context: CallbackContext) The error that happened will be present in :attr:`telegram.ext.CallbackContext.error`. @@ -1824,6 +1837,14 @@ async def callback(update: Optional[object], context: CallbackContext) def remove_error_handler(self, callback: HandlerCallback[object, CCT, None]) -> None: """Removes an error handler. + Hint: + This method currently has no influence on calls to :meth:`process_error` that are + already in progress. + + .. warning:: + This behavior should currently be considered an implementation detail and not as + guaranteed behavior. + Args: callback (:term:`coroutine function`): The error handler to remove. @@ -1832,10 +1853,10 @@ def remove_error_handler(self, callback: HandlerCallback[object, CCT, None]) -> async def process_error( self, - update: Optional[object], + update: object | None, error: Exception, - job: Optional["Job[CCT]"] = None, - coroutine: Optional[_ErrorCoroType[RT]] = None, + job: "Job[CCT] | None" = None, + coroutine: _ErrorCoroType[RT] | None = None, ) -> bool: """Processes an error by passing it to all error handlers registered with :meth:`add_error_handler`. If one of the error handlers raises @@ -1865,10 +1886,12 @@ async def process_error( :class:`telegram.ext.ApplicationHandlerStop`. :obj:`False`, otherwise. """ if self.error_handlers: - for ( - callback, - block, - ) in self.error_handlers.items(): + # We copy the list to avoid issues with concurrent modification of the + # error handlers while iterating over it via add/remove_error_handler. + # Currently considered implementation detail as described in docstrings of + # add/remove_error_handler + error_handler_items = list(self.error_handlers.items()) + for callback, block in error_handler_items: try: context = self.context_types.context.from_error( update=update, diff --git a/telegram/ext/_applicationbuilder.py b/src/telegram/ext/_applicationbuilder.py similarity index 92% rename from telegram/ext/_applicationbuilder.py rename to src/telegram/ext/_applicationbuilder.py index d0ade0492b0..97e55089502 100644 --- a/telegram/ext/_applicationbuilder.py +++ b/src/telegram/ext/_applicationbuilder.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,17 +17,25 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the Builder classes for the telegram.ext module.""" + from asyncio import Queue -from collections.abc import Collection, Coroutine +from collections.abc import Callable, Collection, Coroutine from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, Generic, TypeVar import httpx from telegram._bot import Bot from telegram._utils.defaultvalue import DEFAULT_FALSE, DEFAULT_NONE, DefaultValue -from telegram._utils.types import DVInput, DVType, FilePathInput, HTTPVersion, ODVInput, SocketOpt -from telegram._utils.warnings import warn +from telegram._utils.types import ( + BaseUrl, + DVInput, + DVType, + FilePathInput, + HTTPVersion, + ODVInput, + SocketOpt, +) from telegram.ext._application import Application from telegram.ext._baseupdateprocessor import BaseUpdateProcessor, SimpleUpdateProcessor from telegram.ext._contexttypes import ContextTypes @@ -37,7 +45,6 @@ from telegram.ext._utils.types import BD, BT, CCT, CD, JQ, UD from telegram.request import BaseRequest from telegram.request._httpxrequest import HTTPXRequest -from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import Update @@ -49,7 +56,7 @@ # 'In' stands for input - used in parameters of methods below # pylint: disable=invalid-name InBT = TypeVar("InBT", bound=Bot) -InJQ = TypeVar("InJQ", bound=Union[None, JobQueue]) +InJQ = TypeVar("InJQ", bound=None | JobQueue) InCCT = TypeVar("InCCT", bound="CallbackContext") InUD = TypeVar("InUD") InCD = TypeVar("InCD") @@ -116,6 +123,9 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]): .. seealso:: :wiki:`Your First Bot `, :wiki:`Builder Pattern ` + .. versionchanged:: 22.0 + Removed deprecated methods ``proxy_url`` and ``get_updates_proxy_url``. + .. _`builder pattern`: https://en.wikipedia.org/wiki/Builder_pattern """ @@ -164,10 +174,10 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]): def __init__(self: "InitApplicationBuilder"): self._token: DVType[str] = DefaultValue("") - self._base_url: DVType[str] = DefaultValue("https://api.telegram.org/bot") - self._base_file_url: DVType[str] = DefaultValue("https://api.telegram.org/file/bot") + self._base_url: DVType[BaseUrl] = DefaultValue("https://api.telegram.org/bot") + self._base_file_url: DVType[BaseUrl] = DefaultValue("https://api.telegram.org/file/bot") self._connection_pool_size: DVInput[int] = DEFAULT_NONE - self._proxy: DVInput[Union[str, httpx.Proxy, httpx.URL]] = DEFAULT_NONE + self._proxy: DVInput[str | httpx.Proxy | httpx.URL] = DEFAULT_NONE self._socket_options: DVInput[Collection[SocketOpt]] = DEFAULT_NONE self._connect_timeout: ODVInput[float] = DEFAULT_NONE self._read_timeout: ODVInput[float] = DEFAULT_NONE @@ -176,7 +186,7 @@ def __init__(self: "InitApplicationBuilder"): self._pool_timeout: ODVInput[float] = DEFAULT_NONE self._request: DVInput[BaseRequest] = DEFAULT_NONE self._get_updates_connection_pool_size: DVInput[int] = DEFAULT_NONE - self._get_updates_proxy: DVInput[Union[str, httpx.Proxy, httpx.URL]] = DEFAULT_NONE + self._get_updates_proxy: DVInput[str | httpx.Proxy | httpx.URL] = DEFAULT_NONE self._get_updates_socket_options: DVInput[Collection[SocketOpt]] = DEFAULT_NONE self._get_updates_connect_timeout: ODVInput[float] = DEFAULT_NONE self._get_updates_read_timeout: ODVInput[float] = DEFAULT_NONE @@ -187,10 +197,10 @@ def __init__(self: "InitApplicationBuilder"): self._private_key: ODVInput[bytes] = DEFAULT_NONE self._private_key_password: ODVInput[bytes] = DEFAULT_NONE self._defaults: ODVInput[Defaults] = DEFAULT_NONE - self._arbitrary_callback_data: Union[DefaultValue[bool], int] = DEFAULT_FALSE + self._arbitrary_callback_data: DefaultValue[bool] | int = DEFAULT_FALSE self._local_mode: DVType[bool] = DEFAULT_FALSE self._bot: DVInput[Bot] = DEFAULT_NONE - self._update_queue: DVType[Queue[Union[Update, object]]] = DefaultValue(Queue()) + self._update_queue: DVType[Queue[Update | object]] = DefaultValue(Queue()) try: self._job_queue: ODVInput[JobQueue] = DefaultValue(JobQueue()) @@ -207,9 +217,9 @@ def __init__(self: "InitApplicationBuilder"): max_concurrent_updates=1 ) self._updater: ODVInput[Updater] = DEFAULT_NONE - self._post_init: Optional[Callable[[Application], Coroutine[Any, Any, None]]] = None - self._post_shutdown: Optional[Callable[[Application], Coroutine[Any, Any, None]]] = None - self._post_stop: Optional[Callable[[Application], Coroutine[Any, Any, None]]] = None + self._post_init: Callable[[Application], Coroutine[Any, Any, None]] | None = None + self._post_shutdown: Callable[[Application], Coroutine[Any, Any, None]] | None = None + self._post_stop: Callable[[Application], Coroutine[Any, Any, None]] | None = None self._rate_limiter: ODVInput[BaseRateLimiter] = DEFAULT_NONE self._http_version: DVInput[str] = DefaultValue("1.1") @@ -340,7 +350,7 @@ def build( def application_class( self: BuilderType, application_class: type[Application[Any, Any, Any, Any, Any, Any]], - kwargs: Optional[dict[str, object]] = None, + kwargs: dict[str, object] | None = None, ) -> BuilderType: """Sets a custom subclass instead of :class:`telegram.ext.Application`. The subclass's ``__init__`` should look like this @@ -378,15 +388,19 @@ def token(self: BuilderType, token: str) -> BuilderType: self._token = token return self - def base_url(self: BuilderType, base_url: str) -> BuilderType: + def base_url(self: BuilderType, base_url: BaseUrl) -> BuilderType: """Sets the base URL for :attr:`telegram.ext.Application.bot`. If not called, will default to ``'https://api.telegram.org/bot'``. .. seealso:: :paramref:`telegram.Bot.base_url`, :wiki:`Local Bot API Server `, :meth:`base_file_url` + .. versionchanged:: 21.11 + Supports callable input and string formatting. + Args: - base_url (:obj:`str`): The URL. + base_url (:obj:`str` | Callable[[:obj:`str`], :obj:`str`]): The URL or + input for the URL as accepted by :paramref:`telegram.Bot.base_url`. Returns: :class:`ApplicationBuilder`: The same builder with the updated argument. @@ -396,15 +410,19 @@ def base_url(self: BuilderType, base_url: str) -> BuilderType: self._base_url = base_url return self - def base_file_url(self: BuilderType, base_file_url: str) -> BuilderType: + def base_file_url(self: BuilderType, base_file_url: BaseUrl) -> BuilderType: """Sets the base file URL for :attr:`telegram.ext.Application.bot`. If not called, will default to ``'https://api.telegram.org/file/bot'``. .. seealso:: :paramref:`telegram.Bot.base_file_url`, :wiki:`Local Bot API Server `, :meth:`base_url` + .. versionchanged:: 21.11 + Supports callable input and string formatting. + Args: - base_file_url (:obj:`str`): The URL. + base_file_url (:obj:`str` | Callable[[:obj:`str`], :obj:`str`]): The URL or + input for the URL as accepted by :paramref:`telegram.Bot.base_file_url`. Returns: :class:`ApplicationBuilder`: The same builder with the updated argument. @@ -500,31 +518,7 @@ def connection_pool_size(self: BuilderType, connection_pool_size: int) -> Builde self._connection_pool_size = connection_pool_size return self - def proxy_url(self: BuilderType, proxy_url: str) -> BuilderType: - """Legacy name for :meth:`proxy`, kept for backward compatibility. - - .. seealso:: :meth:`get_updates_proxy` - - .. deprecated:: 20.7 - - Args: - proxy_url (:obj:`str` | ``httpx.Proxy`` | ``httpx.URL``): See - :paramref:`telegram.ext.ApplicationBuilder.proxy.proxy`. - - Returns: - :class:`ApplicationBuilder`: The same builder with the updated argument. - """ - warn( - PTBDeprecationWarning( - "20.7", - "`ApplicationBuilder.proxy_url` is deprecated. Use `ApplicationBuilder.proxy` " - "instead.", - ), - stacklevel=2, - ) - return self.proxy(proxy_url) - - def proxy(self: BuilderType, proxy: Union[str, httpx.Proxy, httpx.URL]) -> BuilderType: + def proxy(self: BuilderType, proxy: str | httpx.Proxy | httpx.URL) -> BuilderType: """Sets the proxy for the :paramref:`~telegram.request.HTTPXRequest.proxy` parameter of :attr:`telegram.Bot.request`. Defaults to :obj:`None`. @@ -563,7 +557,7 @@ def socket_options(self: BuilderType, socket_options: Collection[SocketOpt]) -> self._socket_options = socket_options return self - def connect_timeout(self: BuilderType, connect_timeout: Optional[float]) -> BuilderType: + def connect_timeout(self: BuilderType, connect_timeout: float | None) -> BuilderType: """Sets the connection attempt timeout for the :paramref:`~telegram.request.HTTPXRequest.connect_timeout` parameter of :attr:`telegram.Bot.request`. Defaults to ``5.0``. @@ -581,7 +575,7 @@ def connect_timeout(self: BuilderType, connect_timeout: Optional[float]) -> Buil self._connect_timeout = connect_timeout return self - def read_timeout(self: BuilderType, read_timeout: Optional[float]) -> BuilderType: + def read_timeout(self: BuilderType, read_timeout: float | None) -> BuilderType: """Sets the waiting timeout for the :paramref:`~telegram.request.HTTPXRequest.read_timeout` parameter of :attr:`telegram.Bot.request`. Defaults to ``5.0``. @@ -599,7 +593,7 @@ def read_timeout(self: BuilderType, read_timeout: Optional[float]) -> BuilderTyp self._read_timeout = read_timeout return self - def write_timeout(self: BuilderType, write_timeout: Optional[float]) -> BuilderType: + def write_timeout(self: BuilderType, write_timeout: float | None) -> BuilderType: """Sets the write operation timeout for the :paramref:`~telegram.request.HTTPXRequest.write_timeout` parameter of :attr:`telegram.Bot.request`. Defaults to ``5.0``. @@ -617,9 +611,7 @@ def write_timeout(self: BuilderType, write_timeout: Optional[float]) -> BuilderT self._write_timeout = write_timeout return self - def media_write_timeout( - self: BuilderType, media_write_timeout: Optional[float] - ) -> BuilderType: + def media_write_timeout(self: BuilderType, media_write_timeout: float | None) -> BuilderType: """Sets the media write operation timeout for the :paramref:`~telegram.request.HTTPXRequest.media_write_timeout` parameter of :attr:`telegram.Bot.request`. Defaults to ``20``. @@ -637,7 +629,7 @@ def media_write_timeout( self._media_write_timeout = media_write_timeout return self - def pool_timeout(self: BuilderType, pool_timeout: Optional[float]) -> BuilderType: + def pool_timeout(self: BuilderType, pool_timeout: float | None) -> BuilderType: """Sets the connection pool's connection freeing timeout for the :paramref:`~telegram.request.HTTPXRequest.pool_timeout` parameter of :attr:`telegram.Bot.request`. Defaults to ``1.0``. @@ -734,32 +726,8 @@ def get_updates_connection_pool_size( self._get_updates_connection_pool_size = get_updates_connection_pool_size return self - def get_updates_proxy_url(self: BuilderType, get_updates_proxy_url: str) -> BuilderType: - """Legacy name for :meth:`get_updates_proxy`, kept for backward compatibility. - - .. seealso:: :meth:`proxy` - - .. deprecated:: 20.7 - - Args: - get_updates_proxy_url (:obj:`str` | ``httpx.Proxy`` | ``httpx.URL``): See - :paramref:`telegram.ext.ApplicationBuilder.get_updates_proxy.get_updates_proxy`. - - Returns: - :class:`ApplicationBuilder`: The same builder with the updated argument. - """ - warn( - PTBDeprecationWarning( - "20.7", - "`ApplicationBuilder.get_updates_proxy_url` is deprecated. Use " - "`ApplicationBuilder.get_updates_proxy` instead.", - ), - stacklevel=2, - ) - return self.get_updates_proxy(get_updates_proxy_url) - def get_updates_proxy( - self: BuilderType, get_updates_proxy: Union[str, httpx.Proxy, httpx.URL] + self: BuilderType, get_updates_proxy: str | httpx.Proxy | httpx.URL ) -> BuilderType: """Sets the proxy for the :paramref:`telegram.request.HTTPXRequest.proxy` parameter which is used for :meth:`telegram.Bot.get_updates`. Defaults to :obj:`None`. @@ -802,7 +770,7 @@ def get_updates_socket_options( return self def get_updates_connect_timeout( - self: BuilderType, get_updates_connect_timeout: Optional[float] + self: BuilderType, get_updates_connect_timeout: float | None ) -> BuilderType: """Sets the connection attempt timeout for the :paramref:`telegram.request.HTTPXRequest.connect_timeout` parameter which is used for @@ -822,7 +790,7 @@ def get_updates_connect_timeout( return self def get_updates_read_timeout( - self: BuilderType, get_updates_read_timeout: Optional[float] + self: BuilderType, get_updates_read_timeout: float | None ) -> BuilderType: """Sets the waiting timeout for the :paramref:`telegram.request.HTTPXRequest.read_timeout` parameter which is used for the @@ -842,7 +810,7 @@ def get_updates_read_timeout( return self def get_updates_write_timeout( - self: BuilderType, get_updates_write_timeout: Optional[float] + self: BuilderType, get_updates_write_timeout: float | None ) -> BuilderType: """Sets the write operation timeout for the :paramref:`telegram.request.HTTPXRequest.write_timeout` parameter which is used for @@ -862,7 +830,7 @@ def get_updates_write_timeout( return self def get_updates_pool_timeout( - self: BuilderType, get_updates_pool_timeout: Optional[float] + self: BuilderType, get_updates_pool_timeout: float | None ) -> BuilderType: """Sets the connection pool's connection freeing timeout for the :paramref:`~telegram.request.HTTPXRequest.pool_timeout` parameter which is used for the @@ -925,8 +893,8 @@ def get_updates_http_version( def private_key( self: BuilderType, - private_key: Union[bytes, FilePathInput], - password: Optional[Union[bytes, FilePathInput]] = None, + private_key: bytes | FilePathInput, + password: bytes | FilePathInput | None = None, ) -> BuilderType: """Sets the private key and corresponding password for decryption of telegram passport data for :attr:`telegram.ext.Application.bot`. @@ -978,7 +946,7 @@ def defaults(self: BuilderType, defaults: "Defaults") -> BuilderType: return self def arbitrary_callback_data( - self: BuilderType, arbitrary_callback_data: Union[bool, int] + self: BuilderType, arbitrary_callback_data: bool | int ) -> BuilderType: """Specifies whether :attr:`telegram.ext.Application.bot` should allow arbitrary objects as callback data for :class:`telegram.InlineKeyboardButton` and how many keyboards should be @@ -1070,7 +1038,7 @@ def update_queue(self: BuilderType, update_queue: "Queue[object]") -> BuilderTyp return self def concurrent_updates( - self: BuilderType, concurrent_updates: Union[bool, int, "BaseUpdateProcessor"] + self: BuilderType, concurrent_updates: "bool | int | BaseUpdateProcessor" ) -> BuilderType: """Specifies if and how many updates may be processed concurrently instead of one by one. If not called, updates will be processed one by one. @@ -1201,7 +1169,7 @@ def context_types( self._context_types = context_types return self # type: ignore[return-value] - def updater(self: BuilderType, updater: Optional[Updater]) -> BuilderType: + def updater(self: BuilderType, updater: Updater | None) -> BuilderType: """Sets a :class:`telegram.ext.Updater` instance for :attr:`telegram.ext.Application.updater`. The :attr:`telegram.ext.Updater.bot` and :attr:`telegram.ext.Updater.update_queue` will be used for diff --git a/telegram/ext/_basepersistence.py b/src/telegram/ext/_basepersistence.py similarity index 98% rename from telegram/ext/_basepersistence.py rename to src/telegram/ext/_basepersistence.py index 3571f6d961b..7d5d2bcc2be 100644 --- a/telegram/ext/_basepersistence.py +++ b/src/telegram/ext/_basepersistence.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the BasePersistence class.""" + from abc import ABC, abstractmethod -from typing import Generic, NamedTuple, NoReturn, Optional +from typing import Generic, NamedTuple, NoReturn from telegram._bot import Bot from telegram.ext._extbot import ExtBot @@ -53,7 +54,7 @@ class PersistenceInput(NamedTuple): callback_data: bool = True -class BasePersistence(Generic[UD, CD, BD], ABC): +class BasePersistence(ABC, Generic[UD, CD, BD]): """Interface class for adding persistence to your bot. Subclass this object for different implementations of a persistent bot. @@ -145,7 +146,7 @@ class BasePersistence(Generic[UD, CD, BD], ABC): def __init__( self, - store_data: Optional[PersistenceInput] = None, + store_data: PersistenceInput | None = None, update_interval: float = 60, ): self.store_data: PersistenceInput = store_data or PersistenceInput() @@ -238,7 +239,7 @@ async def get_bot_data(self) -> BD: """ @abstractmethod - async def get_callback_data(self) -> Optional[CDCData]: + async def get_callback_data(self) -> CDCData | None: """Will be called by :class:`telegram.ext.Application` upon creation with a persistence object. If callback data was stored, it should be returned. @@ -270,7 +271,7 @@ async def get_conversations(self, name: str) -> ConversationDict: @abstractmethod async def update_conversation( - self, name: str, key: ConversationKey, new_state: Optional[object] + self, name: str, key: ConversationKey, new_state: object | None ) -> None: """Will be called when a :class:`telegram.ext.ConversationHandler` changes states. This allows the storage of the new state in the persistence. diff --git a/telegram/ext/_baseratelimiter.py b/src/telegram/ext/_baseratelimiter.py similarity index 95% rename from telegram/ext/_baseratelimiter.py rename to src/telegram/ext/_baseratelimiter.py index ad11e444ac8..f6fe6cb028e 100644 --- a/telegram/ext/_baseratelimiter.py +++ b/src/telegram/ext/_baseratelimiter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,9 +17,10 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains a class that allows to rate limit requests to the Bot API.""" + from abc import ABC, abstractmethod -from collections.abc import Coroutine -from typing import Any, Callable, Generic, Optional, Union +from collections.abc import Callable, Coroutine +from typing import Any, Generic from telegram._utils.types import JSONDict from telegram.ext._utils.types import RLARGS @@ -57,13 +58,13 @@ async def shutdown(self) -> None: @abstractmethod async def process_request( self, - callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, list[JSONDict]]]], + callback: Callable[..., Coroutine[Any, Any, bool | JSONDict | list[JSONDict]]], args: Any, kwargs: dict[str, Any], endpoint: str, data: dict[str, Any], - rate_limit_args: Optional[RLARGS], - ) -> Union[bool, JSONDict, list[JSONDict]]: + rate_limit_args: RLARGS | None, + ) -> bool | JSONDict | list[JSONDict]: """ Process a request. Must be implemented by a subclass. diff --git a/telegram/ext/_baseupdateprocessor.py b/src/telegram/ext/_baseupdateprocessor.py similarity index 87% rename from telegram/ext/_baseupdateprocessor.py rename to src/telegram/ext/_baseupdateprocessor.py index 3d6ad374800..1d231d94114 100644 --- a/telegram/ext/_baseupdateprocessor.py +++ b/src/telegram/ext/_baseupdateprocessor.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,13 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the BaseProcessor class.""" + from abc import ABC, abstractmethod -from asyncio import BoundedSemaphore from contextlib import AbstractAsyncContextManager from types import TracebackType -from typing import TYPE_CHECKING, Any, Optional, TypeVar, final +from typing import TYPE_CHECKING, Any, TypeVar, final + +from telegram.ext._utils.asyncio import TrackedBoundedSemaphore if TYPE_CHECKING: from collections.abc import Awaitable @@ -71,9 +73,9 @@ def __init__(self, max_concurrent_updates: int): self._max_concurrent_updates = max_concurrent_updates if self.max_concurrent_updates < 1: raise ValueError("`max_concurrent_updates` must be a positive integer!") - self._semaphore = BoundedSemaphore(self.max_concurrent_updates) + self._semaphore = TrackedBoundedSemaphore(self.max_concurrent_updates) - async def __aenter__(self: _BUPT) -> _BUPT: # noqa: PYI019 + async def __aenter__(self: _BUPT) -> _BUPT: """|async_context_manager| :meth:`initializes ` the Processor. Returns: @@ -92,9 +94,9 @@ async def __aenter__(self: _BUPT) -> _BUPT: # noqa: PYI019 async def __aexit__( self, - exc_type: Optional[type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> None: """|async_context_manager| :meth:`shuts down ` the Processor.""" await self.shutdown() @@ -104,6 +106,18 @@ def max_concurrent_updates(self) -> int: """:obj:`int`: The maximum number of updates that can be processed concurrently.""" return self._max_concurrent_updates + @property + def current_concurrent_updates(self) -> int: + """:obj:`int`: The number of updates currently being processed. + + Caution: + This value is a snapshot of the current number of updates being processed. It may + change immediately after being read. + + .. versionadded:: 21.11 + """ + return self.max_concurrent_updates - self._semaphore.current_value + @abstractmethod async def do_process_update( self, diff --git a/telegram/ext/_callbackcontext.py b/src/telegram/ext/_callbackcontext.py similarity index 94% rename from telegram/ext/_callbackcontext.py rename to src/telegram/ext/_callbackcontext.py index d03250ca835..2689729c15c 100644 --- a/telegram/ext/_callbackcontext.py +++ b/src/telegram/ext/_callbackcontext.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,7 +20,7 @@ from collections.abc import Awaitable, Generator from re import Match -from typing import TYPE_CHECKING, Any, Generic, NoReturn, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, Generic, NoReturn, TypeVar from telegram._callbackquery import CallbackQuery from telegram._update import Update @@ -128,24 +128,22 @@ class CallbackContext(Generic[BT, UD, CD, BD]): def __init__( self: ST, application: "Application[BT, ST, UD, CD, BD, Any]", - chat_id: Optional[int] = None, - user_id: Optional[int] = None, + chat_id: int | None = None, + user_id: int | None = None, ): self._application: Application[BT, ST, UD, CD, BD, Any] = application - self._chat_id: Optional[int] = chat_id - self._user_id: Optional[int] = user_id - self.args: Optional[list[str]] = None - self.matches: Optional[list[Match[str]]] = None - self.error: Optional[Exception] = None - self.job: Optional[Job[Any]] = None - self.coroutine: Optional[ - Union[Generator[Optional[Future[object]], None, Any], Awaitable[Any]] - ] = None + self._chat_id: int | None = chat_id + self._user_id: int | None = user_id + self.args: list[str] | None = None + self.matches: list[Match[str]] | None = None + self.error: Exception | None = None + self.job: Job[Any] | None = None + self.coroutine: Generator[Future[object] | None, None, Any] | Awaitable[Any] | None = None @property def application(self) -> "Application[BT, ST, UD, CD, BD, Any]": """:class:`telegram.ext.Application`: The application associated with this context.""" - return self._application + return self._application # type: ignore[return-value] @property def bot_data(self) -> BD: @@ -164,7 +162,7 @@ def bot_data(self, _: object) -> NoReturn: ) @property - def chat_data(self) -> Optional[CD]: + def chat_data(self) -> CD | None: """:obj:`ContextTypes.chat_data`: Optional. An object that can be used to keep any data in. For each update from the same chat id it will be the same :obj:`ContextTypes.chat_data`. Defaults to :obj:`dict`. @@ -191,7 +189,7 @@ def chat_data(self, _: object) -> NoReturn: ) @property - def user_data(self) -> Optional[UD]: + def user_data(self) -> UD | None: """:obj:`ContextTypes.user_data`: Optional. An object that can be used to keep any data in. For each update from the same user it will be the same :obj:`ContextTypes.user_data`. Defaults to :obj:`dict`. @@ -275,10 +273,8 @@ def from_error( update: object, error: Exception, application: "Application[BT, CCT, UD, CD, BD, Any]", - job: Optional["Job[Any]"] = None, - coroutine: Optional[ - Union[Generator[Optional["Future[object]"], None, Any], Awaitable[Any]] - ] = None, + job: "Job[Any] | None" = None, + coroutine: Generator["Future[object] | None", None, Any] | Awaitable[Any] | None = None, ) -> "CCT": """ Constructs an instance of :class:`telegram.ext.CallbackContext` to be passed to the error @@ -391,7 +387,7 @@ def bot(self) -> BT: return self._application.bot @property - def job_queue(self) -> Optional["JobQueue[ST]"]: + def job_queue(self) -> "JobQueue[ST] | None": """ :class:`telegram.ext.JobQueue`: The :class:`JobQueue` used by the :class:`telegram.ext.Application`. @@ -417,7 +413,7 @@ def update_queue(self) -> "Queue[object]": return self._application.update_queue @property - def match(self) -> Optional[Match[str]]: + def match(self) -> Match[str] | None: """ :meth:`re.Match `: The first match from :attr:`matches`. Useful if you are only filtering using a single regex filter. diff --git a/telegram/ext/_callbackdatacache.py b/src/telegram/ext/_callbackdatacache.py similarity index 94% rename from telegram/ext/_callbackdatacache.py rename to src/telegram/ext/_callbackdatacache.py index 1052bd5a2a5..fa43faf7dde 100644 --- a/telegram/ext/_callbackdatacache.py +++ b/src/telegram/ext/_callbackdatacache.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,11 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the CallbackDataCache class.""" + import datetime as dtm import time from collections.abc import MutableMapping -from typing import TYPE_CHECKING, Any, Optional, Union, cast +from typing import TYPE_CHECKING, Any, cast from uuid import uuid4 try: @@ -64,14 +65,14 @@ class InvalidCallbackData(TelegramError): __slots__ = ("callback_data",) - def __init__(self, callback_data: Optional[str] = None) -> None: + def __init__(self, callback_data: str | None = None) -> None: super().__init__( "The object belonging to this callback_data was deleted or the callback_data was " "manipulated." ) - self.callback_data: Optional[str] = callback_data + self.callback_data: str | None = callback_data - def __reduce__(self) -> tuple[type, tuple[Optional[str]]]: # type: ignore[override] + def __reduce__(self) -> tuple[type, tuple[str | None]]: # type: ignore[override] """Defines how to serialize the exception for pickle. See :py:meth:`object.__reduce__` for more info. @@ -87,8 +88,8 @@ class _KeyboardData: def __init__( self, keyboard_uuid: str, - access_time: Optional[float] = None, - button_data: Optional[dict[str, object]] = None, + access_time: float | None = None, + button_data: dict[str, object] | None = None, ): self.keyboard_uuid = keyboard_uuid self.button_data = button_data or {} @@ -157,7 +158,7 @@ def __init__( self, bot: "ExtBot[Any]", maxsize: int = 1024, - persistent_data: Optional[CDCData] = None, + persistent_data: CDCData | None = None, ): if not CACHE_TOOLS_AVAILABLE: raise RuntimeError( @@ -270,7 +271,7 @@ def __put_button(callback_data: object, keyboard_data: _KeyboardData) -> str: def __get_keyboard_uuid_and_button_data( self, callback_data: str - ) -> Union[tuple[str, object], tuple[None, InvalidCallbackData]]: + ) -> tuple[str, object] | tuple[None, InvalidCallbackData]: keyboard, button = self.extract_uuids(callback_data) try: # we get the values before calling update() in case KeyErrors are raised @@ -323,7 +324,7 @@ def process_message(self, message: Message) -> None: """ self.__process_message(message) - def __process_message(self, message: Message) -> Optional[str]: + def __process_message(self, message: Message) -> str | None: """As documented in process_message, but returns the uuid of the attached keyboard, if any, which is relevant for process_callback_query. @@ -333,7 +334,7 @@ def __process_message(self, message: Message) -> Optional[str]: return None if message.via_bot: - sender: Optional[User] = message.via_bot + sender: User | None = message.via_bot elif message.from_user: sender = message.from_user else: @@ -347,7 +348,7 @@ def __process_message(self, message: Message) -> Optional[str]: for row in message.reply_markup.inline_keyboard: for button in row: if button.callback_data: - button_data = cast(str, button.callback_data) + button_data = cast("str", button.callback_data) keyboard_id, callback_data = self.__get_keyboard_uuid_and_button_data( button_data ) @@ -431,9 +432,7 @@ def __drop_keyboard(self, keyboard_uuid: str) -> None: with contextlib.suppress(KeyError): self._keyboard_data.pop(keyboard_uuid) - def clear_callback_data( - self, time_cutoff: Optional[Union[float, dtm.datetime]] = None - ) -> None: + def clear_callback_data(self, time_cutoff: float | dtm.datetime | None = None) -> None: """Clears the stored callback data. Args: @@ -449,7 +448,7 @@ def clear_callback_queries(self) -> None: self.__clear(self._callback_queries) def __clear( - self, mapping: MutableMapping, time_cutoff: Optional[Union[float, dtm.datetime]] = None + self, mapping: MutableMapping, time_cutoff: float | dtm.datetime | None = None ) -> None: if not time_cutoff: mapping.clear() diff --git a/telegram/ext/_contexttypes.py b/src/telegram/ext/_contexttypes.py similarity index 99% rename from telegram/ext/_contexttypes.py rename to src/telegram/ext/_contexttypes.py index 2d3a8b357e8..bd975b6bdda 100644 --- a/telegram/ext/_contexttypes.py +++ b/src/telegram/ext/_contexttypes.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the auxiliary class ContextTypes.""" + from typing import Any, Generic, overload from telegram.ext._callbackcontext import CallbackContext diff --git a/telegram/ext/_defaults.py b/src/telegram/ext/_defaults.py similarity index 73% rename from telegram/ext/_defaults.py rename to src/telegram/ext/_defaults.py index 86fb59a38fd..2986aba0b64 100644 --- a/telegram/ext/_defaults.py +++ b/src/telegram/ext/_defaults.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,15 +17,17 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the class Defaults, which allows passing default values to Application.""" + import datetime as dtm -from typing import Any, NoReturn, Optional, final +from typing import TYPE_CHECKING, Any, NoReturn, final -from telegram import LinkPreviewOptions from telegram._utils.datetime import UTC -from telegram._utils.types import ODVInput from telegram._utils.warnings import warn from telegram.warnings import PTBDeprecationWarning +if TYPE_CHECKING: + from telegram import LinkPreviewOptions + @final class Defaults: @@ -38,23 +40,15 @@ class Defaults: Removed the argument and attribute ``timeout``. Specify default timeout behavior for the networking backend directly via :class:`telegram.ext.ApplicationBuilder` instead. + .. versionchanged:: 22.0 + Removed deprecated arguments and properties ``disable_web_page_preview`` and ``quote``. + Use :paramref:`link_preview_options` and :paramref:`do_quote` instead. + Parameters: parse_mode (:obj:`str`, optional): |parse_mode| disable_notification (:obj:`bool`, optional): |disable_notification| - disable_web_page_preview (:obj:`bool`, optional): Disables link previews for links in this - message. Mutually exclusive with :paramref:`link_preview_options`. - - .. deprecated:: 20.8 - Use :paramref:`link_preview_options` instead. This parameter will be removed in - future versions. - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply|. Will be used for :attr:`telegram.ReplyParameters.allow_sending_without_reply`. - quote (:obj:`bool`, optional): |reply_quote| - - .. deprecated:: 20.8 - Use :paramref:`do_quote` instead. This parameter will be removed in future - versions. tzinfo (:class:`datetime.tzinfo`, optional): A timezone to be used for all date(time) inputs appearing throughout PTB, i.e. if a timezone naive date(time) object is passed somewhere, it will be assumed to be in :paramref:`tzinfo`. Defaults to @@ -133,23 +127,21 @@ async def main(): def __init__( self, - parse_mode: Optional[str] = None, - disable_notification: Optional[bool] = None, - disable_web_page_preview: Optional[bool] = None, - quote: Optional[bool] = None, + parse_mode: str | None = None, + disable_notification: bool | None = None, tzinfo: dtm.tzinfo = UTC, block: bool = True, - allow_sending_without_reply: Optional[bool] = None, - protect_content: Optional[bool] = None, - link_preview_options: Optional["LinkPreviewOptions"] = None, - do_quote: Optional[bool] = None, + allow_sending_without_reply: bool | None = None, + protect_content: bool | None = None, + link_preview_options: "LinkPreviewOptions | None" = None, + do_quote: bool | None = None, ): - self._parse_mode: Optional[str] = parse_mode - self._disable_notification: Optional[bool] = disable_notification - self._allow_sending_without_reply: Optional[bool] = allow_sending_without_reply + self._parse_mode: str | None = parse_mode + self._disable_notification: bool | None = disable_notification + self._allow_sending_without_reply: bool | None = allow_sending_without_reply self._tzinfo: dtm.tzinfo = tzinfo self._block: bool = block - self._protect_content: Optional[bool] = protect_content + self._protect_content: bool | None = protect_content if "pytz" in str(self._tzinfo.__class__): # TODO: When dropping support, make sure to update _utils.datetime accordingly @@ -164,37 +156,9 @@ def __init__( stacklevel=2, ) - if disable_web_page_preview is not None and link_preview_options is not None: - raise ValueError( - "`disable_web_page_preview` and `link_preview_options` are mutually exclusive." - ) - if quote is not None and do_quote is not None: - raise ValueError("`quote` and `do_quote` are mutually exclusive") - if disable_web_page_preview is not None: - warn( - PTBDeprecationWarning( - "20.8", - "`Defaults.disable_web_page_preview` is deprecated. Use " - "`Defaults.link_preview_options` instead.", - ), - stacklevel=2, - ) - self._link_preview_options: Optional[LinkPreviewOptions] = LinkPreviewOptions( - is_disabled=disable_web_page_preview - ) - else: - self._link_preview_options = link_preview_options + self._link_preview_options = link_preview_options + self._do_quote = do_quote - if quote is not None: - warn( - PTBDeprecationWarning( - "20.8", "`Defaults.quote` is deprecated. Use `Defaults.do_quote` instead." - ), - stacklevel=2, - ) - self._do_quote: Optional[bool] = quote - else: - self._do_quote = do_quote # Gather all defaults that actually have a default value self._api_defaults = {} for kwarg in ( @@ -223,9 +187,9 @@ def __hash__(self) -> int: ( self._parse_mode, self._disable_notification, - self.disable_web_page_preview, + self._link_preview_options, self._allow_sending_without_reply, - self.quote, + self._do_quote, self._tzinfo, self._block, self._protect_content, @@ -249,7 +213,7 @@ def api_defaults(self) -> dict[str, Any]: # skip-cq: PY-D0003 return self._api_defaults @property - def parse_mode(self) -> Optional[str]: + def parse_mode(self) -> str | None: """:obj:`str`: Optional. Send Markdown or HTML, if you want Telegram apps to show bold, italic, fixed-width text or URLs in your bot's message. """ @@ -260,7 +224,7 @@ def parse_mode(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to parse_mode after initialization.") @property - def explanation_parse_mode(self) -> Optional[str]: + def explanation_parse_mode(self) -> str | None: """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for the corresponding parameter of :meth:`telegram.Bot.send_poll`. """ @@ -273,7 +237,7 @@ def explanation_parse_mode(self, _: object) -> NoReturn: ) @property - def quote_parse_mode(self) -> Optional[str]: + def quote_parse_mode(self) -> str | None: """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for the corresponding parameter of :meth:`telegram.ReplyParameters`. """ @@ -286,7 +250,7 @@ def quote_parse_mode(self, _: object) -> NoReturn: ) @property - def text_parse_mode(self) -> Optional[str]: + def text_parse_mode(self) -> str | None: """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for the corresponding parameter of :class:`telegram.InputPollOption` and :meth:`telegram.Bot.send_gift`. @@ -302,7 +266,7 @@ def text_parse_mode(self, _: object) -> NoReturn: ) @property - def question_parse_mode(self) -> Optional[str]: + def question_parse_mode(self) -> str | None: """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for the corresponding parameter of :meth:`telegram.Bot.send_poll`. @@ -317,7 +281,7 @@ def question_parse_mode(self, _: object) -> NoReturn: ) @property - def disable_notification(self) -> Optional[bool]: + def disable_notification(self) -> bool | None: """:obj:`bool`: Optional. Sends the message silently. Users will receive a notification with no sound. """ @@ -330,24 +294,7 @@ def disable_notification(self, _: object) -> NoReturn: ) @property - def disable_web_page_preview(self) -> ODVInput[bool]: - """:obj:`bool`: Optional. Disables link previews for links in all outgoing - messages. - - .. deprecated:: 20.8 - Use :attr:`link_preview_options` instead. This attribute will be removed in future - versions. - """ - return self._link_preview_options.is_disabled if self._link_preview_options else None - - @disable_web_page_preview.setter - def disable_web_page_preview(self, _: object) -> NoReturn: - raise AttributeError( - "You can not assign a new value to disable_web_page_preview after initialization." - ) - - @property - def allow_sending_without_reply(self) -> Optional[bool]: + def allow_sending_without_reply(self) -> bool | None: """:obj:`bool`: Optional. Pass :obj:`True`, if the message should be sent even if the specified replied-to message is not found. """ @@ -359,20 +306,6 @@ def allow_sending_without_reply(self, _: object) -> NoReturn: "You can not assign a new value to allow_sending_without_reply after initialization." ) - @property - def quote(self) -> Optional[bool]: - """:obj:`bool`: Optional. |reply_quote| - - .. deprecated:: 20.8 - Use :attr:`do_quote` instead. This attribute will be removed in future - versions. - """ - return self._do_quote if self._do_quote is not None else None - - @quote.setter - def quote(self, _: object) -> NoReturn: - raise AttributeError("You can not assign a new value to quote after initialization.") - @property def tzinfo(self) -> dtm.tzinfo: """:obj:`tzinfo`: A timezone to be used for all date(time) objects appearing @@ -397,7 +330,7 @@ def block(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to block after initialization.") @property - def protect_content(self) -> Optional[bool]: + def protect_content(self) -> bool | None: """:obj:`bool`: Optional. Protects the contents of the sent message from forwarding and saving. @@ -412,7 +345,7 @@ def protect_content(self, _: object) -> NoReturn: ) @property - def link_preview_options(self) -> Optional["LinkPreviewOptions"]: + def link_preview_options(self) -> "LinkPreviewOptions | None": """:class:`telegram.LinkPreviewOptions`: Optional. Link preview generation options for all outgoing messages. @@ -421,7 +354,7 @@ def link_preview_options(self) -> Optional["LinkPreviewOptions"]: return self._link_preview_options @property - def do_quote(self) -> Optional[bool]: + def do_quote(self) -> bool | None: """:obj:`bool`: Optional. |reply_quote| .. versionadded:: 20.8 diff --git a/telegram/ext/_dictpersistence.py b/src/telegram/ext/_dictpersistence.py similarity index 95% rename from telegram/ext/_dictpersistence.py rename to src/telegram/ext/_dictpersistence.py index da9749dfc85..ad72e77096c 100644 --- a/telegram/ext/_dictpersistence.py +++ b/src/telegram/ext/_dictpersistence.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,9 +17,10 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the DictPersistence class.""" + import json from copy import deepcopy -from typing import TYPE_CHECKING, Any, Optional, cast +from typing import TYPE_CHECKING, Any, cast from telegram.ext import BasePersistence, PersistenceInput from telegram.ext._utils.types import CDCData, ConversationDict, ConversationKey @@ -92,7 +93,7 @@ class DictPersistence(BasePersistence[dict[Any, Any], dict[Any, Any], dict[Any, def __init__( self, - store_data: Optional[PersistenceInput] = None, + store_data: PersistenceInput | None = None, user_data_json: str = "", chat_data_json: str = "", bot_data_json: str = "", @@ -106,11 +107,11 @@ def __init__( self._bot_data = None self._callback_data = None self._conversations = None - self._user_data_json: Optional[str] = None - self._chat_data_json: Optional[str] = None - self._bot_data_json: Optional[str] = None - self._callback_data_json: Optional[str] = None - self._conversations_json: Optional[str] = None + self._user_data_json: str | None = None + self._chat_data_json: str | None = None + self._bot_data_json: str | None = None + self._callback_data_json: str | None = None + self._conversations_json: str | None = None if user_data_json: try: self._user_data = self._decode_user_chat_data_from_json(user_data_json) @@ -145,7 +146,7 @@ def __init__( self._callback_data = None else: self._callback_data = cast( - CDCData, + "CDCData", ([(one, float(two), three) for one, two, three in data[0]], data[1]), ) self._callback_data_json = callback_data_json @@ -170,7 +171,7 @@ def __init__( ) from exc @property - def user_data(self) -> Optional[dict[int, dict[Any, Any]]]: + def user_data(self) -> dict[int, dict[Any, Any]] | None: """:obj:`dict`: The user_data as a dict.""" return self._user_data @@ -182,7 +183,7 @@ def user_data_json(self) -> str: return json.dumps(self.user_data) @property - def chat_data(self) -> Optional[dict[int, dict[Any, Any]]]: + def chat_data(self) -> dict[int, dict[Any, Any]] | None: """:obj:`dict`: The chat_data as a dict.""" return self._chat_data @@ -194,7 +195,7 @@ def chat_data_json(self) -> str: return json.dumps(self.chat_data) @property - def bot_data(self) -> Optional[dict[Any, Any]]: + def bot_data(self) -> dict[Any, Any] | None: """:obj:`dict`: The bot_data as a dict.""" return self._bot_data @@ -206,7 +207,7 @@ def bot_data_json(self) -> str: return json.dumps(self.bot_data) @property - def callback_data(self) -> Optional[CDCData]: + def callback_data(self) -> CDCData | None: """tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]], \ dict[:obj:`str`, :obj:`str`]]: The metadata on the stored callback data. @@ -225,7 +226,7 @@ def callback_data_json(self) -> str: return json.dumps(self.callback_data) @property - def conversations(self) -> Optional[dict[str, ConversationDict]]: + def conversations(self) -> dict[str, ConversationDict] | None: """:obj:`dict`: The conversations as a dict.""" return self._conversations @@ -268,7 +269,7 @@ async def get_bot_data(self) -> dict[object, object]: self._bot_data = {} return deepcopy(self.bot_data) # type: ignore[arg-type] - async def get_callback_data(self) -> Optional[CDCData]: + async def get_callback_data(self) -> CDCData | None: """Returns the callback_data created from the ``callback_data_json`` or :obj:`None`. .. versionadded:: 13.6 @@ -295,7 +296,7 @@ async def get_conversations(self, name: str) -> ConversationDict: return self.conversations.get(name, {}).copy() # type: ignore[union-attr] async def update_conversation( - self, name: str, key: ConversationKey, new_state: Optional[object] + self, name: str, key: ConversationKey, new_state: object | None ) -> None: """Will update the conversations for the given handler. diff --git a/telegram/ext/_extbot.py b/src/telegram/ext/_extbot.py similarity index 66% rename from telegram/ext/_extbot.py rename to src/telegram/ext/_extbot.py index d21b1f76dcc..c2407a1cb49 100644 --- a/telegram/ext/_extbot.py +++ b/src/telegram/ext/_extbot.py @@ -2,7 +2,7 @@ # pylint: disable=too-many-arguments # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,17 +18,15 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Bot with convenience extensions.""" + import datetime as dtm -from collections.abc import Sequence +from collections.abc import Callable, Sequence from copy import copy from typing import ( TYPE_CHECKING, Any, - Callable, Generic, - Optional, TypeVar, - Union, cast, no_type_check, overload, @@ -36,6 +34,7 @@ from uuid import uuid4 from telegram import ( + AcceptedGiftTypes, Animation, Audio, Bot, @@ -52,38 +51,40 @@ ChatMember, ChatPermissions, ChatPhoto, - Contact, Document, File, ForumTopic, GameHighScore, - Gift, Gifts, InlineKeyboardMarkup, InlineQueryResultsButton, + InputChecklist, InputMedia, + InputPaidMedia, InputPollOption, + InputProfilePhoto, LinkPreviewOptions, - Location, MaskPosition, MenuButton, Message, MessageId, + OwnedGifts, PhotoSize, Poll, PreparedInlineMessage, ReactionType, ReplyParameters, SentWebAppMessage, + StarAmount, StarTransactions, Sticker, StickerSet, + Story, TelegramObject, Update, User, UserChatBoosts, UserProfilePhotos, - Venue, Video, VideoNote, Voice, @@ -93,7 +94,15 @@ from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue from telegram._utils.logging import get_logger from telegram._utils.repr import build_repr_with_selected_attrs -from telegram._utils.types import CorrectOptionID, FileInput, JSONDict, ODVInput, ReplyMarkup +from telegram._utils.types import ( + BaseUrl, + CorrectOptionID, + FileInput, + JSONDict, + ODVInput, + ReplyMarkup, + TimePeriod, +) from telegram.ext._callbackdatacache import CallbackDataCache from telegram.ext._utils.types import RLARGS from telegram.request import BaseRequest @@ -101,21 +110,27 @@ if TYPE_CHECKING: from telegram import ( + Contact, + Gift, InlineQueryResult, InputMediaAudio, InputMediaDocument, InputMediaPhoto, InputMediaVideo, - InputPaidMedia, InputSticker, + InputStoryContent, LabeledPrice, + Location, MessageEntity, PassportElementError, ShippingOption, + StoryArea, + SuggestedPostParameters, + Venue, ) from telegram.ext import BaseRateLimiter, Defaults -HandledTypes = TypeVar("HandledTypes", bound=Union[Message, CallbackQuery, ChatFullInfo]) +HandledTypes = TypeVar("HandledTypes", bound=Message | CallbackQuery | ChatFullInfo) KT = TypeVar("KT", bound=ReplyMarkup) @@ -184,14 +199,14 @@ class ExtBot(Bot, Generic[RLARGS]): def __init__( self: "ExtBot[None]", token: str, - base_url: str = "https://api.telegram.org/bot", - base_file_url: str = "https://api.telegram.org/file/bot", - request: Optional[BaseRequest] = None, - get_updates_request: Optional[BaseRequest] = None, - private_key: Optional[bytes] = None, - private_key_password: Optional[bytes] = None, - defaults: Optional["Defaults"] = None, - arbitrary_callback_data: Union[bool, int] = False, + base_url: BaseUrl = "https://api.telegram.org/bot", + base_file_url: BaseUrl = "https://api.telegram.org/file/bot", + request: BaseRequest | None = None, + get_updates_request: BaseRequest | None = None, + private_key: bytes | None = None, + private_key_password: bytes | None = None, + defaults: "Defaults | None" = None, + arbitrary_callback_data: bool | int = False, local_mode: bool = False, ): ... @@ -199,31 +214,31 @@ def __init__( def __init__( self: "ExtBot[RLARGS]", token: str, - base_url: str = "https://api.telegram.org/bot", - base_file_url: str = "https://api.telegram.org/file/bot", - request: Optional[BaseRequest] = None, - get_updates_request: Optional[BaseRequest] = None, - private_key: Optional[bytes] = None, - private_key_password: Optional[bytes] = None, - defaults: Optional["Defaults"] = None, - arbitrary_callback_data: Union[bool, int] = False, + base_url: BaseUrl = "https://api.telegram.org/bot", + base_file_url: BaseUrl = "https://api.telegram.org/file/bot", + request: BaseRequest | None = None, + get_updates_request: BaseRequest | None = None, + private_key: bytes | None = None, + private_key_password: bytes | None = None, + defaults: "Defaults | None" = None, + arbitrary_callback_data: bool | int = False, local_mode: bool = False, - rate_limiter: Optional["BaseRateLimiter[RLARGS]"] = None, + rate_limiter: "BaseRateLimiter[RLARGS] | None" = None, ): ... def __init__( self, token: str, - base_url: str = "https://api.telegram.org/bot", - base_file_url: str = "https://api.telegram.org/file/bot", - request: Optional[BaseRequest] = None, - get_updates_request: Optional[BaseRequest] = None, - private_key: Optional[bytes] = None, - private_key_password: Optional[bytes] = None, - defaults: Optional["Defaults"] = None, - arbitrary_callback_data: Union[bool, int] = False, + base_url: BaseUrl = "https://api.telegram.org/bot", + base_file_url: BaseUrl = "https://api.telegram.org/file/bot", + request: BaseRequest | None = None, + get_updates_request: BaseRequest | None = None, + private_key: bytes | None = None, + private_key_password: bytes | None = None, + defaults: "Defaults | None" = None, + arbitrary_callback_data: bool | int = False, local_mode: bool = False, - rate_limiter: Optional["BaseRateLimiter[RLARGS]"] = None, + rate_limiter: "BaseRateLimiter[RLARGS] | None" = None, ): super().__init__( token=token, @@ -236,16 +251,16 @@ def __init__( local_mode=local_mode, ) with self._unfrozen(): - self._defaults: Optional[Defaults] = defaults - self._rate_limiter: Optional[BaseRateLimiter] = rate_limiter - self._callback_data_cache: Optional[CallbackDataCache] = None + self._defaults: Defaults | None = defaults + self._rate_limiter: BaseRateLimiter | None = rate_limiter + self._callback_data_cache: CallbackDataCache | None = None # set up callback_data if arbitrary_callback_data is False: return if not isinstance(arbitrary_callback_data, bool): - maxsize = cast(int, arbitrary_callback_data) + maxsize = cast("int", arbitrary_callback_data) else: maxsize = 1024 @@ -265,7 +280,7 @@ def __repr__(self) -> str: @classmethod def _warn( cls, - message: Union[str, PTBUserWarning], + message: str | PTBUserWarning, category: type[Warning] = PTBUserWarning, stacklevel: int = 0, ) -> None: @@ -275,7 +290,7 @@ def _warn( super()._warn(message=message, category=category, stacklevel=stacklevel + 2) @property - def callback_data_cache(self) -> Optional[CallbackDataCache]: + def callback_data_cache(self) -> CallbackDataCache | None: """:class:`telegram.ext.CallbackDataCache`: Optional. The cache for objects passed as callback data for :class:`telegram.InlineKeyboardButton`. @@ -311,8 +326,8 @@ async def shutdown(self) -> None: @classmethod def _merge_api_rl_kwargs( - cls, api_kwargs: Optional[JSONDict], rate_limit_args: Optional[RLARGS] - ) -> Optional[JSONDict]: + cls, api_kwargs: JSONDict | None, rate_limit_args: RLARGS | None + ) -> JSONDict | None: """Inserts the `rate_limit_args` into `api_kwargs` with the special key `__RL_KEY` so that we can extract them later without having to modify the `telegram.Bot` class. """ @@ -324,7 +339,7 @@ def _merge_api_rl_kwargs( return api_kwargs @classmethod - def _extract_rl_kwargs(cls, data: Optional[JSONDict]) -> Optional[RLARGS]: + def _extract_rl_kwargs(cls, data: JSONDict | None) -> RLARGS | None: """Extracts the `rate_limit_args` from `data` if it exists.""" if not data: return None @@ -339,7 +354,7 @@ async def _do_post( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - ) -> Union[bool, JSONDict, list[JSONDict]]: + ) -> bool | JSONDict | list[JSONDict]: """Order of method calls is: Bot.some_method -> Bot._post -> Bot._do_post. So we can override Bot._do_post to add rate limiting. """ @@ -381,13 +396,13 @@ async def _do_post( ) @property - def defaults(self) -> Optional["Defaults"]: + def defaults(self) -> "Defaults | None": """The :class:`telegram.ext.Defaults` used by this bot, if any.""" # This is a property because defaults shouldn't be changed at runtime return self._defaults @property - def rate_limiter(self) -> Optional["BaseRateLimiter[RLARGS]"]: + def rate_limiter(self) -> "BaseRateLimiter[RLARGS] | None": """The :class:`telegram.ext.BaseRateLimiter` used by this bot, if any. .. versionadded:: 20.0 @@ -395,9 +410,7 @@ def rate_limiter(self) -> Optional["BaseRateLimiter[RLARGS]"]: # This is a property because the rate limiter shouldn't be changed at runtime return self._rate_limiter - def _merge_lpo_defaults( - self, lpo: ODVInput[LinkPreviewOptions] - ) -> Optional[LinkPreviewOptions]: + def _merge_lpo_defaults(self, lpo: ODVInput[LinkPreviewOptions]) -> LinkPreviewOptions | None: # This is a standalone method because both _insert_defaults and # _insert_defaults_for_ilq_results need this logic # @@ -458,7 +471,11 @@ def _insert_defaults(self, data: dict[str, object]) -> None: with copied_val._unfrozen(): copied_val.parse_mode = self.defaults.parse_mode data[key] = copied_val - elif key == "media" and isinstance(val, Sequence): + elif ( + key == "media" + and isinstance(val, Sequence) + and not isinstance(val[0], InputPaidMedia) + ): # Copy objects as not to edit them in-place copy_list = [copy(media) for media in val] for media in copy_list: @@ -508,11 +525,11 @@ def _insert_defaults(self, data: dict[str, object]) -> None: new_val.append(new_option) data[key] = new_val - def _replace_keyboard(self, reply_markup: Optional[KT]) -> Optional[KT]: + def _replace_keyboard(self, reply_markup: KT | None) -> KT | None: # If the reply_markup is an inline keyboard and we allow arbitrary callback data, let the # CallbackDataCache build a new keyboard with the data replaced. Otherwise return the input if isinstance(reply_markup, InlineKeyboardMarkup) and self.callback_data_cache is not None: - # for some reason mypy doesn't understand that IKB is a subtype of Optional[KT] + # for some reason mypy doesn't understand that IKB is a subtype of KT | None return self.callback_data_cache.process_keyboard( # type: ignore[return-value] reply_markup ) @@ -558,7 +575,7 @@ def _insert_callback_data(self, obj: HandledTypes) -> HandledTypes: if isinstance(obj, CallbackQuery): self.callback_data_cache.process_callback_query(obj) - return obj # type: ignore[return-value] + return obj if isinstance(obj, Message): if obj.reply_to_message: @@ -573,7 +590,7 @@ def _insert_callback_data(self, obj: HandledTypes) -> HandledTypes: # Finally, handle the message itself self.callback_data_cache.process_message(message=obj) - return obj # type: ignore[return-value] + return obj if isinstance(obj, ChatFullInfo) and obj.pinned_message: self.callback_data_cache.process_message(obj.pinned_message) @@ -585,25 +602,27 @@ async def _send_message( endpoint: str, data: JSONDict, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - caption: Optional[str] = None, + message_thread_id: int | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, link_preview_options: ODVInput["LinkPreviewOptions"] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Any: # We override this method to call self._replace_keyboard and self._insert_callback_data. # This covers most methods that have a reply_markup @@ -629,6 +648,8 @@ async def _send_message( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) if isinstance(result, Message): self._insert_callback_data(result) @@ -636,16 +657,16 @@ async def _send_message( async def get_updates( self, - offset: Optional[int] = None, - limit: Optional[int] = None, - timeout: Optional[int] = None, # noqa: ASYNC109 - allowed_updates: Optional[Sequence[str]] = None, + offset: int | None = None, + limit: int | None = None, + timeout: TimePeriod | None = None, + allowed_updates: Sequence[str] | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> tuple[Update, ...]: updates = await super().get_updates( offset=offset, @@ -666,12 +687,12 @@ async def get_updates( def _effective_inline_results( self, - results: Union[ - Sequence["InlineQueryResult"], Callable[[int], Optional[Sequence["InlineQueryResult"]]] - ], - next_offset: Optional[str] = None, - current_offset: Optional[str] = None, - ) -> tuple[Sequence["InlineQueryResult"], Optional[str]]: + results: ( + Sequence["InlineQueryResult"] | Callable[[int], Sequence["InlineQueryResult"] | None] + ), + next_offset: str | None = None, + current_offset: str | None = None, + ) -> tuple[Sequence["InlineQueryResult"], str | None]: """This method is called by Bot.answer_inline_query to build the actual results list. Overriding this to call self._replace_keyboard suffices """ @@ -746,14 +767,14 @@ def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> "InlineQ async def do_api_request( self, endpoint: str, - api_kwargs: Optional[JSONDict] = None, - return_type: Optional[type[TelegramObject]] = None, + api_kwargs: JSONDict | None = None, + return_type: type[TelegramObject] | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - rate_limit_args: Optional[RLARGS] = None, + rate_limit_args: RLARGS | None = None, ) -> Any: return await super().do_api_request( endpoint=endpoint, @@ -767,17 +788,17 @@ async def do_api_request( async def stop_poll( self, - chat_id: Union[int, str], + chat_id: int | str, message_id: int, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - business_connection_id: Optional[str] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Poll: # We override this method to call self._replace_keyboard return await super().stop_poll( @@ -794,28 +815,32 @@ async def stop_poll( async def copy_message( self, - chat_id: Union[int, str], - from_chat_id: Union[str, int], + chat_id: int | str, + from_chat_id: str | int, message_id: int, - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - show_caption_above_media: Optional[bool] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> MessageId: # We override this method to call self._replace_keyboard return await super().copy_message( @@ -823,6 +848,7 @@ async def copy_message( from_chat_id=from_chat_id, message_id=message_id, caption=caption, + video_start_timestamp=video_start_timestamp, parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, @@ -839,24 +865,28 @@ async def copy_message( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), show_caption_above_media=show_caption_above_media, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) async def copy_messages( self, - chat_id: Union[int, str], - from_chat_id: Union[str, int], + chat_id: int | str, + from_chat_id: str | int, message_ids: Sequence[int], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - remove_caption: Optional[bool] = None, + message_thread_id: int | None = None, + remove_caption: bool | None = None, + direct_messages_topic_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> tuple["MessageId", ...]: # We override this method to call self._replace_keyboard return await super().copy_messages( @@ -872,18 +902,19 @@ async def copy_messages( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + direct_messages_topic_id=direct_messages_topic_id, ) async def get_chat( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> ChatFullInfo: # We override this method to call self._insert_callback_data result = await super().get_chat( @@ -906,8 +937,8 @@ async def add_sticker_to_set( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().add_sticker_to_set( user_id=user_id, @@ -923,17 +954,17 @@ async def add_sticker_to_set( async def answer_callback_query( self, callback_query_id: str, - text: Optional[str] = None, - show_alert: Optional[bool] = None, - url: Optional[str] = None, - cache_time: Optional[int] = None, + text: str | None = None, + show_alert: bool | None = None, + url: str | None = None, + cache_time: TimePeriod | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().answer_callback_query( callback_query_id=callback_query_id, @@ -951,21 +982,21 @@ async def answer_callback_query( async def answer_inline_query( self, inline_query_id: str, - results: Union[ - Sequence["InlineQueryResult"], Callable[[int], Optional[Sequence["InlineQueryResult"]]] - ], - cache_time: Optional[int] = None, - is_personal: Optional[bool] = None, - next_offset: Optional[str] = None, - button: Optional[InlineQueryResultsButton] = None, + results: ( + Sequence["InlineQueryResult"] | Callable[[int], Sequence["InlineQueryResult"] | None] + ), + cache_time: TimePeriod | None = None, + is_personal: bool | None = None, + next_offset: str | None = None, + button: InlineQueryResultsButton | None = None, *, - current_offset: Optional[str] = None, + current_offset: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().answer_inline_query( inline_query_id=inline_query_id, @@ -986,17 +1017,17 @@ async def save_prepared_inline_message( self, user_id: int, result: "InlineQueryResult", - allow_user_chats: Optional[bool] = None, - allow_bot_chats: Optional[bool] = None, - allow_group_chats: Optional[bool] = None, - allow_channel_chats: Optional[bool] = None, + allow_user_chats: bool | None = None, + allow_bot_chats: bool | None = None, + allow_group_chats: bool | None = None, + allow_channel_chats: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> PreparedInlineMessage: return await super().save_prepared_inline_message( user_id=user_id, @@ -1016,14 +1047,14 @@ async def answer_pre_checkout_query( self, pre_checkout_query_id: str, ok: bool, - error_message: Optional[str] = None, + error_message: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().answer_pre_checkout_query( pre_checkout_query_id=pre_checkout_query_id, @@ -1040,15 +1071,15 @@ async def answer_shipping_query( self, shipping_query_id: str, ok: bool, - shipping_options: Optional[Sequence["ShippingOption"]] = None, - error_message: Optional[str] = None, + shipping_options: Sequence["ShippingOption"] | None = None, + error_message: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().answer_shipping_query( shipping_query_id=shipping_query_id, @@ -1071,8 +1102,8 @@ async def answer_web_app_query( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> SentWebAppMessage: return await super().answer_web_app_query( web_app_query_id=web_app_query_id, @@ -1086,15 +1117,15 @@ async def answer_web_app_query( async def approve_chat_join_request( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().approve_chat_join_request( chat_id=chat_id, @@ -1108,17 +1139,17 @@ async def approve_chat_join_request( async def ban_chat_member( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, - until_date: Optional[Union[int, dtm.datetime]] = None, - revoke_messages: Optional[bool] = None, + until_date: int | dtm.datetime | None = None, + revoke_messages: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().ban_chat_member( chat_id=chat_id, @@ -1134,15 +1165,15 @@ async def ban_chat_member( async def ban_chat_sender_chat( self, - chat_id: Union[str, int], + chat_id: str | int, sender_chat_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().ban_chat_sender_chat( chat_id=chat_id, @@ -1156,18 +1187,18 @@ async def ban_chat_sender_chat( async def create_chat_invite_link( self, - chat_id: Union[str, int], - expire_date: Optional[Union[int, dtm.datetime]] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - creates_join_request: Optional[bool] = None, + chat_id: str | int, + expire_date: int | dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + creates_join_request: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> ChatInviteLink: return await super().create_chat_invite_link( chat_id=chat_id, @@ -1187,32 +1218,32 @@ async def create_invoice_link( title: str, description: str, payload: str, - provider_token: Optional[str], currency: str, prices: Sequence["LabeledPrice"], - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, - provider_data: Optional[Union[str, object]] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - is_flexible: Optional[bool] = None, - subscription_period: Optional[Union[int, dtm.timedelta]] = None, - business_connection_id: Optional[str] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + provider_token: str | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, + provider_data: str | object | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + is_flexible: bool | None = None, + subscription_period: TimePeriod | None = None, + business_connection_id: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> str: return await super().create_invoice_link( title=title, @@ -1250,15 +1281,15 @@ async def create_new_sticker_set( name: str, title: str, stickers: Sequence["InputSticker"], - sticker_type: Optional[str] = None, - needs_repainting: Optional[bool] = None, + sticker_type: str | None = None, + needs_repainting: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().create_new_sticker_set( user_id=user_id, @@ -1276,15 +1307,15 @@ async def create_new_sticker_set( async def decline_chat_join_request( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().decline_chat_join_request( chat_id=chat_id, @@ -1298,14 +1329,14 @@ async def decline_chat_join_request( async def delete_chat_photo( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_chat_photo( chat_id=chat_id, @@ -1318,14 +1349,14 @@ async def delete_chat_photo( async def delete_chat_sticker_set( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_chat_sticker_set( chat_id=chat_id, @@ -1338,15 +1369,15 @@ async def delete_chat_sticker_set( async def delete_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_forum_topic( chat_id=chat_id, @@ -1360,15 +1391,15 @@ async def delete_forum_topic( async def delete_message( self, - chat_id: Union[str, int], + chat_id: str | int, message_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_message( chat_id=chat_id, @@ -1382,15 +1413,15 @@ async def delete_message( async def delete_messages( self, - chat_id: Union[str, int], + chat_id: str | int, message_ids: Sequence[int], *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_messages( chat_id=chat_id, @@ -1404,15 +1435,15 @@ async def delete_messages( async def delete_my_commands( self, - scope: Optional[BotCommandScope] = None, - language_code: Optional[str] = None, + scope: BotCommandScope | None = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_my_commands( scope=scope, @@ -1426,14 +1457,14 @@ async def delete_my_commands( async def delete_sticker_from_set( self, - sticker: Union[str, "Sticker"], + sticker: "str | Sticker", *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_sticker_from_set( sticker=sticker, @@ -1446,14 +1477,14 @@ async def delete_sticker_from_set( async def delete_webhook( self, - drop_pending_updates: Optional[bool] = None, + drop_pending_updates: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_webhook( drop_pending_updates=drop_pending_updates, @@ -1466,19 +1497,19 @@ async def delete_webhook( async def edit_chat_invite_link( self, - chat_id: Union[str, int], - invite_link: Union[str, "ChatInviteLink"], - expire_date: Optional[Union[int, dtm.datetime]] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - creates_join_request: Optional[bool] = None, + chat_id: str | int, + invite_link: "str | ChatInviteLink", + expire_date: int | dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + creates_join_request: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> ChatInviteLink: return await super().edit_chat_invite_link( chat_id=chat_id, @@ -1496,17 +1527,17 @@ async def edit_chat_invite_link( async def edit_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, - name: Optional[str] = None, - icon_custom_emoji_id: Optional[str] = None, + name: str | None = None, + icon_custom_emoji_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().edit_forum_topic( chat_id=chat_id, @@ -1522,15 +1553,15 @@ async def edit_forum_topic( async def edit_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, name: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().edit_general_forum_topic( chat_id=chat_id, @@ -1544,23 +1575,23 @@ async def edit_general_forum_topic( async def edit_message_caption( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + caption: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - show_caption_above_media: Optional[bool] = None, - business_connection_id: Optional[str] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().edit_message_caption( chat_id=chat_id, message_id=message_id, @@ -1580,26 +1611,26 @@ async def edit_message_caption( async def edit_message_live_location( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, - live_period: Optional[int] = None, - business_connection_id: Optional[str] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + latitude: float | None = None, + longitude: float | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, + live_period: TimePeriod | None = None, + business_connection_id: str | None = None, *, - location: Optional[Location] = None, + location: "Location | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().edit_message_live_location( chat_id=chat_id, message_id=message_id, @@ -1623,19 +1654,19 @@ async def edit_message_live_location( async def edit_message_media( self, media: "InputMedia", - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - business_connection_id: Optional[str] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().edit_message_media( media=media, chat_id=chat_id, @@ -1652,19 +1683,19 @@ async def edit_message_media( async def edit_message_reply_markup( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - business_connection_id: Optional[str] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().edit_message_reply_markup( chat_id=chat_id, message_id=message_id, @@ -1681,23 +1712,23 @@ async def edit_message_reply_markup( async def edit_message_text( self, text: str, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, - business_connection_id: Optional[str] = None, + business_connection_id: str | None = None, *, - disable_web_page_preview: Optional[bool] = None, + disable_web_page_preview: bool | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().edit_message_text( text=text, chat_id=chat_id, @@ -1718,14 +1749,14 @@ async def edit_message_text( async def export_chat_invite_link( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> str: return await super().export_chat_invite_link( chat_id=chat_id, @@ -1738,49 +1769,58 @@ async def export_chat_invite_link( async def forward_message( self, - chat_id: Union[int, str], - from_chat_id: Union[str, int], + chat_id: int | str, + from_chat_id: str | int, message_id: int, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().forward_message( chat_id=chat_id, from_chat_id=from_chat_id, message_id=message_id, + video_start_timestamp=video_start_timestamp, disable_notification=disable_notification, protect_content=protect_content, message_thread_id=message_thread_id, + suggested_post_parameters=suggested_post_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + direct_messages_topic_id=direct_messages_topic_id, + message_effect_id=message_effect_id, ) async def forward_messages( self, - chat_id: Union[int, str], - from_chat_id: Union[str, int], + chat_id: int | str, + from_chat_id: str | int, message_ids: Sequence[int], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> tuple[MessageId, ...]: return await super().forward_messages( chat_id=chat_id, @@ -1789,6 +1829,7 @@ async def forward_messages( disable_notification=disable_notification, protect_content=protect_content, message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1798,14 +1839,14 @@ async def forward_messages( async def get_chat_administrators( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> tuple[ChatMember, ...]: return await super().get_chat_administrators( chat_id=chat_id, @@ -1818,15 +1859,15 @@ async def get_chat_administrators( async def get_chat_member( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> ChatMember: return await super().get_chat_member( chat_id=chat_id, @@ -1840,14 +1881,14 @@ async def get_chat_member( async def get_chat_member_count( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> int: return await super().get_chat_member_count( chat_id=chat_id, @@ -1860,14 +1901,14 @@ async def get_chat_member_count( async def get_chat_menu_button( self, - chat_id: Optional[int] = None, + chat_id: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> MenuButton: return await super().get_chat_menu_button( chat_id=chat_id, @@ -1880,16 +1921,25 @@ async def get_chat_menu_button( async def get_file( self, - file_id: Union[ - str, Animation, Audio, ChatPhoto, Document, PhotoSize, Sticker, Video, VideoNote, Voice - ], + file_id: ( + str + | Animation + | Audio + | ChatPhoto + | Document + | PhotoSize + | Sticker + | Video + | VideoNote + | Voice + ), *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> File: return await super().get_file( file_id=file_id, @@ -1907,8 +1957,8 @@ async def get_forum_topic_icon_stickers( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> tuple[Sticker, ...]: return await super().get_forum_topic_icon_stickers( read_timeout=read_timeout, @@ -1921,16 +1971,16 @@ async def get_forum_topic_icon_stickers( async def get_game_high_scores( self, user_id: int, - chat_id: Optional[int] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, + chat_id: int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> tuple[GameHighScore, ...]: return await super().get_game_high_scores( user_id=user_id, @@ -1951,8 +2001,8 @@ async def get_me( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> User: return await super().get_me( read_timeout=read_timeout, @@ -1964,15 +2014,15 @@ async def get_me( async def get_my_commands( self, - scope: Optional[BotCommandScope] = None, - language_code: Optional[str] = None, + scope: BotCommandScope | None = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> tuple[BotCommand, ...]: return await super().get_my_commands( scope=scope, @@ -1986,14 +2036,14 @@ async def get_my_commands( async def get_my_default_administrator_rights( self, - for_channels: Optional[bool] = None, + for_channels: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> ChatAdministratorRights: return await super().get_my_default_administrator_rights( for_channels=for_channels, @@ -2012,8 +2062,8 @@ async def get_sticker_set( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> StickerSet: return await super().get_sticker_set( name=name, @@ -2032,8 +2082,8 @@ async def get_custom_emoji_stickers( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> tuple[Sticker, ...]: return await super().get_custom_emoji_stickers( custom_emoji_ids=custom_emoji_ids, @@ -2047,16 +2097,16 @@ async def get_custom_emoji_stickers( async def get_user_profile_photos( self, user_id: int, - offset: Optional[int] = None, - limit: Optional[int] = None, + offset: int | None = None, + limit: int | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> UserProfilePhotos: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "UserProfilePhotos": return await super().get_user_profile_photos( user_id=user_id, offset=offset, @@ -2075,8 +2125,8 @@ async def get_webhook_info( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> WebhookInfo: return await super().get_webhook_info( read_timeout=read_timeout, @@ -2088,14 +2138,14 @@ async def get_webhook_info( async def leave_chat( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().leave_chat( chat_id=chat_id, @@ -2113,8 +2163,8 @@ async def log_out( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().log_out( read_timeout=read_timeout, @@ -2131,8 +2181,8 @@ async def close( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().close( read_timeout=read_timeout, @@ -2144,15 +2194,15 @@ async def close( async def close_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().close_forum_topic( chat_id=chat_id, @@ -2166,14 +2216,14 @@ async def close_forum_topic( async def close_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().close_general_forum_topic( chat_id=chat_id, @@ -2186,17 +2236,17 @@ async def close_general_forum_topic( async def create_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, name: str, - icon_color: Optional[int] = None, - icon_custom_emoji_id: Optional[str] = None, + icon_color: int | None = None, + icon_custom_emoji_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> ForumTopic: return await super().create_forum_topic( chat_id=chat_id, @@ -2212,14 +2262,14 @@ async def create_forum_topic( async def reopen_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().reopen_general_forum_topic( chat_id=chat_id, @@ -2232,14 +2282,14 @@ async def reopen_general_forum_topic( async def hide_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().hide_general_forum_topic( chat_id=chat_id, @@ -2252,14 +2302,14 @@ async def hide_general_forum_topic( async def unhide_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().unhide_general_forum_topic( chat_id=chat_id, @@ -2272,17 +2322,17 @@ async def unhide_general_forum_topic( async def pin_chat_message( self, - chat_id: Union[str, int], + chat_id: str | int, message_id: int, disable_notification: ODVInput[bool] = DEFAULT_NONE, - business_connection_id: Optional[str] = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().pin_chat_message( chat_id=chat_id, @@ -2298,30 +2348,31 @@ async def pin_chat_message( async def promote_chat_member( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, - can_change_info: Optional[bool] = None, - can_post_messages: Optional[bool] = None, - can_edit_messages: Optional[bool] = None, - can_delete_messages: Optional[bool] = None, - can_invite_users: Optional[bool] = None, - can_restrict_members: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - can_promote_members: Optional[bool] = None, - is_anonymous: Optional[bool] = None, - can_manage_chat: Optional[bool] = None, - can_manage_video_chats: Optional[bool] = None, - can_manage_topics: Optional[bool] = None, - can_post_stories: Optional[bool] = None, - can_edit_stories: Optional[bool] = None, - can_delete_stories: Optional[bool] = None, + can_change_info: bool | None = None, + can_post_messages: bool | None = None, + can_edit_messages: bool | None = None, + can_delete_messages: bool | None = None, + can_invite_users: bool | None = None, + can_restrict_members: bool | None = None, + can_pin_messages: bool | None = None, + can_promote_members: bool | None = None, + is_anonymous: bool | None = None, + can_manage_chat: bool | None = None, + can_manage_video_chats: bool | None = None, + can_manage_topics: bool | None = None, + can_post_stories: bool | None = None, + can_edit_stories: bool | None = None, + can_delete_stories: bool | None = None, + can_manage_direct_messages: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().promote_chat_member( chat_id=chat_id, @@ -2341,6 +2392,7 @@ async def promote_chat_member( can_post_stories=can_post_stories, can_edit_stories=can_edit_stories, can_delete_stories=can_delete_stories, + can_manage_direct_messages=can_manage_direct_messages, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2350,15 +2402,15 @@ async def promote_chat_member( async def reopen_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().reopen_forum_topic( chat_id=chat_id, @@ -2372,18 +2424,18 @@ async def reopen_forum_topic( async def restrict_chat_member( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, permissions: ChatPermissions, - until_date: Optional[Union[int, dtm.datetime]] = None, - use_independent_chat_permissions: Optional[bool] = None, + until_date: int | dtm.datetime | None = None, + use_independent_chat_permissions: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().restrict_chat_member( chat_id=chat_id, @@ -2400,15 +2452,15 @@ async def restrict_chat_member( async def revoke_chat_invite_link( self, - chat_id: Union[str, int], - invite_link: Union[str, "ChatInviteLink"], + chat_id: str | int, + invite_link: "str | ChatInviteLink", *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> ChatInviteLink: return await super().revoke_chat_invite_link( chat_id=chat_id, @@ -2422,35 +2474,37 @@ async def revoke_chat_invite_link( async def send_animation( self, - chat_id: Union[int, str], - animation: Union[FileInput, "Animation"], - duration: Optional[int] = None, - width: Optional[int] = None, - height: Optional[int] = None, - caption: Optional[str] = None, + chat_id: int | str, + animation: "FileInput | Animation", + duration: TimePeriod | None = None, + width: int | None = None, + height: int | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "ReplyMarkup | None" = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_animation( chat_id=chat_id, @@ -2480,37 +2534,41 @@ async def send_animation( message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_audio( self, - chat_id: Union[int, str], - audio: Union[FileInput, "Audio"], - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - caption: Optional[str] = None, + chat_id: int | str, + audio: "FileInput | Audio", + duration: TimePeriod | None = None, + performer: str | None = None, + title: str | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_audio( chat_id=chat_id, @@ -2538,21 +2596,23 @@ async def send_audio( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_chat_action( self, - chat_id: Union[str, int], + chat_id: str | int, action: str, - message_thread_id: Optional[int] = None, - business_connection_id: Optional[str] = None, + message_thread_id: int | None = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().send_chat_action( chat_id=chat_id, @@ -2568,29 +2628,31 @@ async def send_chat_action( async def send_contact( self, - chat_id: Union[int, str], - phone_number: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, + chat_id: int | str, + phone_number: str | None = None, + first_name: str | None = None, + last_name: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - vcard: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + vcard: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - contact: Optional[Contact] = None, + contact: "Contact | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_contact( chat_id=chat_id, @@ -2613,30 +2675,100 @@ async def send_contact( business_connection_id=business_connection_id, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), message_effect_id=message_effect_id, + direct_messages_topic_id=direct_messages_topic_id, allow_paid_broadcast=allow_paid_broadcast, + suggested_post_parameters=suggested_post_parameters, ) - async def send_dice( + async def send_checklist( self, - chat_id: Union[int, str], + business_connection_id: str, + chat_id: int, + checklist: InputChecklist, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - emoji: Optional[str] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_effect_id: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Message: + return await super().send_checklist( + business_connection_id=business_connection_id, + chat_id=chat_id, + checklist=checklist, + disable_notification=disable_notification, + protect_content=protect_content, + message_effect_id=message_effect_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + reply_to_message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def edit_message_checklist( + self, + business_connection_id: str, + chat_id: int, + message_id: int, + checklist: InputChecklist, + reply_markup: "InlineKeyboardMarkup | None" = None, *, - reply_to_message_id: Optional[int] = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Message: + return await super().edit_message_checklist( + business_connection_id=business_connection_id, + chat_id=chat_id, + message_id=message_id, + checklist=checklist, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_dice( + self, + chat_id: int | str, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + emoji: str | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_dice( chat_id=chat_id, @@ -2656,35 +2788,39 @@ async def send_dice( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_document( self, - chat_id: Union[int, str], - document: Union[FileInput, "Document"], - caption: Optional[str] = None, + chat_id: int | str, + document: "FileInput | Document", + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_content_type_detection: Optional[bool] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + disable_content_type_detection: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_document( chat_id=chat_id, @@ -2710,6 +2846,8 @@ async def send_document( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_game( @@ -2717,22 +2855,22 @@ async def send_game( chat_id: int, game_short_name: str, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_game( chat_id=chat_id, @@ -2756,44 +2894,46 @@ async def send_game( async def send_invoice( self, - chat_id: Union[int, str], + chat_id: int | str, title: str, description: str, payload: str, - provider_token: Optional[str], currency: str, prices: Sequence["LabeledPrice"], - start_parameter: Optional[str] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - is_flexible: Optional[bool] = None, + provider_token: str | None = None, + start_parameter: str | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + is_flexible: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - provider_data: Optional[Union[str, object]] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + provider_data: str | object | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_invoice( chat_id=chat_id, @@ -2832,35 +2972,39 @@ async def send_invoice( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_location( self, - chat_id: Union[int, str], - latitude: Optional[float] = None, - longitude: Optional[float] = None, + chat_id: int | str, + latitude: float | None = None, + longitude: float | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - live_period: Optional[int] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + live_period: TimePeriod | None = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - location: Optional[Location] = None, + location: "Location | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_location( chat_id=chat_id, @@ -2886,33 +3030,36 @@ async def send_location( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_media_group( self, - chat_id: Union[int, str], + chat_id: int | str, media: Sequence[ - Union["InputMediaAudio", "InputMediaDocument", "InputMediaPhoto", "InputMediaVideo"] + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - caption: Optional[str] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, ) -> tuple[Message, ...]: return await super().send_media_group( chat_id=chat_id, @@ -2934,33 +3081,36 @@ async def send_media_group( caption_entities=caption_entities, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, ) async def send_message( self, - chat_id: Union[int, str], + chat_id: int | str, text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, - entities: Optional[Sequence["MessageEntity"]] = None, + entities: Sequence["MessageEntity"] | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - message_thread_id: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + message_thread_id: int | None = None, link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - disable_web_page_preview: Optional[bool] = None, - reply_to_message_id: Optional[int] = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + disable_web_page_preview: bool | None = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_message( chat_id=chat_id, @@ -2984,35 +3134,69 @@ async def send_message( link_preview_options=link_preview_options, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_message_draft( + self, + chat_id: int, + draft_id: int, + text: str, + message_thread_id: int | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Sequence["MessageEntity"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().send_message_draft( + chat_id=chat_id, + draft_id=draft_id, + text=text, + message_thread_id=message_thread_id, + parse_mode=parse_mode, + entities=entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) async def send_photo( self, - chat_id: Union[int, str], - photo: Union[FileInput, "PhotoSize"], - caption: Optional[str] = None, + chat_id: int | str, + photo: "FileInput | PhotoSize", + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_photo( chat_id=chat_id, @@ -3038,42 +3222,44 @@ async def send_photo( message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_poll( self, - chat_id: Union[int, str], + chat_id: int | str, question: str, - options: Sequence[Union[str, "InputPollOption"]], - is_anonymous: Optional[bool] = None, - type: Optional[str] = None, # pylint: disable=redefined-builtin - allows_multiple_answers: Optional[bool] = None, - correct_option_id: Optional[CorrectOptionID] = None, - is_closed: Optional[bool] = None, + options: Sequence["str | InputPollOption"], + is_anonymous: bool | None = None, + type: str | None = None, # pylint: disable=redefined-builtin + allows_multiple_answers: bool | None = None, + correct_option_id: CorrectOptionID | None = None, + is_closed: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - explanation: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + explanation: str | None = None, explanation_parse_mode: ODVInput[str] = DEFAULT_NONE, - open_period: Optional[int] = None, - close_date: Optional[Union[int, dtm.datetime]] = None, - explanation_entities: Optional[Sequence["MessageEntity"]] = None, + open_period: TimePeriod | None = None, + close_date: int | dtm.datetime | None = None, + explanation_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, question_parse_mode: ODVInput[str] = DEFAULT_NONE, - question_entities: Optional[Sequence["MessageEntity"]] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, + question_entities: Sequence["MessageEntity"] | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, - reply_to_message_id: Optional[int] = None, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_poll( chat_id=chat_id, @@ -3110,26 +3296,28 @@ async def send_poll( async def send_sticker( self, - chat_id: Union[int, str], - sticker: Union[FileInput, "Sticker"], + chat_id: int | str, + sticker: "FileInput | Sticker", disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - emoji: Optional[str] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + emoji: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_sticker( chat_id=chat_id, @@ -3150,37 +3338,41 @@ async def send_sticker( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_venue( self, - chat_id: Union[int, str], - latitude: Optional[float] = None, - longitude: Optional[float] = None, - title: Optional[str] = None, - address: Optional[str] = None, - foursquare_id: Optional[str] = None, + chat_id: int | str, + latitude: float | None = None, + longitude: float | None = None, + title: str | None = None, + address: str | None = None, + foursquare_id: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - venue: Optional[Venue] = None, + venue: "Venue | None" = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_venue( chat_id=chat_id, @@ -3208,40 +3400,46 @@ async def send_venue( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_video( self, - chat_id: Union[int, str], - video: Union[FileInput, "Video"], - duration: Optional[int] = None, - caption: Optional[str] = None, + chat_id: int | str, + video: "FileInput | Video", + duration: TimePeriod | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - width: Optional[int] = None, - height: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + width: int | None = None, + height: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - supports_streaming: Optional[bool] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + supports_streaming: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - show_caption_above_media: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_video( chat_id=chat_id, @@ -3262,6 +3460,8 @@ async def send_video( business_connection_id=business_connection_id, has_spoiler=has_spoiler, thumbnail=thumbnail, + cover=cover, + start_timestamp=start_timestamp, filename=filename, reply_parameters=reply_parameters, read_timeout=read_timeout, @@ -3272,33 +3472,37 @@ async def send_video( message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_video_note( self, - chat_id: Union[int, str], - video_note: Union[FileInput, "VideoNote"], - duration: Optional[int] = None, - length: Optional[int] = None, + chat_id: int | str, + video_note: "FileInput | VideoNote", + duration: TimePeriod | None = None, + length: int | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_video_note( chat_id=chat_id, @@ -3322,34 +3526,38 @@ async def send_video_note( business_connection_id=business_connection_id, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_voice( self, - chat_id: Union[int, str], - voice: Union[FileInput, "Voice"], - duration: Optional[int] = None, - caption: Optional[str] = None, + chat_id: int | str, + voice: "FileInput | Voice", + duration: TimePeriod | None = None, + caption: str | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - reply_parameters: Optional["ReplyParameters"] = None, - business_connection_id: Optional[str] = None, - message_effect_id: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, - *, - reply_to_message_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - filename: Optional[str] = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_voice( chat_id=chat_id, @@ -3373,12 +3581,14 @@ async def send_voice( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), business_connection_id=business_connection_id, message_effect_id=message_effect_id, + direct_messages_topic_id=direct_messages_topic_id, allow_paid_broadcast=allow_paid_broadcast, + suggested_post_parameters=suggested_post_parameters, ) async def set_chat_administrator_custom_title( self, - chat_id: Union[int, str], + chat_id: int | str, user_id: int, custom_title: str, *, @@ -3386,8 +3596,8 @@ async def set_chat_administrator_custom_title( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_administrator_custom_title( chat_id=chat_id, @@ -3402,15 +3612,15 @@ async def set_chat_administrator_custom_title( async def set_chat_description( self, - chat_id: Union[str, int], - description: Optional[str] = None, + chat_id: str | int, + description: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_description( chat_id=chat_id, @@ -3425,15 +3635,15 @@ async def set_chat_description( async def set_user_emoji_status( self, user_id: int, - emoji_status_custom_emoji_id: Optional[str] = None, - emoji_status_expiration_date: Optional[Union[int, dtm.datetime]] = None, + emoji_status_custom_emoji_id: str | None = None, + emoji_status_expiration_date: int | dtm.datetime | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_user_emoji_status( user_id=user_id, @@ -3448,15 +3658,15 @@ async def set_user_emoji_status( async def set_chat_menu_button( self, - chat_id: Optional[int] = None, - menu_button: Optional[MenuButton] = None, + chat_id: int | None = None, + menu_button: MenuButton | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_menu_button( chat_id=chat_id, @@ -3470,16 +3680,16 @@ async def set_chat_menu_button( async def set_chat_permissions( self, - chat_id: Union[str, int], + chat_id: str | int, permissions: ChatPermissions, - use_independent_chat_permissions: Optional[bool] = None, + use_independent_chat_permissions: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_permissions( chat_id=chat_id, @@ -3494,15 +3704,15 @@ async def set_chat_permissions( async def set_chat_photo( self, - chat_id: Union[str, int], + chat_id: str | int, photo: FileInput, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_photo( chat_id=chat_id, @@ -3516,15 +3726,15 @@ async def set_chat_photo( async def set_chat_sticker_set( self, - chat_id: Union[str, int], + chat_id: str | int, sticker_set_name: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_sticker_set( chat_id=chat_id, @@ -3538,15 +3748,15 @@ async def set_chat_sticker_set( async def set_chat_title( self, - chat_id: Union[str, int], + chat_id: str | int, title: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_title( chat_id=chat_id, @@ -3562,19 +3772,19 @@ async def set_game_score( self, user_id: int, score: int, - chat_id: Optional[int] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - force: Optional[bool] = None, - disable_edit_message: Optional[bool] = None, + chat_id: int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + force: bool | None = None, + disable_edit_message: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().set_game_score( user_id=user_id, score=score, @@ -3592,16 +3802,16 @@ async def set_game_score( async def set_my_commands( self, - commands: Sequence[Union[BotCommand, tuple[str, str]]], - scope: Optional[BotCommandScope] = None, - language_code: Optional[str] = None, + commands: Sequence[BotCommand | tuple[str, str]], + scope: BotCommandScope | None = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_my_commands( commands=commands, @@ -3616,15 +3826,15 @@ async def set_my_commands( async def set_my_default_administrator_rights( self, - rights: Optional[ChatAdministratorRights] = None, - for_channels: Optional[bool] = None, + rights: ChatAdministratorRights | None = None, + for_channels: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_my_default_administrator_rights( rights=rights, @@ -3645,8 +3855,8 @@ async def set_passport_data_errors( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_passport_data_errors( user_id=user_id, @@ -3660,15 +3870,15 @@ async def set_passport_data_errors( async def set_sticker_position_in_set( self, - sticker: Union[str, "Sticker"], + sticker: "str | Sticker", position: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_sticker_position_in_set( sticker=sticker, @@ -3685,14 +3895,14 @@ async def set_sticker_set_thumbnail( name: str, user_id: int, format: str, # pylint: disable=redefined-builtin - thumbnail: Optional[FileInput] = None, + thumbnail: "FileInput | None" = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_sticker_set_thumbnail( name=name, @@ -3709,19 +3919,19 @@ async def set_sticker_set_thumbnail( async def set_webhook( self, url: str, - certificate: Optional[FileInput] = None, - max_connections: Optional[int] = None, - allowed_updates: Optional[Sequence[str]] = None, - ip_address: Optional[str] = None, - drop_pending_updates: Optional[bool] = None, - secret_token: Optional[str] = None, + certificate: "FileInput | None" = None, + max_connections: int | None = None, + allowed_updates: Sequence[str] | None = None, + ip_address: str | None = None, + drop_pending_updates: bool | None = None, + secret_token: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_webhook( url=url, @@ -3740,19 +3950,19 @@ async def set_webhook( async def stop_message_live_location( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - business_connection_id: Optional[str] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().stop_message_live_location( chat_id=chat_id, message_id=message_id, @@ -3768,16 +3978,16 @@ async def stop_message_live_location( async def unban_chat_member( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, - only_if_banned: Optional[bool] = None, + only_if_banned: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().unban_chat_member( chat_id=chat_id, @@ -3792,15 +4002,15 @@ async def unban_chat_member( async def unban_chat_sender_chat( self, - chat_id: Union[str, int], + chat_id: str | int, sender_chat_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().unban_chat_sender_chat( chat_id=chat_id, @@ -3814,14 +4024,14 @@ async def unban_chat_sender_chat( async def unpin_all_chat_messages( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().unpin_all_chat_messages( chat_id=chat_id, @@ -3834,16 +4044,16 @@ async def unpin_all_chat_messages( async def unpin_chat_message( self, - chat_id: Union[str, int], - message_id: Optional[int] = None, - business_connection_id: Optional[str] = None, + chat_id: str | int, + message_id: int | None = None, + business_connection_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().unpin_chat_message( chat_id=chat_id, @@ -3858,15 +4068,15 @@ async def unpin_chat_message( async def unpin_all_forum_topic_messages( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().unpin_all_forum_topic_messages( chat_id=chat_id, @@ -3880,14 +4090,14 @@ async def unpin_all_forum_topic_messages( async def unpin_all_general_forum_topic_messages( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().unpin_all_general_forum_topic_messages( chat_id=chat_id, @@ -3908,8 +4118,8 @@ async def upload_sticker_file( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> File: return await super().upload_sticker_file( user_id=user_id, @@ -3924,15 +4134,15 @@ async def upload_sticker_file( async def set_my_description( self, - description: Optional[str] = None, - language_code: Optional[str] = None, + description: str | None = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_my_description( description=description, @@ -3946,15 +4156,15 @@ async def set_my_description( async def set_my_short_description( self, - short_description: Optional[str] = None, - language_code: Optional[str] = None, + short_description: str | None = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_my_short_description( short_description=short_description, @@ -3968,14 +4178,14 @@ async def set_my_short_description( async def get_my_description( self, - language_code: Optional[str] = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> BotDescription: return await super().get_my_description( language_code=language_code, @@ -3988,14 +4198,14 @@ async def get_my_description( async def get_my_short_description( self, - language_code: Optional[str] = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> BotShortDescription: return await super().get_my_short_description( language_code=language_code, @@ -4008,15 +4218,15 @@ async def get_my_short_description( async def set_my_name( self, - name: Optional[str] = None, - language_code: Optional[str] = None, + name: str | None = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_my_name( name=name, @@ -4030,14 +4240,14 @@ async def set_my_name( async def get_my_name( self, - language_code: Optional[str] = None, + language_code: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> BotName: return await super().get_my_name( language_code=language_code, @@ -4051,14 +4261,14 @@ async def get_my_name( async def set_custom_emoji_sticker_set_thumbnail( self, name: str, - custom_emoji_id: Optional[str] = None, + custom_emoji_id: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_custom_emoji_sticker_set_thumbnail( name=name, @@ -4079,8 +4289,8 @@ async def set_sticker_set_title( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_sticker_set_title( name=name, @@ -4100,8 +4310,8 @@ async def delete_sticker_set( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_sticker_set( name=name, @@ -4114,15 +4324,15 @@ async def delete_sticker_set( async def set_sticker_emoji_list( self, - sticker: Union[str, "Sticker"], + sticker: "str | Sticker", emoji_list: Sequence[str], *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_sticker_emoji_list( sticker=sticker, @@ -4136,15 +4346,15 @@ async def set_sticker_emoji_list( async def set_sticker_keywords( self, - sticker: Union[str, "Sticker"], - keywords: Optional[Sequence[str]] = None, + sticker: "str | Sticker", + keywords: Sequence[str] | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_sticker_keywords( sticker=sticker, @@ -4158,15 +4368,15 @@ async def set_sticker_keywords( async def set_sticker_mask_position( self, - sticker: Union[str, "Sticker"], - mask_position: Optional[MaskPosition] = None, + sticker: "str | Sticker", + mask_position: MaskPosition | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_sticker_mask_position( sticker=sticker, @@ -4180,15 +4390,15 @@ async def set_sticker_mask_position( async def get_user_chat_boosts( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> UserChatBoosts: return await super().get_user_chat_boosts( chat_id=chat_id, @@ -4202,17 +4412,17 @@ async def get_user_chat_boosts( async def set_message_reaction( self, - chat_id: Union[str, int], + chat_id: str | int, message_id: int, - reaction: Optional[Union[Sequence[Union[ReactionType, str]], ReactionType, str]] = None, - is_big: Optional[bool] = None, + reaction: Sequence[ReactionType | str] | ReactionType | str | None = None, + is_big: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_message_reaction( chat_id=chat_id, @@ -4226,6 +4436,36 @@ async def set_message_reaction( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def gift_premium_subscription( + self, + user_id: int, + month_count: int, + star_count: int, + text: str | None = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence["MessageEntity"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().gift_premium_subscription( + user_id=user_id, + month_count=month_count, + star_count=star_count, + text=text, + text_parse_mode=text_parse_mode, + text_entities=text_entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + async def get_business_connection( self, business_connection_id: str, @@ -4234,8 +4474,8 @@ async def get_business_connection( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> BusinessConnection: return await super().get_business_connection( business_connection_id=business_connection_id, @@ -4246,25 +4486,39 @@ async def get_business_connection( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) - async def replace_sticker_in_set( + async def get_business_account_gifts( self, - user_id: int, - name: str, - old_sticker: Union[str, "Sticker"], - sticker: "InputSticker", + business_connection_id: str, + exclude_unsaved: bool | None = None, + exclude_saved: bool | None = None, + exclude_unlimited: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> bool: - return await super().replace_sticker_in_set( - user_id=user_id, - name=name, - old_sticker=old_sticker, - sticker=sticker, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> OwnedGifts: + return await super().get_business_account_gifts( + business_connection_id=business_connection_id, + exclude_unsaved=exclude_unsaved, + exclude_saved=exclude_saved, + exclude_unlimited=exclude_unlimited, + exclude_limited_upgradable=exclude_limited_upgradable, + exclude_limited_non_upgradable=exclude_limited_non_upgradable, + exclude_unique=exclude_unique, + exclude_from_blockchain=exclude_from_blockchain, + sort_by_price=sort_by_price, + offset=offset, + limit=limit, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4272,21 +4526,19 @@ async def replace_sticker_in_set( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) - async def refund_star_payment( + async def get_business_account_star_balance( self, - user_id: int, - telegram_payment_charge_id: str, + business_connection_id: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> bool: - return await super().refund_star_payment( - user_id=user_id, - telegram_payment_charge_id=telegram_payment_charge_id, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> StarAmount: + return await super().get_business_account_star_balance( + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4294,21 +4546,23 @@ async def refund_star_payment( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) - async def get_star_transactions( + async def read_business_message( self, - offset: Optional[int] = None, - limit: Optional[int] = None, + business_connection_id: str, + chat_id: int, + message_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> StarTransactions: - return await super().get_star_transactions( - offset=offset, - limit=limit, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().read_business_message( + business_connection_id=business_connection_id, + chat_id=chat_id, + message_id=message_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4316,23 +4570,21 @@ async def get_star_transactions( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) - async def edit_user_star_subscription( + async def delete_business_messages( self, - user_id: int, - telegram_payment_charge_id: str, - is_canceled: bool, + business_connection_id: str, + message_ids: Sequence[int], *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: - return await super().edit_user_star_subscription( - user_id=user_id, - telegram_payment_charge_id=telegram_payment_charge_id, - is_canceled=is_canceled, + return await super().delete_business_messages( + business_connection_id=business_connection_id, + message_ids=message_ids, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4340,75 +4592,67 @@ async def edit_user_star_subscription( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) - async def send_paid_media( + async def post_story( self, - chat_id: Union[str, int], - star_count: int, - media: Sequence["InputPaidMedia"], - caption: Optional[str] = None, + business_connection_id: str, + content: "InputStoryContent", + active_period: TimePeriod, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - show_caption_above_media: Optional[bool] = None, - disable_notification: ODVInput[bool] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + areas: Sequence["StoryArea"] | None = None, + post_to_chat_page: bool | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - reply_parameters: Optional["ReplyParameters"] = None, - reply_markup: Optional[ReplyMarkup] = None, - business_connection_id: Optional[str] = None, - payload: Optional[str] = None, - allow_paid_broadcast: Optional[bool] = None, *, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> Message: - return await super().send_paid_media( - chat_id=chat_id, - star_count=star_count, - media=media, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Story: + return await super().post_story( + business_connection_id=business_connection_id, + content=content, + active_period=active_period, caption=caption, parse_mode=parse_mode, caption_entities=caption_entities, - show_caption_above_media=show_caption_above_media, - disable_notification=disable_notification, + areas=areas, + post_to_chat_page=post_to_chat_page, protect_content=protect_content, - reply_parameters=reply_parameters, - reply_markup=reply_markup, - allow_sending_without_reply=allow_sending_without_reply, - reply_to_message_id=reply_to_message_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), - business_connection_id=business_connection_id, - payload=payload, - allow_paid_broadcast=allow_paid_broadcast, ) - async def create_chat_subscription_invite_link( + async def edit_story( self, - chat_id: Union[str, int], - subscription_period: int, - subscription_price: int, - name: Optional[str] = None, + business_connection_id: str, + story_id: int, + content: "InputStoryContent", + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + areas: Sequence["StoryArea"] | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> ChatInviteLink: - return await super().create_chat_subscription_invite_link( - chat_id=chat_id, - subscription_period=subscription_period, - subscription_price=subscription_price, - name=name, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Story: + return await super().edit_story( + business_connection_id=business_connection_id, + story_id=story_id, + content=content, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + areas=areas, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4416,23 +4660,21 @@ async def create_chat_subscription_invite_link( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) - async def edit_chat_subscription_invite_link( + async def delete_story( self, - chat_id: Union[str, int], - invite_link: Union[str, "ChatInviteLink"], - name: Optional[str] = None, + business_connection_id: str, + story_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> ChatInviteLink: - return await super().edit_chat_subscription_invite_link( - chat_id=chat_id, - invite_link=invite_link, - name=name, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().delete_story( + business_connection_id=business_connection_id, + story_id=story_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4440,17 +4682,23 @@ async def edit_chat_subscription_invite_link( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) - async def get_available_gifts( + async def set_business_account_name( self, + business_connection_id: str, + first_name: str, + last_name: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> Gifts: - return await super().get_available_gifts( + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().set_business_account_name( + business_connection_id=business_connection_id, + first_name=first_name, + last_name=last_name, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4458,29 +4706,21 @@ async def get_available_gifts( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) - async def send_gift( + async def set_business_account_username( self, - user_id: int, - gift_id: Union[str, Gift], - text: Optional[str] = None, - text_parse_mode: ODVInput[str] = DEFAULT_NONE, - text_entities: Optional[Sequence["MessageEntity"]] = None, - pay_for_upgrade: Optional[bool] = None, + business_connection_id: str, + username: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: - return await super().send_gift( - user_id=user_id, - gift_id=gift_id, - text=text, - text_parse_mode=text_parse_mode, - text_entities=text_entities, - pay_for_upgrade=pay_for_upgrade, + return await super().set_business_account_username( + business_connection_id=business_connection_id, + username=username, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4488,21 +4728,21 @@ async def send_gift( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) - async def verify_chat( + async def set_business_account_bio( self, - chat_id: Union[int, str], - custom_description: Optional[str] = None, + business_connection_id: str, + bio: str | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: - return await super().verify_chat( - chat_id=chat_id, - custom_description=custom_description, + return await super().set_business_account_bio( + business_connection_id=business_connection_id, + bio=bio, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4510,21 +4750,23 @@ async def verify_chat( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) - async def verify_user( + async def set_business_account_gift_settings( self, - user_id: int, - custom_description: Optional[str] = None, + business_connection_id: str, + show_gift_button: bool, + accepted_gift_types: AcceptedGiftTypes, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: - return await super().verify_user( - user_id=user_id, - custom_description=custom_description, + return await super().set_business_account_gift_settings( + business_connection_id=business_connection_id, + show_gift_button=show_gift_button, + accepted_gift_types=accepted_gift_types, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4532,19 +4774,23 @@ async def verify_user( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) - async def remove_chat_verification( + async def set_business_account_profile_photo( self, - chat_id: Union[int, str], + business_connection_id: str, + photo: "InputProfilePhoto", + is_public: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: - return await super().remove_chat_verification( - chat_id=chat_id, + return await super().set_business_account_profile_photo( + business_connection_id=business_connection_id, + photo=photo, + is_public=is_public, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4552,19 +4798,623 @@ async def remove_chat_verification( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) - async def remove_user_verification( + async def remove_business_account_profile_photo( self, - user_id: int, + business_connection_id: str, + is_public: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: - return await super().remove_user_verification( - user_id=user_id, + return await super().remove_business_account_profile_photo( + business_connection_id=business_connection_id, + is_public=is_public, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def convert_gift_to_stars( + self, + business_connection_id: str, + owned_gift_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().convert_gift_to_stars( + business_connection_id=business_connection_id, + owned_gift_id=owned_gift_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def upgrade_gift( + self, + business_connection_id: str, + owned_gift_id: str, + keep_original_details: bool | None = None, + star_count: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().upgrade_gift( + business_connection_id=business_connection_id, + owned_gift_id=owned_gift_id, + keep_original_details=keep_original_details, + star_count=star_count, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def transfer_gift( + self, + business_connection_id: str, + owned_gift_id: str, + new_owner_chat_id: int, + star_count: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().transfer_gift( + business_connection_id=business_connection_id, + owned_gift_id=owned_gift_id, + new_owner_chat_id=new_owner_chat_id, + star_count=star_count, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def transfer_business_account_stars( + self, + business_connection_id: str, + star_count: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().transfer_business_account_stars( + business_connection_id=business_connection_id, + star_count=star_count, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def replace_sticker_in_set( + self, + user_id: int, + name: str, + old_sticker: "str | Sticker", + sticker: "InputSticker", + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().replace_sticker_in_set( + user_id=user_id, + name=name, + old_sticker=old_sticker, + sticker=sticker, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def refund_star_payment( + self, + user_id: int, + telegram_payment_charge_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().refund_star_payment( + user_id=user_id, + telegram_payment_charge_id=telegram_payment_charge_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_star_transactions( + self, + offset: int | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> StarTransactions: + return await super().get_star_transactions( + offset=offset, + limit=limit, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def edit_user_star_subscription( + self, + user_id: int, + telegram_payment_charge_id: str, + is_canceled: bool, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().edit_user_star_subscription( + user_id=user_id, + telegram_payment_charge_id=telegram_payment_charge_id, + is_canceled=is_canceled, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_paid_media( + self, + chat_id: str | int, + star_count: int, + media: Sequence["InputPaidMedia"], + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + business_connection_id: str | None = None, + payload: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_thread_id: int | None = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Message: + return await super().send_paid_media( + chat_id=chat_id, + star_count=star_count, + media=media, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + show_caption_above_media=show_caption_above_media, + disable_notification=disable_notification, + protect_content=protect_content, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + business_connection_id=business_connection_id, + payload=payload, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_thread_id=message_thread_id, + ) + + async def create_chat_subscription_invite_link( + self, + chat_id: str | int, + subscription_period: TimePeriod, + subscription_price: int, + name: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> ChatInviteLink: + return await super().create_chat_subscription_invite_link( + chat_id=chat_id, + subscription_period=subscription_period, + subscription_price=subscription_price, + name=name, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def edit_chat_subscription_invite_link( + self, + chat_id: str | int, + invite_link: "str | ChatInviteLink", + name: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> ChatInviteLink: + return await super().edit_chat_subscription_invite_link( + chat_id=chat_id, + invite_link=invite_link, + name=name, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_available_gifts( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Gifts: + return await super().get_available_gifts( + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_gift( + self, + gift_id: "str | Gift", + text: str | None = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence["MessageEntity"] | None = None, + pay_for_upgrade: bool | None = None, + chat_id: str | int | None = None, + user_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().send_gift( + user_id=user_id, + chat_id=chat_id, + gift_id=gift_id, + text=text, + text_parse_mode=text_parse_mode, + text_entities=text_entities, + pay_for_upgrade=pay_for_upgrade, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def verify_chat( + self, + chat_id: int | str, + custom_description: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().verify_chat( + chat_id=chat_id, + custom_description=custom_description, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def verify_user( + self, + user_id: int, + custom_description: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().verify_user( + user_id=user_id, + custom_description=custom_description, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def remove_chat_verification( + self, + chat_id: int | str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().remove_chat_verification( + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def remove_user_verification( + self, + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().remove_user_verification( + user_id=user_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_my_star_balance( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> StarAmount: + return await super().get_my_star_balance( + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def decline_suggested_post( + self, + chat_id: int, + message_id: int, + comment: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().decline_suggested_post( + chat_id=chat_id, + message_id=message_id, + comment=comment, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def approve_suggested_post( + self, + chat_id: int, + message_id: int, + send_date: int | dtm.datetime | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().approve_suggested_post( + chat_id=chat_id, + message_id=message_id, + send_date=send_date, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def repost_story( + self, + business_connection_id: str, + from_chat_id: int, + from_story_id: int, + active_period: TimePeriod, + post_to_chat_page: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Story: + return await super().repost_story( + business_connection_id=business_connection_id, + from_chat_id=from_chat_id, + from_story_id=from_story_id, + active_period=active_period, + post_to_chat_page=post_to_chat_page, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_user_gifts( + self, + user_id: int, + exclude_unlimited: bool | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> OwnedGifts: + return await super().get_user_gifts( + user_id=user_id, + exclude_unlimited=exclude_unlimited, + exclude_limited_upgradable=exclude_limited_upgradable, + exclude_limited_non_upgradable=exclude_limited_non_upgradable, + exclude_from_blockchain=exclude_from_blockchain, + exclude_unique=exclude_unique, + sort_by_price=sort_by_price, + offset=offset, + limit=limit, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_chat_gifts( + self, + chat_id: int | str, + exclude_unsaved: bool | None = None, + exclude_saved: bool | None = None, + exclude_unlimited: bool | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> OwnedGifts: + return await super().get_chat_gifts( + chat_id=chat_id, + exclude_unsaved=exclude_unsaved, + exclude_saved=exclude_saved, + exclude_unlimited=exclude_unlimited, + exclude_limited_upgradable=exclude_limited_upgradable, + exclude_limited_non_upgradable=exclude_limited_non_upgradable, + exclude_from_blockchain=exclude_from_blockchain, + exclude_unique=exclude_unique, + sort_by_price=sort_by_price, + offset=offset, + limit=limit, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4575,6 +5425,7 @@ async def remove_user_verification( # updated camelCase aliases getMe = get_me sendMessage = send_message + sendMessageDraft = send_message_draft deleteMessage = delete_message deleteMessages = delete_messages forwardMessage = forward_message @@ -4654,6 +5505,8 @@ async def remove_user_verification( setPassportDataErrors = set_passport_data_errors sendPoll = send_poll stopPoll = stop_poll + sendChecklist = send_checklist + editMessageChecklist = edit_message_checklist sendDice = send_dice getMyCommands = get_my_commands setMyCommands = set_my_commands @@ -4693,7 +5546,25 @@ async def remove_user_verification( unpinAllGeneralForumTopicMessages = unpin_all_general_forum_topic_messages getUserChatBoosts = get_user_chat_boosts setMessageReaction = set_message_reaction + giftPremiumSubscription = gift_premium_subscription getBusinessConnection = get_business_connection + getBusinessAccountGifts = get_business_account_gifts + getBusinessAccountStarBalance = get_business_account_star_balance + readBusinessMessage = read_business_message + deleteBusinessMessages = delete_business_messages + postStory = post_story + editStory = edit_story + deleteStory = delete_story + setBusinessAccountName = set_business_account_name + setBusinessAccountUsername = set_business_account_username + setBusinessAccountBio = set_business_account_bio + setBusinessAccountGiftSettings = set_business_account_gift_settings + setBusinessAccountProfilePhoto = set_business_account_profile_photo + removeBusinessAccountProfilePhoto = remove_business_account_profile_photo + convertGiftToStars = convert_gift_to_stars + upgradeGift = upgrade_gift + transferGift = transfer_gift + transferBusinessAccountStars = transfer_business_account_stars replaceStickerInSet = replace_sticker_in_set refundStarPayment = refund_star_payment getStarTransactions = get_star_transactions @@ -4707,3 +5578,9 @@ async def remove_user_verification( verifyUser = verify_user removeChatVerification = remove_chat_verification removeUserVerification = remove_user_verification + getMyStarBalance = get_my_star_balance + approveSuggestedPost = approve_suggested_post + declineSuggestedPost = decline_suggested_post + repostStory = repost_story + getUserGifts = get_user_gifts + getChatGifts = get_chat_gifts diff --git a/telegram/py.typed b/src/telegram/ext/_handlers/__init__.py similarity index 100% rename from telegram/py.typed rename to src/telegram/ext/_handlers/__init__.py diff --git a/telegram/ext/_handlers/basehandler.py b/src/telegram/ext/_handlers/basehandler.py similarity index 96% rename from telegram/ext/_handlers/basehandler.py rename to src/telegram/ext/_handlers/basehandler.py index b6353f214cf..6a880191b83 100644 --- a/telegram/ext/_handlers/basehandler.py +++ b/src/telegram/ext/_handlers/basehandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the base class for handlers as used by the Application.""" + from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Generic, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, Generic, TypeVar from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.repr import build_repr_with_selected_attrs @@ -32,7 +33,7 @@ UT = TypeVar("UT") -class BaseHandler(Generic[UT, CCT, RT], ABC): +class BaseHandler(ABC, Generic[UT, CCT, RT]): """The base class for all update handlers. Create custom handlers by inheriting from it. Warning: @@ -113,7 +114,7 @@ def __repr__(self) -> str: return build_repr_with_selected_attrs(self, callback=callback_name) @abstractmethod - def check_update(self, update: object) -> Optional[Union[bool, object]]: + def check_update(self, update: object) -> bool | object | None: """ This method is called to determine if an update should be handled by this handler instance. It should always be overridden. diff --git a/telegram/ext/_handlers/businessconnectionhandler.py b/src/telegram/ext/_handlers/businessconnectionhandler.py similarity index 96% rename from telegram/ext/_handlers/businessconnectionhandler.py rename to src/telegram/ext/_handlers/businessconnectionhandler.py index 975bb475474..f180d22ef6d 100644 --- a/telegram/ext/_handlers/businessconnectionhandler.py +++ b/src/telegram/ext/_handlers/businessconnectionhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the BusinessConnectionHandler class.""" -from typing import Optional, TypeVar + +from typing import TypeVar from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE @@ -67,8 +68,8 @@ async def callback(update: Update, context: CallbackContext) def __init__( self: "BusinessConnectionHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], - user_id: Optional[SCT[int]] = None, - username: Optional[SCT[str]] = None, + user_id: SCT[int] | None = None, + username: SCT[str] | None = None, block: DVType[bool] = DEFAULT_TRUE, ): super().__init__(callback, block=block) diff --git a/telegram/ext/_handlers/businessmessagesdeletedhandler.py b/src/telegram/ext/_handlers/businessmessagesdeletedhandler.py similarity index 96% rename from telegram/ext/_handlers/businessmessagesdeletedhandler.py rename to src/telegram/ext/_handlers/businessmessagesdeletedhandler.py index c2df450f30e..dfbd80fc5f3 100644 --- a/telegram/ext/_handlers/businessmessagesdeletedhandler.py +++ b/src/telegram/ext/_handlers/businessmessagesdeletedhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the BusinessMessagesDeletedHandler class.""" -from typing import Optional, TypeVar + +from typing import TypeVar from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE @@ -67,8 +68,8 @@ async def callback(update: Update, context: CallbackContext) def __init__( self: "BusinessMessagesDeletedHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], - chat_id: Optional[SCT[int]] = None, - username: Optional[SCT[str]] = None, + chat_id: SCT[int] | None = None, + username: SCT[str] | None = None, block: DVType[bool] = DEFAULT_TRUE, ): super().__init__(callback, block=block) diff --git a/telegram/ext/_handlers/callbackqueryhandler.py b/src/telegram/ext/_handlers/callbackqueryhandler.py similarity index 92% rename from telegram/ext/_handlers/callbackqueryhandler.py rename to src/telegram/ext/_handlers/callbackqueryhandler.py index b09f8249c35..8a903e7cd7a 100644 --- a/telegram/ext/_handlers/callbackqueryhandler.py +++ b/src/telegram/ext/_handlers/callbackqueryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,12 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the CallbackQueryHandler class.""" + import asyncio import re +from collections.abc import Callable from re import Match, Pattern -from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE @@ -128,10 +130,8 @@ async def callback(update: Update, context: CallbackContext) def __init__( self: "CallbackQueryHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], - pattern: Optional[ - Union[str, Pattern[str], type, Callable[[object], Optional[bool]]] - ] = None, - game_pattern: Optional[Union[str, Pattern[str]]] = None, + pattern: str | Pattern[str] | type | Callable[[object], bool] | None = None, + game_pattern: str | Pattern[str] | None = None, block: DVType[bool] = DEFAULT_TRUE, ): super().__init__(callback, block=block) @@ -145,12 +145,10 @@ def __init__( if isinstance(game_pattern, str): game_pattern = re.compile(game_pattern) - self.pattern: Optional[ - Union[str, Pattern[str], type, Callable[[object], Optional[bool]]] - ] = pattern - self.game_pattern: Optional[Union[str, Pattern[str]]] = game_pattern + self.pattern: str | Pattern[str] | type | Callable[[object], bool] | None = pattern + self.game_pattern: str | Pattern[str] | None = game_pattern - def check_update(self, update: object) -> Optional[Union[bool, object]]: + def check_update(self, update: object) -> bool | object | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: @@ -198,11 +196,11 @@ def collect_additional_context( context: CCT, update: Update, # noqa: ARG002 application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 - check_result: Union[bool, Match[str]], + check_result: bool | Match[str], ) -> None: """Add the result of ``re.match(pattern, update.callback_query.data)`` to :attr:`CallbackContext.matches` as list with one element. """ if self.pattern: - check_result = cast(Match, check_result) + check_result = cast("Match", check_result) context.matches = [check_result] diff --git a/telegram/ext/_handlers/chatboosthandler.py b/src/telegram/ext/_handlers/chatboosthandler.py similarity index 97% rename from telegram/ext/_handlers/chatboosthandler.py rename to src/telegram/ext/_handlers/chatboosthandler.py index 2fce8c4b3a8..0e7feba0a27 100644 --- a/telegram/ext/_handlers/chatboosthandler.py +++ b/src/telegram/ext/_handlers/chatboosthandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the ChatBoostHandler class.""" -from typing import Final, Optional +from typing import Final from telegram import Update from telegram.ext._handlers.basehandler import BaseHandler @@ -87,8 +87,8 @@ def __init__( self: "ChatBoostHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], chat_boost_types: int = CHAT_BOOST, - chat_id: Optional[int] = None, - chat_username: Optional[str] = None, + chat_id: int | None = None, + chat_username: str | None = None, block: bool = True, ): super().__init__(callback, block=block) diff --git a/telegram/ext/_handlers/chatjoinrequesthandler.py b/src/telegram/ext/_handlers/chatjoinrequesthandler.py similarity index 96% rename from telegram/ext/_handlers/chatjoinrequesthandler.py rename to src/telegram/ext/_handlers/chatjoinrequesthandler.py index 849020fd184..a578c50bafd 100644 --- a/telegram/ext/_handlers/chatjoinrequesthandler.py +++ b/src/telegram/ext/_handlers/chatjoinrequesthandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the ChatJoinRequestHandler class.""" -from typing import Optional - from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import RT, SCT, DVType @@ -83,8 +81,8 @@ async def callback(update: Update, context: CallbackContext) def __init__( self: "ChatJoinRequestHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], - chat_id: Optional[SCT[int]] = None, - username: Optional[SCT[str]] = None, + chat_id: SCT[int] | None = None, + username: SCT[str] | None = None, block: DVType[bool] = DEFAULT_TRUE, ): super().__init__(callback, block=block) diff --git a/telegram/ext/_handlers/chatmemberhandler.py b/src/telegram/ext/_handlers/chatmemberhandler.py similarity index 96% rename from telegram/ext/_handlers/chatmemberhandler.py rename to src/telegram/ext/_handlers/chatmemberhandler.py index a2b281c854b..cfdeae714ea 100644 --- a/telegram/ext/_handlers/chatmemberhandler.py +++ b/src/telegram/ext/_handlers/chatmemberhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the ChatMemberHandler class.""" -from typing import Final, Optional, TypeVar + +from typing import Final, TypeVar from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE @@ -91,11 +92,11 @@ def __init__( callback: HandlerCallback[Update, CCT, RT], chat_member_types: int = MY_CHAT_MEMBER, block: DVType[bool] = DEFAULT_TRUE, - chat_id: Optional[SCT[int]] = None, + chat_id: SCT[int] | None = None, ): super().__init__(callback, block=block) - self.chat_member_types: Optional[int] = chat_member_types + self.chat_member_types: int | None = chat_member_types self._chat_ids = parse_chat_id(chat_id) def check_update(self, update: object) -> bool: diff --git a/telegram/ext/_handlers/choseninlineresulthandler.py b/src/telegram/ext/_handlers/choseninlineresulthandler.py similarity index 91% rename from telegram/ext/_handlers/choseninlineresulthandler.py rename to src/telegram/ext/_handlers/choseninlineresulthandler.py index 3bc80ed144b..8a70582c46d 100644 --- a/telegram/ext/_handlers/choseninlineresulthandler.py +++ b/src/telegram/ext/_handlers/choseninlineresulthandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,9 +17,10 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the ChosenInlineResultHandler class.""" + import re from re import Match, Pattern -from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE @@ -80,16 +81,16 @@ def __init__( self: "ChosenInlineResultHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], block: DVType[bool] = DEFAULT_TRUE, - pattern: Optional[Union[str, Pattern[str]]] = None, + pattern: str | Pattern[str] | None = None, ): super().__init__(callback, block=block) if isinstance(pattern, str): pattern = re.compile(pattern) - self.pattern: Optional[Union[str, Pattern[str]]] = pattern + self.pattern: str | Pattern[str] | None = pattern - def check_update(self, update: object) -> Optional[Union[bool, object]]: + def check_update(self, update: object) -> bool | object | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: @@ -112,11 +113,11 @@ def collect_additional_context( context: CCT, update: Update, # noqa: ARG002 application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 - check_result: Union[bool, Match[str]], + check_result: bool | Match[str], ) -> None: """This function adds the matched regex pattern result to :attr:`telegram.ext.CallbackContext.matches`. """ if self.pattern: - check_result = cast(Match, check_result) + check_result = cast("Match", check_result) context.matches = [check_result] diff --git a/telegram/ext/_handlers/commandhandler.py b/src/telegram/ext/_handlers/commandhandler.py similarity index 95% rename from telegram/ext/_handlers/commandhandler.py rename to src/telegram/ext/_handlers/commandhandler.py index 27f7a42cd49..8056a5acb77 100644 --- a/telegram/ext/_handlers/commandhandler.py +++ b/src/telegram/ext/_handlers/commandhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the CommandHandler class.""" + import re -from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar from telegram import MessageEntity, Update from telegram._utils.defaultvalue import DEFAULT_TRUE @@ -127,9 +128,9 @@ def __init__( self: "CommandHandler[CCT, RT]", command: SCT[str], callback: HandlerCallback[Update, CCT, RT], - filters: Optional[filters_module.BaseFilter] = None, + filters: filters_module.BaseFilter | None = None, block: DVType[bool] = DEFAULT_TRUE, - has_args: Optional[Union[bool, int]] = None, + has_args: bool | int | None = None, ): super().__init__(callback, block=block) @@ -146,12 +147,12 @@ def __init__( filters if filters is not None else filters_module.UpdateType.MESSAGES ) - self.has_args: Optional[Union[bool, int]] = has_args + self.has_args: bool | int | None = has_args if (isinstance(self.has_args, int)) and (self.has_args < 0): raise ValueError("CommandHandler argument has_args cannot be a negative integer") - def _check_correct_args(self, args: list[str]) -> Optional[bool]: + def _check_correct_args(self, args: list[str]) -> bool | None: """Determines whether the args are correct for this handler. Implemented in check_update(). Args: args (:obj:`list`): The args for the handler. @@ -167,7 +168,7 @@ def _check_correct_args(self, args: list[str]) -> Optional[bool]: def check_update( self, update: object - ) -> Optional[Union[bool, tuple[list[str], Optional[Union[bool, FilterDataDict]]]]]: + ) -> bool | tuple[list[str], bool | FilterDataDict | None] | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: @@ -212,7 +213,7 @@ def collect_additional_context( context: CCT, update: Update, # noqa: ARG002 application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 - check_result: Optional[Union[bool, tuple[list[str], Optional[bool]]]], + check_result: bool | tuple[list[str], bool] | None, ) -> None: """Add text after the command to :attr:`CallbackContext.args` as list, split on single whitespaces and add output of data filters to :attr:`CallbackContext` as well. diff --git a/telegram/ext/_handlers/conversationhandler.py b/src/telegram/ext/_handlers/conversationhandler.py similarity index 96% rename from telegram/ext/_handlers/conversationhandler.py rename to src/telegram/ext/_handlers/conversationhandler.py index 1bc5f0398de..044e957aa41 100644 --- a/telegram/ext/_handlers/conversationhandler.py +++ b/src/telegram/ext/_handlers/conversationhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,11 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the ConversationHandler.""" + import asyncio import datetime as dtm from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Final, Generic, NoReturn, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Final, Generic, NoReturn, cast from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE, DefaultValue @@ -291,14 +292,14 @@ def __init__( per_chat: bool = True, per_user: bool = True, per_message: bool = False, - conversation_timeout: Optional[Union[float, dtm.timedelta]] = None, - name: Optional[str] = None, + conversation_timeout: float | dtm.timedelta | None = None, + name: str | None = None, persistent: bool = False, - map_to_parent: Optional[dict[object, object]] = None, + map_to_parent: dict[object, object] | None = None, block: DVType[bool] = DEFAULT_TRUE, ): # these imports need to be here because of circular import error otherwise - from telegram.ext import ( # pylint: disable=import-outside-toplevel + from telegram.ext import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 PollAnswerHandler, PollHandler, PreCheckoutQueryHandler, @@ -319,9 +320,9 @@ def __init__( self._per_user: bool = per_user self._per_chat: bool = per_chat self._per_message: bool = per_message - self._conversation_timeout: Optional[Union[float, dtm.timedelta]] = conversation_timeout - self._name: Optional[str] = name - self._map_to_parent: Optional[dict[object, object]] = map_to_parent + self._conversation_timeout: float | dtm.timedelta | None = conversation_timeout + self._name: str | None = name + self._map_to_parent: dict[object, object] | None = map_to_parent # if conversation_timeout is used, this dict is used to schedule a job which runs when the # conv has timed out. @@ -365,7 +366,7 @@ def __init__( # this loop is going to warn the user about handlers which can work unexpectedly # in conversations for handler in all_handlers: - if isinstance(handler, (StringCommandHandler, StringRegexHandler)): + if isinstance(handler, StringCommandHandler | StringRegexHandler): warn( "The `ConversationHandler` only handles updates of type `telegram.Update`. " f"{handler.__class__.__name__} handles updates of type `str`.", @@ -388,13 +389,11 @@ def __init__( elif self.per_chat and ( isinstance( handler, - ( - ShippingQueryHandler, - InlineQueryHandler, - ChosenInlineResultHandler, - PreCheckoutQueryHandler, - PollAnswerHandler, - ), + ShippingQueryHandler + | InlineQueryHandler + | ChosenInlineResultHandler + | PreCheckoutQueryHandler + | PollAnswerHandler, ) ): warn( @@ -528,7 +527,7 @@ def per_message(self, _: object) -> NoReturn: @property def conversation_timeout( self, - ) -> Optional[Union[float, dtm.timedelta]]: + ) -> float | dtm.timedelta | None: """:obj:`float` | :obj:`datetime.timedelta`: Optional. When this handler is inactive more than this timeout (in seconds), it will be automatically ended. @@ -542,7 +541,7 @@ def conversation_timeout(self, _: object) -> NoReturn: ) @property - def name(self) -> Optional[str]: + def name(self) -> str | None: """:obj:`str`: Optional. The name for this :class:`ConversationHandler`.""" return self._name @@ -563,7 +562,7 @@ def persistent(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to persistent after initialization.") @property - def map_to_parent(self) -> Optional[dict[object, object]]: + def map_to_parent(self) -> dict[object, object] | None: """dict[:obj:`object`, :obj:`object`]: Optional. A :obj:`dict` that can be used to instruct a nested :class:`ConversationHandler` to transition into a mapped state on its parent :class:`ConversationHandler` in place of a specified nested state. @@ -599,7 +598,7 @@ async def _initialize_persistence( current_conversations = self._conversations self._conversations = cast( - TrackingDict[ConversationKey, object], + "TrackingDict[ConversationKey, object]", TrackingDict(), ) # In the conversation already processed updates @@ -633,7 +632,7 @@ def _get_key(self, update: Update) -> ConversationKey: chat = update.effective_chat user = update.effective_user - key: list[Union[int, str]] = [] + key: list[int | str] = [] if self.per_chat: if chat is None: @@ -704,7 +703,7 @@ def _schedule_job( _LOGGER.exception("Failed to schedule timeout.", exc_info=exc) # pylint: disable=too-many-return-statements - def check_update(self, update: object) -> Optional[_CheckUpdateType[CCT]]: + def check_update(self, update: object) -> _CheckUpdateType[CCT] | None: """ Determines whether an update should be handled by this conversation handler, and if so in which state the conversation currently is. @@ -732,7 +731,7 @@ def check_update(self, update: object) -> Optional[_CheckUpdateType[CCT]]: key = self._get_key(update) state = self._conversations.get(key) - check: Optional[object] = None + check: object | None = None # Resolve futures if isinstance(state, PendingState): @@ -760,7 +759,7 @@ def check_update(self, update: object) -> Optional[_CheckUpdateType[CCT]]: _LOGGER.debug("Selecting conversation %s with state %s", str(key), str(state)) - handler: Optional[BaseHandler] = None + handler: BaseHandler | None = None # Search entry points for a match if state is None or self.allow_reentry: @@ -801,7 +800,7 @@ async def handle_update( # type: ignore[override] application: "Application[Any, CCT, Any, Any, Any, Any]", check_result: _CheckUpdateType[CCT], context: CCT, - ) -> Optional[object]: + ) -> object | None: """Send the update to the callback for the current state and BaseHandler Args: @@ -896,7 +895,7 @@ async def handle_update( # type: ignore[override] return None def _update_state( - self, new_state: object, key: ConversationKey, handler: Optional[BaseHandler] = None + self, new_state: object, key: ConversationKey, handler: BaseHandler | None = None ) -> None: if new_state == self.END: if key in self._conversations: @@ -924,7 +923,7 @@ async def _trigger_timeout(self, context: CCT) -> None: :obj:`True` is handled. """ job = cast("Job", context.job) - ctxt = cast(_ConversationTimeoutContext, job.data) + ctxt = cast("_ConversationTimeoutContext", job.data) _LOGGER.debug( "Conversation timeout was triggered for conversation %s!", ctxt.conversation_key diff --git a/telegram/ext/_handlers/inlinequeryhandler.py b/src/telegram/ext/_handlers/inlinequeryhandler.py similarity index 88% rename from telegram/ext/_handlers/inlinequeryhandler.py rename to src/telegram/ext/_handlers/inlinequeryhandler.py index 31106ba33a6..de04b25b431 100644 --- a/telegram/ext/_handlers/inlinequeryhandler.py +++ b/src/telegram/ext/_handlers/inlinequeryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,9 +17,10 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the InlineQueryHandler class.""" + import re from re import Match, Pattern -from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE @@ -90,19 +91,19 @@ async def callback(update: Update, context: CallbackContext) def __init__( self: "InlineQueryHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], - pattern: Optional[Union[str, Pattern[str]]] = None, + pattern: str | Pattern[str] | None = None, block: DVType[bool] = DEFAULT_TRUE, - chat_types: Optional[list[str]] = None, + chat_types: list[str] | None = None, ): super().__init__(callback, block=block) if isinstance(pattern, str): pattern = re.compile(pattern) - self.pattern: Optional[Union[str, Pattern[str]]] = pattern - self.chat_types: Optional[list[str]] = chat_types + self.pattern: str | Pattern[str] | None = pattern + self.chat_types: list[str] | None = chat_types - def check_update(self, update: object) -> Optional[Union[bool, Match[str]]]: + def check_update(self, update: object) -> bool | Match[str] | None: """ Determines whether an update should be passed to this handler's :attr:`callback`. @@ -118,11 +119,7 @@ def check_update(self, update: object) -> Optional[Union[bool, Match[str]]]: update.inline_query.chat_type not in self.chat_types ): return False - if ( - self.pattern - and update.inline_query.query - and (match := re.match(self.pattern, update.inline_query.query)) - ): + if self.pattern and (match := re.match(self.pattern, update.inline_query.query)): return match if not self.pattern: return True @@ -133,11 +130,11 @@ def collect_additional_context( context: CCT, update: Update, # noqa: ARG002 application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 - check_result: Optional[Union[bool, Match[str]]], + check_result: bool | Match[str] | None, ) -> None: """Add the result of ``re.match(pattern, update.inline_query.query)`` to :attr:`CallbackContext.matches` as list with one element. """ if self.pattern: - check_result = cast(Match, check_result) + check_result = cast("Match", check_result) context.matches = [check_result] diff --git a/telegram/ext/_handlers/messagehandler.py b/src/telegram/ext/_handlers/messagehandler.py similarity index 93% rename from telegram/ext/_handlers/messagehandler.py rename to src/telegram/ext/_handlers/messagehandler.py index 625531a565e..f9bb840c098 100644 --- a/telegram/ext/_handlers/messagehandler.py +++ b/src/telegram/ext/_handlers/messagehandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the MessageHandler class.""" -from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union + +from typing import TYPE_CHECKING, Any, TypeVar from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE @@ -76,7 +77,7 @@ async def callback(update: Update, context: CallbackContext) def __init__( self: "MessageHandler[CCT, RT]", - filters: Optional[filters_module.BaseFilter], + filters: filters_module.BaseFilter | None, callback: HandlerCallback[Update, CCT, RT], block: DVType[bool] = DEFAULT_TRUE, ): @@ -85,7 +86,7 @@ def __init__( filters if filters is not None else filters_module.ALL ) - def check_update(self, update: object) -> Optional[Union[bool, dict[str, list[Any]]]]: + def check_update(self, update: object) -> bool | dict[str, list[Any]] | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: @@ -104,7 +105,7 @@ def collect_additional_context( context: CCT, update: Update, # noqa: ARG002 application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 - check_result: Optional[Union[bool, dict[str, object]]], + check_result: bool | dict[str, object] | None, ) -> None: """Adds possible output of data filters to the :class:`CallbackContext`.""" if isinstance(check_result, dict): diff --git a/telegram/ext/_handlers/messagereactionhandler.py b/src/telegram/ext/_handlers/messagereactionhandler.py similarity index 96% rename from telegram/ext/_handlers/messagereactionhandler.py rename to src/telegram/ext/_handlers/messagereactionhandler.py index 15f4f3d3e72..2b638f1d761 100644 --- a/telegram/ext/_handlers/messagereactionhandler.py +++ b/src/telegram/ext/_handlers/messagereactionhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the MessageReactionHandler class.""" -from typing import Final, Optional +from typing import Final from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE @@ -112,10 +112,10 @@ async def callback(update: Update, context: CallbackContext) def __init__( self: "MessageReactionHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], - chat_id: Optional[SCT[int]] = None, - chat_username: Optional[SCT[str]] = None, - user_id: Optional[SCT[int]] = None, - user_username: Optional[SCT[str]] = None, + chat_id: SCT[int] | None = None, + chat_username: SCT[str] | None = None, + user_id: SCT[int] | None = None, + user_username: SCT[str] | None = None, message_reaction_types: int = MESSAGE_REACTION, block: DVType[bool] = DEFAULT_TRUE, ): diff --git a/telegram/ext/_handlers/paidmediapurchasedhandler.py b/src/telegram/ext/_handlers/paidmediapurchasedhandler.py similarity index 96% rename from telegram/ext/_handlers/paidmediapurchasedhandler.py rename to src/telegram/ext/_handlers/paidmediapurchasedhandler.py index 2f273e9cfd3..484e05ccaa5 100644 --- a/telegram/ext/_handlers/paidmediapurchasedhandler.py +++ b/src/telegram/ext/_handlers/paidmediapurchasedhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the PaidMediaPurchased class.""" -from typing import Optional - from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import SCT, DVType @@ -66,8 +64,8 @@ async def callback(update: Update, context: CallbackContext) def __init__( self: "PaidMediaPurchasedHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], - user_id: Optional[SCT[int]] = None, - username: Optional[SCT[str]] = None, + user_id: SCT[int] | None = None, + username: SCT[str] | None = None, block: DVType[bool] = DEFAULT_TRUE, ): super().__init__(callback, block=block) diff --git a/telegram/ext/_handlers/pollanswerhandler.py b/src/telegram/ext/_handlers/pollanswerhandler.py similarity index 99% rename from telegram/ext/_handlers/pollanswerhandler.py rename to src/telegram/ext/_handlers/pollanswerhandler.py index 69f189361ca..5564b588d40 100644 --- a/telegram/ext/_handlers/pollanswerhandler.py +++ b/src/telegram/ext/_handlers/pollanswerhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the PollAnswerHandler class.""" - from telegram import Update from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._utils.types import CCT, RT diff --git a/telegram/ext/_handlers/pollhandler.py b/src/telegram/ext/_handlers/pollhandler.py similarity index 99% rename from telegram/ext/_handlers/pollhandler.py rename to src/telegram/ext/_handlers/pollhandler.py index 36fc9bc0066..a029bb559d1 100644 --- a/telegram/ext/_handlers/pollhandler.py +++ b/src/telegram/ext/_handlers/pollhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the PollHandler class.""" - from telegram import Update from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._utils.types import CCT, RT diff --git a/telegram/ext/_handlers/precheckoutqueryhandler.py b/src/telegram/ext/_handlers/precheckoutqueryhandler.py similarity index 94% rename from telegram/ext/_handlers/precheckoutqueryhandler.py rename to src/telegram/ext/_handlers/precheckoutqueryhandler.py index 04bf395bffb..5fb1dab2680 100644 --- a/telegram/ext/_handlers/precheckoutqueryhandler.py +++ b/src/telegram/ext/_handlers/precheckoutqueryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,10 +18,9 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the PreCheckoutQueryHandler class.""" - import re from re import Pattern -from typing import Optional, TypeVar, Union +from typing import TypeVar from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE @@ -77,11 +76,11 @@ def __init__( self: "PreCheckoutQueryHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], block: DVType[bool] = DEFAULT_TRUE, - pattern: Optional[Union[str, Pattern[str]]] = None, + pattern: str | Pattern[str] | None = None, ): super().__init__(callback, block=block) - self.pattern: Optional[Pattern[str]] = re.compile(pattern) if pattern is not None else None + self.pattern: Pattern[str] | None = re.compile(pattern) if pattern is not None else None def check_update(self, update: object) -> bool: """Determines whether an update should be passed to this handler's :attr:`callback`. diff --git a/telegram/ext/_handlers/prefixhandler.py b/src/telegram/ext/_handlers/prefixhandler.py similarity index 95% rename from telegram/ext/_handlers/prefixhandler.py rename to src/telegram/ext/_handlers/prefixhandler.py index a6e4f38c2ad..2fce0d39e71 100644 --- a/telegram/ext/_handlers/prefixhandler.py +++ b/src/telegram/ext/_handlers/prefixhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the PrefixHandler class.""" + import itertools -from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE @@ -127,7 +128,7 @@ def __init__( prefix: SCT[str], command: SCT[str], callback: HandlerCallback[Update, CCT, RT], - filters: Optional[filters_module.BaseFilter] = None, + filters: filters_module.BaseFilter | None = None, block: DVType[bool] = DEFAULT_TRUE, ): super().__init__(callback=callback, block=block) @@ -145,7 +146,7 @@ def __init__( def check_update( self, update: object - ) -> Optional[Union[bool, tuple[list[str], Optional[Union[bool, dict[Any, Any]]]]]]: + ) -> bool | tuple[list[str], bool | dict[Any, Any] | None] | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: @@ -173,7 +174,7 @@ def collect_additional_context( context: CCT, update: Update, # noqa: ARG002 application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 - check_result: Optional[Union[bool, tuple[list[str], Optional[bool]]]], + check_result: bool | tuple[list[str], bool] | None, ) -> None: """Add text after the command to :attr:`CallbackContext.args` as list, split on single whitespaces and add output of data filters to :attr:`CallbackContext` as well. diff --git a/telegram/ext/_handlers/shippingqueryhandler.py b/src/telegram/ext/_handlers/shippingqueryhandler.py similarity index 99% rename from telegram/ext/_handlers/shippingqueryhandler.py rename to src/telegram/ext/_handlers/shippingqueryhandler.py index 1795a93ff80..c46fad19210 100644 --- a/telegram/ext/_handlers/shippingqueryhandler.py +++ b/src/telegram/ext/_handlers/shippingqueryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the ShippingQueryHandler class.""" - from telegram import Update from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._utils.types import CCT, RT diff --git a/telegram/ext/_handlers/stringcommandhandler.py b/src/telegram/ext/_handlers/stringcommandhandler.py similarity index 95% rename from telegram/ext/_handlers/stringcommandhandler.py rename to src/telegram/ext/_handlers/stringcommandhandler.py index 4ce7a9bac6b..df8ae3fdfec 100644 --- a/telegram/ext/_handlers/stringcommandhandler.py +++ b/src/telegram/ext/_handlers/stringcommandhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the StringCommandHandler class.""" -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import DVType @@ -79,7 +79,7 @@ def __init__( super().__init__(callback, block=block) self.command: str = command - def check_update(self, update: object) -> Optional[list[str]]: + def check_update(self, update: object) -> list[str] | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: @@ -100,7 +100,7 @@ def collect_additional_context( context: CCT, update: str, # noqa: ARG002 application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 - check_result: Optional[list[str]], + check_result: list[str] | None, ) -> None: """Add text after the command to :attr:`CallbackContext.args` as list, split on single whitespaces. diff --git a/telegram/ext/_handlers/stringregexhandler.py b/src/telegram/ext/_handlers/stringregexhandler.py similarity index 93% rename from telegram/ext/_handlers/stringregexhandler.py rename to src/telegram/ext/_handlers/stringregexhandler.py index dda00525132..1d315888e09 100644 --- a/telegram/ext/_handlers/stringregexhandler.py +++ b/src/telegram/ext/_handlers/stringregexhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,7 +20,7 @@ import re from re import Match, Pattern -from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import DVType @@ -76,7 +76,7 @@ async def callback(update: str, context: CallbackContext) def __init__( self: "StringRegexHandler[CCT, RT]", - pattern: Union[str, Pattern[str]], + pattern: str | Pattern[str], callback: HandlerCallback[str, CCT, RT], block: DVType[bool] = DEFAULT_TRUE, ): @@ -85,9 +85,9 @@ def __init__( if isinstance(pattern, str): pattern = re.compile(pattern) - self.pattern: Union[str, Pattern[str]] = pattern + self.pattern: str | Pattern[str] = pattern - def check_update(self, update: object) -> Optional[Match[str]]: + def check_update(self, update: object) -> Match[str] | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: @@ -106,7 +106,7 @@ def collect_additional_context( context: CCT, update: str, # noqa: ARG002 application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 - check_result: Optional[Match[str]], + check_result: Match[str] | None, ) -> None: """Add the result of ``re.match(pattern, update)`` to :attr:`CallbackContext.matches` as list with one element. diff --git a/telegram/ext/_handlers/typehandler.py b/src/telegram/ext/_handlers/typehandler.py similarity index 97% rename from telegram/ext/_handlers/typehandler.py rename to src/telegram/ext/_handlers/typehandler.py index 0d6ce78c889..5265a94719d 100644 --- a/telegram/ext/_handlers/typehandler.py +++ b/src/telegram/ext/_handlers/typehandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the TypeHandler class.""" -from typing import Optional, TypeVar +from typing import TypeVar from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import DVType @@ -81,7 +81,7 @@ def __init__( ): super().__init__(callback, block=block) self.type: GenericUT[UT] = type - self.strict: Optional[bool] = strict + self.strict: bool | None = strict def check_update(self, update: object) -> bool: """Determines whether an update should be passed to this handler's :attr:`callback`. diff --git a/telegram/ext/_jobqueue.py b/src/telegram/ext/_jobqueue.py similarity index 93% rename from telegram/ext/_jobqueue.py rename to src/telegram/ext/_jobqueue.py index 70c640544c3..7ad54530913 100644 --- a/telegram/ext/_jobqueue.py +++ b/src/telegram/ext/_jobqueue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,12 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes JobQueue and Job.""" + import asyncio import datetime as dtm import re import weakref -from typing import TYPE_CHECKING, Any, Generic, Optional, Union, cast, overload +from typing import TYPE_CHECKING, Any, Generic, cast, overload try: from apscheduler.executors.asyncio import AsyncIOExecutor @@ -51,6 +52,18 @@ _LOGGER = get_logger(__name__, class_name="JobQueue") +def _get_callback_name(callback: object) -> str: + """Get the name of a callback function or callable object. + + Args: + callback: A callable object (function, method, or callable class instance) + + Returns: + The name of the callback, using __name__ if available, otherwise the class name. + """ + return getattr(callback, "__name__", None) or callback.__class__.__name__ + + class JobQueue(Generic[CCT]): """This class allows you to periodically perform tasks with the bot. It is a convenience wrapper for the APScheduler library. @@ -107,7 +120,7 @@ def __init__(self) -> None: '"python-telegram-bot[job-queue]"`.' ) - self._application: Optional[weakref.ReferenceType[Application]] = None + self._application: weakref.ReferenceType[Application] | None = None self._executor = AsyncIOExecutor() self.scheduler: "AsyncIOScheduler" = AsyncIOScheduler( # noqa: UP037 **self.scheduler_configuration @@ -180,18 +193,18 @@ def _parse_time_input(self, time: None, shift_day: bool = False) -> None: ... @overload def _parse_time_input( self, - time: Union[float, dtm.timedelta, dtm.datetime, dtm.time], + time: float | dtm.timedelta | dtm.datetime | dtm.time, shift_day: bool = False, ) -> dtm.datetime: ... def _parse_time_input( self, - time: Union[float, dtm.timedelta, dtm.datetime, dtm.time, None], + time: float | dtm.timedelta | dtm.datetime | dtm.time | None, shift_day: bool = False, - ) -> Optional[dtm.datetime]: + ) -> dtm.datetime | None: if time is None: return None - if isinstance(time, (int, float)): + if isinstance(time, int | float): return self._tz_now() + dtm.timedelta(seconds=time) if isinstance(time, dtm.timedelta): return self._tz_now() + time @@ -247,12 +260,12 @@ async def job_callback(job_queue: "JobQueue[CCT]", job: "Job[CCT]") -> None: def run_once( self, callback: JobCallback[CCT], - when: Union[float, dtm.timedelta, dtm.datetime, dtm.time], - data: Optional[object] = None, - name: Optional[str] = None, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, - job_kwargs: Optional[JSONDict] = None, + when: float | dtm.timedelta | dtm.datetime | dtm.time, + data: object | None = None, + name: str | None = None, + chat_id: int | None = None, + user_id: int | None = None, + job_kwargs: JSONDict | None = None, ) -> "Job[CCT]": """Creates a new :class:`Job` instance that runs once and adds it to the queue. @@ -311,7 +324,7 @@ async def callback(context: CallbackContext) if not job_kwargs: job_kwargs = {} - name = name or callback.__name__ + name = name or _get_callback_name(callback) job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id) date_time = self._parse_time_input(when, shift_day=True) @@ -331,14 +344,14 @@ async def callback(context: CallbackContext) def run_repeating( self, callback: JobCallback[CCT], - interval: Union[float, dtm.timedelta], - first: Optional[Union[float, dtm.timedelta, dtm.datetime, dtm.time]] = None, - last: Optional[Union[float, dtm.timedelta, dtm.datetime, dtm.time]] = None, - data: Optional[object] = None, - name: Optional[str] = None, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, - job_kwargs: Optional[JSONDict] = None, + interval: float | dtm.timedelta, + first: float | dtm.timedelta | dtm.datetime | dtm.time | None = None, + last: float | dtm.timedelta | dtm.datetime | dtm.time | None = None, + data: object | None = None, + name: str | None = None, + chat_id: int | None = None, + user_id: int | None = None, + job_kwargs: JSONDict | None = None, ) -> "Job[CCT]": """Creates a new :class:`Job` instance that runs at specified intervals and adds it to the queue. @@ -429,7 +442,7 @@ async def callback(context: CallbackContext) if not job_kwargs: job_kwargs = {} - name = name or callback.__name__ + name = name or _get_callback_name(callback) job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id) dt_first = self._parse_time_input(first) @@ -460,11 +473,11 @@ def run_monthly( callback: JobCallback[CCT], when: dtm.time, day: int, - data: Optional[object] = None, - name: Optional[str] = None, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, - job_kwargs: Optional[JSONDict] = None, + data: object | None = None, + name: str | None = None, + chat_id: int | None = None, + user_id: int | None = None, + job_kwargs: JSONDict | None = None, ) -> "Job[CCT]": """Creates a new :class:`Job` that runs on a monthly basis and adds it to the queue. @@ -515,7 +528,7 @@ async def callback(context: CallbackContext) if not job_kwargs: job_kwargs = {} - name = name or callback.__name__ + name = name or _get_callback_name(callback) job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id) j = self.scheduler.add_job( @@ -538,11 +551,11 @@ def run_daily( callback: JobCallback[CCT], time: dtm.time, days: tuple[int, ...] = _ALL_DAYS, - data: Optional[object] = None, - name: Optional[str] = None, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, - job_kwargs: Optional[JSONDict] = None, + data: object | None = None, + name: str | None = None, + chat_id: int | None = None, + user_id: int | None = None, + job_kwargs: JSONDict | None = None, ) -> "Job[CCT]": """Creates a new :class:`Job` that runs on a daily basis and adds it to the queue. @@ -598,7 +611,7 @@ async def callback(context: CallbackContext) if not job_kwargs: job_kwargs = {} - name = name or callback.__name__ + name = name or _get_callback_name(callback) job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id) j = self.scheduler.add_job( @@ -621,10 +634,10 @@ def run_custom( self, callback: JobCallback[CCT], job_kwargs: JSONDict, - data: Optional[object] = None, - name: Optional[str] = None, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, + data: object | None = None, + name: str | None = None, + chat_id: int | None = None, + user_id: int | None = None, ) -> "Job[CCT]": """Creates a new custom defined :class:`Job`. @@ -661,7 +674,7 @@ async def callback(context: CallbackContext) queue. """ - name = name or callback.__name__ + name = name or _get_callback_name(callback) job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id) j = self.scheduler.add_job(self.job_callback, args=(self, job), name=name, **job_kwargs) @@ -698,7 +711,7 @@ async def stop(self, wait: bool = True) -> None: # so give it a tiny bit of time to actually shut down. await asyncio.sleep(0.01) - def jobs(self, pattern: Union[str, re.Pattern[str], None] = None) -> tuple["Job[CCT]", ...]: + def jobs(self, pattern: str | re.Pattern[str] | None = None) -> tuple["Job[CCT]", ...]: """Returns a tuple of all *scheduled* jobs that are currently in the :class:`JobQueue`. Args: @@ -818,10 +831,10 @@ async def callback(context: CallbackContext) def __init__( self, callback: JobCallback[CCT], - data: Optional[object] = None, - name: Optional[str] = None, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, + data: object | None = None, + name: str | None = None, + chat_id: int | None = None, + user_id: int | None = None, ): if not APS_AVAILABLE: raise RuntimeError( @@ -830,10 +843,10 @@ def __init__( ) self.callback: JobCallback[CCT] = callback - self.data: Optional[object] = data - self.name: Optional[str] = name or callback.__name__ - self.chat_id: Optional[int] = chat_id - self.user_id: Optional[int] = user_id + self.data: object | None = data + self.name: str | None = name or _get_callback_name(callback) + self.chat_id: int | None = chat_id + self.user_id: int | None = user_id self._removed = False self._enabled = False @@ -898,7 +911,7 @@ def __repr__(self) -> str: self, id=self.job.id, name=self.name, - callback=self.callback.__name__, + callback=_get_callback_name(self.callback), trigger=self.job.trigger, ) @@ -930,7 +943,7 @@ def enabled(self, status: bool) -> None: self._enabled = status @property - def next_t(self) -> Optional[dtm.datetime]: + def next_t(self) -> dtm.datetime | None: """ :class:`datetime.datetime`: Datetime for the next job execution. Datetime is localized according to :attr:`datetime.datetime.tzinfo`. diff --git a/telegram/ext/_picklepersistence.py b/src/telegram/ext/_picklepersistence.py similarity index 95% rename from telegram/ext/_picklepersistence.py rename to src/telegram/ext/_picklepersistence.py index c2f4c01e383..acabff8da88 100644 --- a/telegram/ext/_picklepersistence.py +++ b/src/telegram/ext/_picklepersistence.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,12 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the PicklePersistence class.""" + import pickle +from collections.abc import Callable from copy import deepcopy from pathlib import Path -from typing import Any, Callable, Optional, TypeVar, Union, cast, overload +from typing import Any, TypeVar, cast, overload from telegram import Bot, TelegramObject from telegram._utils.types import FilePathInput @@ -86,7 +88,7 @@ def reducer_override( return _custom_reduction(obj) - def persistent_id(self, obj: object) -> Optional[str]: + def persistent_id(self, obj: object) -> str | None: """Used to 'mark' the Bot, so it can be replaced later. See https://docs.python.org/3/library/pickle.html#pickle.Pickler.persistent_id for more info """ @@ -108,7 +110,7 @@ def __init__(self, bot: Bot, *args: Any, **kwargs: Any): self._bot = bot super().__init__(*args, **kwargs) - def persistent_load(self, pid: str) -> Optional[Bot]: + def persistent_load(self, pid: str) -> Bot | None: """Replaces the bot with the current bot if known, else it is replaced by :obj:`None`.""" if pid == _REPLACED_KNOWN_BOT: return self._bot @@ -201,7 +203,7 @@ class PicklePersistence(BasePersistence[UD, CD, BD]): def __init__( self: "PicklePersistence[dict[Any, Any], dict[Any, Any], dict[Any, Any]]", filepath: FilePathInput, - store_data: Optional[PersistenceInput] = None, + store_data: PersistenceInput | None = None, single_file: bool = True, on_flush: bool = False, update_interval: float = 60, @@ -211,33 +213,33 @@ def __init__( def __init__( self: "PicklePersistence[UD, CD, BD]", filepath: FilePathInput, - store_data: Optional[PersistenceInput] = None, + store_data: PersistenceInput | None = None, single_file: bool = True, on_flush: bool = False, update_interval: float = 60, - context_types: Optional[ContextTypes[Any, UD, CD, BD]] = None, + context_types: ContextTypes[Any, UD, CD, BD] | None = None, ): ... def __init__( self, filepath: FilePathInput, - store_data: Optional[PersistenceInput] = None, + store_data: PersistenceInput | None = None, single_file: bool = True, on_flush: bool = False, update_interval: float = 60, - context_types: Optional[ContextTypes[Any, UD, CD, BD]] = None, + context_types: ContextTypes[Any, UD, CD, BD] | None = None, ): super().__init__(store_data=store_data, update_interval=update_interval) self.filepath: Path = Path(filepath) - self.single_file: Optional[bool] = single_file - self.on_flush: Optional[bool] = on_flush - self.user_data: Optional[dict[int, UD]] = None - self.chat_data: Optional[dict[int, CD]] = None - self.bot_data: Optional[BD] = None - self.callback_data: Optional[CDCData] = None - self.conversations: Optional[dict[str, dict[tuple[Union[int, str], ...], object]]] = None + self.single_file: bool | None = single_file + self.on_flush: bool | None = on_flush + self.user_data: dict[int, UD] | None = None + self.chat_data: dict[int, CD] | None = None + self.bot_data: BD | None = None + self.callback_data: CDCData | None = None + self.conversations: dict[str, dict[tuple[int | str, ...], object]] | None = None self.context_types: ContextTypes[Any, UD, CD, BD] = cast( - ContextTypes[Any, UD, CD, BD], context_types or ContextTypes() + "ContextTypes[Any, UD, CD, BD]", context_types or ContextTypes() ) def _load_singlefile(self) -> None: @@ -342,7 +344,7 @@ async def get_bot_data(self) -> BD: self._load_singlefile() return deepcopy(self.bot_data) # type: ignore[return-value] - async def get_callback_data(self) -> Optional[CDCData]: + async def get_callback_data(self) -> CDCData | None: """Returns the callback data from the pickle file if it exists or :obj:`None`. .. versionadded:: 13.6 @@ -386,7 +388,7 @@ async def get_conversations(self, name: str) -> ConversationDict: return self.conversations.get(name, {}).copy() # type: ignore[union-attr] async def update_conversation( - self, name: str, key: ConversationKey, new_state: Optional[object] + self, name: str, key: ConversationKey, new_state: object | None ) -> None: """Will update the conversations for the given handler and depending on :attr:`on_flush` save the pickle file. diff --git a/telegram/ext/_updater.py b/src/telegram/ext/_updater.py similarity index 72% rename from telegram/ext/_updater.py rename to src/telegram/ext/_updater.py index 11c225c4f33..672768afb8e 100644 --- a/telegram/ext/_updater.py +++ b/src/telegram/ext/_updater.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,17 +20,19 @@ import asyncio import contextlib +import datetime as dtm import ssl -from collections.abc import Coroutine, Sequence +from collections.abc import Callable, Coroutine, Sequence from pathlib import Path from types import TracebackType -from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar -from telegram._utils.defaultvalue import DEFAULT_80, DEFAULT_IP, DEFAULT_NONE, DefaultValue +from telegram._utils.defaultvalue import DEFAULT_80, DEFAULT_IP, DefaultValue from telegram._utils.logging import get_logger from telegram._utils.repr import build_repr_with_selected_attrs -from telegram._utils.types import DVType, ODVInput -from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut +from telegram._utils.types import DVType, TimePeriod +from telegram.error import TelegramError +from telegram.ext._utils.networkloop import network_retry_loop try: from telegram.ext._utils.webhookhandler import WebhookAppClass, WebhookServer @@ -117,13 +119,13 @@ def __init__( self._last_update_id = 0 self._running = False self._initialized = False - self._httpd: Optional[WebhookServer] = None + self._httpd: WebhookServer | None = None self.__lock = asyncio.Lock() - self.__polling_task: Optional[asyncio.Task] = None + self.__polling_task: asyncio.Task | None = None self.__polling_task_stop_event: asyncio.Event = asyncio.Event() - self.__polling_cleanup_cb: Optional[Callable[[], Coroutine[Any, Any, None]]] = None + self.__polling_cleanup_cb: Callable[[], Coroutine[Any, Any, None]] | None = None - async def __aenter__(self: _UpdaterType) -> _UpdaterType: # noqa: PYI019 + async def __aenter__(self: _UpdaterType) -> _UpdaterType: """ |async_context_manager| :meth:`initializes ` the Updater. @@ -143,9 +145,9 @@ async def __aenter__(self: _UpdaterType) -> _UpdaterType: # noqa: PYI019 async def __aexit__( self, - exc_type: Optional[type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> None: """|async_context_manager| :meth:`shuts down ` the Updater.""" # Make sure not to return `True` so that exceptions are not suppressed @@ -205,67 +207,42 @@ async def shutdown(self) -> None: async def start_polling( self, poll_interval: float = 0.0, - timeout: int = 10, # noqa: ASYNC109 - bootstrap_retries: int = -1, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - allowed_updates: Optional[Sequence[str]] = None, - drop_pending_updates: Optional[bool] = None, - error_callback: Optional[Callable[[TelegramError], None]] = None, + timeout: TimePeriod = dtm.timedelta(seconds=10), + bootstrap_retries: int = 0, + allowed_updates: Sequence[str] | None = None, + drop_pending_updates: bool | None = None, + error_callback: Callable[[TelegramError], None] | None = None, ) -> "asyncio.Queue[object]": """Starts polling updates from Telegram. .. versionchanged:: 20.0 Removed the ``clean`` argument in favor of :paramref:`drop_pending_updates`. + .. versionchanged:: 22.0 + Removed the deprecated arguments ``read_timeout``, ``write_timeout``, + ``connect_timeout``, and ``pool_timeout`` in favor of setting the timeouts via + the corresponding methods of :class:`telegram.ext.ApplicationBuilder`. or + by specifying the timeout via :paramref:`telegram.Bot.get_updates_request`. + Args: poll_interval (:obj:`float`, optional): Time to wait between polling updates from Telegram in seconds. Default is ``0.0``. - timeout (:obj:`int`, optional): Passed to - :paramref:`telegram.Bot.get_updates.timeout`. Defaults to ``10`` seconds. - bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the - :class:`telegram.ext.Updater` will retry on failures on the Telegram server. + timeout (:obj:`int` | :class:`datetime.timedelta`, optional): Passed to + :paramref:`telegram.Bot.get_updates.timeout`. Defaults to + ``timedelta(seconds=10)``. + + .. versionchanged:: v22.2 + |time-period-input| + bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of + will retry on failures on the Telegram server. - * < 0 - retry indefinitely (default) - * 0 - no retries + * < 0 - retry indefinitely + * 0 - no retries (default) * > 0 - retry up to X times - read_timeout (:obj:`float`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.read_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - - .. versionchanged:: 20.7 - Defaults to :attr:`~telegram.request.BaseRequest.DEFAULT_NONE` instead of - ``2``. - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_read_timeout` or - :paramref:`telegram.Bot.get_updates_request`. - write_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.write_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_write_timeout` or - :paramref:`telegram.Bot.get_updates_request`. - connect_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.connect_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_connect_timeout` or - :paramref:`telegram.Bot.get_updates_request`. - pool_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.pool_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_pool_timeout` or - :paramref:`telegram.Bot.get_updates_request`. + + .. versionchanged:: 21.11 + The default value will be changed to from ``-1`` to ``0``. Indefinite retries + during bootstrapping are not recommended. allowed_updates (Sequence[:obj:`str`], optional): Passed to :meth:`telegram.Bot.get_updates`. @@ -319,10 +296,6 @@ def callback(error: telegram.error.TelegramError) await self._start_polling( poll_interval=poll_interval, timeout=timeout, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, bootstrap_retries=bootstrap_retries, drop_pending_updates=drop_pending_updates, allowed_updates=allowed_updates, @@ -341,16 +314,12 @@ def callback(error: telegram.error.TelegramError) async def _start_polling( self, poll_interval: float, - timeout: int, # noqa: ASYNC109 - read_timeout: ODVInput[float], - write_timeout: ODVInput[float], - connect_timeout: ODVInput[float], - pool_timeout: ODVInput[float], + timeout: TimePeriod, bootstrap_retries: int, - drop_pending_updates: Optional[bool], - allowed_updates: Optional[Sequence[str]], + drop_pending_updates: bool | None, + allowed_updates: Sequence[str] | None, ready: asyncio.Event, - error_callback: Optional[Callable[[TelegramError], None]], + error_callback: Callable[[TelegramError], None] | None, ) -> None: _LOGGER.debug("Updater started (polling)") @@ -366,15 +335,11 @@ async def _start_polling( _LOGGER.debug("Bootstrap done") - async def polling_action_cb() -> bool: + async def polling_action_cb() -> None: try: updates = await self.bot.get_updates( offset=self._last_update_id, timeout=timeout, - read_timeout=read_timeout, - connect_timeout=connect_timeout, - write_timeout=write_timeout, - pool_timeout=pool_timeout, allowed_updates=allowed_updates, ) except TelegramError: @@ -387,7 +352,7 @@ async def polling_action_cb() -> bool: "Received data was *not* processed!", exc_info=exc, ) - return True + return if updates: if not self.running: @@ -400,7 +365,7 @@ async def polling_action_cb() -> bool: await self.update_queue.put(update) self._last_update_id = updates[-1].update_id + 1 # Add one to 'confirm' it - return True # Keep fetching updates & don't quit. Polls with poll_interval. + return def default_error_callback(exc: TelegramError) -> None: _LOGGER.exception("Exception happened while polling for updates.", exc_info=exc) @@ -409,12 +374,15 @@ def default_error_callback(exc: TelegramError) -> None: # updates from Telegram and inserts them in the update queue of the # Application. self.__polling_task = asyncio.create_task( - self._network_loop_retry( + network_retry_loop( + is_running=lambda: self.running, action_cb=polling_action_cb, on_err_cb=error_callback or default_error_callback, - description="getting Updates", + description="Polling Updates", interval=poll_interval, stop_event=self.__polling_task_stop_event, + max_retries=-1, + repeat_on_success=True, ), name="Updater:start_polling:polling_task", ) @@ -432,17 +400,13 @@ async def _get_updates_cleanup() -> None: await self.bot.get_updates( offset=self._last_update_id, # We don't want to do long polling here! - timeout=0, - read_timeout=read_timeout, - connect_timeout=connect_timeout, - write_timeout=write_timeout, - pool_timeout=pool_timeout, + timeout=dtm.timedelta(seconds=0), allowed_updates=allowed_updates, ) except TelegramError: _LOGGER.exception( - "Error while calling `get_updates` one more time to mark all fetched updates " - "as read: %s. Suppressing error to ensure graceful shutdown. When polling for " + "Error while calling `get_updates` one more time to mark all fetched updates. " + "Suppressing error to ensure graceful shutdown. When polling for " "updates is restarted, updates may be fetched again. Please adjust timeouts " "via `ApplicationBuilder` or the parameter `get_updates_request` of `Bot`.", ) @@ -457,16 +421,16 @@ async def start_webhook( listen: DVType[str] = DEFAULT_IP, port: DVType[int] = DEFAULT_80, url_path: str = "", - cert: Optional[Union[str, Path]] = None, - key: Optional[Union[str, Path]] = None, + cert: str | Path | None = None, + key: str | Path | None = None, bootstrap_retries: int = 0, - webhook_url: Optional[str] = None, - allowed_updates: Optional[Sequence[str]] = None, - drop_pending_updates: Optional[bool] = None, - ip_address: Optional[str] = None, + webhook_url: str | None = None, + allowed_updates: Sequence[str] | None = None, + drop_pending_updates: bool | None = None, + ip_address: str | None = None, max_connections: int = 40, - secret_token: Optional[str] = None, - unix: Optional[Union[str, Path, "socket"]] = None, + secret_token: str | None = None, + unix: "str | Path | socket | None" = None, ) -> "asyncio.Queue[object]": """ Starts a small http server to listen for updates via webhook. If :paramref:`cert` @@ -507,8 +471,8 @@ async def start_webhook( Telegram servers before actually starting to poll. Default is :obj:`False`. .. versionadded :: 13.4 - bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the - :class:`telegram.ext.Updater` will retry on failures on the Telegram server. + bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of + will retry on failures on the Telegram server. * < 0 - retry indefinitely * 0 - no retries (default) @@ -631,16 +595,16 @@ async def _start_webhook( port: int, url_path: str, bootstrap_retries: int, - allowed_updates: Optional[Sequence[str]], - cert: Optional[Union[str, Path]] = None, - key: Optional[Union[str, Path]] = None, - drop_pending_updates: Optional[bool] = None, - webhook_url: Optional[str] = None, - ready: Optional[asyncio.Event] = None, - ip_address: Optional[str] = None, + allowed_updates: Sequence[str] | None, + cert: str | Path | None = None, + key: str | Path | None = None, + drop_pending_updates: bool | None = None, + webhook_url: str | None = None, + ready: asyncio.Event | None = None, + ip_address: str | None = None, max_connections: int = 40, - secret_token: Optional[str] = None, - unix: Optional[Union[str, Path, "socket"]] = None, + secret_token: str | None = None, + unix: "str | Path | socket | None" = None, ) -> None: _LOGGER.debug("Updater thread started (webhook)") @@ -657,7 +621,7 @@ async def _start_webhook( # the SSL handshake, e.g. in case a reverse proxy is used if cert is not None and key is not None: try: - ssl_ctx: Optional[ssl.SSLContext] = ssl.create_default_context( + ssl_ctx: ssl.SSLContext | None = ssl.create_default_context( ssl.Purpose.CLIENT_AUTH ) ssl_ctx.load_cert_chain(cert, key) # type: ignore[union-attr] @@ -698,104 +662,30 @@ def _gen_webhook_url(protocol: str, listen: str, port: int, url_path: str) -> st # say differently! return f"{protocol}://{listen}:{port}{url_path}" - async def _network_loop_retry( - self, - action_cb: Callable[..., Coroutine], - on_err_cb: Callable[[TelegramError], None], - description: str, - interval: float, - stop_event: Optional[asyncio.Event], - ) -> None: - """Perform a loop calling `action_cb`, retrying after network errors. - - Stop condition for loop: `self.running` evaluates :obj:`False` or return value of - `action_cb` evaluates :obj:`False`. - - Args: - action_cb (:term:`coroutine function`): Network oriented callback function to call. - on_err_cb (:obj:`callable`): Callback to call when TelegramError is caught. Receives - the exception object as a parameter. - description (:obj:`str`): Description text to use for logs and exception raised. - interval (:obj:`float` | :obj:`int`): Interval to sleep between each call to - `action_cb`. - stop_event (:class:`asyncio.Event` | :obj:`None`): Event to wait on for stopping the - loop. Setting the event will make the loop exit even if `action_cb` is currently - running. - - """ - - async def do_action() -> bool: - if not stop_event: - return await action_cb() - - action_cb_task = asyncio.create_task(action_cb()) - stop_task = asyncio.create_task(stop_event.wait()) - done, pending = await asyncio.wait( - (action_cb_task, stop_task), return_when=asyncio.FIRST_COMPLETED - ) - with contextlib.suppress(asyncio.CancelledError): - for task in pending: - task.cancel() - - if stop_task in done: - _LOGGER.debug("Network loop retry %s was cancelled", description) - return False - - return action_cb_task.result() - - _LOGGER.debug("Start network loop retry %s", description) - cur_interval = interval - while self.running: - try: - if not await do_action(): - break - except RetryAfter as exc: - _LOGGER.info("%s", exc) - cur_interval = 0.5 + exc.retry_after - except TimedOut as toe: - _LOGGER.debug("Timed out %s: %s", description, toe) - # If failure is due to timeout, we should retry asap. - cur_interval = 0 - except InvalidToken: - _LOGGER.exception("Invalid token; aborting") - raise - except TelegramError as telegram_exc: - on_err_cb(telegram_exc) - - # increase waiting times on subsequent errors up to 30secs - cur_interval = 1 if cur_interval == 0 else min(30, 1.5 * cur_interval) - else: - cur_interval = interval - - if cur_interval: - await asyncio.sleep(cur_interval) - async def _bootstrap( self, max_retries: int, - webhook_url: Optional[str], - allowed_updates: Optional[Sequence[str]], - drop_pending_updates: Optional[bool] = None, - cert: Optional[bytes] = None, + webhook_url: str | None, + allowed_updates: Sequence[str] | None, + drop_pending_updates: bool | None = None, + cert: bytes | None = None, bootstrap_interval: float = 1, - ip_address: Optional[str] = None, + ip_address: str | None = None, max_connections: int = 40, - secret_token: Optional[str] = None, + secret_token: str | None = None, ) -> None: """Prepares the setup for fetching updates: delete or set the webhook and drop pending updates if appropriate. If there are unsuccessful attempts, this will retry as specified by :paramref:`max_retries`. """ - retries = 0 - async def bootstrap_del_webhook() -> bool: + async def bootstrap_del_webhook() -> None: _LOGGER.debug("Deleting webhook") if drop_pending_updates: _LOGGER.debug("Dropping pending updates from Telegram server") await self.bot.delete_webhook(drop_pending_updates=drop_pending_updates) - return False - async def bootstrap_set_webhook() -> bool: + async def bootstrap_set_webhook() -> None: _LOGGER.debug("Setting webhook") if drop_pending_updates: _LOGGER.debug("Dropping pending updates from Telegram server") @@ -808,47 +698,29 @@ async def bootstrap_set_webhook() -> bool: max_connections=max_connections, secret_token=secret_token, ) - return False - - def bootstrap_on_err_cb(exc: Exception) -> None: - # We need this since retries is an immutable object otherwise and the changes - # wouldn't propagate outside of thi function - nonlocal retries - - if not isinstance(exc, InvalidToken) and (max_retries < 0 or retries < max_retries): - retries += 1 - _LOGGER.warning( - "Failed bootstrap phase; try=%s max_retries=%s", retries, max_retries - ) - else: - _LOGGER.error("Failed bootstrap phase after %s retries (%s)", retries, exc) - raise exc # Dropping pending updates from TG can be efficiently done with the drop_pending_updates # parameter of delete/start_webhook, even in the case of polling. Also, we want to make # sure that no webhook is configured in case of polling, so we just always call # delete_webhook for polling if drop_pending_updates or not webhook_url: - await self._network_loop_retry( - bootstrap_del_webhook, - bootstrap_on_err_cb, - "bootstrap del webhook", - bootstrap_interval, + await network_retry_loop( + action_cb=bootstrap_del_webhook, + description="Bootstrap delete Webhook", + interval=bootstrap_interval, stop_event=None, + max_retries=max_retries, ) - # Reset the retries counter for the next _network_loop_retry call - retries = 0 - # Restore/set webhook settings, if needed. Again, we don't know ahead if a webhook is set, # so we set it anyhow. if webhook_url: - await self._network_loop_retry( - bootstrap_set_webhook, - bootstrap_on_err_cb, - "bootstrap set webhook", - bootstrap_interval, + await network_retry_loop( + action_cb=bootstrap_set_webhook, + description="Bootstrap Set Webhook", + interval=bootstrap_interval, stop_event=None, + max_retries=max_retries, ) async def stop(self) -> None: diff --git a/telegram/ext/_utils/__init__.py b/src/telegram/ext/_utils/__init__.py similarity index 96% rename from telegram/ext/_utils/__init__.py rename to src/telegram/ext/_utils/__init__.py index da47adbab6c..7210c0bcce1 100644 --- a/telegram/ext/_utils/__init__.py +++ b/src/telegram/ext/_utils/__init__.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/telegram/ext/_utils/_update_parsing.py b/src/telegram/ext/_utils/_update_parsing.py similarity index 90% rename from telegram/ext/_utils/_update_parsing.py rename to src/telegram/ext/_utils/_update_parsing.py index 2d62a6b05f9..536dfcf8faf 100644 --- a/telegram/ext/_utils/_update_parsing.py +++ b/src/telegram/ext/_utils/_update_parsing.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,12 +25,11 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ -from typing import Optional from telegram._utils.types import SCT -def parse_chat_id(chat_id: Optional[SCT[int]]) -> frozenset[int]: +def parse_chat_id(chat_id: SCT[int] | None) -> frozenset[int]: """Accepts a chat id or collection of chat ids and returns a frozenset of chat ids.""" if chat_id is None: return frozenset() @@ -39,7 +38,7 @@ def parse_chat_id(chat_id: Optional[SCT[int]]) -> frozenset[int]: return frozenset(chat_id) -def parse_username(username: Optional[SCT[str]]) -> frozenset[str]: +def parse_username(username: SCT[str] | None) -> frozenset[str]: """Accepts a username or collection of usernames and returns a frozenset of usernames. Strips the leading ``@`` if present. """ diff --git a/src/telegram/ext/_utils/asyncio.py b/src/telegram/ext/_utils/asyncio.py new file mode 100644 index 00000000000..ec6e44d0d97 --- /dev/null +++ b/src/telegram/ext/_utils/asyncio.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains helper functions related to the std-lib asyncio module. + +.. versionadded:: 21.11 + +Warning: + Contents of this module are intended to be used internally by the library and *not* by the + user. Changes to this module are not considered breaking changes and may not be documented in + the changelog. +""" + +import asyncio +from typing import Literal + + +class TrackedBoundedSemaphore(asyncio.BoundedSemaphore): + """Simple subclass of :class:`asyncio.BoundedSemaphore` that tracks the current value of the + semaphore. While there is an attribute ``_value`` in the superclass, it's private and we + don't want to rely on it. + """ + + __slots__ = ("_current_value",) + + def __init__(self, value: int = 1) -> None: + super().__init__(value) + self._current_value = value + + @property + def current_value(self) -> int: + return self._current_value + + async def acquire(self) -> Literal[True]: + await super().acquire() + self._current_value -= 1 + return True + + def release(self) -> None: + super().release() + self._current_value += 1 diff --git a/src/telegram/ext/_utils/networkloop.py b/src/telegram/ext/_utils/networkloop.py new file mode 100644 index 00000000000..f3696b589aa --- /dev/null +++ b/src/telegram/ext/_utils/networkloop.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains a network retry loop implementation. +Its specifically tailored to handling the Telegram API and its errors. + +.. versionadded:: 21.11 + +Hint: + It was originally part of the `Updater` class, but as part of #4657 it was extracted into its + own module to be used by other parts of the library. + +Warning: + Contents of this module are intended to be used internally by the library and *not* by the + user. Changes to this module are not considered breaking changes and may not be documented in + the changelog. +""" + +import asyncio +import contextlib +from collections.abc import Callable, Coroutine + +from telegram._utils.logging import get_logger +from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut + +_LOGGER = get_logger(__name__) + + +async def network_retry_loop( + *, + action_cb: Callable[..., Coroutine], + on_err_cb: Callable[[TelegramError], None] | None = None, + description: str, + interval: float, + stop_event: asyncio.Event | None = None, + is_running: Callable[[], bool] | None = None, + max_retries: int, + repeat_on_success: bool = False, +) -> None: + """Perform a loop calling `action_cb`, retrying after network errors. + + Stop condition for loop in case of ``max_retries < 0``: + * `is_running()` evaluates :obj:`False` + * `stop_event` is set. + * calling `action_cb` succeeds and `repeat_on_success` is :obj:`False`. + + Additional stop condition for loop in case of `max_retries >= 0``: + * a call to `action_cb` succeeds + * or `max_retries` is reached. + + Args: + action_cb (:term:`coroutine function`): Network oriented callback function to call. + on_err_cb (:obj:`callable`): Optional. Callback to call when TelegramError is caught. + Receives the exception object as a parameter. + + Hint: + Only required if you want to handle the error in a special way. Logging about + the error is already handled by the loop. + + Important: + Must not raise exceptions! If it does, the loop will be aborted. + description (:obj:`str`): Description text to use for logs and exception raised. + interval (:obj:`float` | :obj:`int`): Interval to sleep between each call to + `action_cb`. + stop_event (:class:`asyncio.Event` | :obj:`None`): Event to wait on for stopping the + loop. Setting the event will make the loop exit even if `action_cb` is currently + running. Defaults to :obj:`None`. + is_running (:obj:`callable`): Function to check if the loop should continue running. + Must return a boolean value. Defaults to `lambda: True`. + max_retries (:obj:`int`): Maximum number of retries before stopping the loop. + + * < 0: Retry indefinitely. + * 0: No retries. + * > 0: Number of retries. + + repeat_on_success (:obj:`bool`): Whether to repeat the action after a successful call. + Defaults to :obj:`False`. + + Raises: + ValueError: When passing `repeat_on_success=True` and `max_retries >= 0`. This case is + currently not supported. + + """ + if repeat_on_success and max_retries >= 0: # pragma: no cover + # This case here is only for completeness. It should not be used anywhere in the library. + raise ValueError("Cannot use repeat_on_success=True with max_retries >= 0") + + log_prefix = f"Network Retry Loop ({description}):" + effective_is_running = is_running or (lambda: True) + + def check_max_retries_and_log(current_retries: int, exception_info: str = "") -> bool: + """Check if max retries reached and log accordingly. + + Args: + current_retries: The current retry count. + exception_info: Additional context about the exception (e.g., "Timed out: ..."). + + Returns: + bool: True if max retries reached (should abort), False otherwise (should retry). + """ + prefix_with_info = f"{log_prefix} {exception_info}" if exception_info else log_prefix + + if max_retries < 0 or current_retries < max_retries: + _LOGGER.debug( + "%s Failed run number %s of %s. Retrying.", + prefix_with_info, + current_retries, + max_retries, + ) + return False + _LOGGER.exception( + "%s Failed run number %s of %s. Aborting.", + prefix_with_info, + current_retries, + max_retries, + ) + return True + + async def do_action() -> None: + if not stop_event: + await action_cb() + return + + action_cb_task = asyncio.create_task(action_cb()) + stop_task = asyncio.create_task(stop_event.wait()) + done, pending = await asyncio.wait( + (action_cb_task, stop_task), return_when=asyncio.FIRST_COMPLETED + ) + with contextlib.suppress(asyncio.CancelledError): + for task in pending: + task.cancel() + + if stop_task in done: + _LOGGER.debug("%s Cancelled", log_prefix) + return + + # Calling `result()` on `action_cb_task` will raise an exception if the task failed. + # this is important to propagate the error to the caller. + action_cb_task.result() + + _LOGGER.debug("%s Starting", log_prefix) + cur_interval = interval + retries = 0 + while effective_is_running(): + try: + await do_action() + if not repeat_on_success: + _LOGGER.debug("%s Action succeeded. Stopping loop.", log_prefix) + break + except RetryAfter as exc: + slack_time = 0.5 + # pylint: disable=protected-access + cur_interval = slack_time + exc._retry_after.total_seconds() + exception_info = f"{exc}. Adding {slack_time} seconds to the specified time." + + # Check max_retries for RetryAfter as well + if check_max_retries_and_log(retries, exception_info): + raise + except TimedOut as toe: + # If failure is due to timeout, we should retry asap. + cur_interval = 0 + exception_info = f"Timed out: {toe}." + + # Check max_retries for TimedOut as well + if check_max_retries_and_log(retries, exception_info): + raise + except InvalidToken: + _LOGGER.exception("%s Invalid token. Aborting retry loop.", log_prefix) + raise + except TelegramError as telegram_exc: + if on_err_cb: + on_err_cb(telegram_exc) + + if check_max_retries_and_log(retries): + raise + + # increase waiting times on subsequent errors up to 30secs + cur_interval = 1 if cur_interval == 0 else min(30, 1.5 * cur_interval) + else: + cur_interval = interval + finally: + retries += 1 + + if cur_interval: + await asyncio.sleep(cur_interval) diff --git a/telegram/ext/_utils/stack.py b/src/telegram/ext/_utils/stack.py similarity index 95% rename from telegram/ext/_utils/stack.py rename to src/telegram/ext/_utils/stack.py index e4eef2ba92f..9c7c097ec06 100644 --- a/telegram/ext/_utils/stack.py +++ b/src/telegram/ext/_utils/stack.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,16 +25,16 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + from pathlib import Path from types import FrameType -from typing import Optional from telegram._utils.logging import get_logger _LOGGER = get_logger(__name__) -def was_called_by(frame: Optional[FrameType], caller: Path) -> bool: +def was_called_by(frame: FrameType | None, caller: Path) -> bool: """Checks if the passed frame was called by the specified file. Example: diff --git a/telegram/ext/_utils/trackingdict.py b/src/telegram/ext/_utils/trackingdict.py similarity index 96% rename from telegram/ext/_utils/trackingdict.py rename to src/telegram/ext/_utils/trackingdict.py index 810ecc6df49..bf7af4d5dab 100644 --- a/telegram/ext/_utils/trackingdict.py +++ b/src/telegram/ext/_utils/trackingdict.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,9 +25,10 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + from collections import UserDict from collections.abc import Mapping -from typing import Final, Generic, Optional, TypeVar, Union +from typing import Final, Generic, TypeVar from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue @@ -63,7 +64,7 @@ def __delitem__(self, key: _KT) -> None: self.__track_write(key) super().__delitem__(key) - def __track_write(self, key: Union[_KT, set[_KT]]) -> None: + def __track_write(self, key: _KT | set[_KT]) -> None: if isinstance(key, set): self._write_access_keys |= key else: @@ -116,7 +117,7 @@ def clear(self) -> None: # Mypy seems a bit inconsistent about what it wants as types for `default` and return value # so we just ignore a bit - def setdefault(self: "TrackingDict[_KT, _T]", key: _KT, default: Optional[_T] = None) -> _T: + def setdefault(self: "TrackingDict[_KT, _T]", key: _KT, default: _T | None = None) -> _T: if key in self: return self[key] diff --git a/telegram/ext/_utils/types.py b/src/telegram/ext/_utils/types.py similarity index 87% rename from telegram/ext/_utils/types.py rename to src/telegram/ext/_utils/types.py index 6aa35c89e22..dab51c6601a 100644 --- a/telegram/ext/_utils/types.py +++ b/src/telegram/ext/_utils/types.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,12 +25,11 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ -from collections.abc import Coroutine, MutableMapping -from typing import TYPE_CHECKING, Any, Callable, TypeVar, Union -if TYPE_CHECKING: - from typing import Optional +from collections.abc import Callable, Coroutine, MutableMapping +from typing import TYPE_CHECKING, Any, TypeVar +if TYPE_CHECKING: from telegram import Bot from telegram.ext import BaseRateLimiter, CallbackContext, JobQueue @@ -53,9 +52,9 @@ .. versionadded:: 20.0 """ -ConversationKey = tuple[Union[int, str], ...] +ConversationKey = tuple[int | str, ...] ConversationDict = MutableMapping[ConversationKey, object] -"""dict[tuple[:obj:`int` | :obj:`str`, ...], Optional[:obj:`object`]]: +"""dict[tuple[:obj:`int` | :obj:`str`, ...], :obj:`object` | None]: Dicts as maintained by the :class:`telegram.ext.ConversationHandler`. .. versionadded:: 13.6 @@ -89,12 +88,12 @@ .. versionadded:: 13.6 """ -JQ = TypeVar("JQ", bound=Union[None, "JobQueue"]) +JQ = TypeVar("JQ", bound="None | JobQueue") """Type of the job queue. .. versionadded:: 20.0""" -RL = TypeVar("RL", bound="Optional[BaseRateLimiter]") +RL = TypeVar("RL", bound="BaseRateLimiter | None") """Type of the rate limiter. .. versionadded:: 20.0""" diff --git a/telegram/ext/_utils/webhookhandler.py b/src/telegram/ext/_utils/webhookhandler.py similarity index 94% rename from telegram/ext/_utils/webhookhandler.py rename to src/telegram/ext/_utils/webhookhandler.py index 0c101c8b620..4fdd7798bb6 100644 --- a/telegram/ext/_utils/webhookhandler.py +++ b/src/telegram/ext/_utils/webhookhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -24,7 +24,7 @@ from socket import socket from ssl import SSLContext from types import TracebackType -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING # Instead of checking for ImportError here, we do that in `updater.py`, where we import from # this module. Doing it here would be tricky, as the classes below subclass tornado classes @@ -67,8 +67,8 @@ def __init__( listen: str, port: int, webhook_app: "WebhookAppClass", - ssl_ctx: Optional[SSLContext], - unix: Optional[Union[str, Path, socket]] = None, + ssl_ctx: SSLContext | None, + unix: str | Path | socket | None = None, ): if unix and not UNIX_AVAILABLE: raise RuntimeError("This OS does not support binding unix sockets.") @@ -84,7 +84,7 @@ def __init__( self._server_lock = asyncio.Lock() self._shutdown_lock = asyncio.Lock() - async def serve_forever(self, ready: Optional[asyncio.Event] = None) -> None: + async def serve_forever(self, ready: asyncio.Event | None = None) -> None: async with self._server_lock: if self.unix: self._http_server.add_socket(self.unix) @@ -116,7 +116,7 @@ def __init__( webhook_path: str, bot: "Bot", update_queue: asyncio.Queue, - secret_token: Optional[str] = None, + secret_token: str | None = None, ): self.shared_objects = { "bot": bot, @@ -210,9 +210,9 @@ def _validate_post(self) -> None: def log_exception( self, - typ: Optional[type[BaseException]], - value: Optional[BaseException], - tb: Optional[TracebackType], + typ: type[BaseException] | None, + value: BaseException | None, + tb: TracebackType | None, ) -> None: """Override the default logging and instead use our custom logging.""" _LOGGER.debug( diff --git a/telegram/ext/filters.py b/src/telegram/ext/filters.py similarity index 90% rename from telegram/ext/filters.py rename to src/telegram/ext/filters.py index e1e16c6b2da..417fd6010a1 100644 --- a/telegram/ext/filters.py +++ b/src/telegram/ext/filters.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -34,6 +34,9 @@ * Filters which do both (like ``Filters.text``) are now split as ready-to-use version ``filters.TEXT`` and class version ``filters.Text(...)``. +.. versionchanged:: 22.0 + Removed deprecated attribute `CHAT`. + """ __all__ = ( @@ -43,10 +46,12 @@ "AUDIO", "BOOST_ADDED", "CAPTION", - "CHAT", + "CHECKLIST", "COMMAND", "CONTACT", + "DIRECT_MESSAGES", "EFFECT_ID", + "FORUM", "FORWARDED", "GAME", "GIVEAWAY", @@ -68,6 +73,7 @@ "SENDER_BOOST_COUNT", "STORY", "SUCCESSFUL_PAYMENT", + "SUGGESTED_POST_INFO", "TEXT", "USER", "USER_ATTACHMENT", @@ -106,7 +112,7 @@ from abc import ABC, abstractmethod from collections.abc import Collection, Iterable, Sequence from re import Match, Pattern -from typing import NoReturn, Optional, Union, cast +from typing import NoReturn, cast from telegram import Chat as TGChat from telegram import ( @@ -183,7 +189,7 @@ class variable. __slots__ = ("_data_filter", "_name") - def __init__(self, name: Optional[str] = None, data_filter: bool = False): + def __init__(self, name: str | None = None, data_filter: bool = False): self._name = self.__class__.__name__ if name is None else name self._data_filter = data_filter @@ -259,7 +265,7 @@ def name(self) -> str: def name(self, name: str) -> None: self._name = name - def check_update(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: + def check_update(self, update: Update) -> bool | FilterDataDict | None: """Checks if the specified update should be handled by this filter. .. versionchanged:: 21.1 @@ -299,7 +305,7 @@ class MessageFilter(BaseFilter): __slots__ = () - def check_update(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: + def check_update(self, update: Update) -> bool | FilterDataDict | None: """Checks if the specified update should be handled by this filter by passing :attr:`~telegram.Update.effective_message` to :meth:`filter`. @@ -317,7 +323,7 @@ def check_update(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: return False @abstractmethod - def filter(self, message: Message) -> Optional[Union[bool, FilterDataDict]]: + def filter(self, message: Message) -> bool | FilterDataDict | None: """This method must be overwritten. Args: @@ -341,7 +347,7 @@ class UpdateFilter(BaseFilter): __slots__ = () - def check_update(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: + def check_update(self, update: Update) -> bool | FilterDataDict | None: """Checks if the specified update should be handled by this filter. Args: @@ -356,7 +362,7 @@ def check_update(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: return self.filter(update) if super().check_update(update) else False @abstractmethod - def filter(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: + def filter(self, update: Update) -> bool | FilterDataDict | None: """This method must be overwritten. Args: @@ -409,8 +415,8 @@ class _MergedFilter(UpdateFilter): def __init__( self, base_filter: BaseFilter, - and_filter: Optional[BaseFilter] = None, - or_filter: Optional[BaseFilter] = None, + and_filter: BaseFilter | None = None, + or_filter: BaseFilter | None = None, ): super().__init__() self.base_filter = base_filter @@ -428,7 +434,7 @@ def __init__( self.data_filter = True @staticmethod - def _merge(base_output: Union[bool, dict], comp_output: Union[bool, dict]) -> FilterDataDict: + def _merge(base_output: bool | dict, comp_output: bool | dict) -> FilterDataDict: base = base_output if isinstance(base_output, dict) else {} comp = comp_output if isinstance(comp_output, dict) else {} for k in comp: @@ -445,7 +451,7 @@ def _merge(base_output: Union[bool, dict], comp_output: Union[bool, dict]) -> Fi return base # pylint: disable=too-many-return-statements - def filter(self, update: Update) -> Union[bool, FilterDataDict]: + def filter(self, update: Update) -> bool | FilterDataDict: base_output = self.base_filter.check_update(update) # We need to check if the filters are data filters and if so return the merged data. # If it's not a data filter or an or_filter but no matches return bool @@ -503,7 +509,7 @@ def __init__(self, base_filter: BaseFilter, xor_filter: BaseFilter): self.xor_filter = xor_filter self.merged_filter = (base_filter & ~xor_filter) | (~base_filter & xor_filter) - def filter(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: + def filter(self, update: Update) -> bool | FilterDataDict | None: return self.merged_filter.check_update(update) @property @@ -522,7 +528,7 @@ def filter(self, message: Message) -> bool: # noqa: ARG002 return True -ALL = _All(name="filters.ALL") +ALL = _All(name="filters.ALL") # pylint: disable=invalid-name """All Messages.""" @@ -557,7 +563,7 @@ def filter(self, message: Message) -> bool: return bool(message.audio) -AUDIO = _Audio(name="filters.AUDIO") +AUDIO = _Audio(name="filters.AUDIO") # pylint: disable=invalid-name """Messages that contain :attr:`telegram.Message.audio`.""" @@ -578,8 +584,8 @@ class Caption(MessageFilter): __slots__ = ("strings",) - def __init__(self, strings: Optional[Union[list[str], tuple[str, ...]]] = None): - self.strings: Optional[Sequence[str]] = strings + def __init__(self, strings: list[str] | tuple[str, ...] | None = None): + self.strings: Sequence[str] | None = strings super().__init__(name=f"filters.Caption({strings})" if strings else "filters.CAPTION") def filter(self, message: Message) -> bool: @@ -641,13 +647,13 @@ class CaptionRegex(MessageFilter): __slots__ = ("pattern",) - def __init__(self, pattern: Union[str, Pattern[str]]): + def __init__(self, pattern: str | Pattern[str]): if isinstance(pattern, str): pattern = re.compile(pattern) self.pattern: Pattern[str] = pattern super().__init__(name=f"filters.CaptionRegex({self.pattern})", data_filter=True) - def filter(self, message: Message) -> Optional[dict[str, list[Match[str]]]]: + def filter(self, message: Message) -> dict[str, list[Match[str]]] | None: if message.caption and (match := self.pattern.search(message.caption)): return {"matches": [match]} return {} @@ -664,8 +670,8 @@ class _ChatUserBaseFilter(MessageFilter, ABC): def __init__( self, - chat_id: Optional[SCT[int]] = None, - username: Optional[SCT[str]] = None, + chat_id: SCT[int] | None = None, + username: SCT[str] | None = None, allow_empty: bool = False, ): super().__init__() @@ -680,9 +686,9 @@ def __init__( self._set_usernames(username) @abstractmethod - def _get_chat_or_user(self, message: Message) -> Union[TGChat, TGUser, None]: ... + def _get_chat_or_user(self, message: Message) -> TGChat | TGUser | None: ... - def _set_chat_ids(self, chat_id: Optional[SCT[int]]) -> None: + def _set_chat_ids(self, chat_id: SCT[int] | None) -> None: if chat_id and self._usernames: raise RuntimeError( f"Can't set {self._chat_id_name} in conjunction with (already set) " @@ -690,7 +696,7 @@ def _set_chat_ids(self, chat_id: Optional[SCT[int]]) -> None: ) self._chat_ids = set(parse_chat_id(chat_id)) - def _set_usernames(self, username: Optional[SCT[str]]) -> None: + def _set_usernames(self, username: SCT[str] | None) -> None: if username and self._chat_ids: raise RuntimeError( f"Can't set {self._username_name} in conjunction with (already set) " @@ -835,7 +841,7 @@ class Chat(_ChatUserBaseFilter): __slots__ = () - def _get_chat_or_user(self, message: Message) -> Optional[TGChat]: + def _get_chat_or_user(self, message: Message) -> TGChat | None: return message.chat def add_chat_ids(self, chat_id: SCT[int]) -> None: @@ -859,21 +865,6 @@ def remove_chat_ids(self, chat_id: SCT[int]) -> None: return super()._remove_chat_ids(chat_id) -class _Chat(MessageFilter): - __slots__ = () - - def filter(self, message: Message) -> bool: - return bool(message.chat) - - -CHAT = _Chat(name="filters.CHAT") -"""This filter filters *any* message that has a :attr:`telegram.Message.chat`. - -.. deprecated:: 20.8 - This filter has no effect since :attr:`telegram.Message.chat` is always present. -""" - - class ChatType: # A convenience namespace for Chat types. """Subset for filtering the type of chat. @@ -933,6 +924,20 @@ def filter(self, message: Message) -> bool: """Updates from supergroup.""" +class _Checklist(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.checklist) + + +CHECKLIST = _Checklist(name="filters.CHECKLIST") +"""Messages that contain :attr:`telegram.Message.checklist`. + +.. versionadded:: 22.3 +""" + + class Command(MessageFilter): """ Messages with a :attr:`telegram.MessageEntity.BOT_COMMAND`. By default, only allows @@ -993,10 +998,10 @@ def filter(self, message: Message) -> bool: class _Dice(MessageFilter): __slots__ = ("emoji", "values") - def __init__(self, values: Optional[SCT[int]] = None, emoji: Optional[DiceEmojiEnum] = None): + def __init__(self, values: SCT[int] | None = None, emoji: DiceEmojiEnum | None = None): super().__init__() - self.emoji: Optional[DiceEmojiEnum] = emoji - self.values: Optional[Collection[int]] = [values] if isinstance(values, int) else values + self.emoji: DiceEmojiEnum | None = emoji + self.values: Collection[int] | None = [values] if isinstance(values, int) else values if emoji: # for filters.Dice.BASKETBALL self.name = f"filters.Dice.{emoji.name}" @@ -1146,6 +1151,22 @@ def __init__(self, values: SCT[int]): """Dice messages with the emoji 🎰. Matches any dice value.""" +class _DirectMessages(UpdateFilter): + __slots__ = () + + def filter(self, update: Update) -> bool: + return bool(update.effective_chat and update.effective_chat.is_direct_messages) + + +DIRECT_MESSAGES = _DirectMessages(name="filters.DIRECT_MESSAGES") +"""Filter chats which are the direct messages for a channel. + +.. seealso:: :attr:`telegram.Chat.is_direct_messages` + +.. versionadded:: 22.4 +""" + + class Document: """ Subset for messages containing a document/file. @@ -1241,7 +1262,7 @@ class FileExtension(MessageFilter): __slots__ = ("_file_extension", "is_case_sensitive") - def __init__(self, file_extension: Optional[str], case_sensitive: bool = False): + def __init__(self, file_extension: str | None, case_sensitive: bool = False): super().__init__() self.is_case_sensitive: bool = case_sensitive if file_extension is None: @@ -1364,6 +1385,20 @@ def filter(self, message: Message) -> bool: return any(entity.type == self.entity_type for entity in message.entities) +class _Forum(UpdateFilter): + __slots__ = () + + def filter(self, update: Update) -> bool: + return bool(update.effective_chat and update.effective_chat.is_forum) + + +FORUM = _Forum(name="filters.FORUM") +"""Messages that are from a forum (topics enabled) chat. + +.. versionadded:: 22.4 +""" + + class _Forwarded(MessageFilter): __slots__ = () @@ -1431,7 +1466,7 @@ class ForwardedFrom(_ChatUserBaseFilter): __slots__ = () - def _get_chat_or_user(self, message: Message) -> Union[TGUser, TGChat, None]: + def _get_chat_or_user(self, message: Message) -> TGUser | TGChat | None: if (forward_origin := message.forward_origin) is None: return None @@ -1601,10 +1636,10 @@ class Language(MessageFilter): def __init__(self, lang: SCT[str]): if isinstance(lang, str): - lang = cast(str, lang) + lang = cast("str", lang) self.lang: Sequence[str] = [lang] else: - lang = cast(list[str], lang) + lang = cast("list[str]", lang) self.lang = lang super().__init__(name=f"filters.Language({self.lang})") @@ -1648,7 +1683,7 @@ class Mention(MessageFilter): __slots__ = ("_mentions",) - def __init__(self, mentions: SCT[Union[int, str, TGUser]]): + def __init__(self, mentions: SCT[int | str | TGUser]): super().__init__(name=f"filters.Mention({mentions})") if isinstance(mentions, Iterable) and not isinstance(mentions, str): self._mentions = {self._fix_mention_username(mention) for mention in mentions} @@ -1656,13 +1691,13 @@ def __init__(self, mentions: SCT[Union[int, str, TGUser]]): self._mentions = {self._fix_mention_username(mentions)} @staticmethod - def _fix_mention_username(mention: Union[int, str, TGUser]) -> Union[int, str, TGUser]: + def _fix_mention_username(mention: int | str | TGUser) -> int | str | TGUser: if not isinstance(mention, str): return mention return mention.lstrip("@") @classmethod - def _check_mention(cls, message: Message, mention: Union[int, str, TGUser]) -> bool: + def _check_mention(cls, message: Message, mention: int | str | TGUser) -> bool: if not message.entities: return False @@ -1776,13 +1811,13 @@ class Regex(MessageFilter): __slots__ = ("pattern",) - def __init__(self, pattern: Union[str, Pattern[str]]): + def __init__(self, pattern: str | Pattern[str]): if isinstance(pattern, str): pattern = re.compile(pattern) self.pattern: Pattern[str] = pattern super().__init__(name=f"filters.Regex({self.pattern})", data_filter=True) - def filter(self, message: Message) -> Optional[dict[str, list[Match[str]]]]: + def filter(self, message: Message) -> dict[str, list[Match[str]]] | None: if message.text and (match := self.pattern.search(message.text)): return {"matches": [match]} return {} @@ -1892,7 +1927,7 @@ def add_chat_ids(self, chat_id: SCT[int]) -> None: """ return super()._add_chat_ids(chat_id) - def _get_chat_or_user(self, message: Message) -> Optional[TGChat]: + def _get_chat_or_user(self, message: Message) -> TGChat | None: return message.sender_chat def remove_chat_ids(self, chat_id: SCT[int]) -> None: @@ -1915,6 +1950,9 @@ class StatusUpdate: Caution: ``filters.StatusUpdate`` itself is *not* a filter, but just a convenience namespace. + + .. versionchanged:: 22.0 + Removed deprecated attribute `USER_SHARED`. """ __slots__ = () @@ -1928,7 +1966,10 @@ def filter(self, update: Update) -> bool: StatusUpdate.CHAT_BACKGROUND_SET.check_update(update) or StatusUpdate.CHAT_CREATED.check_update(update) or StatusUpdate.CHAT_SHARED.check_update(update) + or StatusUpdate.CHECKLIST_TASKS_ADDED.check_update(update) + or StatusUpdate.CHECKLIST_TASKS_DONE.check_update(update) or StatusUpdate.CONNECTED_WEBSITE.check_update(update) + or StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED.check_update(update) or StatusUpdate.DELETE_CHAT_PHOTO.check_update(update) or StatusUpdate.FORUM_TOPIC_CLOSED.check_update(update) or StatusUpdate.FORUM_TOPIC_CREATED.check_update(update) @@ -1936,6 +1977,8 @@ def filter(self, update: Update) -> bool: or StatusUpdate.FORUM_TOPIC_REOPENED.check_update(update) or StatusUpdate.GENERAL_FORUM_TOPIC_HIDDEN.check_update(update) or StatusUpdate.GENERAL_FORUM_TOPIC_UNHIDDEN.check_update(update) + or StatusUpdate.GIFT.check_update(update) + or StatusUpdate.GIFT_UPGRADE_SENT.check_update(update) or StatusUpdate.GIVEAWAY_COMPLETED.check_update(update) or StatusUpdate.GIVEAWAY_CREATED.check_update(update) or StatusUpdate.LEFT_CHAT_MEMBER.check_update(update) @@ -1944,11 +1987,17 @@ def filter(self, update: Update) -> bool: or StatusUpdate.NEW_CHAT_MEMBERS.check_update(update) or StatusUpdate.NEW_CHAT_PHOTO.check_update(update) or StatusUpdate.NEW_CHAT_TITLE.check_update(update) + or StatusUpdate.PAID_MESSAGE_PRICE_CHANGED.check_update(update) or StatusUpdate.PINNED_MESSAGE.check_update(update) or StatusUpdate.PROXIMITY_ALERT_TRIGGERED.check_update(update) or StatusUpdate.REFUNDED_PAYMENT.check_update(update) + or StatusUpdate.SUGGESTED_POST_APPROVAL_FAILED.check_update(update) + or StatusUpdate.SUGGESTED_POST_APPROVED.check_update(update) + or StatusUpdate.SUGGESTED_POST_DECLINED.check_update(update) + or StatusUpdate.SUGGESTED_POST_PAID.check_update(update) + or StatusUpdate.SUGGESTED_POST_REFUNDED.check_update(update) + or StatusUpdate.UNIQUE_GIFT.check_update(update) or StatusUpdate.USERS_SHARED.check_update(update) - or StatusUpdate.USER_SHARED.check_update(update) or StatusUpdate.VIDEO_CHAT_ENDED.check_update(update) or StatusUpdate.VIDEO_CHAT_PARTICIPANTS_INVITED.check_update(update) or StatusUpdate.VIDEO_CHAT_SCHEDULED.check_update(update) @@ -1996,6 +2045,30 @@ def filter(self, message: Message) -> bool: .. versionadded:: 20.1 """ + class _ChecklistTasksAdded(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.checklist_tasks_added) + + CHECKLIST_TASKS_ADDED = _ChecklistTasksAdded(name="filters.StatusUpdate.CHECKLIST_TASKS_ADDED") + """Messages that contain :attr:`telegram.Message.checklist_tasks_added`. + + .. versionadded:: 22.3 + """ + + class _ChecklistTasksDone(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.checklist_tasks_done) + + CHECKLIST_TASKS_DONE = _ChecklistTasksDone(name="filters.StatusUpdate.CHECKLIST_TASKS_DONE") + """Messages that contain :attr:`telegram.Message.checklist_tasks_done`. + + .. versionadded:: 22.3 + """ + class _ConnectedWebsite(MessageFilter): __slots__ = () @@ -2005,6 +2078,20 @@ def filter(self, message: Message) -> bool: CONNECTED_WEBSITE = _ConnectedWebsite(name="filters.StatusUpdate.CONNECTED_WEBSITE") """Messages that contain :attr:`telegram.Message.connected_website`.""" + class _DirectMessagePriceChanged(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.direct_message_price_changed) + + DIRECT_MESSAGE_PRICE_CHANGED = _DirectMessagePriceChanged( + name="filters.StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED" + ) + """Messages that contain :attr:`telegram.Message.direct_message_price_changed`. + + .. versionadded:: 22.3 + """ + class _DeleteChatPhoto(MessageFilter): __slots__ = () @@ -2090,6 +2177,30 @@ def filter(self, message: Message) -> bool: .. versionadded:: 20.0 """ + class _Gift(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.gift) + + GIFT = _Gift(name="filters.StatusUpdate.GIFT") + """Messages that contain :attr:`telegram.Message.gift`. + + .. versionadded:: 22.1 + """ + + class _GiftUpgradeSent(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.gift_upgrade_sent) + + GIFT_UPGRADE_SENT = _GiftUpgradeSent(name="filters.StatusUpdate.GIFT_UPGRADE_SENT") + """Messages that contain :attr:`telegram.Message.gift_upgrade_sent`. + + .. versionadded:: 22.6 + """ + class _GiveawayCreated(MessageFilter): __slots__ = () @@ -2173,6 +2284,20 @@ def filter(self, message: Message) -> bool: NEW_CHAT_TITLE = _NewChatTitle(name="filters.StatusUpdate.NEW_CHAT_TITLE") """Messages that contain :attr:`telegram.Message.new_chat_title`.""" + class _PaidMessagePriceChanged(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.paid_message_price_changed) + + PAID_MESSAGE_PRICE_CHANGED = _PaidMessagePriceChanged( + name="filters.StatusUpdate.PAID_MESSAGE_PRICE_CHANGED" + ) + """Messages that contain :attr:`telegram.Message.paid_message_price_changed`. + + .. versionadded:: 22.1 + """ + class _PinnedMessage(MessageFilter): __slots__ = () @@ -2204,26 +2329,79 @@ def filter(self, message: Message) -> bool: .. versionadded:: 21.4 """ - class _UserShared(MessageFilter): + class _SuggestedPostApprovalFailed(MessageFilter): __slots__ = () def filter(self, message: Message) -> bool: - return bool(message.api_kwargs.get("user_shared")) + return bool(message.suggested_post_approval_failed) - USER_SHARED = _UserShared(name="filters.StatusUpdate.USER_SHARED") - """Messages that contain ``"user_shared"`` in :attr:`telegram.TelegramObject.api_kwargs`. + SUGGESTED_POST_APPROVAL_FAILED = _SuggestedPostApprovalFailed( + "filters.StatusUpdate.SUGGESTED_POST_APPROVAL_FAILED" + ) + """Messages that contain :attr:`telegram.Message.suggested_post_approval_failed`. + .. versionadded:: 22.4 + """ - Warning: - This will only catch the legacy ``user_shared`` field, not the - new :attr:`telegram.Message.users_shared` attribute! + class _SuggestedPostApproved(MessageFilter): + __slots__ = () - .. versionchanged:: 21.0 - Now relies on :attr:`telegram.TelegramObject.api_kwargs` as the native attribute - ``Message.user_shared`` was removed. + def filter(self, message: Message) -> bool: + return bool(message.suggested_post_approved) - .. versionadded:: 20.1 - .. deprecated:: 20.8 - Use :attr:`USERS_SHARED` instead. + SUGGESTED_POST_APPROVED = _SuggestedPostApproved( + "filters.StatusUpdate.SUGGESTED_POST_APPROVED" + ) + """Messages that contain :attr:`telegram.Message.suggested_post_approved`. + .. versionadded:: 22.4 + """ + + class _SuggestedPostDeclined(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.suggested_post_declined) + + SUGGESTED_POST_DECLINED = _SuggestedPostDeclined( + "filters.StatusUpdate.SUGGESTED_POST_DECLINED" + ) + """Messages that contain :attr:`telegram.Message.suggested_post_declined`. + .. versionadded:: 22.4 + """ + + class _SuggestedPostPaid(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.suggested_post_paid) + + SUGGESTED_POST_PAID = _SuggestedPostPaid("filters.StatusUpdate.SUGGESTED_POST_PAID") + """Messages that contain :attr:`telegram.Message.suggested_post_paid`. + .. versionadded:: 22.4 + """ + + class _SuggestedPostRefunded(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.suggested_post_refunded) + + SUGGESTED_POST_REFUNDED = _SuggestedPostRefunded( + "filters.StatusUpdate.SUGGESTED_POST_REFUNDED" + ) + """Messages that contain :attr:`telegram.Message.suggested_post_refunded`. + .. versionadded:: 22.4 + """ + + class _UniqueGift(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.unique_gift) + + UNIQUE_GIFT = _UniqueGift(name="filters.StatusUpdate.UNIQUE_GIFT") + """Messages that contain :attr:`telegram.Message.unique_gift`. + + .. versionadded:: 22.1 """ class _UsersShared(MessageFilter): @@ -2436,8 +2614,8 @@ class SuccessfulPayment(MessageFilter): __slots__ = ("invoice_payloads",) - def __init__(self, invoice_payloads: Optional[Union[list[str], tuple[str, ...]]] = None): - self.invoice_payloads: Optional[Sequence[str]] = invoice_payloads + def __init__(self, invoice_payloads: list[str] | tuple[str, ...] | None = None): + self.invoice_payloads: Sequence[str] | None = invoice_payloads super().__init__( name=( f"filters.SuccessfulPayment({invoice_payloads})" @@ -2460,6 +2638,20 @@ def filter(self, message: Message) -> bool: """Messages that contain :attr:`telegram.Message.successful_payment`.""" +class _SuggestedPostInfo(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.suggested_post_info) + + +SUGGESTED_POST_INFO = _SuggestedPostInfo(name="filters.SUGGESTED_POST_INFO") +"""Messages that contain :attr:`telegram.Message.suggested_post_info`. + +.. versionadded:: 22.4 +""" + + class Text(MessageFilter): """Text Messages. If a list of strings is passed, it filters messages to only allow those whose text is appearing in the given list. @@ -2491,8 +2683,8 @@ class Text(MessageFilter): __slots__ = ("strings",) - def __init__(self, strings: Optional[Union[list[str], tuple[str, ...]]] = None): - self.strings: Optional[Sequence[str]] = strings + def __init__(self, strings: list[str] | tuple[str, ...] | None = None): + self.strings: Sequence[str] | None = strings super().__init__(name=f"filters.Text({strings})" if strings else "filters.TEXT") def filter(self, message: Message) -> bool: @@ -2501,7 +2693,7 @@ def filter(self, message: Message) -> bool: return message.text in self.strings if message.text else False -TEXT = Text() +TEXT = Text() # pylint: disable=invalid-name """ Shortcut for :class:`telegram.ext.filters.Text()`. @@ -2670,14 +2862,14 @@ class User(_ChatUserBaseFilter): def __init__( self, - user_id: Optional[SCT[int]] = None, - username: Optional[SCT[str]] = None, + user_id: SCT[int] | None = None, + username: SCT[str] | None = None, allow_empty: bool = False, ): super().__init__(chat_id=user_id, username=username, allow_empty=allow_empty) self._chat_id_name = "user_id" - def _get_chat_or_user(self, message: Message) -> Optional[TGUser]: + def _get_chat_or_user(self, message: Message) -> TGUser | None: return message.from_user @property @@ -2699,7 +2891,7 @@ def user_ids(self) -> frozenset[int]: @user_ids.setter def user_ids(self, user_id: SCT[int]) -> None: - self.chat_ids = user_id # type: ignore[assignment] + self.chat_ids = user_id def add_user_ids(self, user_id: SCT[int]) -> None: """ @@ -2808,14 +3000,14 @@ class ViaBot(_ChatUserBaseFilter): def __init__( self, - bot_id: Optional[SCT[int]] = None, - username: Optional[SCT[str]] = None, + bot_id: SCT[int] | None = None, + username: SCT[str] | None = None, allow_empty: bool = False, ): super().__init__(chat_id=bot_id, username=username, allow_empty=allow_empty) self._chat_id_name = "bot_id" - def _get_chat_or_user(self, message: Message) -> Optional[TGUser]: + def _get_chat_or_user(self, message: Message) -> TGUser | None: return message.via_bot @property @@ -2837,7 +3029,7 @@ def bot_ids(self) -> frozenset[int]: @bot_ids.setter def bot_ids(self, bot_id: SCT[int]) -> None: - self.chat_ids = bot_id # type: ignore[assignment] + self.chat_ids = bot_id def add_bot_ids(self, bot_id: SCT[int]) -> None: """ @@ -2880,7 +3072,7 @@ def filter(self, message: Message) -> bool: return bool(message.video) -VIDEO = _Video(name="filters.VIDEO") +VIDEO = _Video(name="filters.VIDEO") # pylint: disable=invalid-name """Messages that contain :attr:`telegram.Message.video`.""" diff --git a/telegram/helpers.py b/src/telegram/helpers.py similarity index 92% rename from telegram/helpers.py rename to src/telegram/helpers.py index 81dd4b6c11a..1c30a303afc 100644 --- a/telegram/helpers.py +++ b/src/telegram/helpers.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -33,7 +33,7 @@ import re from html import escape -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING from telegram._utils.types import MarkdownVersion from telegram.constants import MessageLimit, MessageType @@ -43,7 +43,7 @@ def escape_markdown( - text: str, version: MarkdownVersion = 1, entity_type: Optional[str] = None + text: str, version: MarkdownVersion = 1, entity_type: str | None = None ) -> str: """Helper function to escape telegram markup symbols. @@ -77,7 +77,7 @@ def escape_markdown( return re.sub(f"([{re.escape(escape_chars)}])", r"\\\1", text) -def mention_html(user_id: Union[int, str], name: str) -> str: +def mention_html(user_id: int | str, name: str) -> str: """ Helper function to create a user mention as HTML tag. @@ -91,7 +91,7 @@ def mention_html(user_id: Union[int, str], name: str) -> str: return f'{escape(name)}' -def mention_markdown(user_id: Union[int, str], name: str, version: MarkdownVersion = 1) -> str: +def mention_markdown(user_id: int | str, name: str, version: MarkdownVersion = 1) -> str: """ Helper function to create a user mention in Markdown syntax. @@ -110,7 +110,7 @@ def mention_markdown(user_id: Union[int, str], name: str, version: MarkdownVersi return f"[{escape_markdown(name, version=version)}]({tg_link})" -def effective_message_type(entity: Union["Message", "Update"]) -> Optional[str]: +def effective_message_type(entity: "Message | Update") -> str | None: """ Extracts the type of message as a string identifier from a :class:`telegram.Message` or a :class:`telegram.Update`. @@ -125,7 +125,10 @@ def effective_message_type(entity: Union["Message", "Update"]) -> Optional[str]: """ # Importing on file-level yields cyclic Import Errors - from telegram import Message, Update # pylint: disable=import-outside-toplevel + from telegram import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + Message, + Update, + ) if isinstance(entity, Message): message = entity @@ -144,7 +147,7 @@ def effective_message_type(entity: Union["Message", "Update"]) -> Optional[str]: def create_deep_linked_url( - bot_username: str, payload: Optional[str] = None, group: bool = False + bot_username: str, payload: str | None = None, group: bool = False ) -> str: """ Creates a deep-linked URL for this :paramref:`~create_deep_linked_url.bot_username` with the diff --git a/src/telegram/py.typed b/src/telegram/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/telegram/request/__init__.py b/src/telegram/request/__init__.py similarity index 97% rename from telegram/request/__init__.py rename to src/telegram/request/__init__.py index 87040d7bfae..dd3639c81a2 100644 --- a/telegram/request/__init__.py +++ b/src/telegram/request/__init__.py @@ -1,6 +1,6 @@ # !/usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/telegram/request/_baserequest.py b/src/telegram/request/_baserequest.py similarity index 82% rename from telegram/request/_baserequest.py rename to src/telegram/request/_baserequest.py index 38729186440..d12a03ff6c4 100644 --- a/telegram/request/_baserequest.py +++ b/src/telegram/request/_baserequest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,19 +17,19 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an abstract class to make POST and GET requests.""" + import abc import json from contextlib import AbstractAsyncContextManager from http import HTTPStatus from types import TracebackType -from typing import Final, Optional, TypeVar, Union, final +from typing import Final, TypeVar, final from telegram._utils.defaultvalue import DEFAULT_NONE as _DEFAULT_NONE from telegram._utils.defaultvalue import DefaultValue from telegram._utils.logging import get_logger from telegram._utils.strings import TextEncoding from telegram._utils.types import JSONDict, ODVInput -from telegram._utils.warnings import warn from telegram._version import __version__ as ptb_ver from telegram.error import ( BadRequest, @@ -42,7 +42,6 @@ TelegramError, ) from telegram.request._requestdata import RequestData -from telegram.warnings import PTBDeprecationWarning RT = TypeVar("RT", bound="BaseRequest") @@ -123,9 +122,9 @@ async def __aenter__(self: RT) -> RT: async def __aexit__( self, - exc_type: Optional[type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> None: """|async_context_manager| :meth:`shuts down ` the Request.""" # Make sure not to return `True` so that exceptions are not suppressed @@ -133,22 +132,19 @@ async def __aexit__( await self.shutdown() @property - def read_timeout(self) -> Optional[float]: + @abc.abstractmethod + def read_timeout(self) -> float | None: """This property must return the default read timeout in seconds used by this class. More precisely, the returned value should be the one used when :paramref:`post.read_timeout` of :meth:post` is not passed/equal to :attr:`DEFAULT_NONE`. .. versionadded:: 20.7 - - Warning: - For now this property does not need to be implemented by subclasses and will raise - :exc:`NotImplementedError` if accessed without being overridden. However, in future - versions, this property will be abstract and must be implemented by subclasses. + .. versionchanged:: 22.0 + This property is now required to be implemented by subclasses. Returns: :obj:`float` | :obj:`None`: The read timeout in seconds. """ - raise NotImplementedError @abc.abstractmethod async def initialize(self) -> None: @@ -162,12 +158,12 @@ async def shutdown(self) -> None: async def post( self, url: str, - request_data: Optional[RequestData] = None, + request_data: RequestData | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - ) -> Union[JSONDict, list[JSONDict], bool]: + ) -> JSONDict | list[JSONDict] | bool: """Makes a request to the Bot API handles the return code and parses the answer. Warning: @@ -264,7 +260,7 @@ async def _request_wrapper( self, url: str, method: str, - request_data: Optional[RequestData] = None, + request_data: RequestData | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -305,31 +301,6 @@ async def _request_wrapper( TelegramError """ - # Import needs to be here since HTTPXRequest is a subclass of BaseRequest - from telegram.request import HTTPXRequest # pylint: disable=import-outside-toplevel - - # 20 is the documented default value for all the media related bot methods and custom - # implementations of BaseRequest may explicitly rely on that. Hence, we follow the - # standard deprecation policy and deprecate starting with version 20.7. - # For our own implementation HTTPXRequest, we can handle that ourselves, so we skip the - # warning in that case. - has_files = request_data and request_data.multipart_data - if ( - has_files - and not isinstance(self, HTTPXRequest) - and isinstance(write_timeout, DefaultValue) - ): - warn( - PTBDeprecationWarning( - "20.7", - f"The `write_timeout` parameter passed to {self.__class__.__name__}.do_request" - " will default to `BaseRequest.DEFAULT_NONE` instead of 20 in future versions " - "for *all* methods of the `Bot` class, including methods sending media.", - ), - stacklevel=3, - ) - write_timeout = 20 - try: code, payload = await self.do_request( url=url, @@ -347,45 +318,61 @@ async def _request_wrapper( if HTTPStatus.OK <= code <= 299: # 200-299 range are HTTP success statuses + # starting with Py 3.12 we can use `HTTPStatus.is_success` return payload - response_data = self.parse_json_payload(payload) - - description = response_data.get("description") - message = description if description else "Unknown HTTPError" + try: + message = f"{HTTPStatus(code).phrase} ({code})" + except ValueError: + message = f"Unknown HTTPError ({code})" - # In some special cases, we can raise more informative exceptions: - # see https://core.telegram.org/bots/api#responseparameters and - # https://core.telegram.org/bots/api#making-requests - # TGs response also has the fields 'ok' and 'error_code'. - # However, we rather rely on the HTTP status code for now. - parameters = response_data.get("parameters") - if parameters: - migrate_to_chat_id = parameters.get("migrate_to_chat_id") - if migrate_to_chat_id: - raise ChatMigrated(migrate_to_chat_id) - retry_after = parameters.get("retry_after") - if retry_after: - raise RetryAfter(retry_after) + parsing_exception: TelegramError | None = None - message += f"\nThe server response contained unknown parameters: {parameters}" + try: + response_data = self.parse_json_payload(payload) + except TelegramError as exc: + message += f". Parsing the server response {payload!r} failed" + parsing_exception = exc + else: + message = response_data.get("description") or message + + # In some special cases, we can raise more informative exceptions: + # see https://core.telegram.org/bots/api#responseparameters and + # https://core.telegram.org/bots/api#making-requests + # TGs response also has the fields 'ok' and 'error_code'. + # However, we rather rely on the HTTP status code for now. + parameters = response_data.get("parameters") + if parameters: + migrate_to_chat_id = parameters.get("migrate_to_chat_id") + if migrate_to_chat_id: + raise ChatMigrated(migrate_to_chat_id) + retry_after = parameters.get("retry_after") + if retry_after: + raise RetryAfter(retry_after) + + message += f". The server response contained unknown parameters: {parameters}" if code == HTTPStatus.FORBIDDEN: # 403 - raise Forbidden(message) - if code in (HTTPStatus.NOT_FOUND, HTTPStatus.UNAUTHORIZED): # 404 and 401 + exception: TelegramError = Forbidden(message) + elif code in (HTTPStatus.NOT_FOUND, HTTPStatus.UNAUTHORIZED): # 404 and 401 # TG returns 404 Not found for # 1) malformed tokens # 2) correct tokens but non-existing method, e.g. api.tg.org/botTOKEN/unkonwnMethod # 2) is relevant only for Bot.do_api_request, where we have special handing for it. # TG returns 401 Unauthorized for correctly formatted tokens that are not valid - raise InvalidToken(message) - if code == HTTPStatus.BAD_REQUEST: # 400 - raise BadRequest(message) - if code == HTTPStatus.CONFLICT: # 409 - raise Conflict(message) - if code == HTTPStatus.BAD_GATEWAY: # 502 - raise NetworkError(description or "Bad Gateway") - raise NetworkError(f"{message} ({code})") + exception = InvalidToken(message) + elif code == HTTPStatus.BAD_REQUEST: # 400 + exception = BadRequest(message) + elif code == HTTPStatus.CONFLICT: # 409 + exception = Conflict(message) + elif code == HTTPStatus.BAD_GATEWAY: # 502 + exception = NetworkError(message) + else: + exception = NetworkError(message) + + if parsing_exception: + raise exception from parsing_exception + raise exception @staticmethod def parse_json_payload(payload: bytes) -> JSONDict: @@ -417,7 +404,7 @@ async def do_request( self, url: str, method: str, - request_data: Optional[RequestData] = None, + request_data: RequestData | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, diff --git a/telegram/request/_httpxrequest.py b/src/telegram/request/_httpxrequest.py similarity index 87% rename from telegram/request/_httpxrequest.py rename to src/telegram/request/_httpxrequest.py index b31efbfcb07..080fd3d0735 100644 --- a/telegram/request/_httpxrequest.py +++ b/src/telegram/request/_httpxrequest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,19 +17,18 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains methods to make POST and GET requests using the httpx library.""" + from collections.abc import Collection -from typing import Any, Optional, Union +from typing import Any import httpx from telegram._utils.defaultvalue import DefaultValue from telegram._utils.logging import get_logger from telegram._utils.types import HTTPVersion, ODVInput, SocketOpt -from telegram._utils.warnings import warn from telegram.error import NetworkError, TimedOut from telegram.request._baserequest import BaseRequest from telegram.request._requestdata import RequestData -from telegram.warnings import PTBDeprecationWarning # Note to future devs: # Proxies are currently only tested manually. The httpx development docs have a nice guide on that: @@ -45,17 +44,18 @@ class HTTPXRequest(BaseRequest): .. versionadded:: 20.0 + .. versionchanged:: 22.0 + Removed the deprecated parameter ``proxy_url``. Use :paramref:`proxy` instead. + Args: connection_pool_size (:obj:`int`, optional): Number of connections to keep in the - connection pool. Defaults to ``1``. - - Note: - Independent of the value, one additional connection will be reserved for - :meth:`telegram.Bot.get_updates`. - proxy_url (:obj:`str`, optional): Legacy name for :paramref:`proxy`, kept for backward - compatibility. Defaults to :obj:`None`. + connection pool. Defaults to ``256``. - .. deprecated:: 20.7 + .. versionchanged:: 22.4 + Set the default to ``256``. + Stopped applying to ``httpx.Limits.max_keepalive_connections``. Now only applies to + ``httpx.Limits.max_connections``. See `Resource Limits + `_ read_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum amount of time (in seconds) to wait for a response from Telegram's server. This value is used unless a different value is passed to :meth:`do_request`. @@ -144,30 +144,17 @@ class HTTPXRequest(BaseRequest): def __init__( self, - connection_pool_size: int = 1, - proxy_url: Optional[Union[str, httpx.Proxy, httpx.URL]] = None, - read_timeout: Optional[float] = 5.0, - write_timeout: Optional[float] = 5.0, - connect_timeout: Optional[float] = 5.0, - pool_timeout: Optional[float] = 1.0, + connection_pool_size: int = 256, + read_timeout: float | None = 5.0, + write_timeout: float | None = 5.0, + connect_timeout: float | None = 5.0, + pool_timeout: float | None = 1.0, http_version: HTTPVersion = "1.1", - socket_options: Optional[Collection[SocketOpt]] = None, - proxy: Optional[Union[str, httpx.Proxy, httpx.URL]] = None, - media_write_timeout: Optional[float] = 20.0, - httpx_kwargs: Optional[dict[str, Any]] = None, + socket_options: Collection[SocketOpt] | None = None, + proxy: str | httpx.Proxy | httpx.URL | None = None, + media_write_timeout: float | None = 20.0, + httpx_kwargs: dict[str, Any] | None = None, ): - if proxy_url is not None and proxy is not None: - raise ValueError("The parameters `proxy_url` and `proxy` are mutually exclusive.") - - if proxy_url is not None: - proxy = proxy_url - warn( - PTBDeprecationWarning( - "20.7", "The parameter `proxy_url` is deprecated. Use `proxy` instead." - ), - stacklevel=2, - ) - self._http_version = http_version self._media_write_timeout = media_write_timeout timeout = httpx.Timeout( @@ -178,7 +165,6 @@ def __init__( ) limits = httpx.Limits( max_connections=connection_pool_size, - max_keepalive_connections=connection_pool_size, ) if http_version not in ("1.1", "2", "2.0"): @@ -228,7 +214,7 @@ def http_version(self) -> str: return self._http_version @property - def read_timeout(self) -> Optional[float]: + def read_timeout(self) -> float | None: """See :attr:`BaseRequest.read_timeout`. Returns: @@ -257,7 +243,7 @@ async def do_request( self, url: str, method: str, - request_data: Optional[RequestData] = None, + request_data: RequestData | None = None, read_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, write_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, connect_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, diff --git a/telegram/request/_requestdata.py b/src/telegram/request/_requestdata.py similarity index 90% rename from telegram/request/_requestdata.py rename to src/telegram/request/_requestdata.py index b8da33cc07b..91517d9455a 100644 --- a/telegram/request/_requestdata.py +++ b/src/telegram/request/_requestdata.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,9 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains a class that holds the parameters of a request to the Bot API.""" + import json -from typing import Any, Optional, Union, final +from typing import Any, final from urllib.parse import urlencode from telegram._utils.strings import TextEncoding @@ -45,19 +46,19 @@ class RequestData: __slots__ = ("_parameters", "contains_files") - def __init__(self, parameters: Optional[list[RequestParameter]] = None): + def __init__(self, parameters: list[RequestParameter] | None = None): self._parameters: list[RequestParameter] = parameters or [] self.contains_files: bool = any(param.input_files for param in self._parameters) @property - def parameters(self) -> dict[str, Union[str, int, list[Any], dict[Any, Any]]]: + def parameters(self) -> dict[str, str | int | list[Any] | dict[Any, Any]]: """Gives the parameters as mapping of parameter name to the parameter value, which can be a single object of type :obj:`int`, :obj:`float`, :obj:`str` or :obj:`bool` or any (possibly nested) composition of lists, tuples and dictionaries, where each entry, key and value is of one of the mentioned types. Returns: - dict[:obj:`str`, Union[:obj:`str`, :obj:`int`, list[any], dict[any, any]]] + dict[:obj:`str`, :obj:`str` | :obj:`int` | list[any] | dict[any, any]] """ return { param.name: param.value # type: ignore[misc] @@ -84,7 +85,7 @@ def json_parameters(self) -> dict[str, str]: if param.json_value is not None } - def url_encoded_parameters(self, encode_kwargs: Optional[dict[str, Any]] = None) -> str: + def url_encoded_parameters(self, encode_kwargs: dict[str, Any] | None = None) -> str: """Encodes the parameters with :func:`urllib.parse.urlencode`. Args: @@ -98,7 +99,7 @@ def url_encoded_parameters(self, encode_kwargs: Optional[dict[str, Any]] = None) return urlencode(self.json_parameters, **encode_kwargs) return urlencode(self.json_parameters) - def parametrized_url(self, url: str, encode_kwargs: Optional[dict[str, Any]] = None) -> str: + def parametrized_url(self, url: str, encode_kwargs: dict[str, Any] | None = None) -> str: """Shortcut for attaching the return value of :meth:`url_encoded_parameters` to the :paramref:`url`. diff --git a/telegram/request/_requestparameter.py b/src/telegram/request/_requestparameter.py similarity index 75% rename from telegram/request/_requestparameter.py rename to src/telegram/request/_requestparameter.py index 51f9b618709..26a2285324f 100644 --- a/telegram/request/_requestparameter.py +++ b/src/telegram/request/_requestparameter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,17 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains a class that describes a single parameter of a request to the Bot API.""" + import datetime as dtm import json from collections.abc import Sequence from dataclasses import dataclass -from typing import Optional, final +from typing import final +from telegram._files._inputstorycontent import InputStoryContent from telegram._files.inputfile import InputFile from telegram._files.inputmedia import InputMedia, InputPaidMedia +from telegram._files.inputprofilephoto import InputProfilePhoto, InputProfilePhotoStatic from telegram._files.inputsticker import InputSticker from telegram._telegramobject import TelegramObject from telegram._utils.datetime import to_timestamp @@ -62,10 +65,10 @@ class RequestParameter: name: str value: object - input_files: Optional[list[InputFile]] + input_files: list[InputFile] | None @property - def json_value(self) -> Optional[str]: + def json_value(self) -> str | None: """The JSON dumped :attr:`value` or :obj:`None` if :attr:`value` is :obj:`None`. The latter can currently only happen if :attr:`input_files` has exactly one element that must not be uploaded via an attach:// URI. @@ -77,7 +80,7 @@ def json_value(self) -> Optional[str]: return json.dumps(self.value) @property - def multipart_data(self) -> Optional[UploadFileDict]: + def multipart_data(self) -> UploadFileDict | None: """A dict with the file data to upload, if any. .. versionchanged:: 21.5 @@ -115,6 +118,14 @@ def _value_and_input_files_from_input( # pylint: disable=too-many-return-statem """ if isinstance(value, dtm.datetime): return to_timestamp(value), [] + if isinstance(value, dtm.timedelta): + seconds = value.total_seconds() + # We convert to int for completeness for whole seconds + if seconds.is_integer(): + return int(seconds), [] + # The Bot API doesn't document behavior for fractions of seconds so far, but we don't + # want to silently drop them + return seconds, [] if isinstance(value, StringEnum): return value.value, [] if isinstance(value, InputFile): @@ -122,7 +133,7 @@ def _value_and_input_files_from_input( # pylint: disable=too-many-return-statem return value.attach_uri, [value] return None, [value] - if isinstance(value, (InputMedia, InputPaidMedia)) and isinstance(value.media, InputFile): + if isinstance(value, InputMedia | InputPaidMedia) and isinstance(value.media, InputFile): # We call to_dict and change the returned dict instead of overriding # value.media in case the same value is reused for another request data = value.to_dict() @@ -140,6 +151,31 @@ def _value_and_input_files_from_input( # pylint: disable=too-many-return-statem return data, [value.media, thumbnail] return data, [value.media] + + if isinstance(value, InputProfilePhoto): + attr = "photo" if isinstance(value, InputProfilePhotoStatic) else "animation" + if not isinstance(media := getattr(value, attr), InputFile): + # We don't have to upload anything + return value.to_dict(), [] + + # We call to_dict and change the returned dict instead of overriding + # value.photo in case the same value is reused for another request + data = value.to_dict() + data[attr] = media.attach_uri + return data, [media] + + if isinstance(value, InputStoryContent): + attr = value.type + if not isinstance(media := getattr(value, attr), InputFile): + # We don't have to upload anything + return value.to_dict(), [] + + # We call to_dict and change the returned dict instead of overriding + # value.photo in case the same value is reused for another request + data = value.to_dict() + data[attr] = media.attach_uri + return data, [media] + if isinstance(value, InputSticker) and isinstance(value.sticker, InputFile): # We call to_dict and change the returned dict instead of overriding # value.sticker in case the same value is reused for another request @@ -157,7 +193,7 @@ def from_input(cls, key: str, value: object) -> "RequestParameter": """Builds an instance of this class for a given key-value pair that represents the raw input as passed along from a method of :class:`telegram.Bot`. """ - if not isinstance(value, (str, bytes)) and isinstance(value, Sequence): + if not isinstance(value, str | bytes) and isinstance(value, Sequence): param_values = [] input_files = [] for obj in value: @@ -165,11 +201,7 @@ def from_input(cls, key: str, value: object) -> "RequestParameter": if param_value is not None: param_values.append(param_value) input_files.extend(input_file) - return RequestParameter( - name=key, value=param_values, input_files=input_files if input_files else None - ) + return RequestParameter(name=key, value=param_values, input_files=input_files or None) param_value, input_files = cls._value_and_input_files_from_input(value) - return RequestParameter( - name=key, value=param_value, input_files=input_files if input_files else None - ) + return RequestParameter(name=key, value=param_value, input_files=input_files or None) diff --git a/telegram/warnings.py b/src/telegram/warnings.py similarity index 99% rename from telegram/warnings.py rename to src/telegram/warnings.py index d475eeb03cc..53d93db4271 100644 --- a/telegram/warnings.py +++ b/src/telegram/warnings.py @@ -1,6 +1,6 @@ #! /usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/telegram/_business.py b/telegram/_business.py deleted file mode 100644 index 412eae73051..00000000000 --- a/telegram/_business.py +++ /dev/null @@ -1,455 +0,0 @@ -#!/usr/bin/env python -# pylint: disable=redefined-builtin -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 -# Leandro Toledo de Souza -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser Public License for more details. -# -# You should have received a copy of the GNU Lesser Public License -# along with this program. If not, see [http://www.gnu.org/licenses/] -"""This module contains the Telegram Business related classes.""" -import datetime as dtm -from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional - -from telegram._chat import Chat -from telegram._files.location import Location -from telegram._files.sticker import Sticker -from telegram._telegramobject import TelegramObject -from telegram._user import User -from telegram._utils.argumentparsing import parse_sequence_arg -from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp -from telegram._utils.types import JSONDict - -if TYPE_CHECKING: - from telegram import Bot - - -class BusinessConnection(TelegramObject): - """ - Describes the connection of the bot with a business account. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal if their :attr:`id`, :attr:`user`, :attr:`user_chat_id`, :attr:`date`, - :attr:`can_reply`, and :attr:`is_enabled` are equal. - - .. versionadded:: 21.1 - - Args: - id (:obj:`str`): Unique identifier of the business connection. - user (:class:`telegram.User`): Business account user that created the business connection. - user_chat_id (:obj:`int`): Identifier of a private chat with the user who created the - business connection. - date (:obj:`datetime.datetime`): Date the connection was established in Unix time. - can_reply (:obj:`bool`): True, if the bot can act on behalf of the business account in - chats that were active in the last 24 hours. - is_enabled (:obj:`bool`): True, if the connection is active. - - Attributes: - id (:obj:`str`): Unique identifier of the business connection. - user (:class:`telegram.User`): Business account user that created the business connection. - user_chat_id (:obj:`int`): Identifier of a private chat with the user who created the - business connection. - date (:obj:`datetime.datetime`): Date the connection was established in Unix time. - can_reply (:obj:`bool`): True, if the bot can act on behalf of the business account in - chats that were active in the last 24 hours. - is_enabled (:obj:`bool`): True, if the connection is active. - """ - - __slots__ = ( - "can_reply", - "date", - "id", - "is_enabled", - "user", - "user_chat_id", - ) - - def __init__( - self, - id: str, - user: "User", - user_chat_id: int, - date: dtm.datetime, - can_reply: bool, - is_enabled: bool, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - self.id: str = id - self.user: User = user - self.user_chat_id: int = user_chat_id - self.date: dtm.datetime = date - self.can_reply: bool = can_reply - self.is_enabled: bool = is_enabled - - self._id_attrs = ( - self.id, - self.user, - self.user_chat_id, - self.date, - self.can_reply, - self.is_enabled, - ) - - self._freeze() - - @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["BusinessConnection"]: - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - - if not data: - return None - - # Get the local timezone from the bot if it has defaults - loc_tzinfo = extract_tzinfo_from_defaults(bot) - - data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo) - data["user"] = User.de_json(data.get("user"), bot) - - return super().de_json(data=data, bot=bot) - - -class BusinessMessagesDeleted(TelegramObject): - """ - This object is received when messages are deleted from a connected business account. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal if their :attr:`business_connection_id`, :attr:`message_ids`, and - :attr:`chat` are equal. - - .. versionadded:: 21.1 - - Args: - business_connection_id (:obj:`str`): Unique identifier of the business connection. - chat (:class:`telegram.Chat`): Information about a chat in the business account. The bot - may not have access to the chat or the corresponding user. - message_ids (Sequence[:obj:`int`]): A list of identifiers of the deleted messages in the - chat of the business account. - - Attributes: - business_connection_id (:obj:`str`): Unique identifier of the business connection. - chat (:class:`telegram.Chat`): Information about a chat in the business account. The bot - may not have access to the chat or the corresponding user. - message_ids (tuple[:obj:`int`]): A list of identifiers of the deleted messages in the - chat of the business account. - """ - - __slots__ = ( - "business_connection_id", - "chat", - "message_ids", - ) - - def __init__( - self, - business_connection_id: str, - chat: Chat, - message_ids: Sequence[int], - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - self.business_connection_id: str = business_connection_id - self.chat: Chat = chat - self.message_ids: tuple[int, ...] = parse_sequence_arg(message_ids) - - self._id_attrs = ( - self.business_connection_id, - self.chat, - self.message_ids, - ) - - self._freeze() - - @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["BusinessMessagesDeleted"]: - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - - if not data: - return None - - data["chat"] = Chat.de_json(data.get("chat"), bot) - - return super().de_json(data=data, bot=bot) - - -class BusinessIntro(TelegramObject): - """ - This object contains information about the start page settings of a Telegram Business account. - - Objects of this class are comparable in terms of equality. - Two objects of this class are considered equal, if their - :attr:`title`, :attr:`message` and :attr:`sticker` are equal. - - .. versionadded:: 21.1 - - Args: - title (:obj:`str`, optional): Title text of the business intro. - message (:obj:`str`, optional): Message text of the business intro. - sticker (:class:`telegram.Sticker`, optional): Sticker of the business intro. - - Attributes: - title (:obj:`str`): Optional. Title text of the business intro. - message (:obj:`str`): Optional. Message text of the business intro. - sticker (:class:`telegram.Sticker`): Optional. Sticker of the business intro. - """ - - __slots__ = ( - "message", - "sticker", - "title", - ) - - def __init__( - self, - title: Optional[str] = None, - message: Optional[str] = None, - sticker: Optional[Sticker] = None, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - self.title: Optional[str] = title - self.message: Optional[str] = message - self.sticker: Optional[Sticker] = sticker - - self._id_attrs = (self.title, self.message, self.sticker) - - self._freeze() - - @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["BusinessIntro"]: - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - - if not data: - return None - - data["sticker"] = Sticker.de_json(data.get("sticker"), bot) - - return super().de_json(data=data, bot=bot) - - -class BusinessLocation(TelegramObject): - """ - This object contains information about the location of a Telegram Business account. - - Objects of this class are comparable in terms of equality. - Two objects of this class are considered equal, if their - :attr:`address` is equal. - - .. versionadded:: 21.1 - - Args: - address (:obj:`str`): Address of the business. - location (:class:`telegram.Location`, optional): Location of the business. - - Attributes: - address (:obj:`str`): Address of the business. - location (:class:`telegram.Location`): Optional. Location of the business. - """ - - __slots__ = ( - "address", - "location", - ) - - def __init__( - self, - address: str, - location: Optional[Location] = None, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - self.address: str = address - self.location: Optional[Location] = location - - self._id_attrs = (self.address,) - - self._freeze() - - @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["BusinessLocation"]: - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - - if not data: - return None - - data["location"] = Location.de_json(data.get("location"), bot) - - return super().de_json(data=data, bot=bot) - - -class BusinessOpeningHoursInterval(TelegramObject): - """ - This object describes an interval of time during which a business is open. - - Objects of this class are comparable in terms of equality. - Two objects of this class are considered equal, if their - :attr:`opening_minute` and :attr:`closing_minute` are equal. - - .. versionadded:: 21.1 - - Examples: - A day has (24 * 60 =) 1440 minutes, a week has (7 * 1440 =) 10080 minutes. - Starting the the minute's sequence from Monday, example values of - :attr:`opening_minute`, :attr:`closing_minute` will map to the following day times: - - * Monday - 8am to 8:30pm: - - ``opening_minute = 480`` :guilabel:`8 * 60` - - ``closing_minute = 1230`` :guilabel:`20 * 60 + 30` - * Tuesday - 24 hours: - - ``opening_minute = 1440`` :guilabel:`24 * 60` - - ``closing_minute = 2879`` :guilabel:`2 * 24 * 60 - 1` - * Sunday - 12am - 11:58pm: - - ``opening_minute = 8640`` :guilabel:`6 * 24 * 60` - - ``closing_minute = 10078`` :guilabel:`7 * 24 * 60 - 2` - - Args: - opening_minute (:obj:`int`): The minute's sequence number in a week, starting on Monday, - marking the start of the time interval during which the business is open; - 0 - 7 * 24 * 60. - closing_minute (:obj:`int`): The minute's - sequence number in a week, starting on Monday, marking the end of the time interval - during which the business is open; 0 - 8 * 24 * 60 - - Attributes: - opening_minute (:obj:`int`): The minute's sequence number in a week, starting on Monday, - marking the start of the time interval during which the business is open; - 0 - 7 * 24 * 60. - closing_minute (:obj:`int`): The minute's - sequence number in a week, starting on Monday, marking the end of the time interval - during which the business is open; 0 - 8 * 24 * 60 - """ - - __slots__ = ("_closing_time", "_opening_time", "closing_minute", "opening_minute") - - def __init__( - self, - opening_minute: int, - closing_minute: int, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - self.opening_minute: int = opening_minute - self.closing_minute: int = closing_minute - - self._opening_time: Optional[tuple[int, int, int]] = None - self._closing_time: Optional[tuple[int, int, int]] = None - - self._id_attrs = (self.opening_minute, self.closing_minute) - - self._freeze() - - def _parse_minute(self, minute: int) -> tuple[int, int, int]: - return (minute // 1440, minute % 1440 // 60, minute % 1440 % 60) - - @property - def opening_time(self) -> tuple[int, int, int]: - """Convenience attribute. A :obj:`tuple` parsed from :attr:`opening_minute`. It contains - the `weekday`, `hour` and `minute` in the same ranges as :attr:`datetime.datetime.weekday`, - :attr:`datetime.datetime.hour` and :attr:`datetime.datetime.minute` - - Returns: - tuple[:obj:`int`, :obj:`int`, :obj:`int`]: - """ - if self._opening_time is None: - self._opening_time = self._parse_minute(self.opening_minute) - return self._opening_time - - @property - def closing_time(self) -> tuple[int, int, int]: - """Convenience attribute. A :obj:`tuple` parsed from :attr:`closing_minute`. It contains - the `weekday`, `hour` and `minute` in the same ranges as :attr:`datetime.datetime.weekday`, - :attr:`datetime.datetime.hour` and :attr:`datetime.datetime.minute` - - Returns: - tuple[:obj:`int`, :obj:`int`, :obj:`int`]: - """ - if self._closing_time is None: - self._closing_time = self._parse_minute(self.closing_minute) - return self._closing_time - - -class BusinessOpeningHours(TelegramObject): - """ - This object describes the opening hours of a business. - - Objects of this class are comparable in terms of equality. - Two objects of this class are considered equal, if their - :attr:`time_zone_name` and :attr:`opening_hours` are equal. - - .. versionadded:: 21.1 - - Args: - time_zone_name (:obj:`str`): Unique name of the time zone for which the opening - hours are defined. - opening_hours (Sequence[:class:`telegram.BusinessOpeningHoursInterval`]): List of - time intervals describing business opening hours. - - Attributes: - time_zone_name (:obj:`str`): Unique name of the time zone for which the opening - hours are defined. - opening_hours (Sequence[:class:`telegram.BusinessOpeningHoursInterval`]): List of - time intervals describing business opening hours. - """ - - __slots__ = ("opening_hours", "time_zone_name") - - def __init__( - self, - time_zone_name: str, - opening_hours: Sequence[BusinessOpeningHoursInterval], - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - self.time_zone_name: str = time_zone_name - self.opening_hours: Sequence[BusinessOpeningHoursInterval] = parse_sequence_arg( - opening_hours - ) - - self._id_attrs = (self.time_zone_name, self.opening_hours) - - self._freeze() - - @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: Optional["Bot"] = None - ) -> Optional["BusinessOpeningHours"]: - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - - if not data: - return None - - data["opening_hours"] = BusinessOpeningHoursInterval.de_list( - data.get("opening_hours"), bot - ) - - return super().de_json(data=data, bot=bot) diff --git a/telegram/_gifts.py b/telegram/_gifts.py deleted file mode 100644 index d711320911f..00000000000 --- a/telegram/_gifts.py +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env python -# pylint: disable=redefined-builtin -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 -# Leandro Toledo de Souza -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser Public License for more details. -# -# You should have received a copy of the GNU Lesser Public License -# along with this program. If not, see [http://www.gnu.org/licenses/] -"""This module contains classes related to gifs sent by bots.""" -from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional - -from telegram._files.sticker import Sticker -from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg -from telegram._utils.types import JSONDict - -if TYPE_CHECKING: - from telegram import Bot - - -class Gift(TelegramObject): - """This object represents a gift that can be sent by the bot. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal if their :attr:`id` is equal. - - .. versionadded:: 21.8 - - Args: - id (:obj:`str`): Unique identifier of the gift - sticker (:class:`~telegram.Sticker`): The sticker that represents the gift - star_count (:obj:`int`): The number of Telegram Stars that must be paid to send the sticker - total_count (:obj:`int`, optional): The total number of the gifts of this type that can be - sent; for limited gifts only - remaining_count (:obj:`int`, optional): The number of remaining gifts of this type that can - be sent; for limited gifts only - upgrade_star_count (:obj:`int`, optional): The number of Telegram Stars that must be paid - to upgrade the gift to a unique one - - .. versionadded:: 21.10 - - Attributes: - id (:obj:`str`): Unique identifier of the gift - sticker (:class:`~telegram.Sticker`): The sticker that represents the gift - star_count (:obj:`int`): The number of Telegram Stars that must be paid to send the sticker - total_count (:obj:`int`): Optional. The total number of the gifts of this type that can be - sent; for limited gifts only - remaining_count (:obj:`int`): Optional. The number of remaining gifts of this type that can - be sent; for limited gifts only - upgrade_star_count (:obj:`int`): Optional. The number of Telegram Stars that must be paid - to upgrade the gift to a unique one - - .. versionadded:: 21.10 - - """ - - __slots__ = ( - "id", - "remaining_count", - "star_count", - "sticker", - "total_count", - "upgrade_star_count", - ) - - def __init__( - self, - id: str, - sticker: Sticker, - star_count: int, - total_count: Optional[int] = None, - remaining_count: Optional[int] = None, - upgrade_star_count: Optional[int] = None, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - self.id: str = id - self.sticker: Sticker = sticker - self.star_count: int = star_count - self.total_count: Optional[int] = total_count - self.remaining_count: Optional[int] = remaining_count - self.upgrade_star_count: Optional[int] = upgrade_star_count - - self._id_attrs = (self.id,) - - self._freeze() - - @classmethod - def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Gift"]: - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - - if not data: - return None - - data["sticker"] = Sticker.de_json(data.get("sticker"), bot) - return super().de_json(data=data, bot=bot) - - -class Gifts(TelegramObject): - """This object represent a list of gifts. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal if their :attr:`gifts` are equal. - - .. versionadded:: 21.8 - - Args: - gifts (Sequence[:class:`Gift`]): The sequence of gifts - - Attributes: - gifts (tuple[:class:`Gift`]): The sequence of gifts - - """ - - __slots__ = ("gifts",) - - def __init__( - self, - gifts: Sequence[Gift], - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - self.gifts: tuple[Gift, ...] = parse_sequence_arg(gifts) - - self._id_attrs = (self.gifts,) - - self._freeze() - - @classmethod - def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Gifts"]: - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - - if not data: - return None - - data["gifts"] = Gift.de_list(data.get("gifts"), bot) - return super().de_json(data=data, bot=bot) diff --git a/telegram/_utils/argumentparsing.py b/telegram/_utils/argumentparsing.py deleted file mode 100644 index e72848a9ef9..00000000000 --- a/telegram/_utils/argumentparsing.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 -# Leandro Toledo de Souza -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser Public License for more details. -# -# You should have received a copy of the GNU Lesser Public License -# along with this program. If not, see [http://www.gnu.org/licenses/]. -"""This module contains helper functions related to parsing arguments for classes and methods. - -Warning: - Contents of this module are intended to be used internally by the library and *not* by the - user. Changes to this module are not considered breaking changes and may not be documented in - the changelog. -""" -from collections.abc import Sequence -from typing import Optional, TypeVar - -from telegram._linkpreviewoptions import LinkPreviewOptions -from telegram._utils.types import ODVInput - -T = TypeVar("T") - - -def parse_sequence_arg(arg: Optional[Sequence[T]]) -> tuple[T, ...]: - """Parses an optional sequence into a tuple - - Args: - arg (:obj:`Sequence`): The sequence to parse. - - Returns: - :obj:`Tuple`: The sequence converted to a tuple or an empty tuple. - """ - return tuple(arg) if arg else () - - -def parse_lpo_and_dwpp( - disable_web_page_preview: Optional[bool], link_preview_options: ODVInput[LinkPreviewOptions] -) -> ODVInput[LinkPreviewOptions]: - """Wrapper around warn_about_deprecated_arg_return_new_arg. Takes care of converting - disable_web_page_preview to LinkPreviewOptions. - """ - if disable_web_page_preview and link_preview_options: - raise ValueError( - "Parameters `disable_web_page_preview` and `link_preview_options` are mutually " - "exclusive." - ) - - if disable_web_page_preview is not None: - link_preview_options = LinkPreviewOptions(is_disabled=disable_web_page_preview) - - return link_preview_options diff --git a/tests/README.rst b/tests/README.rst index a6724558041..54792ceb561 100644 --- a/tests/README.rst +++ b/tests/README.rst @@ -6,6 +6,12 @@ PTB uses `pytest`_ for testing. To run the tests, you need to have pytest installed along with a few other dependencies. You can find the list of dependencies in the ``pyproject.toml`` file in the root of the repository. +Since PTB uses a src-based layout, make sure you have installed the package in development mode before running the tests: + +.. code-block:: bash + + $ pip install -e . + Running tests ============= @@ -36,7 +42,7 @@ such that tests marked with ``@pytest.mark.xdist_group("name")`` are run on the .. code-block:: bash - $ pytest -n auto --dist=loadgroup + $ pytest -n auto --dist=worksteal This will result in a significant speedup, but may cause some tests to fail. If you want to run the failed tests in isolation, you can use the ``--lf`` flag: @@ -88,7 +94,7 @@ Debugging tests Writing tests can be challenging, and fixing failing tests can be even more so. To help with this, PTB has started to adopt the use of ``logging`` in the test suite. You can insert debug logging statements in your tests to help you understand what's going on. To enable these logs, you can set -``log_level = DEBUG`` in ``setup.cfg`` or use the ``--log-level=INFO`` flag when running the tests. +``log_level = DEBUG`` in ``pyproject.toml`` or use the ``--log-level=INFO`` flag when running the tests. If a test is large and complicated, it is recommended to leave the debug logs for others to use as well. diff --git a/tests/_files/__init__.py b/tests/_files/__init__.py index 040bfc78668..c95cb3c9741 100644 --- a/tests/_files/__init__.py +++ b/tests/_files/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_files/conftest.py b/tests/_files/conftest.py index eeb59e888c1..c83ea80ab88 100644 --- a/tests/_files/conftest.py +++ b/tests/_files/conftest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Module to provide fixtures most of which are used in test_inputmedia.py.""" + import pytest from telegram.error import BadRequest diff --git a/tests/_files/test_animation.py b/tests/_files/test_animation.py index cbdc8b5a7ca..df4ae468949 100644 --- a/tests/_files/test_animation.py +++ b/tests/_files/test_animation.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio +import datetime as dtm import os from pathlib import Path @@ -27,6 +28,7 @@ from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -42,7 +44,7 @@ class AnimationTestBase: animation_file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" width = 320 height = 180 - duration = 1 + duration = dtm.timedelta(seconds=1) # animation_file_url = 'https://python-telegram-bot.org/static/testfiles/game.gif' # Shortened link, the above one is cached with the wrong duration. animation_file_url = "http://bit.ly/2L18jua" @@ -76,7 +78,7 @@ def test_de_json(self, offline_bot, animation): "file_unique_id": self.animation_file_unique_id, "width": self.width, "height": self.height, - "duration": self.duration, + "duration": self.duration.total_seconds(), "thumbnail": animation.thumbnail.to_dict(), "file_name": self.file_name, "mime_type": self.mime_type, @@ -89,6 +91,7 @@ def test_de_json(self, offline_bot, animation): assert animation.file_name == self.file_name assert animation.mime_type == self.mime_type assert animation.file_size == self.file_size + assert animation._duration == self.duration def test_to_dict(self, animation): animation_dict = animation.to_dict() @@ -98,12 +101,31 @@ def test_to_dict(self, animation): assert animation_dict["file_unique_id"] == animation.file_unique_id assert animation_dict["width"] == animation.width assert animation_dict["height"] == animation.height - assert animation_dict["duration"] == animation.duration + assert animation_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(animation_dict["duration"], int) assert animation_dict["thumbnail"] == animation.thumbnail.to_dict() assert animation_dict["file_name"] == animation.file_name assert animation_dict["mime_type"] == animation.mime_type assert animation_dict["file_size"] == animation.file_size + def test_time_period_properties(self, PTB_TIMEDELTA, animation): + if PTB_TIMEDELTA: + assert animation.duration == self.duration + assert isinstance(animation.duration, dtm.timedelta) + else: + assert animation.duration == int(self.duration.total_seconds()) + assert isinstance(animation.duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, animation): + animation.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = Animation( self.animation_file_id, @@ -138,7 +160,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): ) @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_animation_local_files(self, monkeypatch, offline_bot, chat_id, local_mode): + async def test_send_animation_local_files( + self, monkeypatch, offline_bot, chat_id, local_mode, dummy_message_dict + ): try: offline_bot._local_mode = local_mode # For just test that the correct paths are passed as we have no local Bot API set up @@ -156,6 +180,7 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = isinstance(data.get("animation"), InputFile) and isinstance( data.get("thumbnail"), InputFile ) + return dummy_message_dict monkeypatch.setattr(offline_bot, "_post", make_assertion) await offline_bot.send_animation(chat_id, file, thumbnail=file) @@ -210,11 +235,14 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): class TestAnimationWithRequest(AnimationTestBase): - async def test_send_all_args(self, bot, chat_id, animation_file, animation, thumb_file): + @pytest.mark.parametrize("duration", [1, dtm.timedelta(seconds=1)]) + async def test_send_all_args( + self, bot, chat_id, animation_file, animation, thumb_file, duration + ): message = await bot.send_animation( chat_id, animation_file, - duration=self.duration, + duration=duration, width=self.width, height=self.height, caption=self.caption, diff --git a/tests/_files/test_audio.py b/tests/_files/test_audio.py index afdd8c75432..0bd9b2e6fd8 100644 --- a/tests/_files/test_audio.py +++ b/tests/_files/test_audio.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio +import datetime as dtm import os from pathlib import Path @@ -27,6 +28,7 @@ from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -42,7 +44,7 @@ class AudioTestBase: performer = "Leandro Toledo" title = "Teste" file_name = "telegram.mp3" - duration = 3 + duration = dtm.timedelta(seconds=3) # audio_file_url = 'https://python-telegram-bot.org/static/testfiles/telegram.mp3' # Shortened link, the above one is cached with the wrong duration. audio_file_url = "https://goo.gl/3En24v" @@ -70,12 +72,12 @@ def test_creation(self, audio): assert audio.file_unique_id def test_expected_values(self, audio): - assert audio.duration == self.duration + assert audio._duration == self.duration assert audio.performer is None assert audio.title is None assert audio.mime_type == self.mime_type assert audio.file_size == self.file_size - assert audio.thumbnail.file_size == self.thumb_file_size + assert audio.thumbnail.file_size in [self.thumb_file_size, 1395] assert audio.thumbnail.width == self.thumb_width assert audio.thumbnail.height == self.thumb_height @@ -83,7 +85,7 @@ def test_de_json(self, offline_bot, audio): json_dict = { "file_id": self.audio_file_id, "file_unique_id": self.audio_file_unique_id, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), "performer": self.performer, "title": self.title, "file_name": self.file_name, @@ -96,7 +98,7 @@ def test_de_json(self, offline_bot, audio): assert json_audio.file_id == self.audio_file_id assert json_audio.file_unique_id == self.audio_file_unique_id - assert json_audio.duration == self.duration + assert json_audio._duration == self.duration assert json_audio.performer == self.performer assert json_audio.title == self.title assert json_audio.file_name == self.file_name @@ -110,11 +112,30 @@ def test_to_dict(self, audio): assert isinstance(audio_dict, dict) assert audio_dict["file_id"] == audio.file_id assert audio_dict["file_unique_id"] == audio.file_unique_id - assert audio_dict["duration"] == audio.duration + assert audio_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(audio_dict["duration"], int) assert audio_dict["mime_type"] == audio.mime_type assert audio_dict["file_size"] == audio.file_size assert audio_dict["file_name"] == audio.file_name + def test_time_period_properties(self, PTB_TIMEDELTA, audio): + if PTB_TIMEDELTA: + assert audio.duration == self.duration + assert isinstance(audio.duration, dtm.timedelta) + else: + assert audio.duration == int(self.duration.total_seconds()) + assert isinstance(audio.duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, audio): + audio.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self, audio): a = Audio(audio.file_id, audio.file_unique_id, audio.duration) b = Audio("", audio.file_unique_id, audio.duration) @@ -150,7 +171,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): assert await offline_bot.send_audio(chat_id, audio_file, filename="custom_filename") @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_audio_local_files(self, monkeypatch, offline_bot, chat_id, local_mode): + async def test_send_audio_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: offline_bot._local_mode = local_mode # For just test that the correct paths are passed as we have no local Bot API set up @@ -166,6 +189,7 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = isinstance(data.get("audio"), InputFile) and isinstance( data.get("thumbnail"), InputFile ) + return dummy_message_dict monkeypatch.setattr(offline_bot, "_post", make_assertion) await offline_bot.send_audio(chat_id, file, thumbnail=file) @@ -211,12 +235,13 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): class TestAudioWithRequest(AudioTestBase): - async def test_send_all_args(self, bot, chat_id, audio_file, thumb_file): + @pytest.mark.parametrize("duration", [3, dtm.timedelta(seconds=3)]) + async def test_send_all_args(self, bot, chat_id, audio_file, thumb_file, duration): message = await bot.send_audio( chat_id, audio=audio_file, caption=self.caption, - duration=self.duration, + duration=duration, performer=self.performer, title=self.title, disable_notification=False, @@ -232,7 +257,7 @@ async def test_send_all_args(self, bot, chat_id, audio_file, thumb_file): assert isinstance(message.audio.file_unique_id, str) assert message.audio.file_unique_id is not None assert message.audio.file_id is not None - assert message.audio.duration == self.duration + assert message.audio._duration == self.duration assert message.audio.performer == self.performer assert message.audio.title == self.title assert message.audio.file_name == self.file_name diff --git a/tests/_files/test_chatphoto.py b/tests/_files/test_chatphoto.py index 74262ba3743..651d2ced060 100644 --- a/tests/_files/test_chatphoto.py +++ b/tests/_files/test_chatphoto.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_files/test_contact.py b/tests/_files/test_contact.py index a6a1b27e774..b7e282d0271 100644 --- a/tests/_files/test_contact.py +++ b/tests/_files/test_contact.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_files/test_document.py b/tests/_files/test_document.py index 71ce508e4fd..224e05aa2fa 100644 --- a/tests/_files/test_document.py +++ b/tests/_files/test_document.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -67,7 +67,7 @@ def test_expected_values(self, document): assert document.file_size == self.file_size assert document.mime_type == self.mime_type assert document.file_name == self.file_name - assert document.thumbnail.file_size == self.thumb_file_size + assert document.thumbnail.file_size in [self.thumb_file_size, 7980] assert document.thumbnail.width == self.thumb_width assert document.thumbnail.height == self.thumb_height @@ -169,7 +169,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): ) @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_document_local_files(self, monkeypatch, offline_bot, chat_id, local_mode): + async def test_send_document_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: offline_bot._local_mode = local_mode # For just test that the correct paths are passed as we have no local Bot API set up @@ -187,6 +189,7 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = isinstance(data.get("document"), InputFile) and isinstance( data.get("thumbnail"), InputFile ) + return dummy_message_dict monkeypatch.setattr(offline_bot, "_post", make_assertion) await offline_bot.send_document(chat_id, file, thumbnail=file) diff --git a/tests/_files/test_file.py b/tests/_files/test_file.py index 0a3d317cb4a..f1091f6cd9d 100644 --- a/tests/_files/test_file.py +++ b/tests/_files/test_file.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_files/test_inputfile.py b/tests/_files/test_inputfile.py index 1375037e809..70d26a50a17 100644 --- a/tests/_files/test_inputfile.py +++ b/tests/_files/test_inputfile.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index 5aef4e62da4..d43a853ec7d 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,8 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio import copy +import datetime as dtm from collections.abc import Sequence -from typing import Optional import pytest @@ -40,6 +40,7 @@ from telegram.constants import InputMediaType, ParseMode from telegram.error import BadRequest from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.files import data_file from tests.auxil.networking import expect_bad_request from tests.auxil.slots import mro_slots @@ -58,6 +59,8 @@ def input_media_video(class_thumb_file): parse_mode=InputMediaVideoTestBase.parse_mode, caption_entities=InputMediaVideoTestBase.caption_entities, thumbnail=class_thumb_file, + cover=class_thumb_file, + start_timestamp=InputMediaVideoTestBase.start_timestamp, supports_streaming=InputMediaVideoTestBase.supports_streaming, has_spoiler=InputMediaVideoTestBase.has_spoiler, show_caption_above_media=InputMediaVideoTestBase.show_caption_above_media, @@ -130,6 +133,8 @@ def input_paid_media_video(class_thumb_file): return InputPaidMediaVideo( media=InputMediaVideoTestBase.media, thumbnail=class_thumb_file, + cover=class_thumb_file, + start_timestamp=InputMediaVideoTestBase.start_timestamp, width=InputMediaVideoTestBase.width, height=InputMediaVideoTestBase.height, duration=InputMediaVideoTestBase.duration, @@ -143,7 +148,8 @@ class InputMediaVideoTestBase: caption = "My Caption" width = 3 height = 4 - duration = 5 + duration = dtm.timedelta(seconds=5) + start_timestamp = 3 parse_mode = "HTML" supports_streaming = True caption_entities = [MessageEntity(MessageEntity.BOLD, 0, 2)] @@ -164,11 +170,13 @@ def test_expected_values(self, input_media_video): assert input_media_video.caption == self.caption assert input_media_video.width == self.width assert input_media_video.height == self.height - assert input_media_video.duration == self.duration + assert input_media_video._duration == self.duration assert input_media_video.parse_mode == self.parse_mode assert input_media_video.caption_entities == tuple(self.caption_entities) assert input_media_video.supports_streaming == self.supports_streaming assert isinstance(input_media_video.thumbnail, InputFile) + assert isinstance(input_media_video.cover, InputFile) + assert input_media_video.start_timestamp == self.start_timestamp assert input_media_video.has_spoiler == self.has_spoiler assert input_media_video.show_caption_above_media == self.show_caption_above_media @@ -183,7 +191,8 @@ def test_to_dict(self, input_media_video): assert input_media_video_dict["caption"] == input_media_video.caption assert input_media_video_dict["width"] == input_media_video.width assert input_media_video_dict["height"] == input_media_video.height - assert input_media_video_dict["duration"] == input_media_video.duration + assert input_media_video_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(input_media_video_dict["duration"], int) assert input_media_video_dict["parse_mode"] == input_media_video.parse_mode assert input_media_video_dict["caption_entities"] == [ ce.to_dict() for ce in input_media_video.caption_entities @@ -194,8 +203,30 @@ def test_to_dict(self, input_media_video): input_media_video_dict["show_caption_above_media"] == input_media_video.show_caption_above_media ) + assert input_media_video_dict["cover"] == input_media_video.cover + assert input_media_video_dict["start_timestamp"] == input_media_video.start_timestamp - def test_with_video(self, video): + def test_time_period_properties(self, PTB_TIMEDELTA, input_media_video): + duration = input_media_video.duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_video): + input_media_video.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + + def test_with_video(self, video, PTB_TIMEDELTA): # fixture found in test_video input_media_video = InputMediaVideo(video, caption="test 3") assert input_media_video.type == self.type_ @@ -214,10 +245,13 @@ def test_with_video_file(self, video_file): def test_with_local_files(self): input_media_video = InputMediaVideo( - data_file("telegram.mp4"), thumbnail=data_file("telegram.jpg") + data_file("telegram.mp4"), + thumbnail=data_file("telegram.jpg"), + cover=data_file("telegram.jpg"), ) assert input_media_video.media == data_file("telegram.mp4").as_uri() assert input_media_video.thumbnail == data_file("telegram.jpg").as_uri() + assert input_media_video.cover == data_file("telegram.jpg").as_uri() def test_type_enum_conversion(self): # Since we have a lot of different test classes for all the input media types, we test this @@ -312,7 +346,7 @@ class InputMediaAnimationTestBase: caption_entities = [MessageEntity(MessageEntity.BOLD, 0, 2)] width = 30 height = 30 - duration = 1 + duration = dtm.timedelta(seconds=1) has_spoiler = True show_caption_above_media = True @@ -333,6 +367,7 @@ def test_expected_values(self, input_media_animation): assert isinstance(input_media_animation.thumbnail, InputFile) assert input_media_animation.has_spoiler == self.has_spoiler assert input_media_animation.show_caption_above_media == self.show_caption_above_media + assert input_media_animation._duration == self.duration def test_caption_entities_always_tuple(self): input_media_animation = InputMediaAnimation(self.media) @@ -349,13 +384,34 @@ def test_to_dict(self, input_media_animation): ] assert input_media_animation_dict["width"] == input_media_animation.width assert input_media_animation_dict["height"] == input_media_animation.height - assert input_media_animation_dict["duration"] == input_media_animation.duration + assert input_media_animation_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(input_media_animation_dict["duration"], int) assert input_media_animation_dict["has_spoiler"] == input_media_animation.has_spoiler assert ( input_media_animation_dict["show_caption_above_media"] == input_media_animation.show_caption_above_media ) + def test_time_period_properties(self, PTB_TIMEDELTA, input_media_animation): + duration = input_media_animation.duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_animation): + input_media_animation.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_with_animation(self, animation): # fixture found in test_animation input_media_animation = InputMediaAnimation(animation, caption="test 2") @@ -382,7 +438,7 @@ class InputMediaAudioTestBase: type_ = "audio" media = "NOTAREALFILEID" caption = "My Caption" - duration = 3 + duration = dtm.timedelta(seconds=3) performer = "performer" title = "title" parse_mode = "HTML" @@ -400,7 +456,7 @@ def test_expected_values(self, input_media_audio): assert input_media_audio.type == self.type_ assert input_media_audio.media == self.media assert input_media_audio.caption == self.caption - assert input_media_audio.duration == self.duration + assert input_media_audio._duration == self.duration assert input_media_audio.performer == self.performer assert input_media_audio.title == self.title assert input_media_audio.parse_mode == self.parse_mode @@ -416,7 +472,9 @@ def test_to_dict(self, input_media_audio): assert input_media_audio_dict["type"] == input_media_audio.type assert input_media_audio_dict["media"] == input_media_audio.media assert input_media_audio_dict["caption"] == input_media_audio.caption - assert input_media_audio_dict["duration"] == input_media_audio.duration + assert isinstance(input_media_audio_dict["duration"], int) + assert input_media_audio_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(input_media_audio_dict["duration"], int) assert input_media_audio_dict["performer"] == input_media_audio.performer assert input_media_audio_dict["title"] == input_media_audio.title assert input_media_audio_dict["parse_mode"] == input_media_audio.parse_mode @@ -424,6 +482,26 @@ def test_to_dict(self, input_media_audio): ce.to_dict() for ce in input_media_audio.caption_entities ] + def test_time_period_properties(self, PTB_TIMEDELTA, input_media_audio): + duration = input_media_audio.duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_audio): + input_media_audio.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_with_audio(self, audio): # fixture found in test_audio input_media_audio = InputMediaAudio(audio, caption="test 3") @@ -562,9 +640,11 @@ def test_expected_values(self, input_paid_media_video): assert input_paid_media_video.media == self.media assert input_paid_media_video.width == self.width assert input_paid_media_video.height == self.height - assert input_paid_media_video.duration == self.duration + assert input_paid_media_video._duration == self.duration assert input_paid_media_video.supports_streaming == self.supports_streaming assert isinstance(input_paid_media_video.thumbnail, InputFile) + assert isinstance(input_paid_media_video.cover, InputFile) + assert input_paid_media_video.start_timestamp == self.start_timestamp def test_to_dict(self, input_paid_media_video): input_paid_media_video_dict = input_paid_media_video.to_dict() @@ -572,12 +652,38 @@ def test_to_dict(self, input_paid_media_video): assert input_paid_media_video_dict["media"] == input_paid_media_video.media assert input_paid_media_video_dict["width"] == input_paid_media_video.width assert input_paid_media_video_dict["height"] == input_paid_media_video.height - assert input_paid_media_video_dict["duration"] == input_paid_media_video.duration + assert input_paid_media_video_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(input_paid_media_video_dict["duration"], int) assert ( input_paid_media_video_dict["supports_streaming"] == input_paid_media_video.supports_streaming ) assert input_paid_media_video_dict["thumbnail"] == input_paid_media_video.thumbnail + assert input_paid_media_video_dict["cover"] == input_paid_media_video.cover + assert ( + input_paid_media_video_dict["start_timestamp"] + == input_paid_media_video.start_timestamp + ) + + def test_time_period_properties(self, PTB_TIMEDELTA, input_paid_media_video): + duration = input_paid_media_video.duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_paid_media_video): + input_paid_media_video.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning def test_with_video(self, video): # fixture found in test_video @@ -596,10 +702,13 @@ def test_with_video_file(self, video_file): def test_with_local_files(self): input_paid_media_video = InputPaidMediaVideo( - data_file("telegram.mp4"), thumbnail=data_file("telegram.jpg") + data_file("telegram.mp4"), + thumbnail=data_file("telegram.jpg"), + cover=data_file("telegram.jpg"), ) assert input_paid_media_video.media == data_file("telegram.mp4").as_uri() assert input_paid_media_video.thumbnail == data_file("telegram.jpg").as_uri() + assert input_paid_media_video.cover == data_file("telegram.jpg").as_uri() @pytest.fixture(scope="module") @@ -650,7 +759,7 @@ async def test_send_media_group_throws_error_with_group_caption_and_individual_c ): with pytest.raises( ValueError, - match="You can only supply either group caption or media with captions.", + match="You can only supply either group caption or media with captions\\.", ): await offline_bot.send_media_group(chat_id, group, caption="foo") @@ -688,7 +797,6 @@ async def test_send_media_group_with_thumbs( self, offline_bot, chat_id, video_file, photo_file, monkeypatch ): async def make_assertion(method, url, request_data: RequestData, *args, **kwargs): - nonlocal input_video files = request_data.multipart_data video_check = files[input_video.media.attach_name] == input_video.media.field_tuple thumb_check = ( @@ -706,7 +814,7 @@ async def test_edit_message_media_with_thumb( self, offline_bot, chat_id, video_file, photo_file, monkeypatch ): async def make_assertion( - method: str, url: str, request_data: Optional[RequestData] = None, *args, **kwargs + method: str, url: str, request_data: RequestData | None = None, *args, **kwargs ): files = request_data.multipart_data video_check = files[input_video.media.attach_name] == input_video.media.field_tuple @@ -895,7 +1003,8 @@ async def test_send_media_group_all_args(self, bot, raw_bot, chat_id, media_grou # make sure that the media_group was not modified assert media_group == copied_media_group assert all( - a.parse_mode == b.parse_mode for a, b in zip(media_group, copied_media_group) + a.parse_mode == b.parse_mode + for a, b in zip(media_group, copied_media_group, strict=False) ) assert isinstance(messages, tuple) @@ -1131,9 +1240,9 @@ def build_media(parse_mode, med_type): # make sure that the media was not modified assert media.parse_mode == copied_media.parse_mode - async def test_send_paid_media(self, bot, channel_id, photo_file, video_file): + async def test_send_paid_media(self, bot, chat_id, photo_file, video_file): msg = await bot.send_paid_media( - chat_id=channel_id, + chat_id=chat_id, star_count=20, media=[ InputPaidMediaPhoto(media=photo_file), diff --git a/tests/_files/test_inputprofilephoto.py b/tests/_files/test_inputprofilephoto.py new file mode 100644 index 00000000000..0eddcdf469d --- /dev/null +++ b/tests/_files/test_inputprofilephoto.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + +import pytest + +from telegram import ( + InputFile, + InputProfilePhoto, + InputProfilePhotoAnimated, + InputProfilePhotoStatic, +) +from telegram.constants import InputProfilePhotoType +from tests.auxil.files import data_file +from tests.auxil.slots import mro_slots + + +class TestInputProfilePhotoWithoutRequest: + def test_type_enum_conversion(self): + instance = InputProfilePhoto(type="static") + assert isinstance(instance.type, InputProfilePhotoType) + assert instance.type is InputProfilePhotoType.STATIC + + instance = InputProfilePhoto(type="animated") + assert isinstance(instance.type, InputProfilePhotoType) + assert instance.type is InputProfilePhotoType.ANIMATED + + instance = InputProfilePhoto(type="unknown") + assert isinstance(instance.type, str) + assert instance.type == "unknown" + + +@pytest.fixture(scope="module") +def input_profile_photo_static(): + return InputProfilePhotoStatic(photo=InputProfilePhotoStaticTestBase.photo.read_bytes()) + + +class InputProfilePhotoStaticTestBase: + type_ = "static" + photo = data_file("telegram.jpg") + + +class TestInputProfilePhotoStaticWithoutRequest(InputProfilePhotoStaticTestBase): + def test_slot_behaviour(self, input_profile_photo_static): + inst = input_profile_photo_static + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_profile_photo_static): + inst = input_profile_photo_static + assert inst.type == self.type_ + assert isinstance(inst.photo, InputFile) + + def test_to_dict(self, input_profile_photo_static): + inst = input_profile_photo_static + data = inst.to_dict() + assert data["type"] == self.type_ + assert data["photo"] == inst.photo + + def test_with_local_file(self): + inst = InputProfilePhotoStatic(photo=data_file("telegram.jpg")) + assert inst.photo == data_file("telegram.jpg").as_uri() + + def test_type_enum_conversion(self, input_profile_photo_static): + assert input_profile_photo_static.type is InputProfilePhotoType.STATIC + + +@pytest.fixture(scope="module") +def input_profile_photo_animated(): + return InputProfilePhotoAnimated( + animation=InputProfilePhotoAnimatedTestBase.animation.read_bytes(), + main_frame_timestamp=InputProfilePhotoAnimatedTestBase.main_frame_timestamp, + ) + + +class InputProfilePhotoAnimatedTestBase: + type_ = "animated" + animation = data_file("telegram2.mp4") + main_frame_timestamp = dtm.timedelta(seconds=42, milliseconds=43) + + +class TestInputProfilePhotoAnimatedWithoutRequest(InputProfilePhotoAnimatedTestBase): + def test_slot_behaviour(self, input_profile_photo_animated): + inst = input_profile_photo_animated + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_profile_photo_animated): + inst = input_profile_photo_animated + assert inst.type == self.type_ + assert isinstance(inst.animation, InputFile) + assert inst.main_frame_timestamp == self.main_frame_timestamp + + def test_to_dict(self, input_profile_photo_animated): + inst = input_profile_photo_animated + data = inst.to_dict() + assert data["type"] == self.type_ + assert data["animation"] == inst.animation + assert data["main_frame_timestamp"] == self.main_frame_timestamp.total_seconds() + + def test_with_local_file(self): + inst = InputProfilePhotoAnimated( + animation=data_file("telegram2.mp4"), + main_frame_timestamp=self.main_frame_timestamp, + ) + assert inst.animation == data_file("telegram2.mp4").as_uri() + + def test_type_enum_conversion(self, input_profile_photo_animated): + assert input_profile_photo_animated.type is InputProfilePhotoType.ANIMATED + + @pytest.mark.parametrize( + "timestamp", + [ + dtm.timedelta(days=2), + dtm.timedelta(seconds=2 * 24 * 60 * 60), + 2 * 24 * 60 * 60, + float(2 * 24 * 60 * 60), + ], + ) + def test_main_frame_timestamp_conversion(self, timestamp): + inst = InputProfilePhotoAnimated( + animation=self.animation, + main_frame_timestamp=timestamp, + ) + assert isinstance(inst.main_frame_timestamp, dtm.timedelta) + assert inst.main_frame_timestamp == dtm.timedelta(days=2) + + assert ( + InputProfilePhotoAnimated( + animation=self.animation, + main_frame_timestamp=None, + ).main_frame_timestamp + is None + ) diff --git a/tests/_files/test_inputsticker.py b/tests/_files/test_inputsticker.py index 32fe86398ad..43af66f356c 100644 --- a/tests/_files/test_inputsticker.py +++ b/tests/_files/test_inputsticker.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_files/test_inputstorycontent.py b/tests/_files/test_inputstorycontent.py new file mode 100644 index 00000000000..7c923071b90 --- /dev/null +++ b/tests/_files/test_inputstorycontent.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import datetime as dtm + +import pytest + +from telegram import InputFile, InputStoryContent, InputStoryContentPhoto, InputStoryContentVideo +from telegram.constants import InputStoryContentType +from tests.auxil.files import data_file +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def input_story_content(): + return InputStoryContent( + type=InputStoryContentTestBase.type, + ) + + +class InputStoryContentTestBase: + type = InputStoryContent.PHOTO + + +class TestInputStoryContent(InputStoryContentTestBase): + def test_slot_behaviour(self, input_story_content): + inst = input_story_content + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_type_enum_conversion(self): + assert type(InputStoryContent(type="video").type) is InputStoryContentType + assert InputStoryContent(type="unknown").type == "unknown" + + +@pytest.fixture(scope="module") +def input_story_content_photo(): + return InputStoryContentPhoto(photo=InputStoryContentPhotoTestBase.photo.read_bytes()) + + +class InputStoryContentPhotoTestBase: + type = InputStoryContentType.PHOTO + photo = data_file("telegram.jpg") + + +class TestInputStoryContentPhotoWithoutRequest(InputStoryContentPhotoTestBase): + def test_slot_behaviour(self, input_story_content_photo): + inst = input_story_content_photo + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_story_content_photo): + inst = input_story_content_photo + assert inst.type is self.type + assert isinstance(inst.photo, InputFile) + + def test_to_dict(self, input_story_content_photo): + inst = input_story_content_photo + json_dict = inst.to_dict() + assert json_dict["type"] is self.type + assert json_dict["photo"] == inst.photo + + def test_with_photo_file(self, photo_file): + inst = InputStoryContentPhoto(photo=photo_file) + assert inst.type is self.type + assert isinstance(inst.photo, InputFile) + + def test_with_local_files(self): + inst = InputStoryContentPhoto(photo=data_file("telegram.jpg")) + assert inst.photo == data_file("telegram.jpg").as_uri() + + +@pytest.fixture(scope="module") +def input_story_content_video(): + return InputStoryContentVideo( + video=InputStoryContentVideoTestBase.video.read_bytes(), + duration=InputStoryContentVideoTestBase.duration, + cover_frame_timestamp=InputStoryContentVideoTestBase.cover_frame_timestamp, + is_animation=InputStoryContentVideoTestBase.is_animation, + ) + + +class InputStoryContentVideoTestBase: + type = InputStoryContentType.VIDEO + video = data_file("telegram.mp4") + duration = dtm.timedelta(seconds=30) + cover_frame_timestamp = dtm.timedelta(seconds=15) + is_animation = False + + +class TestInputStoryContentVideoWithoutRequest(InputStoryContentVideoTestBase): + def test_slot_behaviour(self, input_story_content_video): + inst = input_story_content_video + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_story_content_video): + inst = input_story_content_video + assert inst.type is self.type + assert isinstance(inst.video, InputFile) + assert inst.duration == self.duration + assert inst.cover_frame_timestamp == self.cover_frame_timestamp + assert inst.is_animation is self.is_animation + + def test_to_dict(self, input_story_content_video): + inst = input_story_content_video + json_dict = inst.to_dict() + assert json_dict["type"] is self.type + assert json_dict["video"] == inst.video + assert json_dict["duration"] == self.duration.total_seconds() + assert json_dict["cover_frame_timestamp"] == self.cover_frame_timestamp.total_seconds() + assert json_dict["is_animation"] is self.is_animation + + @pytest.mark.parametrize( + ("argument", "expected"), + [(4, 4), (4.0, 4), (dtm.timedelta(seconds=4), 4), (4.5, 4.5)], + ) + def test_to_dict_float_time_period(self, argument, expected): + # We test that whole number conversion works properly. Only tested here but + # relevant for some other classes too (e.g InputProfilePhotoAnimated.main_frame_timestamp) + inst = InputStoryContentVideo( + video=self.video.read_bytes(), + duration=argument, + cover_frame_timestamp=argument, + ) + json_dict = inst.to_dict() + + assert json_dict["duration"] == expected + assert type(json_dict["duration"]) is type(expected) + assert json_dict["cover_frame_timestamp"] == expected + assert type(json_dict["cover_frame_timestamp"]) is type(expected) + + def test_with_video_file(self, video_file): + inst = InputStoryContentVideo(video=video_file) + assert inst.type is self.type + assert isinstance(inst.video, InputFile) + + def test_with_local_files(self): + inst = InputStoryContentVideo(video=data_file("telegram.mp4")) + assert inst.video == data_file("telegram.mp4").as_uri() + + @pytest.mark.parametrize("timestamp", [dtm.timedelta(seconds=60), 60, float(60)]) + @pytest.mark.parametrize("field", ["duration", "cover_frame_timestamp"]) + def test_time_period_arg_conversion(self, field, timestamp): + inst = InputStoryContentVideo( + video=self.video, + **{field: timestamp}, + ) + value = getattr(inst, field) + assert isinstance(value, dtm.timedelta) + assert value == dtm.timedelta(seconds=60) + + inst = InputStoryContentVideo( + video=self.video, + **{field: None}, + ) + value = getattr(inst, field) + assert value is None diff --git a/tests/_files/test_location.py b/tests/_files/test_location.py index 7664c765feb..0deaea2e2f5 100644 --- a/tests/_files/test_location.py +++ b/tests/_files/test_location.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio +import datetime as dtm import pytest @@ -24,6 +25,7 @@ from telegram.constants import ParseMode from telegram.error import BadRequest from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.build_messages import make_message from tests.auxil.slots import mro_slots @@ -44,7 +46,7 @@ class LocationTestBase: latitude = -23.691288 longitude = -46.788279 horizontal_accuracy = 999 - live_period = 60 + live_period = dtm.timedelta(seconds=60) heading = 90 proximity_alert_radius = 50 @@ -60,7 +62,7 @@ def test_de_json(self, offline_bot): "latitude": self.latitude, "longitude": self.longitude, "horizontal_accuracy": self.horizontal_accuracy, - "live_period": self.live_period, + "live_period": int(self.live_period.total_seconds()), "heading": self.heading, "proximity_alert_radius": self.proximity_alert_radius, } @@ -70,7 +72,7 @@ def test_de_json(self, offline_bot): assert location.latitude == self.latitude assert location.longitude == self.longitude assert location.horizontal_accuracy == self.horizontal_accuracy - assert location.live_period == self.live_period + assert location._live_period == self.live_period assert location.heading == self.heading assert location.proximity_alert_radius == self.proximity_alert_radius @@ -80,10 +82,29 @@ def test_to_dict(self, location): assert location_dict["latitude"] == location.latitude assert location_dict["longitude"] == location.longitude assert location_dict["horizontal_accuracy"] == location.horizontal_accuracy - assert location_dict["live_period"] == location.live_period + assert location_dict["live_period"] == int(self.live_period.total_seconds()) + assert isinstance(location_dict["live_period"], int) assert location["heading"] == location.heading assert location["proximity_alert_radius"] == location.proximity_alert_radius + def test_time_period_properties(self, PTB_TIMEDELTA, location): + if PTB_TIMEDELTA: + assert location.live_period == self.live_period + assert isinstance(location.live_period, dtm.timedelta) + else: + assert location.live_period == int(self.live_period.total_seconds()) + assert isinstance(location.live_period, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, location): + location.live_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`live_period` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = Location(self.longitude, self.latitude) b = Location(self.longitude, self.latitude) @@ -238,12 +259,16 @@ async def test_send_location_default_protect_content(self, chat_id, default_bot, assert not unprotected.has_protected_content @pytest.mark.xfail - async def test_send_live_location(self, bot, chat_id): + @pytest.mark.parametrize( + ("live_period", "edit_live_period"), + [(80, 200), (dtm.timedelta(seconds=80), dtm.timedelta(seconds=200))], + ) + async def test_send_live_location(self, bot, chat_id, live_period, edit_live_period): message = await bot.send_location( chat_id=chat_id, latitude=52.223880, longitude=5.166146, - live_period=80, + live_period=live_period, horizontal_accuracy=50, heading=90, proximity_alert_radius=1000, @@ -266,7 +291,7 @@ async def test_send_live_location(self, bot, chat_id): horizontal_accuracy=30, heading=10, proximity_alert_radius=500, - live_period=200, + live_period=edit_live_period, ) assert pytest.approx(message2.location.latitude, rel=1e-5) == 52.223098 @@ -276,7 +301,7 @@ async def test_send_live_location(self, bot, chat_id): assert message2.location.proximity_alert_radius == 500 assert message2.location.live_period == 200 - await bot.stop_message_live_location(message.chat_id, message.message_id) + assert await bot.stop_message_live_location(message.chat_id, message.message_id) with pytest.raises(BadRequest, match="Message can't be edited"): await bot.edit_message_live_location( message.chat_id, message.message_id, latitude=52.223880, longitude=5.164306 diff --git a/tests/_files/test_photo.py b/tests/_files/test_photo.py index 961bd71c8dc..83ba28ea701 100644 --- a/tests/_files/test_photo.py +++ b/tests/_files/test_photo.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -44,7 +44,7 @@ class PhotoTestBase: photo_file_url = "https://python-telegram-bot.org/static/testfiles/telegram_new.jpg" # For some reason the file size is not the same after switching to httpx # so we accept three different sizes here. Shouldn't be too much - file_size = [29176, 27662] + file_size = [29176, 27662, 27330] class TestPhotoWithoutRequest(PhotoTestBase): @@ -144,7 +144,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): assert await offline_bot.send_photo(chat_id, photo_file, filename="custom_filename") @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_photo_local_files(self, monkeypatch, offline_bot, chat_id, local_mode): + async def test_send_photo_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: offline_bot._local_mode = local_mode # For just test that the correct paths are passed as we have no local Bot API set up @@ -158,6 +160,7 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = data.get("photo") == expected else: test_flag = isinstance(data.get("photo"), InputFile) + return dummy_message_dict monkeypatch.setattr(offline_bot, "_post", make_assertion) await offline_bot.send_photo(chat_id, file) diff --git a/tests/_files/test_sticker.py b/tests/_files/test_sticker.py index a10611fab35..798e3fc08f3 100644 --- a/tests/_files/test_sticker.py +++ b/tests/_files/test_sticker.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -256,7 +256,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): assert await offline_bot.send_sticker(sticker=sticker, chat_id=chat_id) @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_sticker_local_files(self, monkeypatch, offline_bot, chat_id, local_mode): + async def test_send_sticker_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: offline_bot._local_mode = local_mode # For just test that the correct paths are passed as we have no local Bot API set up @@ -270,6 +272,7 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = data.get("sticker") == expected else: test_flag = isinstance(data.get("sticker"), InputFile) + return dummy_message_dict monkeypatch.setattr(offline_bot, "_post", make_assertion) await offline_bot.send_sticker(chat_id, file) @@ -581,6 +584,7 @@ async def make_assertion(_, data, *args, **kwargs): if local_mode else isinstance(data.get("sticker"), InputFile) ) + return File(file_id="file_id", file_unique_id="file_unique_id").to_dict() monkeypatch.setattr(offline_bot, "_post", make_assertion) await offline_bot.upload_sticker_file( @@ -761,6 +765,14 @@ async def test_create_sticker_set( try: ss = await bot.get_sticker_set(sticker_set) assert isinstance(ss, StickerSet) + + if len(ss.stickers) > 100: + try: + for i in range(1, 50): + await bot.delete_sticker_from_set(ss.stickers[-i].file_id) + except BadRequest as e: + if e.message != "Stickerset_not_modified": + raise Exception("stickerset is growing too large.") from None except BadRequest as e: if not e.message == "Stickerset_invalid": raise e diff --git a/tests/_files/test_venue.py b/tests/_files/test_venue.py index bf7ecaf6311..894cc97bfa3 100644 --- a/tests/_files/test_venue.py +++ b/tests/_files/test_venue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_files/test_video.py b/tests/_files/test_video.py index 97198d46ecd..366e7d1a9fb 100644 --- a/tests/_files/test_video.py +++ b/tests/_files/test_video.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio +import datetime as dtm import os from pathlib import Path @@ -27,6 +28,7 @@ from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -37,14 +39,27 @@ from tests.auxil.slots import mro_slots +# Override `video` fixture to provide start_timestamp +@pytest.fixture(scope="module") +async def video(bot, chat_id): + with data_file("telegram.mp4").open("rb") as f: + return ( + await bot.send_video( + chat_id, video=f, start_timestamp=VideoTestBase.start_timestamp, read_timeout=50 + ) + ).video + + class VideoTestBase: width = 360 height = 640 - duration = 5 + duration = dtm.timedelta(seconds=5) file_size = 326534 mime_type = "video/mp4" supports_streaming = True file_name = "telegram.mp4" + start_timestamp = dtm.timedelta(seconds=3) + cover = (PhotoSize("file_id", "unique_id", 640, 360, file_size=0),) thumb_width = 180 thumb_height = 320 thumb_file_size = 1767 @@ -77,9 +92,10 @@ def test_creation(self, video): def test_expected_values(self, video): assert video.width == self.width assert video.height == self.height - assert video.duration == self.duration + assert video._duration == self.duration assert video.file_size == self.file_size assert video.mime_type == self.mime_type + assert video._start_timestamp == self.start_timestamp def test_de_json(self, offline_bot): json_dict = { @@ -87,10 +103,12 @@ def test_de_json(self, offline_bot): "file_unique_id": self.video_file_unique_id, "width": self.width, "height": self.height, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), "mime_type": self.mime_type, "file_size": self.file_size, "file_name": self.file_name, + "start_timestamp": int(self.start_timestamp.total_seconds()), + "cover": [photo_size.to_dict() for photo_size in self.cover], } json_video = Video.de_json(json_dict, offline_bot) assert json_video.api_kwargs == {} @@ -99,10 +117,12 @@ def test_de_json(self, offline_bot): assert json_video.file_unique_id == self.video_file_unique_id assert json_video.width == self.width assert json_video.height == self.height - assert json_video.duration == self.duration + assert json_video._duration == self.duration assert json_video.mime_type == self.mime_type assert json_video.file_size == self.file_size assert json_video.file_name == self.file_name + assert json_video._start_timestamp == self.start_timestamp + assert json_video.cover == self.cover def test_to_dict(self, video): video_dict = video.to_dict() @@ -112,10 +132,39 @@ def test_to_dict(self, video): assert video_dict["file_unique_id"] == video.file_unique_id assert video_dict["width"] == video.width assert video_dict["height"] == video.height - assert video_dict["duration"] == video.duration + assert video_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(video_dict["duration"], int) assert video_dict["mime_type"] == video.mime_type assert video_dict["file_size"] == video.file_size assert video_dict["file_name"] == video.file_name + assert video_dict["start_timestamp"] == int(self.start_timestamp.total_seconds()) + assert isinstance(video_dict["start_timestamp"], int) + + def test_time_period_properties(self, PTB_TIMEDELTA, video): + if PTB_TIMEDELTA: + assert video.duration == self.duration + assert isinstance(video.duration, dtm.timedelta) + + assert video.start_timestamp == self.start_timestamp + assert isinstance(video.start_timestamp, dtm.timedelta) + else: + assert video.duration == int(self.duration.total_seconds()) + assert isinstance(video.duration, int) + + assert video.start_timestamp == int(self.start_timestamp.total_seconds()) + assert isinstance(video.start_timestamp, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, video): + video.duration + video.start_timestamp + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 2 + for i, attr in enumerate(["duration", "start_timestamp"]): + assert f"`{attr}` will be of type `datetime.timedelta`" in str(recwarn[i].message) + assert recwarn[i].category is PTBDeprecationWarning def test_equality(self, video): a = Video(video.file_id, video.file_unique_id, self.width, self.height, self.duration) @@ -157,7 +206,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): assert await offline_bot.send_video(chat_id, video_file, filename="custom_filename") @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_video_local_files(self, monkeypatch, offline_bot, chat_id, local_mode): + async def test_send_video_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: offline_bot._local_mode = local_mode # For just test that the correct paths are passed as we have no local Bot API set up @@ -173,6 +224,7 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = isinstance(data.get("video"), InputFile) and isinstance( data.get("thumbnail"), InputFile ) + return dummy_message_dict monkeypatch.setattr(offline_bot, "_post", make_assertion) await offline_bot.send_video(chat_id, file, thumbnail=file) @@ -218,11 +270,14 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): class TestVideoWithRequest(VideoTestBase): - async def test_send_all_args(self, bot, chat_id, video_file, video, thumb_file): + @pytest.mark.parametrize("duration", [dtm.timedelta(seconds=5), 5]) + async def test_send_all_args( + self, bot, chat_id, video_file, video, thumb_file, photo_file, duration + ): message = await bot.send_video( chat_id, video_file, - duration=self.duration, + duration=duration, caption=self.caption, supports_streaming=self.supports_streaming, disable_notification=False, @@ -231,6 +286,8 @@ async def test_send_all_args(self, bot, chat_id, video_file, video, thumb_file): height=video.height, parse_mode="Markdown", thumbnail=thumb_file, + cover=photo_file, + start_timestamp=self.start_timestamp, has_spoiler=True, show_caption_above_media=True, ) @@ -251,6 +308,11 @@ async def test_send_all_args(self, bot, chat_id, video_file, video, thumb_file): assert message.video.thumbnail.width == self.thumb_width assert message.video.thumbnail.height == self.thumb_height + assert message.video._start_timestamp == self.start_timestamp + + assert isinstance(message.video.cover, tuple) + assert isinstance(message.video.cover[0], PhotoSize) + assert message.video.file_name == self.file_name assert message.has_protected_content assert message.has_media_spoiler diff --git a/tests/_files/test_videonote.py b/tests/_files/test_videonote.py index b639f968b87..3af79baf497 100644 --- a/tests/_files/test_videonote.py +++ b/tests/_files/test_videonote.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio +import datetime as dtm import os from pathlib import Path @@ -26,6 +27,7 @@ from telegram.constants import ParseMode from telegram.error import BadRequest, TelegramError from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -50,7 +52,7 @@ async def video_note(bot, chat_id): class VideoNoteTestBase: length = 240 - duration = 3 + duration = dtm.timedelta(seconds=3) file_size = 132084 thumb_width = 240 thumb_height = 240 @@ -80,17 +82,12 @@ def test_creation(self, video_note): assert video_note.thumbnail.file_id assert video_note.thumbnail.file_unique_id - def test_expected_values(self, video_note): - assert video_note.length == self.length - assert video_note.duration == self.duration - assert video_note.file_size == self.file_size - def test_de_json(self, offline_bot): json_dict = { "file_id": self.videonote_file_id, "file_unique_id": self.videonote_file_unique_id, "length": self.length, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), "file_size": self.file_size, } json_video_note = VideoNote.de_json(json_dict, offline_bot) @@ -99,7 +96,7 @@ def test_de_json(self, offline_bot): assert json_video_note.file_id == self.videonote_file_id assert json_video_note.file_unique_id == self.videonote_file_unique_id assert json_video_note.length == self.length - assert json_video_note.duration == self.duration + assert json_video_note._duration == self.duration assert json_video_note.file_size == self.file_size def test_to_dict(self, video_note): @@ -109,9 +106,28 @@ def test_to_dict(self, video_note): assert video_note_dict["file_id"] == video_note.file_id assert video_note_dict["file_unique_id"] == video_note.file_unique_id assert video_note_dict["length"] == video_note.length - assert video_note_dict["duration"] == video_note.duration + assert video_note_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(video_note_dict["duration"], int) assert video_note_dict["file_size"] == video_note.file_size + def test_time_period_properties(self, PTB_TIMEDELTA, video_note): + if PTB_TIMEDELTA: + assert video_note.duration == self.duration + assert isinstance(video_note.duration, dtm.timedelta) + else: + assert video_note.duration == int(self.duration.total_seconds()) + assert isinstance(video_note.duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, video_note): + video_note.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self, video_note): a = VideoNote(video_note.file_id, video_note.file_unique_id, self.length, self.duration) b = VideoNote("", video_note.file_unique_id, self.length, self.duration) @@ -157,7 +173,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): @pytest.mark.parametrize("local_mode", [True, False]) async def test_send_video_note_local_files( - self, monkeypatch, offline_bot, chat_id, local_mode + self, monkeypatch, offline_bot, chat_id, local_mode, dummy_message_dict ): try: offline_bot._local_mode = local_mode @@ -176,6 +192,7 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = isinstance(data.get("video_note"), InputFile) and isinstance( data.get("thumbnail"), InputFile ) + return dummy_message_dict monkeypatch.setattr(offline_bot, "_post", make_assertion) await offline_bot.send_video_note(chat_id, file, thumbnail=file) @@ -223,11 +240,14 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): class TestVideoNoteWithRequest(VideoNoteTestBase): - async def test_send_all_args(self, bot, chat_id, video_note_file, video_note, thumb_file): + @pytest.mark.parametrize("duration", [3, dtm.timedelta(seconds=3)]) + async def test_send_all_args( + self, bot, chat_id, video_note_file, video_note, thumb_file, duration + ): message = await bot.send_video_note( chat_id, video_note_file, - duration=self.duration, + duration=duration, length=self.length, disable_notification=False, protect_content=True, diff --git a/tests/_files/test_voice.py b/tests/_files/test_voice.py index ccba583de4f..e1e47759db9 100644 --- a/tests/_files/test_voice.py +++ b/tests/_files/test_voice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio +import datetime as dtm import os from pathlib import Path @@ -27,6 +28,7 @@ from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -50,7 +52,7 @@ async def voice(bot, chat_id): class VoiceTestBase: - duration = 3 + duration = dtm.timedelta(seconds=3) mime_type = "audio/ogg" file_size = 9199 caption = "Test *voice*" @@ -74,7 +76,7 @@ async def test_creation(self, voice): assert voice.file_unique_id def test_expected_values(self, voice): - assert voice.duration == self.duration + assert voice._duration == self.duration assert voice.mime_type == self.mime_type assert voice.file_size == self.file_size @@ -82,7 +84,7 @@ def test_de_json(self, offline_bot): json_dict = { "file_id": self.voice_file_id, "file_unique_id": self.voice_file_unique_id, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), "mime_type": self.mime_type, "file_size": self.file_size, } @@ -91,7 +93,7 @@ def test_de_json(self, offline_bot): assert json_voice.file_id == self.voice_file_id assert json_voice.file_unique_id == self.voice_file_unique_id - assert json_voice.duration == self.duration + assert json_voice._duration == self.duration assert json_voice.mime_type == self.mime_type assert json_voice.file_size == self.file_size @@ -101,10 +103,29 @@ def test_to_dict(self, voice): assert isinstance(voice_dict, dict) assert voice_dict["file_id"] == voice.file_id assert voice_dict["file_unique_id"] == voice.file_unique_id - assert voice_dict["duration"] == voice.duration + assert voice_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(voice_dict["duration"], int) assert voice_dict["mime_type"] == voice.mime_type assert voice_dict["file_size"] == voice.file_size + def test_time_period_properties(self, PTB_TIMEDELTA, voice): + if PTB_TIMEDELTA: + assert voice.duration == self.duration + assert isinstance(voice.duration, dtm.timedelta) + else: + assert voice.duration == int(self.duration.total_seconds()) + assert isinstance(voice.duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, voice): + voice.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self, voice): a = Voice(voice.file_id, voice.file_unique_id, self.duration) b = Voice("", voice.file_unique_id, self.duration) @@ -145,7 +166,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): assert await offline_bot.send_voice(chat_id, voice=voice) @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_voice_local_files(self, monkeypatch, offline_bot, chat_id, local_mode): + async def test_send_voice_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: offline_bot._local_mode = local_mode # For just test that the correct paths are passed as we have no local Bot API set up @@ -159,6 +182,7 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = data.get("voice") == expected else: test_flag = isinstance(data.get("voice"), InputFile) + return dummy_message_dict monkeypatch.setattr(offline_bot, "_post", make_assertion) await offline_bot.send_voice(chat_id, file) @@ -204,11 +228,12 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): class TestVoiceWithRequest(VoiceTestBase): - async def test_send_all_args(self, bot, chat_id, voice_file, voice): + @pytest.mark.parametrize("duration", [3, dtm.timedelta(seconds=3)]) + async def test_send_all_args(self, bot, chat_id, voice_file, voice, duration): message = await bot.send_voice( chat_id, voice_file, - duration=self.duration, + duration=duration, caption=self.caption, disable_notification=False, protect_content=True, diff --git a/tests/_games/__init__.py b/tests/_games/__init__.py index 040bfc78668..c95cb3c9741 100644 --- a/tests/_games/__init__.py +++ b/tests/_games/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_games/test_game.py b/tests/_games/test_game.py index 499d2e55567..4db32d4c9aa 100644 --- a/tests/_games/test_game.py +++ b/tests/_games/test_game.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_games/test_gamehighscore.py b/tests/_games/test_gamehighscore.py index cd84900dbc5..d0d6008b449 100644 --- a/tests/_games/test_gamehighscore.py +++ b/tests/_games/test_gamehighscore.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -55,8 +55,6 @@ def test_de_json(self, offline_bot): assert highscore.user == self.user assert highscore.score == self.score - assert GameHighScore.de_json(None, offline_bot) is None - def test_to_dict(self, game_highscore): game_highscore_dict = game_highscore.to_dict() diff --git a/tests/_inline/__init__.py b/tests/_inline/__init__.py index 040bfc78668..c95cb3c9741 100644 --- a/tests/_inline/__init__.py +++ b/tests/_inline/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinekeyboardbutton.py b/tests/_inline/test_inlinekeyboardbutton.py index 8c2c98a4684..f1f0f798931 100644 --- a/tests/_inline/test_inlinekeyboardbutton.py +++ b/tests/_inline/test_inlinekeyboardbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -159,9 +159,6 @@ def test_de_json(self, offline_bot): ) assert inline_keyboard_button.copy_text == self.copy_text - none = InlineKeyboardButton.de_json({}, offline_bot) - assert none is None - def test_equality(self): a = InlineKeyboardButton("text", callback_data="data") b = InlineKeyboardButton("text", callback_data="data") diff --git a/tests/_inline/test_inlinekeyboardmarkup.py b/tests/_inline/test_inlinekeyboardmarkup.py index 9fdd1b1acd1..3de2901ca6f 100644 --- a/tests/_inline/test_inlinekeyboardmarkup.py +++ b/tests/_inline/test_inlinekeyboardmarkup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequery.py b/tests/_inline/test_inlinequery.py index cc2ab9fe297..9e16035117b 100644 --- a/tests/_inline/test_inlinequery.py +++ b/tests/_inline/test_inlinequery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequeryresultarticle.py b/tests/_inline/test_inlinequeryresultarticle.py index 80134cdbfd6..9d99e4a52c0 100644 --- a/tests/_inline/test_inlinequeryresultarticle.py +++ b/tests/_inline/test_inlinequeryresultarticle.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -28,7 +28,6 @@ InputTextMessageContent, ) from telegram.constants import InlineQueryResultType -from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -40,7 +39,6 @@ def inline_query_result_article(): input_message_content=InlineQueryResultArticleTestBase.input_message_content, reply_markup=InlineQueryResultArticleTestBase.reply_markup, url=InlineQueryResultArticleTestBase.url, - hide_url=InlineQueryResultArticleTestBase.hide_url, description=InlineQueryResultArticleTestBase.description, thumbnail_url=InlineQueryResultArticleTestBase.thumbnail_url, thumbnail_height=InlineQueryResultArticleTestBase.thumbnail_height, @@ -55,7 +53,6 @@ class InlineQueryResultArticleTestBase: input_message_content = InputTextMessageContent("input_message_content") reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) url = "url" - hide_url = True description = "description" thumbnail_url = "thumb url" thumbnail_height = 10 @@ -79,7 +76,6 @@ def test_expected_values(self, inline_query_result_article): ) assert inline_query_result_article.reply_markup.to_dict() == self.reply_markup.to_dict() assert inline_query_result_article.url == self.url - assert inline_query_result_article.hide_url == self.hide_url assert inline_query_result_article.description == self.description assert inline_query_result_article.thumbnail_url == self.thumbnail_url assert inline_query_result_article.thumbnail_height == self.thumbnail_height @@ -101,7 +97,6 @@ def test_to_dict(self, inline_query_result_article): == inline_query_result_article.reply_markup.to_dict() ) assert inline_query_result_article_dict["url"] == inline_query_result_article.url - assert inline_query_result_article_dict["hide_url"] == inline_query_result_article.hide_url assert ( inline_query_result_article_dict["description"] == inline_query_result_article.description @@ -158,31 +153,3 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) - - def test_deprecation_warning_for_hide_url(self): - with pytest.warns(PTBDeprecationWarning, match="The argument `hide_url`") as record: - InlineQueryResultArticle( - self.id_, self.title, self.input_message_content, hide_url=True - ) - - assert record[0].filename == __file__, "wrong stacklevel!" - - with pytest.warns(PTBDeprecationWarning, match="The argument `hide_url`") as record: - InlineQueryResultArticle( - self.id_, self.title, self.input_message_content, hide_url=False - ) - - assert record[0].filename == __file__, "wrong stacklevel!" - - assert ( - InlineQueryResultArticle( - self.id_, self.title, self.input_message_content, hide_url=True - ).hide_url - is True - ) - assert ( - InlineQueryResultArticle( - self.id_, self.title, self.input_message_content, hide_url=False - ).hide_url - is False - ) diff --git a/tests/_inline/test_inlinequeryresultaudio.py b/tests/_inline/test_inlinequeryresultaudio.py index 4c781655910..896d1f263bd 100644 --- a/tests/_inline/test_inlinequeryresultaudio.py +++ b/tests/_inline/test_inlinequeryresultaudio.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import ( @@ -27,6 +29,7 @@ InputTextMessageContent, MessageEntity, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -52,7 +55,7 @@ class InlineQueryResultAudioTestBase: audio_url = "audio url" title = "title" performer = "performer" - audio_duration = "audio_duration" + audio_duration = dtm.timedelta(seconds=10) caption = "caption" parse_mode = "Markdown" caption_entities = [MessageEntity(MessageEntity.ITALIC, 0, 7)] @@ -73,7 +76,7 @@ def test_expected_values(self, inline_query_result_audio): assert inline_query_result_audio.audio_url == self.audio_url assert inline_query_result_audio.title == self.title assert inline_query_result_audio.performer == self.performer - assert inline_query_result_audio.audio_duration == self.audio_duration + assert inline_query_result_audio._audio_duration == self.audio_duration assert inline_query_result_audio.caption == self.caption assert inline_query_result_audio.parse_mode == self.parse_mode assert inline_query_result_audio.caption_entities == tuple(self.caption_entities) @@ -92,10 +95,10 @@ def test_to_dict(self, inline_query_result_audio): assert inline_query_result_audio_dict["audio_url"] == inline_query_result_audio.audio_url assert inline_query_result_audio_dict["title"] == inline_query_result_audio.title assert inline_query_result_audio_dict["performer"] == inline_query_result_audio.performer - assert ( - inline_query_result_audio_dict["audio_duration"] - == inline_query_result_audio.audio_duration + assert inline_query_result_audio_dict["audio_duration"] == int( + self.audio_duration.total_seconds() ) + assert isinstance(inline_query_result_audio_dict["audio_duration"], int) assert inline_query_result_audio_dict["caption"] == inline_query_result_audio.caption assert inline_query_result_audio_dict["parse_mode"] == inline_query_result_audio.parse_mode assert inline_query_result_audio_dict["caption_entities"] == [ @@ -114,6 +117,28 @@ def test_caption_entities_always_tuple(self): inline_query_result_audio = InlineQueryResultAudio(self.id_, self.audio_url, self.title) assert inline_query_result_audio.caption_entities == () + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_audio): + audio_duration = inline_query_result_audio.audio_duration + + if PTB_TIMEDELTA: + assert audio_duration == self.audio_duration + assert isinstance(audio_duration, dtm.timedelta) + else: + assert audio_duration == int(self.audio_duration.total_seconds()) + assert isinstance(audio_duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_result_audio): + inline_query_result_audio.audio_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`audio_duration` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InlineQueryResultAudio(self.id_, self.audio_url, self.title) b = InlineQueryResultAudio(self.id_, self.title, self.title) diff --git a/tests/_inline/test_inlinequeryresultcachedaudio.py b/tests/_inline/test_inlinequeryresultcachedaudio.py index 6379af83b41..6bbbfac4abb 100644 --- a/tests/_inline/test_inlinequeryresultcachedaudio.py +++ b/tests/_inline/test_inlinequeryresultcachedaudio.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequeryresultcacheddocument.py b/tests/_inline/test_inlinequeryresultcacheddocument.py index d3949ca11e9..f9a7fd59233 100644 --- a/tests/_inline/test_inlinequeryresultcacheddocument.py +++ b/tests/_inline/test_inlinequeryresultcacheddocument.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequeryresultcachedgif.py b/tests/_inline/test_inlinequeryresultcachedgif.py index 70b99d0906d..481b100ae90 100644 --- a/tests/_inline/test_inlinequeryresultcachedgif.py +++ b/tests/_inline/test_inlinequeryresultcachedgif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequeryresultcachedmpeg4gif.py b/tests/_inline/test_inlinequeryresultcachedmpeg4gif.py index db3aa282db0..b3712bc6eaf 100644 --- a/tests/_inline/test_inlinequeryresultcachedmpeg4gif.py +++ b/tests/_inline/test_inlinequeryresultcachedmpeg4gif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequeryresultcachedphoto.py b/tests/_inline/test_inlinequeryresultcachedphoto.py index 0c69bee3105..0433a6b6cc6 100644 --- a/tests/_inline/test_inlinequeryresultcachedphoto.py +++ b/tests/_inline/test_inlinequeryresultcachedphoto.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequeryresultcachedsticker.py b/tests/_inline/test_inlinequeryresultcachedsticker.py index 235e975b765..062db4f4d00 100644 --- a/tests/_inline/test_inlinequeryresultcachedsticker.py +++ b/tests/_inline/test_inlinequeryresultcachedsticker.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequeryresultcachedvideo.py b/tests/_inline/test_inlinequeryresultcachedvideo.py index 4bd7cf08be4..3b1b7dfbef3 100644 --- a/tests/_inline/test_inlinequeryresultcachedvideo.py +++ b/tests/_inline/test_inlinequeryresultcachedvideo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequeryresultcachedvoice.py b/tests/_inline/test_inlinequeryresultcachedvoice.py index fe92178e6bc..4fe608aa047 100644 --- a/tests/_inline/test_inlinequeryresultcachedvoice.py +++ b/tests/_inline/test_inlinequeryresultcachedvoice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequeryresultcontact.py b/tests/_inline/test_inlinequeryresultcontact.py index 6188273659b..3fee14f7a17 100644 --- a/tests/_inline/test_inlinequeryresultcontact.py +++ b/tests/_inline/test_inlinequeryresultcontact.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequeryresultdocument.py b/tests/_inline/test_inlinequeryresultdocument.py index 2c49eab3e39..326e330b36d 100644 --- a/tests/_inline/test_inlinequeryresultdocument.py +++ b/tests/_inline/test_inlinequeryresultdocument.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequeryresultgame.py b/tests/_inline/test_inlinequeryresultgame.py index 4b835c029a0..15c783a3fed 100644 --- a/tests/_inline/test_inlinequeryresultgame.py +++ b/tests/_inline/test_inlinequeryresultgame.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequeryresultgif.py b/tests/_inline/test_inlinequeryresultgif.py index 878b9b61d3c..9b25286eb0f 100644 --- a/tests/_inline/test_inlinequeryresultgif.py +++ b/tests/_inline/test_inlinequeryresultgif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,6 +16,8 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import ( @@ -26,6 +28,7 @@ InputTextMessageContent, MessageEntity, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -55,7 +58,7 @@ class InlineQueryResultGifTestBase: gif_url = "gif url" gif_width = 10 gif_height = 15 - gif_duration = 1 + gif_duration = dtm.timedelta(seconds=1) thumbnail_url = "thumb url" thumbnail_mime_type = "image/jpeg" title = "title" @@ -84,7 +87,7 @@ def test_expected_values(self, inline_query_result_gif): assert inline_query_result_gif.gif_url == self.gif_url assert inline_query_result_gif.gif_width == self.gif_width assert inline_query_result_gif.gif_height == self.gif_height - assert inline_query_result_gif.gif_duration == self.gif_duration + assert inline_query_result_gif._gif_duration == self.gif_duration assert inline_query_result_gif.thumbnail_url == self.thumbnail_url assert inline_query_result_gif.thumbnail_mime_type == self.thumbnail_mime_type assert inline_query_result_gif.title == self.title @@ -107,7 +110,10 @@ def test_to_dict(self, inline_query_result_gif): assert inline_query_result_gif_dict["gif_url"] == inline_query_result_gif.gif_url assert inline_query_result_gif_dict["gif_width"] == inline_query_result_gif.gif_width assert inline_query_result_gif_dict["gif_height"] == inline_query_result_gif.gif_height - assert inline_query_result_gif_dict["gif_duration"] == inline_query_result_gif.gif_duration + assert inline_query_result_gif_dict["gif_duration"] == int( + self.gif_duration.total_seconds() + ) + assert isinstance(inline_query_result_gif_dict["gif_duration"], int) assert ( inline_query_result_gif_dict["thumbnail_url"] == inline_query_result_gif.thumbnail_url ) @@ -134,6 +140,26 @@ def test_to_dict(self, inline_query_result_gif): == inline_query_result_gif.show_caption_above_media ) + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_gif): + gif_duration = inline_query_result_gif.gif_duration + + if PTB_TIMEDELTA: + assert gif_duration == self.gif_duration + assert isinstance(gif_duration, dtm.timedelta) + else: + assert gif_duration == int(self.gif_duration.total_seconds()) + assert isinstance(gif_duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_result_gif): + inline_query_result_gif.gif_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`gif_duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InlineQueryResultGif(self.id_, self.gif_url, self.thumbnail_url) b = InlineQueryResultGif(self.id_, self.gif_url, self.thumbnail_url) diff --git a/tests/_inline/test_inlinequeryresultlocation.py b/tests/_inline/test_inlinequeryresultlocation.py index db9c64cfd10..183e1818268 100644 --- a/tests/_inline/test_inlinequeryresultlocation.py +++ b/tests/_inline/test_inlinequeryresultlocation.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,6 +16,8 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import ( @@ -25,6 +27,7 @@ InlineQueryResultVoice, InputTextMessageContent, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -54,7 +57,7 @@ class InlineQueryResultLocationTestBase: longitude = 1.0 title = "title" horizontal_accuracy = 999 - live_period = 70 + live_period = dtm.timedelta(seconds=70) heading = 90 proximity_alert_radius = 1000 thumbnail_url = "thumb url" @@ -77,7 +80,7 @@ def test_expected_values(self, inline_query_result_location): assert inline_query_result_location.latitude == self.latitude assert inline_query_result_location.longitude == self.longitude assert inline_query_result_location.title == self.title - assert inline_query_result_location.live_period == self.live_period + assert inline_query_result_location._live_period == self.live_period assert inline_query_result_location.thumbnail_url == self.thumbnail_url assert inline_query_result_location.thumbnail_width == self.thumbnail_width assert inline_query_result_location.thumbnail_height == self.thumbnail_height @@ -104,10 +107,10 @@ def test_to_dict(self, inline_query_result_location): == inline_query_result_location.longitude ) assert inline_query_result_location_dict["title"] == inline_query_result_location.title - assert ( - inline_query_result_location_dict["live_period"] - == inline_query_result_location.live_period + assert inline_query_result_location_dict["live_period"] == int( + self.live_period.total_seconds() ) + assert isinstance(inline_query_result_location_dict["live_period"], int) assert ( inline_query_result_location_dict["thumbnail_url"] == inline_query_result_location.thumbnail_url @@ -138,6 +141,28 @@ def test_to_dict(self, inline_query_result_location): == inline_query_result_location.proximity_alert_radius ) + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_location): + live_period = inline_query_result_location.live_period + + if PTB_TIMEDELTA: + assert live_period == self.live_period + assert isinstance(live_period, dtm.timedelta) + else: + assert live_period == int(self.live_period.total_seconds()) + assert isinstance(live_period, int) + + def test_time_period_int_deprecated( + self, recwarn, PTB_TIMEDELTA, inline_query_result_location + ): + inline_query_result_location.live_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`live_period` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InlineQueryResultLocation(self.id_, self.longitude, self.latitude, self.title) b = InlineQueryResultLocation(self.id_, self.longitude, self.latitude, self.title) diff --git a/tests/_inline/test_inlinequeryresultmpeg4gif.py b/tests/_inline/test_inlinequeryresultmpeg4gif.py index 03b6ca991d1..7b1fa84e5d1 100644 --- a/tests/_inline/test_inlinequeryresultmpeg4gif.py +++ b/tests/_inline/test_inlinequeryresultmpeg4gif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,6 +16,8 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import ( @@ -26,6 +28,7 @@ InputTextMessageContent, MessageEntity, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -55,7 +58,7 @@ class InlineQueryResultMpeg4GifTestBase: mpeg4_url = "mpeg4 url" mpeg4_width = 10 mpeg4_height = 15 - mpeg4_duration = 1 + mpeg4_duration = dtm.timedelta(seconds=1) thumbnail_url = "thumb url" thumbnail_mime_type = "image/jpeg" title = "title" @@ -80,7 +83,7 @@ def test_expected_values(self, inline_query_result_mpeg4_gif): assert inline_query_result_mpeg4_gif.mpeg4_url == self.mpeg4_url assert inline_query_result_mpeg4_gif.mpeg4_width == self.mpeg4_width assert inline_query_result_mpeg4_gif.mpeg4_height == self.mpeg4_height - assert inline_query_result_mpeg4_gif.mpeg4_duration == self.mpeg4_duration + assert inline_query_result_mpeg4_gif._mpeg4_duration == self.mpeg4_duration assert inline_query_result_mpeg4_gif.thumbnail_url == self.thumbnail_url assert inline_query_result_mpeg4_gif.thumbnail_mime_type == self.thumbnail_mime_type assert inline_query_result_mpeg4_gif.title == self.title @@ -118,10 +121,10 @@ def test_to_dict(self, inline_query_result_mpeg4_gif): inline_query_result_mpeg4_gif_dict["mpeg4_height"] == inline_query_result_mpeg4_gif.mpeg4_height ) - assert ( - inline_query_result_mpeg4_gif_dict["mpeg4_duration"] - == inline_query_result_mpeg4_gif.mpeg4_duration + assert inline_query_result_mpeg4_gif_dict["mpeg4_duration"] == int( + self.mpeg4_duration.total_seconds() ) + assert isinstance(inline_query_result_mpeg4_gif_dict["mpeg4_duration"], int) assert ( inline_query_result_mpeg4_gif_dict["thumbnail_url"] == inline_query_result_mpeg4_gif.thumbnail_url @@ -154,6 +157,30 @@ def test_to_dict(self, inline_query_result_mpeg4_gif): == inline_query_result_mpeg4_gif.show_caption_above_media ) + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_mpeg4_gif): + mpeg4_duration = inline_query_result_mpeg4_gif.mpeg4_duration + + if PTB_TIMEDELTA: + assert mpeg4_duration == self.mpeg4_duration + assert isinstance(mpeg4_duration, dtm.timedelta) + else: + assert mpeg4_duration == int(self.mpeg4_duration.total_seconds()) + assert isinstance(mpeg4_duration, int) + + def test_time_period_int_deprecated( + self, recwarn, PTB_TIMEDELTA, inline_query_result_mpeg4_gif + ): + inline_query_result_mpeg4_gif.mpeg4_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`mpeg4_duration` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InlineQueryResultMpeg4Gif(self.id_, self.mpeg4_url, self.thumbnail_url) b = InlineQueryResultMpeg4Gif(self.id_, self.mpeg4_url, self.thumbnail_url) diff --git a/tests/_inline/test_inlinequeryresultphoto.py b/tests/_inline/test_inlinequeryresultphoto.py index 18899744387..3f4d0f12b56 100644 --- a/tests/_inline/test_inlinequeryresultphoto.py +++ b/tests/_inline/test_inlinequeryresultphoto.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_inlinequeryresultsbutton.py b/tests/_inline/test_inlinequeryresultsbutton.py similarity index 94% rename from tests/test_inlinequeryresultsbutton.py rename to tests/_inline/test_inlinequeryresultsbutton.py index 192fdc2904d..fbc92c3649e 100644 --- a/tests/test_inlinequeryresultsbutton.py +++ b/tests/_inline/test_inlinequeryresultsbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -52,9 +52,6 @@ def test_to_dict(self, inline_query_results_button): assert inline_query_results_button_dict["web_app"] == self.web_app.to_dict() def test_de_json(self, offline_bot): - assert InlineQueryResultsButton.de_json(None, offline_bot) is None - assert InlineQueryResultsButton.de_json({}, offline_bot) is None - json_dict = { "text": self.text, "start_parameter": self.start_parameter, diff --git a/tests/_inline/test_inlinequeryresultvenue.py b/tests/_inline/test_inlinequeryresultvenue.py index 50d881247e4..f6bc75c0108 100644 --- a/tests/_inline/test_inlinequeryresultvenue.py +++ b/tests/_inline/test_inlinequeryresultvenue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinequeryresultvideo.py b/tests/_inline/test_inlinequeryresultvideo.py index d165d9af3f2..9d918f91626 100644 --- a/tests/_inline/test_inlinequeryresultvideo.py +++ b/tests/_inline/test_inlinequeryresultvideo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,6 +16,8 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import ( @@ -26,6 +28,7 @@ InputTextMessageContent, MessageEntity, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -57,7 +60,7 @@ class InlineQueryResultVideoTestBase: mime_type = "mime type" video_width = 10 video_height = 15 - video_duration = 15 + video_duration = dtm.timedelta(seconds=15) thumbnail_url = "thumbnail url" title = "title" caption = "caption" @@ -83,7 +86,7 @@ def test_expected_values(self, inline_query_result_video): assert inline_query_result_video.mime_type == self.mime_type assert inline_query_result_video.video_width == self.video_width assert inline_query_result_video.video_height == self.video_height - assert inline_query_result_video.video_duration == self.video_duration + assert inline_query_result_video._video_duration == self.video_duration assert inline_query_result_video.thumbnail_url == self.thumbnail_url assert inline_query_result_video.title == self.title assert inline_query_result_video.description == self.description @@ -118,10 +121,10 @@ def test_to_dict(self, inline_query_result_video): inline_query_result_video_dict["video_height"] == inline_query_result_video.video_height ) - assert ( - inline_query_result_video_dict["video_duration"] - == inline_query_result_video.video_duration + assert inline_query_result_video_dict["video_duration"] == int( + self.video_duration.total_seconds() ) + assert isinstance(inline_query_result_video_dict["video_duration"], int) assert ( inline_query_result_video_dict["thumbnail_url"] == inline_query_result_video.thumbnail_url @@ -148,6 +151,29 @@ def test_to_dict(self, inline_query_result_video): == inline_query_result_video.show_caption_above_media ) + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_video): + iqrv = inline_query_result_video + if PTB_TIMEDELTA: + assert iqrv.video_duration == self.video_duration + assert isinstance(iqrv.video_duration, dtm.timedelta) + else: + assert iqrv.video_duration == int(self.video_duration.total_seconds()) + assert isinstance(iqrv.video_duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_result_video): + value = inline_query_result_video.video_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + assert isinstance(value, dtm.timedelta) + else: + assert len(recwarn) == 1 + assert "`video_duration` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning + assert isinstance(value, int) + def test_equality(self): a = InlineQueryResultVideo( self.id_, self.video_url, self.mime_type, self.thumbnail_url, self.title diff --git a/tests/_inline/test_inlinequeryresultvoice.py b/tests/_inline/test_inlinequeryresultvoice.py index 01662700c74..ccc9db85494 100644 --- a/tests/_inline/test_inlinequeryresultvoice.py +++ b/tests/_inline/test_inlinequeryresultvoice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,6 +16,8 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import ( @@ -26,6 +28,7 @@ InputTextMessageContent, MessageEntity, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -49,7 +52,7 @@ class InlineQueryResultVoiceTestBase: type_ = "voice" voice_url = "voice url" title = "title" - voice_duration = "voice_duration" + voice_duration = dtm.timedelta(seconds=10) caption = "caption" parse_mode = "HTML" caption_entities = [MessageEntity(MessageEntity.ITALIC, 0, 7)] @@ -69,7 +72,7 @@ def test_expected_values(self, inline_query_result_voice): assert inline_query_result_voice.id == self.id_ assert inline_query_result_voice.voice_url == self.voice_url assert inline_query_result_voice.title == self.title - assert inline_query_result_voice.voice_duration == self.voice_duration + assert inline_query_result_voice._voice_duration == self.voice_duration assert inline_query_result_voice.caption == self.caption assert inline_query_result_voice.parse_mode == self.parse_mode assert inline_query_result_voice.caption_entities == tuple(self.caption_entities) @@ -96,10 +99,10 @@ def test_to_dict(self, inline_query_result_voice): assert inline_query_result_voice_dict["id"] == inline_query_result_voice.id assert inline_query_result_voice_dict["voice_url"] == inline_query_result_voice.voice_url assert inline_query_result_voice_dict["title"] == inline_query_result_voice.title - assert ( - inline_query_result_voice_dict["voice_duration"] - == inline_query_result_voice.voice_duration + assert inline_query_result_voice_dict["voice_duration"] == int( + self.voice_duration.total_seconds() ) + assert isinstance(inline_query_result_voice_dict["voice_duration"], int) assert inline_query_result_voice_dict["caption"] == inline_query_result_voice.caption assert inline_query_result_voice_dict["parse_mode"] == inline_query_result_voice.parse_mode assert inline_query_result_voice_dict["caption_entities"] == [ @@ -114,6 +117,28 @@ def test_to_dict(self, inline_query_result_voice): == inline_query_result_voice.reply_markup.to_dict() ) + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_voice): + voice_duration = inline_query_result_voice.voice_duration + + if PTB_TIMEDELTA: + assert voice_duration == self.voice_duration + assert isinstance(voice_duration, dtm.timedelta) + else: + assert voice_duration == int(self.voice_duration.total_seconds()) + assert isinstance(voice_duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_result_voice): + inline_query_result_voice.voice_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`voice_duration` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InlineQueryResultVoice(self.id_, self.voice_url, self.title) b = InlineQueryResultVoice(self.id_, self.voice_url, self.title) diff --git a/tests/_inline/test_inputcontactmessagecontent.py b/tests/_inline/test_inputcontactmessagecontent.py index ff44d59aa37..e70b87c4d07 100644 --- a/tests/_inline/test_inputcontactmessagecontent.py +++ b/tests/_inline/test_inputcontactmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inputinvoicemessagecontent.py b/tests/_inline/test_inputinvoicemessagecontent.py index 88927d18138..4e0dc9acae3 100644 --- a/tests/_inline/test_inputinvoicemessagecontent.py +++ b/tests/_inline/test_inputinvoicemessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -204,8 +204,6 @@ def test_to_dict(self, input_invoice_message_content): ) def test_de_json(self, offline_bot): - assert InputInvoiceMessageContent.de_json({}, bot=offline_bot) is None - json_dict = { "title": self.title, "description": self.description, @@ -283,10 +281,10 @@ def test_equality(self): self.title, self.description, self.payload, - self.provider_token, self.currency, # the first prices amount & the second lebal changed [LabeledPrice("label1", 24), LabeledPrice("label22", 314)], + self.provider_token, ) d = InputInvoiceMessageContent( self.title, diff --git a/tests/_inline/test_inputlocationmessagecontent.py b/tests/_inline/test_inputlocationmessagecontent.py index 05e86086852..f5cc16c1386 100644 --- a/tests/_inline/test_inputlocationmessagecontent.py +++ b/tests/_inline/test_inputlocationmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,9 +16,12 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import InputLocationMessageContent, Location +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -37,7 +40,7 @@ def input_location_message_content(): class InputLocationMessageContentTestBase: latitude = -23.691288 longitude = -46.788279 - live_period = 80 + live_period = dtm.timedelta(seconds=80) horizontal_accuracy = 50.5 heading = 90 proximity_alert_radius = 999 @@ -53,7 +56,7 @@ def test_slot_behaviour(self, input_location_message_content): def test_expected_values(self, input_location_message_content): assert input_location_message_content.longitude == self.longitude assert input_location_message_content.latitude == self.latitude - assert input_location_message_content.live_period == self.live_period + assert input_location_message_content._live_period == self.live_period assert input_location_message_content.horizontal_accuracy == self.horizontal_accuracy assert input_location_message_content.heading == self.heading assert input_location_message_content.proximity_alert_radius == self.proximity_alert_radius @@ -70,10 +73,10 @@ def test_to_dict(self, input_location_message_content): input_location_message_content_dict["longitude"] == input_location_message_content.longitude ) - assert ( - input_location_message_content_dict["live_period"] - == input_location_message_content.live_period + assert input_location_message_content_dict["live_period"] == int( + self.live_period.total_seconds() ) + assert isinstance(input_location_message_content_dict["live_period"], int) assert ( input_location_message_content_dict["horizontal_accuracy"] == input_location_message_content.horizontal_accuracy @@ -87,6 +90,28 @@ def test_to_dict(self, input_location_message_content): == input_location_message_content.proximity_alert_radius ) + def test_time_period_properties(self, PTB_TIMEDELTA, input_location_message_content): + live_period = input_location_message_content.live_period + + if PTB_TIMEDELTA: + assert live_period == self.live_period + assert isinstance(live_period, dtm.timedelta) + else: + assert live_period == int(self.live_period.total_seconds()) + assert isinstance(live_period, int) + + def test_time_period_int_deprecated( + self, recwarn, PTB_TIMEDELTA, input_location_message_content + ): + input_location_message_content.live_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`live_period` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InputLocationMessageContent(123, 456, 70) b = InputLocationMessageContent(123, 456, 90) diff --git a/tests/_inline/test_inputtextmessagecontent.py b/tests/_inline/test_inputtextmessagecontent.py index cd70615a9f5..252fbda7446 100644 --- a/tests/_inline/test_inputtextmessagecontent.py +++ b/tests/_inline/test_inputtextmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inputvenuemessagecontent.py b/tests/_inline/test_inputvenuemessagecontent.py index b176514aabf..b55eb23e8af 100644 --- a/tests/_inline/test_inputvenuemessagecontent.py +++ b/tests/_inline/test_inputvenuemessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_preparedinlinemessage.py b/tests/_inline/test_preparedinlinemessage.py index 545a19b4dbf..c9f3deeb320 100644 --- a/tests/_inline/test_preparedinlinemessage.py +++ b/tests/_inline/test_preparedinlinemessage.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_passport/__init__.py b/tests/_passport/__init__.py index 040bfc78668..c95cb3c9741 100644 --- a/tests/_passport/__init__.py +++ b/tests/_passport/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_passport/test_encryptedcredentials.py b/tests/_passport/test_encryptedcredentials.py index 94c22936f5d..154f8b35fea 100644 --- a/tests/_passport/test_encryptedcredentials.py +++ b/tests/_passport/test_encryptedcredentials.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_passport/test_encryptedpassportelement.py b/tests/_passport/test_encryptedpassportelement.py index 2301a80afbf..f1bab591b36 100644 --- a/tests/_passport/test_encryptedpassportelement.py +++ b/tests/_passport/test_encryptedpassportelement.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_passport/test_no_passport.py b/tests/_passport/test_no_passport.py index 4e861894bf3..b8f48d27bcb 100644 --- a/tests/_passport/test_no_passport.py +++ b/tests/_passport/test_no_passport.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -26,6 +26,7 @@ with the TEST_WITH_OPT_DEPS environment variable set to False in addition to the regular test suite """ + import pytest import telegram diff --git a/tests/_passport/test_passport.py b/tests/_passport/test_passport.py index 8f5776fd819..5b87074105c 100644 --- a/tests/_passport/test_passport.py +++ b/tests/_passport/test_passport.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # flake8: noqa: E501 # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -418,7 +418,10 @@ def test_bot_init_invalid_key(self, offline_bot): with pytest.raises(TypeError): Bot(offline_bot.token, private_key="Invalid key!") - with pytest.raises(ValueError, match="Could not deserialize key data"): + # Different error messages for different cryptography versions + with pytest.raises( + ValueError, match=r"(Could not deserialize key data)|(Unable to load PEM file)" + ): Bot(offline_bot.token, private_key=b"Invalid key!") def test_all_types(self, passport_data, offline_bot, all_passport_data): diff --git a/tests/_passport/test_passportelementerrordatafield.py b/tests/_passport/test_passportelementerrordatafield.py index 0811cb413a3..d0e53bbb162 100644 --- a/tests/_passport/test_passportelementerrordatafield.py +++ b/tests/_passport/test_passportelementerrordatafield.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_passport/test_passportelementerrorfile.py b/tests/_passport/test_passportelementerrorfile.py index b149077b754..95309431c80 100644 --- a/tests/_passport/test_passportelementerrorfile.py +++ b/tests/_passport/test_passportelementerrorfile.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_passport/test_passportelementerrorfiles.py b/tests/_passport/test_passportelementerrorfiles.py index d5b9ad14530..ea55875fbf7 100644 --- a/tests/_passport/test_passportelementerrorfiles.py +++ b/tests/_passport/test_passportelementerrorfiles.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,7 +19,6 @@ import pytest from telegram import PassportElementErrorFiles, PassportElementErrorSelfie -from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -49,8 +48,8 @@ def test_slot_behaviour(self, passport_element_error_files): def test_expected_values(self, passport_element_error_files): assert passport_element_error_files.source == self.source assert passport_element_error_files.type == self.type_ - assert isinstance(passport_element_error_files.file_hashes, list) - assert passport_element_error_files.file_hashes == self.file_hashes + assert isinstance(passport_element_error_files.file_hashes, tuple) + assert passport_element_error_files.file_hashes == tuple(self.file_hashes) assert passport_element_error_files.message == self.message def test_to_dict(self, passport_element_error_files): @@ -60,9 +59,8 @@ def test_to_dict(self, passport_element_error_files): assert passport_element_error_files_dict["source"] == passport_element_error_files.source assert passport_element_error_files_dict["type"] == passport_element_error_files.type assert passport_element_error_files_dict["message"] == passport_element_error_files.message - assert ( - passport_element_error_files_dict["file_hashes"] - == passport_element_error_files.file_hashes + assert passport_element_error_files_dict["file_hashes"] == list( + passport_element_error_files.file_hashes ) def test_equality(self): @@ -88,13 +86,3 @@ def test_equality(self): assert a != f assert hash(a) != hash(f) - - def test_file_hashes_deprecated(self, passport_element_error_files, recwarn): - passport_element_error_files.file_hashes - assert len(recwarn) == 1 - assert ( - "The attribute `file_hashes` will return a tuple instead of a list in future major" - " versions." in str(recwarn[0].message) - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__ diff --git a/tests/_passport/test_passportelementerrorfrontside.py b/tests/_passport/test_passportelementerrorfrontside.py index 59233004c41..a4f7e56aa3f 100644 --- a/tests/_passport/test_passportelementerrorfrontside.py +++ b/tests/_passport/test_passportelementerrorfrontside.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_passport/test_passportelementerrorreverseside.py b/tests/_passport/test_passportelementerrorreverseside.py index 1a702d47aef..97ce2b71efe 100644 --- a/tests/_passport/test_passportelementerrorreverseside.py +++ b/tests/_passport/test_passportelementerrorreverseside.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_passport/test_passportelementerrorselfie.py b/tests/_passport/test_passportelementerrorselfie.py index daa9aee1ee1..5bc87b6a579 100644 --- a/tests/_passport/test_passportelementerrorselfie.py +++ b/tests/_passport/test_passportelementerrorselfie.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_passport/test_passportelementerrortranslationfile.py b/tests/_passport/test_passportelementerrortranslationfile.py index 2fb01c6668b..f1ccdfb9fb8 100644 --- a/tests/_passport/test_passportelementerrortranslationfile.py +++ b/tests/_passport/test_passportelementerrortranslationfile.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_passport/test_passportelementerrortranslationfiles.py b/tests/_passport/test_passportelementerrortranslationfiles.py index 8694de896e9..e2be406fb7e 100644 --- a/tests/_passport/test_passportelementerrortranslationfiles.py +++ b/tests/_passport/test_passportelementerrortranslationfiles.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,7 +19,6 @@ import pytest from telegram import PassportElementErrorSelfie, PassportElementErrorTranslationFiles -from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -51,8 +50,8 @@ def test_slot_behaviour(self, passport_element_error_translation_files): def test_expected_values(self, passport_element_error_translation_files): assert passport_element_error_translation_files.source == self.source assert passport_element_error_translation_files.type == self.type_ - assert isinstance(passport_element_error_translation_files.file_hashes, list) - assert passport_element_error_translation_files.file_hashes == self.file_hashes + assert isinstance(passport_element_error_translation_files.file_hashes, tuple) + assert passport_element_error_translation_files.file_hashes == tuple(self.file_hashes) assert passport_element_error_translation_files.message == self.message def test_to_dict(self, passport_element_error_translation_files): @@ -73,9 +72,8 @@ def test_to_dict(self, passport_element_error_translation_files): passport_element_error_translation_files_dict["message"] == passport_element_error_translation_files.message ) - assert ( - passport_element_error_translation_files_dict["file_hashes"] - == passport_element_error_translation_files.file_hashes + assert passport_element_error_translation_files_dict["file_hashes"] == list( + passport_element_error_translation_files.file_hashes ) def test_equality(self): @@ -101,13 +99,3 @@ def test_equality(self): assert a != f assert hash(a) != hash(f) - - def test_file_hashes_deprecated(self, passport_element_error_translation_files, recwarn): - passport_element_error_translation_files.file_hashes - assert len(recwarn) == 1 - assert ( - "The attribute `file_hashes` will return a tuple instead of a list in future major" - " versions." in str(recwarn[0].message) - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__ diff --git a/tests/_passport/test_passportelementerrorunspecified.py b/tests/_passport/test_passportelementerrorunspecified.py index ea1050be64b..f367df4d3da 100644 --- a/tests/_passport/test_passportelementerrorunspecified.py +++ b/tests/_passport/test_passportelementerrorunspecified.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_passport/test_passportfile.py b/tests/_passport/test_passportfile.py index add24ab5b08..6eb5ad24cf5 100644 --- a/tests/_passport/test_passportfile.py +++ b/tests/_passport/test_passportfile.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,10 +16,12 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import Bot, File, PassportElementError, PassportFile -from telegram.warnings import PTBDeprecationWarning +from telegram._utils.datetime import UTC, to_timestamp from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -44,7 +46,7 @@ class PassportFileTestBase: file_id = "data" file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" file_size = 50 - file_date = 1532879128 + file_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) class TestPassportFileWithoutRequest(PassportFileTestBase): @@ -67,7 +69,27 @@ def test_to_dict(self, passport_file): assert passport_file_dict["file_id"] == passport_file.file_id assert passport_file_dict["file_unique_id"] == passport_file.file_unique_id assert passport_file_dict["file_size"] == passport_file.file_size - assert passport_file_dict["file_date"] == passport_file.file_date + assert passport_file_dict["file_date"] == to_timestamp(passport_file.file_date) + + def test_de_json_localization(self, passport_file, tz_bot, offline_bot, raw_bot): + json_dict = { + "file_id": self.file_id, + "file_unique_id": self.file_unique_id, + "file_size": self.file_size, + "file_date": to_timestamp(self.file_date), + } + + pf = PassportFile.de_json(json_dict, offline_bot) + pf_raw = PassportFile.de_json(json_dict, raw_bot) + pf_tz = PassportFile.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + date_offset = pf_tz.file_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(pf_tz.file_date.replace(tzinfo=None)) + + assert pf_raw.file_date.tzinfo == UTC + assert pf.file_date.tzinfo == UTC + assert date_offset == tz_bot_offset def test_equality(self): a = PassportFile(self.file_id, self.file_unique_id, self.file_size, self.file_date) @@ -89,16 +111,6 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) - def test_file_date_deprecated(self, passport_file, recwarn): - passport_file.file_date - assert len(recwarn) == 1 - assert ( - "The attribute `file_date` will return a datetime instead of an integer in future" - " major versions." in str(recwarn[0].message) - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__ - async def test_get_file_instance_method(self, monkeypatch, passport_file): async def make_assertion(*_, **kwargs): result = kwargs["file_id"] == passport_file.file_id diff --git a/tests/_payment/__init__.py b/tests/_payment/__init__.py index 040bfc78668..c95cb3c9741 100644 --- a/tests/_payment/__init__.py +++ b/tests/_payment/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_payment/stars/test_affiliateinfo.py b/tests/_payment/stars/test_affiliateinfo.py index 7a21c2cf95b..b6e72cc724f 100644 --- a/tests/_payment/stars/test_affiliateinfo.py +++ b/tests/_payment/stars/test_affiliateinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -64,9 +64,6 @@ def test_de_json(self, offline_bot): assert ai.amount == self.amount assert ai.nanostar_amount == self.nanostar_amount - assert AffiliateInfo.de_json(None, offline_bot) is None - assert AffiliateInfo.de_json({}, offline_bot) is None - def test_to_dict(self, affiliate_info): ai_dict = affiliate_info.to_dict() diff --git a/tests/_payment/stars/test_revenuewithdrawelstate.py b/tests/_payment/stars/test_revenuewithdrawelstate.py index c5265be96c2..da24129b68e 100644 --- a/tests/_payment/stars/test_revenuewithdrawelstate.py +++ b/tests/_payment/stars/test_revenuewithdrawelstate.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -77,9 +77,6 @@ def test_de_json(self, offline_bot): assert rws.api_kwargs == {} assert rws.type == "unknown" - assert RevenueWithdrawalState.de_json(None, offline_bot) is None - assert RevenueWithdrawalState.de_json({}, offline_bot) is None - @pytest.mark.parametrize( ("state", "subclass"), [ @@ -129,8 +126,6 @@ def test_de_json(self, offline_bot): assert rws.api_kwargs == {} assert rws.type == "pending" - assert RevenueWithdrawalStatePending.de_json(None, offline_bot) is None - def test_to_dict(self, revenue_withdrawal_state_pending): json_dict = revenue_withdrawal_state_pending.to_dict() assert json_dict == {"type": "pending"} @@ -168,8 +163,6 @@ def test_de_json(self, offline_bot): assert rws.date == self.date assert rws.url == self.url - assert RevenueWithdrawalStateSucceeded.de_json(None, offline_bot) is None - def test_to_dict(self, revenue_withdrawal_state_succeeded): json_dict = revenue_withdrawal_state_succeeded.to_dict() assert json_dict["type"] == "succeeded" @@ -213,8 +206,6 @@ def test_de_json(self, offline_bot): assert rws.api_kwargs == {} assert rws.type == "failed" - assert RevenueWithdrawalStateFailed.de_json(None, offline_bot) is None - def test_to_dict(self, revenue_withdrawal_state_failed): json_dict = revenue_withdrawal_state_failed.to_dict() assert json_dict == {"type": "failed"} diff --git a/tests/_payment/stars/test_staramount.py b/tests/_payment/stars/test_staramount.py new file mode 100644 index 00000000000..6f9ea649450 --- /dev/null +++ b/tests/_payment/stars/test_staramount.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import pytest + +from telegram import StarAmount +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def star_amount(): + return StarAmount( + amount=StarTransactionTestBase.amount, + nanostar_amount=StarTransactionTestBase.nanostar_amount, + ) + + +class StarTransactionTestBase: + amount = 100 + nanostar_amount = 356 + + +class TestStarAmountWithoutRequest(StarTransactionTestBase): + def test_slot_behaviour(self, star_amount): + inst = star_amount + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "amount": self.amount, + "nanostar_amount": self.nanostar_amount, + } + st = StarAmount.de_json(json_dict, offline_bot) + assert st.api_kwargs == {} + assert st.amount == self.amount + assert st.nanostar_amount == self.nanostar_amount + + def test_to_dict(self, star_amount): + expected_dict = { + "amount": self.amount, + "nanostar_amount": self.nanostar_amount, + } + assert star_amount.to_dict() == expected_dict + + def test_equality(self, star_amount): + a = star_amount + b = StarAmount(amount=self.amount, nanostar_amount=self.nanostar_amount) + c = StarAmount(amount=99, nanostar_amount=99) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) diff --git a/tests/_payment/stars/test_startransactions.py b/tests/_payment/stars/test_startransactions.py index 4d6553b508f..aa3ef7fe592 100644 --- a/tests/_payment/stars/test_startransactions.py +++ b/tests/_payment/stars/test_startransactions.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -63,6 +63,7 @@ class StarTransactionTestBase: nanostar_amount = 365 date = to_timestamp(dtm.datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=UTC)) source = TransactionPartnerUser( + transaction_type="premium_purchase", user=User( id=2, is_bot=False, @@ -89,7 +90,6 @@ def test_de_json(self, offline_bot): "receiver": self.receiver.to_dict(), } st = StarTransaction.de_json(json_dict, offline_bot) - st_none = StarTransaction.de_json(None, offline_bot) assert st.api_kwargs == {} assert st.id == self.id assert st.amount == self.amount @@ -97,7 +97,6 @@ def test_de_json(self, offline_bot): assert st.date == from_timestamp(self.date) assert st.source == self.source assert st.receiver == self.receiver - assert st_none is None def test_de_json_star_transaction_localization( self, tz_bot, offline_bot, raw_bot, star_transaction @@ -146,6 +145,7 @@ def test_equality(self): amount=3, date=to_timestamp(dtm.datetime.utcnow()), source=TransactionPartnerUser( + transaction_type="other_type", user=User( id=3, is_bot=False, @@ -178,10 +178,8 @@ def test_de_json(self, offline_bot): "transactions": [t.to_dict() for t in self.transactions], } st = StarTransactions.de_json(json_dict, offline_bot) - st_none = StarTransactions.de_json(None, offline_bot) assert st.api_kwargs == {} assert st.transactions == tuple(self.transactions) - assert st_none is None def test_to_dict(self, star_transactions): expected_dict = { diff --git a/tests/_payment/stars/test_transactionpartner.py b/tests/_payment/stars/test_transactionpartner.py index 99cfe383377..e17e8480b62 100644 --- a/tests/_payment/stars/test_transactionpartner.py +++ b/tests/_payment/stars/test_transactionpartner.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -22,12 +22,14 @@ from telegram import ( AffiliateInfo, + Chat, Gift, PaidMediaVideo, RevenueWithdrawalStatePending, Sticker, TransactionPartner, TransactionPartnerAffiliateProgram, + TransactionPartnerChat, TransactionPartnerFragment, TransactionPartnerOther, TransactionPartnerTelegramAds, @@ -61,6 +63,7 @@ class TransactionPartnerTestBase: first_name="user", last_name="user", ) + transaction_type = "premium_purchase" invoice_payload = "invoice_payload" paid_media = ( PaidMediaVideo( @@ -95,6 +98,11 @@ class TransactionPartnerTestBase: amount=42, ) request_count = 42 + chat = Chat( + id=3, + type=Chat.CHANNEL, + ) + premium_subscription_duration = 3 class TestTransactionPartnerWithoutRequest(TransactionPartnerTestBase): @@ -114,9 +122,6 @@ def test_de_json(self, offline_bot): assert transaction_partner.api_kwargs == {} assert transaction_partner.type == "unknown" - assert TransactionPartner.de_json(None, offline_bot) is None - assert TransactionPartner.de_json({}, offline_bot) is None - @pytest.mark.parametrize( ("tp_type", "subclass"), [ @@ -126,6 +131,7 @@ def test_de_json(self, offline_bot): ("telegram_ads", TransactionPartnerTelegramAds), ("telegram_api", TransactionPartnerTelegramApi), ("other", TransactionPartnerOther), + ("chat", TransactionPartnerChat), ], ) def test_subclass(self, offline_bot, tp_type, subclass): @@ -133,6 +139,7 @@ def test_subclass(self, offline_bot, tp_type, subclass): "type": tp_type, "commission_per_mille": self.commission_per_mille, "user": self.user.to_dict(), + "transaction_type": self.transaction_type, "request_count": self.request_count, } tp = TransactionPartner.de_json(json_dict, offline_bot) @@ -191,9 +198,6 @@ def test_de_json(self, offline_bot): assert tp.commission_per_mille == self.commission_per_mille assert tp.sponsor_user == self.sponsor_user - assert TransactionPartnerAffiliateProgram.de_json(None, offline_bot) is None - assert TransactionPartnerAffiliateProgram.de_json({}, offline_bot) is None - def test_to_dict(self, transaction_partner_affiliate_program): json_dict = transaction_partner_affiliate_program.to_dict() assert json_dict["type"] == self.type @@ -243,8 +247,6 @@ def test_de_json(self, offline_bot): assert tp.type == "fragment" assert tp.withdrawal_state == self.withdrawal_state - assert TransactionPartnerFragment.de_json(None, offline_bot) is None - def test_to_dict(self, transaction_partner_fragment): json_dict = transaction_partner_fragment.to_dict() assert json_dict["type"] == self.type @@ -269,11 +271,13 @@ def test_equality(self, transaction_partner_fragment): @pytest.fixture def transaction_partner_user(): return TransactionPartnerUser( + transaction_type=TransactionPartnerTestBase.transaction_type, user=TransactionPartnerTestBase.user, invoice_payload=TransactionPartnerTestBase.invoice_payload, paid_media=TransactionPartnerTestBase.paid_media, paid_media_payload=TransactionPartnerTestBase.paid_media_payload, subscription_period=TransactionPartnerTestBase.subscription_period, + premium_subscription_duration=TransactionPartnerTestBase.premium_subscription_duration, ) @@ -289,38 +293,43 @@ def test_slot_behaviour(self, transaction_partner_user): def test_de_json(self, offline_bot): json_dict = { "user": self.user.to_dict(), + "transaction_type": self.transaction_type, "invoice_payload": self.invoice_payload, "paid_media": [pm.to_dict() for pm in self.paid_media], "paid_media_payload": self.paid_media_payload, "subscription_period": self.subscription_period.total_seconds(), + "premium_subscription_duration": self.premium_subscription_duration, } tp = TransactionPartnerUser.de_json(json_dict, offline_bot) assert tp.api_kwargs == {} assert tp.type == "user" assert tp.user == self.user + assert tp.transaction_type == self.transaction_type assert tp.invoice_payload == self.invoice_payload assert tp.paid_media == self.paid_media assert tp.paid_media_payload == self.paid_media_payload assert tp.subscription_period == self.subscription_period - - assert TransactionPartnerUser.de_json(None, offline_bot) is None - assert TransactionPartnerUser.de_json({}, offline_bot) is None + assert tp.premium_subscription_duration == self.premium_subscription_duration def test_to_dict(self, transaction_partner_user): json_dict = transaction_partner_user.to_dict() assert json_dict["type"] == self.type + assert json_dict["transaction_type"] == self.transaction_type assert json_dict["user"] == self.user.to_dict() assert json_dict["invoice_payload"] == self.invoice_payload assert json_dict["paid_media"] == [pm.to_dict() for pm in self.paid_media] assert json_dict["paid_media_payload"] == self.paid_media_payload assert json_dict["subscription_period"] == self.subscription_period.total_seconds() + assert json_dict["premium_subscription_duration"] == self.premium_subscription_duration def test_equality(self, transaction_partner_user): a = transaction_partner_user b = TransactionPartnerUser( + transaction_type=self.transaction_type, user=self.user, ) c = TransactionPartnerUser( + transaction_type=self.transaction_type, user=User(id=1, is_bot=False, first_name="user", last_name="user"), ) d = User(id=1, is_bot=False, first_name="user", last_name="user") @@ -355,8 +364,6 @@ def test_de_json(self, offline_bot): assert tp.api_kwargs == {} assert tp.type == "other" - assert TransactionPartnerOther.de_json(None, offline_bot) is None - def test_to_dict(self, transaction_partner_other): json_dict = transaction_partner_other.to_dict() assert json_dict == {"type": self.type} @@ -397,8 +404,6 @@ def test_de_json(self, offline_bot): assert tp.api_kwargs == {} assert tp.type == "telegram_ads" - assert TransactionPartnerTelegramAds.de_json(None, offline_bot) is None - def test_to_dict(self, transaction_partner_telegram_ads): json_dict = transaction_partner_telegram_ads.to_dict() assert json_dict == {"type": self.type} @@ -442,8 +447,6 @@ def test_de_json(self, offline_bot): assert tp.type == "telegram_api" assert tp.request_count == self.request_count - assert TransactionPartnerTelegramApi.de_json(None, offline_bot) is None - def test_to_dict(self, transaction_partner_telegram_api): json_dict = transaction_partner_telegram_api.to_dict() assert json_dict["type"] == self.type @@ -467,3 +470,58 @@ def test_equality(self, transaction_partner_telegram_api): assert a != d assert hash(a) != hash(d) + + +@pytest.fixture +def transaction_partner_chat(): + return TransactionPartnerChat( + chat=TransactionPartnerTestBase.chat, + gift=TransactionPartnerTestBase.gift, + ) + + +class TestTransactionPartnerChatWithoutRequest(TransactionPartnerTestBase): + type = TransactionPartnerType.CHAT + + def test_slot_behaviour(self, transaction_partner_chat): + inst = transaction_partner_chat + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "chat": self.chat.to_dict(), + "gift": self.gift.to_dict(), + } + tp = TransactionPartnerChat.de_json(json_dict, offline_bot) + assert tp.api_kwargs == {} + assert tp.type == "chat" + assert tp.chat == self.chat + assert tp.gift == self.gift + + def test_to_dict(self, transaction_partner_chat): + json_dict = transaction_partner_chat.to_dict() + assert json_dict["type"] == self.type + assert json_dict["chat"] == self.chat.to_dict() + assert json_dict["gift"] == self.gift.to_dict() + + def test_equality(self, transaction_partner_chat): + a = transaction_partner_chat + b = TransactionPartnerChat( + chat=self.chat, + gift=self.gift, + ) + c = TransactionPartnerChat( + chat=Chat(id=1, type=Chat.CHANNEL), + ) + d = Chat(id=1, type=Chat.CHANNEL) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/_payment/test_invoice.py b/tests/_payment/test_invoice.py index 501ba353744..06985f552aa 100644 --- a/tests/_payment/test_invoice.py +++ b/tests/_payment/test_invoice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -127,12 +127,12 @@ async def make_assertion(*args, **_): async def test_send_all_args_create_invoice_link( self, offline_bot, monkeypatch, subscription_period ): - async def make_assertion(*args, **_): - kwargs = args[1] + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + kwargs = request_data.parameters sp = kwargs.pop("subscription_period") == 42 return all(kwargs[i] == i for i in kwargs) and sp - monkeypatch.setattr(offline_bot, "_post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) assert await offline_bot.create_invoice_link( title="title", description="description", @@ -269,9 +269,9 @@ async def test_send_invoice_default_protect_content( self.title, self.description, self.payload, - provider_token, self.currency, self.prices, + provider_token, **kwargs, ) for kwargs in ({}, {"protect_content": False}) @@ -301,7 +301,6 @@ async def test_send_invoice_default_allow_sending_without_reply( self.title, self.description, self.payload, - "", # using tg stars "XTR", [self.prices[0]], allow_sending_without_reply=custom, @@ -315,9 +314,9 @@ async def test_send_invoice_default_allow_sending_without_reply( self.title, self.description, self.payload, - provider_token, self.currency, self.prices, + provider_token, reply_to_message_id=reply_to_message.message_id, ) assert message.reply_to_message is None @@ -328,9 +327,9 @@ async def test_send_invoice_default_allow_sending_without_reply( self.title, self.description, self.payload, - provider_token, self.currency, self.prices, + provider_token, reply_to_message_id=reply_to_message.message_id, ) @@ -340,9 +339,9 @@ async def test_send_all_args_send_invoice(self, bot, chat_id, provider_token): self.title, self.description, self.payload, - provider_token, self.currency, self.prices, + provider_token=provider_token, max_tip_amount=self.max_tip_amount, suggested_tip_amounts=self.suggested_tip_amounts, start_parameter=self.start_parameter, diff --git a/tests/_payment/test_labeledprice.py b/tests/_payment/test_labeledprice.py index 5957396e09b..babea2aae62 100644 --- a/tests/_payment/test_labeledprice.py +++ b/tests/_payment/test_labeledprice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_payment/test_orderinfo.py b/tests/_payment/test_orderinfo.py index eab7f7a28b9..3f12a4b582e 100644 --- a/tests/_payment/test_orderinfo.py +++ b/tests/_payment/test_orderinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_payment/test_precheckoutquery.py b/tests/_payment/test_precheckoutquery.py index e75dc377e54..e8352d52be7 100644 --- a/tests/_payment/test_precheckoutquery.py +++ b/tests/_payment/test_precheckoutquery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_payment/test_refundedpayment.py b/tests/_payment/test_refundedpayment.py index 922f84933e9..cff668fad3b 100644 --- a/tests/_payment/test_refundedpayment.py +++ b/tests/_payment/test_refundedpayment.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_payment/test_shippingaddress.py b/tests/_payment/test_shippingaddress.py index fbe8275c431..8e7348afea7 100644 --- a/tests/_payment/test_shippingaddress.py +++ b/tests/_payment/test_shippingaddress.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_payment/test_shippingoption.py b/tests/_payment/test_shippingoption.py index fa29765a824..953e2cf25d6 100644 --- a/tests/_payment/test_shippingoption.py +++ b/tests/_payment/test_shippingoption.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_payment/test_shippingquery.py b/tests/_payment/test_shippingquery.py index 36ba7cbd1f7..23606faedce 100644 --- a/tests/_payment/test_shippingquery.py +++ b/tests/_payment/test_shippingquery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_payment/test_successfulpayment.py b/tests/_payment/test_successfulpayment.py index e4daf589771..51d485052d6 100644 --- a/tests/_payment/test_successfulpayment.py +++ b/tests/_payment/test_successfulpayment.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_utils/__init__.py b/tests/_utils/__init__.py index 040bfc78668..c95cb3c9741 100644 --- a/tests/_utils/__init__.py +++ b/tests/_utils/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_utils/test_datetime.py b/tests/_utils/test_datetime.py index dfcaca67587..7267449d2a2 100644 --- a/tests/_utils/test_datetime.py +++ b/tests/_utils/test_datetime.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,6 +23,7 @@ import pytest from telegram._utils import datetime as tg_dtm +from telegram._utils.datetime import get_zone_info from telegram.ext import Defaults # sample time specification values categorised into absolute / delta / time-of-day @@ -65,7 +66,7 @@ def test_localize_utc(self): @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="pytz not installed") def test_localize_pytz(self): dt = dtm.datetime(2023, 1, 1, 12, 0, 0) - import pytz + import pytz # noqa: PLC0415 tzinfo = pytz.timezone("Europe/Berlin") localized_dt = tg_dtm.localize(dt, tzinfo) @@ -118,9 +119,9 @@ def test_to_float_timestamp_delta(self): reference_t = 0 for i in DELTA_TIME_SPECS: delta = i.total_seconds() if hasattr(i, "total_seconds") else i - assert ( - tg_dtm.to_float_timestamp(i, reference_t) == reference_t + delta - ), f"failed for {i}" + assert tg_dtm.to_float_timestamp(i, reference_t) == reference_t + delta, ( + f"failed for {i}" + ) def test_to_float_timestamp_time_of_day(self): """Conversion from time-of-day specification to timestamp""" @@ -138,7 +139,10 @@ def test_to_float_timestamp_time_of_day_timezone(self, timezone): # of an xpass when the test is run in a timezone with the same UTC offset ref_datetime = dtm.datetime(1970, 1, 1, 12) utc_offset = timezone.utcoffset(ref_datetime) - ref_t, time_of_day = tg_dtm._datetime_to_float_timestamp(ref_datetime), ref_datetime.time() + ref_t, time_of_day = ( + tg_dtm._datetime_to_float_timestamp(ref_datetime), + ref_datetime.time(), + ) aware_time_of_day = tg_dtm.localize(ref_datetime, timezone).timetz() # first test that naive time is assumed to be utc: @@ -168,7 +172,7 @@ def test_to_timestamp(self): assert tg_dtm.to_timestamp(i) == int(tg_dtm.to_float_timestamp(i)), f"Failed for {i}" def test_to_timestamp_none(self): - # this 'convenience' behaviour has been left left for backwards compatibility + # this 'convenience' behaviour has been left for backwards compatibility assert tg_dtm.to_timestamp(None) is None def test_from_timestamp_none(self): @@ -192,3 +196,35 @@ def test_extract_tzinfo_from_defaults(self, tz_bot, bot, raw_bot): assert tg_dtm.extract_tzinfo_from_defaults(tz_bot) == tz_bot.defaults.tzinfo assert tg_dtm.extract_tzinfo_from_defaults(bot) is None assert tg_dtm.extract_tzinfo_from_defaults(raw_bot) is None + + def test_get_zone_info_with_valid_timezone_string(self): + """Test with a valid timezone string.""" + tz = "Asia/Tokyo" + result = get_zone_info(tz) + assert isinstance(result, zoneinfo.ZoneInfo) + assert str(result) == "Asia/Tokyo" + + def test_get_zone_info_with_invalid_timezone_string(self): + """Test with an invalid timezone string.""" + with pytest.raises( + zoneinfo.ZoneInfoNotFoundError, + match=r"No time zone found.*Invalid/Timezone.*install the tzdata", + ): + get_zone_info("Invalid/Timezone") + + @pytest.mark.parametrize( + ("arg", "timedelta_result", "number_result"), + [ + (None, None, None), + (dtm.timedelta(seconds=10), dtm.timedelta(seconds=10), 10), + (dtm.timedelta(seconds=10.5), dtm.timedelta(seconds=10.5), 10.5), + ], + ) + def test_get_timedelta_value(self, PTB_TIMEDELTA, arg, timedelta_result, number_result): + result = tg_dtm.get_timedelta_value(arg, attribute="") + + if PTB_TIMEDELTA: + assert result == timedelta_result + else: + assert result == number_result + assert type(result) is type(number_result) diff --git a/tests/_utils/test_defaultvalue.py b/tests/_utils/test_defaultvalue.py index f8278fd13cd..b9a8f8eb99c 100644 --- a/tests/_utils/test_defaultvalue.py +++ b/tests/_utils/test_defaultvalue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_utils/test_files.py b/tests/_utils/test_files.py index 7d0b5454416..8797ffcb9c4 100644 --- a/tests/_utils/test_files.py +++ b/tests/_utils/test_files.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -75,7 +75,7 @@ def test_parse_file_input_string(self, string, expected_local, expected_non_loca telegram._utils.files.parse_file_input(string, local_mode=False), InputFile ) elif expected_non_local is ValueError: - with pytest.raises(ValueError, match="but local mode is not enabled."): + with pytest.raises(ValueError, match="but local mode is not enabled\\."): telegram._utils.files.parse_file_input(string, local_mode=False) else: assert ( @@ -156,3 +156,13 @@ def test_load_file_subprocess_pipe(self): proc.kill() # This exception may be thrown if the process has finished before we had the chance # to kill it. + + @pytest.mark.filterwarnings("error::ResourceWarning") + def test_parse_file_input_path_no_resource_warning(self): + """Test that parsing a Path input doesn't generate ResourceWarning.""" + test_file = data_file(filename="telegram.png") + + # This should not raise a ResourceWarning + result = telegram._utils.files.parse_file_input(test_file) + assert isinstance(result, InputFile) + assert result.filename.endswith(".png") diff --git a/tests/auxil/__init__.py b/tests/auxil/__init__.py index 040bfc78668..c95cb3c9741 100644 --- a/tests/auxil/__init__.py +++ b/tests/auxil/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/auxil/asyncio_helpers.py b/tests/auxil/asyncio_helpers.py index 430568ab0cc..ab97105976a 100644 --- a/tests/auxil/asyncio_helpers.py +++ b/tests/auxil/asyncio_helpers.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio -from typing import Callable +from collections.abc import Callable def call_after(function: Callable, after: Callable): diff --git a/tests/auxil/bot_method_checks.py b/tests/auxil/bot_method_checks.py index 7e50a8dae85..6714e65655a 100644 --- a/tests/auxil/bot_method_checks.py +++ b/tests/auxil/bot_method_checks.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,15 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Provides functions to test both methods.""" + import datetime as dtm import functools import inspect import re import zoneinfo -from collections.abc import Collection, Iterable -from typing import Any, Callable, Optional +from collections.abc import Callable, Collection, Iterable +from types import GenericAlias +from typing import Any, ForwardRef import pytest @@ -31,10 +33,10 @@ from telegram import ( Bot, ChatPermissions, - File, InlineQueryResultArticle, InlineQueryResultCachedPhoto, InputMediaPhoto, + InputPaidMediaPhoto, InputTextMessageContent, LinkPreviewOptions, ReplyParameters, @@ -46,6 +48,7 @@ from telegram.constants import InputMediaType from telegram.ext import Defaults, ExtBot from telegram.request import RequestData +from tests.auxil.dummy_objects import get_dummy_object_json_dict FORWARD_REF_PATTERN = re.compile(r"ForwardRef\('(?P\w+)'\)") """ A pattern to find a class name in a ForwardRef typing annotation. @@ -58,7 +61,7 @@ def check_shortcut_signature( bot_method: Callable, shortcut_kwargs: list[str], additional_kwargs: list[str], - annotation_overrides: Optional[dict[str, tuple[Any, Any]]] = None, + annotation_overrides: dict[str, tuple[Any, Any]] | None = None, ) -> bool: """ Checks that the signature of a shortcut matches the signature of the underlying bot method. @@ -68,7 +71,7 @@ def check_shortcut_signature( bot_method: The bot method, e.g. :meth:`telegram.Bot.send_message` shortcut_kwargs: The kwargs passed by the shortcut directly, e.g. ``chat_id`` additional_kwargs: Additional kwargs of the shortcut that the bot method doesn't have, e.g. - ``quote``. + ``do_quote``. annotation_overrides: A dictionary of exceptions for the annotation comparison. The key is the name of the argument, the value is a tuple of the expected annotation and the default value. E.g. ``{'parse_mode': (str, 'None')}``. @@ -78,7 +81,7 @@ def check_shortcut_signature( """ annotation_overrides = annotation_overrides or {} - def resolve_class(class_name: str) -> Optional[type]: + def resolve_class(class_name: str) -> type | None: """Attempts to resolve a PTB class (telegram module only) from a ForwardRef. E.g. resolves from "StickerSet". @@ -137,6 +140,7 @@ def resolve_class(class_name: str) -> Optional[type]: for shortcut_arg, bot_arg in zip( shortcut_sig.parameters[kwarg].annotation.__args__, bot_sig.parameters[kwarg].annotation.__args__, + strict=False, ): shortcut_arg_to_check = shortcut_arg # for ruff match = FORWARD_REF_PATTERN.search(str(shortcut_arg)) @@ -193,8 +197,8 @@ async def check_shortcut_call( shortcut_method: Callable, bot: ExtBot, bot_method_name: str, - skip_params: Optional[Iterable[str]] = None, - shortcut_kwargs: Optional[Iterable[str]] = None, + skip_params: Iterable[str] | None = None, + shortcut_kwargs: Iterable[str] | None = None, ) -> bool: """ Checks that a shortcut passes all the existing arguments to the underlying bot method. Use as:: @@ -226,21 +230,16 @@ async def check_shortcut_call( shortcut_signature = inspect.signature(shortcut_method) # auto_pagination: Special casing for InlineQuery.answer - # quote: Don't test deprecated "quote" parameter of Message.reply_* - kwargs = { - name: name - for name in shortcut_signature.parameters - if name not in ["auto_pagination", "quote"] - } + kwargs = {name: name for name in shortcut_signature.parameters if name != "auto_pagination"} if "reply_parameters" in kwargs: kwargs["reply_parameters"] = ReplyParameters(message_id=1) # We tested this for a long time, but Bot API 7.0 deprecated it in favor of - # reply_parameters. In the transition phase, both exist in a mutually exclusive - # way. Testing both cases would require a lot of additional code, so we just - # ignore this parameter here until it is removed. - kwargs.pop("reply_to_message_id", None) - expected_args.discard("reply_to_message_id") + # reply_parameters. Testing both cases would require a lot of additional code, so we just + # ignore these parameters here. + for arg in ["reply_to_message_id", "allow_sending_without_reply"]: + kwargs.pop(arg, None) + expected_args.discard(arg) async def make_assertion(**kw): # name == value makes sure that @@ -252,16 +251,12 @@ async def make_assertion(**kw): if name in ignored_args or (value == name or (name == "reply_parameters" and value.message_id == 1)) } - if not received_kwargs == expected_args: + if received_kwargs != expected_args: raise Exception( f"{orig_bot_method.__name__} did not receive correct value for the parameters " f"{expected_args - received_kwargs}" ) - if bot_method_name == "get_file": - # This is here mainly for PassportFile.get_file, which calls .set_credentials on the - # return value - return File(file_id="result", file_unique_id="result") return True setattr(bot, bot_method_name, make_assertion) @@ -288,8 +283,13 @@ def build_kwargs( elif name in ["prices", "commands", "errors"]: kws[name] = [] elif name == "media": - media = InputMediaPhoto("media", parse_mode=manually_passed_value) - if "list" in str(param.annotation).lower(): + if "star_count" in signature.parameters: + media = InputPaidMediaPhoto("media") + else: + media = InputMediaPhoto("media", parse_mode=manually_passed_value) + + param_annotation = str(param.annotation).lower() + if "sequence" in param_annotation or "list" in param_annotation: kws[name] = [media] else: kws[name] = media @@ -392,6 +392,33 @@ def make_assertion_for_link_preview_options( ) +def _check_forward_ref(obj: object) -> str | object: + if isinstance(obj, ForwardRef): + return obj.__forward_arg__ + return obj + + +def guess_return_type_name(method: Callable[[...], Any]) -> tuple[str | object, bool]: + # Using typing.get_type_hints(method) would be the nicer as it also resolves ForwardRefs + # and string annotations. But it also wants to resolve the parameter annotations, which + # need additional namespaces and that's not worth the struggle for now … + return_annotation = _check_forward_ref(inspect.signature(method).return_annotation) + as_tuple = False + + if isinstance(return_annotation, GenericAlias): + if return_annotation.__origin__ is tuple: + as_tuple = True + else: + raise ValueError( + f"Return type of {method.__name__} is a GenericAlias. This can not be handled yet." + ) + + # For tuples and Unions, we simply take the first element + if hasattr(return_annotation, "__args__"): + return _check_forward_ref(return_annotation.__args__[0]), as_tuple + return return_annotation, as_tuple + + _EUROPE_BERLIN_TS = to_timestamp( dtm.datetime(2000, 1, 1, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin")) ) @@ -483,7 +510,8 @@ def check_input_media(m: dict): ) media = data.pop("media", None) - if media: + paid_media = media and data.pop("star_count", None) + if media and not paid_media: if isinstance(media, dict) and isinstance(media.get("type", None), InputMediaType): check_input_media(media) else: @@ -547,15 +575,6 @@ def check_input_media(m: dict): if default_value_expected and date_param != _AMERICA_NEW_YORK_TS: pytest.fail(f"Naive `{key}` should have been interpreted as America/New_York") - if method_name in ["get_file", "get_small_file", "get_big_file"]: - # This is here mainly for PassportFile.get_file, which calls .set_credentials on the - # return value - out = File(file_id="result", file_unique_id="result") - return out.to_dict() - # Otherwise return None by default, as TGObject.de_json/list(None) in [None, []] - # That way we can check what gets passed to Request.post without having to actually - # make a request - # Some methods expect specific output, so we allow to customize that if isinstance(return_value, TelegramObject): return return_value.to_dict() return return_value @@ -564,7 +583,6 @@ def check_input_media(m: dict): async def check_defaults_handling( method: Callable, bot: Bot, - return_value=None, no_default_kwargs: Collection[str] = frozenset(), ) -> bool: """ @@ -574,9 +592,6 @@ async def check_defaults_handling( method: The shortcut/bot_method bot: The bot. May be a telegram.Bot or a telegram.ext.ExtBot. In the former case, all default values will be converted to None. - return_value: Optional. The return value of Bot._post that the method expects. Defaults to - None. get_file is automatically handled. If this is a `TelegramObject`, Bot._post will - return the `to_dict` representation of it. no_default_kwargs: Optional. A collection of keyword arguments that should not have default values. Defaults to an empty frozenset. @@ -603,21 +618,17 @@ async def check_defaults_handling( kwargs_need_default.remove("parse_mode") defaults_no_custom_defaults = Defaults() - kwargs = {kwarg: "custom_default" for kwarg in inspect.signature(Defaults).parameters} + kwargs = dict.fromkeys(inspect.signature(Defaults).parameters, "custom_default") kwargs["tzinfo"] = zoneinfo.ZoneInfo("America/New_York") - kwargs.pop("disable_web_page_preview") # mutually exclusive with link_preview_options - kwargs.pop("quote") # mutually exclusive with do_quote kwargs["link_preview_options"] = LinkPreviewOptions( url="custom_default", show_above_text="custom_default" ) defaults_custom_defaults = Defaults(**kwargs) - expected_return_values = [None, ()] if return_value is None else [return_value] - if method.__name__ in ["get_file", "get_small_file", "get_big_file"]: - expected_return_values = [File(file_id="result", file_unique_id="result")] - request = bot._request[0] if get_updates else bot.request orig_post = request.post + return_value = get_dummy_object_json_dict(*guess_return_type_name(method)) + try: if raw_bot: combinations = [(None, None)] @@ -641,7 +652,7 @@ async def check_defaults_handling( expected_defaults_value=expected_defaults_value, ) request.post = assertion_callback - assert await method(**kwargs) in expected_return_values + await method(**kwargs) # 2: test that we get the manually passed non-None value kwargs = build_kwargs( @@ -656,7 +667,7 @@ async def check_defaults_handling( expected_defaults_value=expected_defaults_value, ) request.post = assertion_callback - assert await method(**kwargs) in expected_return_values + await method(**kwargs) # 3: test that we get the manually passed None value kwargs = build_kwargs( @@ -671,7 +682,7 @@ async def check_defaults_handling( expected_defaults_value=expected_defaults_value, ) request.post = assertion_callback - assert await method(**kwargs) in expected_return_values + await method(**kwargs) except Exception as exc: raise exc finally: diff --git a/tests/auxil/build_messages.py b/tests/auxil/build_messages.py index 64f3b5a9ffd..710a5a915ec 100644 --- a/tests/auxil/build_messages.py +++ b/tests/auxil/build_messages.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/auxil/ci_bots.py b/tests/auxil/ci_bots.py index 81e0c4819b8..f3124fbcead 100644 --- a/tests/auxil/ci_bots.py +++ b/tests/auxil/ci_bots.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Provide a bot to tests""" + import base64 import json import os @@ -30,7 +31,7 @@ # These bots are only able to talk in our test chats, so they are quite useless for other # purposes than testing. FALLBACKS = ( - "W3sidG9rZW4iOiAiNTc5Njk0NzE0OkFBRnBLOHc2emtrVXJENHhTZVl3RjNNTzhlLTRHcm1jeTdjIiwgInBheW1lbnRfc" + "W3sidG9rZW4iOiAiNTc5Njk0NzE0OkFBRmdqXzhmNFVlV1hrb3VVUnpUZThhRUY0UGNFQkRxdlY0IiwgInBheW1lbnRfc" "HJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TmpRME5qWmxOekk1WWpKaSIsICJjaGF0X2lkIjogIjY3NTY2Nj" "IyNCIsICJzdXBlcl9ncm91cF9pZCI6ICItMTAwMTMxMDkxMTEzNSIsICJmb3J1bV9ncm91cF9pZCI6ICItMTAwMTgzODA" "wNDU3NyIsICJjaGFubmVsX2lkIjogIkBweXRob250ZWxlZ3JhbWJvdHRlc3RzIiwgIm5hbWUiOiAiUFRCIHRlc3RzIGZh" @@ -49,9 +50,7 @@ BOTS = json.loads(base64.b64decode(BOTS).decode(TextEncoding.UTF_8)) JOB_INDEX = int(JOB_INDEX) -FALLBACKS = json.loads( - base64.b64decode(FALLBACKS).decode(TextEncoding.UTF_8) -) # type: list[dict[str, str]] +FALLBACKS = json.loads(base64.b64decode(FALLBACKS).decode(TextEncoding.UTF_8)) # type: list[dict[str, str]] class BotInfoProvider: diff --git a/tests/auxil/constants.py b/tests/auxil/constants.py index a394b8fbfdf..4eef931d972 100644 --- a/tests/auxil/constants.py +++ b/tests/auxil/constants.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/auxil/dummy_objects.py b/tests/auxil/dummy_objects.py new file mode 100644 index 00000000000..84bba3aa45b --- /dev/null +++ b/tests/auxil/dummy_objects.py @@ -0,0 +1,199 @@ +import datetime as dtm +from collections.abc import Sequence +from typing import TypeAlias + +from telegram import ( + AcceptedGiftTypes, + BotCommand, + BotDescription, + BotName, + BotShortDescription, + BusinessBotRights, + BusinessConnection, + Chat, + ChatAdministratorRights, + ChatBoost, + ChatBoostSource, + ChatFullInfo, + ChatInviteLink, + ChatMember, + File, + ForumTopic, + GameHighScore, + Gift, + Gifts, + MenuButton, + MessageId, + OwnedGiftRegular, + OwnedGifts, + Poll, + PollOption, + PreparedInlineMessage, + SentWebAppMessage, + StarAmount, + StarTransaction, + StarTransactions, + Sticker, + StickerSet, + Story, + TelegramObject, + Update, + User, + UserChatBoosts, + UserProfilePhotos, + WebhookInfo, +) +from tests.auxil.build_messages import make_message + +_DUMMY_USER = User( + id=123456, is_bot=False, first_name="Dummy", last_name="User", username="dummy_user" +) +_DUMMY_DATE = dtm.datetime(1970, 1, 1, 0, 0, 0, 0, tzinfo=dtm.timezone.utc) +_DUMMY_STICKER = Sticker( + file_id="dummy_file_id", + file_unique_id="dummy_file_unique_id", + width=1, + height=1, + is_animated=False, + is_video=False, + type="dummy_type", +) + +_PREPARED_DUMMY_OBJECTS: dict[str, object] = { + "bool": True, + "BotCommand": BotCommand(command="dummy_command", description="dummy_description"), + "BotDescription": BotDescription(description="dummy_description"), + "BotName": BotName(name="dummy_name"), + "BotShortDescription": BotShortDescription(short_description="dummy_short_description"), + "BusinessConnection": BusinessConnection( + user=_DUMMY_USER, + id="123", + user_chat_id=123456, + date=_DUMMY_DATE, + is_enabled=True, + rights=BusinessBotRights(can_reply=True), + ), + "Chat": Chat(id=123456, type="dummy_type"), + "ChatAdministratorRights": ChatAdministratorRights.all_rights(), + "ChatFullInfo": ChatFullInfo( + id=123456, + type="dummy_type", + accent_color_id=1, + max_reaction_count=1, + accepted_gift_types=AcceptedGiftTypes( + unlimited_gifts=True, + limited_gifts=True, + unique_gifts=True, + premium_subscription=True, + gifts_from_channels=True, + ), + ), + "ChatInviteLink": ChatInviteLink( + "dummy_invite_link", + creator=_DUMMY_USER, + is_primary=True, + is_revoked=False, + creates_join_request=False, + ), + "ChatMember": ChatMember(user=_DUMMY_USER, status="dummy_status"), + "File": File(file_id="dummy_file_id", file_unique_id="dummy_file_unique_id"), + "ForumTopic": ForumTopic(message_thread_id=2, name="dummy_name", icon_color=1), + "Gifts": Gifts(gifts=[Gift(id="dummy_id", sticker=_DUMMY_STICKER, star_count=1)]), + "GameHighScore": GameHighScore(position=1, user=_DUMMY_USER, score=1), + "int": 123456, + "MenuButton": MenuButton(type="dummy_type"), + "Message": make_message("dummy_text"), + # Bad hack to get tests passing (we should not be using annotations as a key here) + "Message | bool": make_message("dummy_text"), + "MessageId": MessageId(123456), + "OwnedGifts": OwnedGifts( + total_count=1, + gifts=[ + OwnedGiftRegular( + gift=Gift( + id="id1", + sticker=Sticker( + "file_id", "file_unique_id", 512, 512, False, False, "regular" + ), + star_count=5, + ), + send_date=_DUMMY_DATE, + owned_gift_id="some_id_1", + ) + ], + ), + "Poll": Poll( + id="dummy_id", + question="dummy_question", + options=[PollOption(text="dummy_text", voter_count=1)], + is_closed=False, + is_anonymous=False, + total_voter_count=1, + type="dummy_type", + allows_multiple_answers=False, + ), + "PreparedInlineMessage": PreparedInlineMessage(id="dummy_id", expiration_date=_DUMMY_DATE), + "SentWebAppMessage": SentWebAppMessage(inline_message_id="dummy_inline_message_id"), + "StarAmount": StarAmount(amount=100, nanostar_amount=356), + "StarTransactions": StarTransactions( + transactions=[StarTransaction(id="dummy_id", amount=1, date=_DUMMY_DATE)] + ), + "Sticker": _DUMMY_STICKER, + "StickerSet": StickerSet( + name="dummy_name", + title="dummy_title", + stickers=[_DUMMY_STICKER], + sticker_type="dummy_type", + ), + "Story": Story(chat=Chat(123, "prive"), id=123), + "str": "dummy_string", + "Update": Update(update_id=123456), + "User": _DUMMY_USER, + "UserChatBoosts": UserChatBoosts( + boosts=[ + ChatBoost( + boost_id="dummy_id", + add_date=_DUMMY_DATE, + expiration_date=_DUMMY_DATE, + source=ChatBoostSource(source="dummy_source"), + ) + ] + ), + "UserProfilePhotos": UserProfilePhotos(total_count=1, photos=[[]]), + "WebhookInfo": WebhookInfo( + url="dummy_url", + has_custom_certificate=False, + pending_update_count=1, + ), +} + + +def get_dummy_object(obj_type: type | str, as_tuple: bool = False) -> object: + obj_type_name = obj_type.__name__ if isinstance(obj_type, type) else obj_type + if (return_value := _PREPARED_DUMMY_OBJECTS.get(obj_type_name)) is None: + raise ValueError( + f"Dummy object of type '{obj_type_name}' not found. Please add it manually." + ) + + if as_tuple: + return (return_value,) + return return_value + + +_RETURN_TYPES: TypeAlias = bool | int | str | dict[str, object] +_RETURN_TYPE: TypeAlias = _RETURN_TYPES | tuple[_RETURN_TYPES, ...] + + +def _serialize_dummy_object(obj: object) -> _RETURN_TYPE: + if isinstance(obj, Sequence) and not isinstance(obj, str): + return tuple(_serialize_dummy_object(item) for item in obj) + if isinstance(obj, str | int | bool): + return obj + if isinstance(obj, TelegramObject): + return obj.to_dict() + + raise ValueError(f"Serialization of object of type '{type(obj)}' is not supported yet.") + + +def get_dummy_object_json_dict(obj_type: type | str, as_tuple: bool = False) -> _RETURN_TYPE: + return _serialize_dummy_object(get_dummy_object(obj_type, as_tuple=as_tuple)) diff --git a/tests/auxil/envvars.py b/tests/auxil/envvars.py index 5fb2d20c8a1..a4d83051de2 100644 --- a/tests/auxil/envvars.py +++ b/tests/auxil/envvars.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -24,7 +24,7 @@ def env_var_2_bool(env_var: object) -> bool: return env_var if not isinstance(env_var, str): return False - return env_var.lower().strip() == "true" + return env_var.lower().strip() in ["true", "1"] GITHUB_ACTIONS: bool = env_var_2_bool(os.getenv("GITHUB_ACTIONS", "false")) diff --git a/tests/auxil/files.py b/tests/auxil/files.py index 21571b1988a..d1bbc75e8b5 100644 --- a/tests/auxil/files.py +++ b/tests/auxil/files.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,6 +19,7 @@ from pathlib import Path PROJECT_ROOT_PATH = Path(__file__).parent.parent.parent.resolve() +SOURCE_ROOT_PATH = PROJECT_ROOT_PATH / "src" / "telegram" TEST_DATA_PATH = PROJECT_ROOT_PATH / "tests" / "data" diff --git a/tests/auxil/monkeypatch.py b/tests/auxil/monkeypatch.py index 377d4ebedcd..303c4e42629 100644 --- a/tests/auxil/monkeypatch.py +++ b/tests/auxil/monkeypatch.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/auxil/networking.py b/tests/auxil/networking.py index d23a1215e25..7aa49b096cf 100644 --- a/tests/auxil/networking.py +++ b/tests/auxil/networking.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. from pathlib import Path -from typing import Optional import pytest from httpx import AsyncClient, AsyncHTTPTransport, Response @@ -38,7 +37,7 @@ async def _request_wrapper( self, method: str, url: str, - request_data: Optional[RequestData] = None, + request_data: RequestData | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -71,6 +70,10 @@ async def initialize(self) -> None: async def shutdown(self) -> None: pass + @property + def read_timeout(self): + return 1 + def __init__(self, *args, **kwargs): pass @@ -78,7 +81,7 @@ async def do_request( self, url: str, method: str, - request_data: Optional[RequestData] = None, + request_data: RequestData | None = None, read_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, write_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, connect_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, @@ -113,13 +116,13 @@ async def expect_bad_request(func, message, reason): async def send_webhook_message( ip: str, port: int, - payload_str: Optional[str], + payload_str: str | None, url_path: str = "", content_len: int = -1, content_type: str = "application/json", - get_method: Optional[str] = None, - secret_token: Optional[str] = None, - unix: Optional[Path] = None, + get_method: str | None = None, + secret_token: str | None = None, + unix: Path | None = None, ) -> Response: headers = { "content-type": content_type, diff --git a/tests/auxil/pytest_classes.py b/tests/auxil/pytest_classes.py index d27f06bd8c5..278cefd55d4 100644 --- a/tests/auxil/pytest_classes.py +++ b/tests/auxil/pytest_classes.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,6 +20,7 @@ modify behavior of the respective parent classes in order to make them easier to use in the pytest framework. A common change is to allow monkeypatching of the class members by not enforcing slots in the subclasses.""" + from telegram import Bot, Message, User from telegram.ext import Application, ExtBot, Updater from tests.auxil.ci_bots import BOT_INFO_PROVIDER @@ -35,7 +36,7 @@ def _get_bot_user(token: str) -> User: # generate the correct user_id from the token (token from bot_info is random each test run). # This is important in e.g. bot equality tests. The other parameters like first_name don't # matter as much. In the future we may provide a way to get all the correct info from the token - user_id = int(token.split(":")[0]) + user_id = int(token.split(":", maxsplit=1)[0]) first_name = bot_info.get( "name", ) @@ -66,7 +67,7 @@ def __init__(self, *args, **kwargs): self._unfreeze() # Here we override get_me for caching because we don't want to call the API repeatedly in tests - async def get_me(self, *args, **kwargs): + async def get_me(self, *args, **kwargs) -> User: return await _mocked_get_me(self) @@ -77,7 +78,7 @@ def __init__(self, *args, **kwargs): self._unfreeze() # Here we override get_me for caching because we don't want to call the API repeatedly in tests - async def get_me(self, *args, **kwargs): + async def get_me(self, *args, **kwargs) -> User: return await _mocked_get_me(self) diff --git a/tests/auxil/slots.py b/tests/auxil/slots.py index 4abb0f6014d..a8eef155264 100644 --- a/tests/auxil/slots.py +++ b/tests/auxil/slots.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/auxil/timezones.py b/tests/auxil/timezones.py index 5391dfcfdbd..cb2eee776c4 100644 --- a/tests/auxil/timezones.py +++ b/tests/auxil/timezones.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/conftest.py b/tests/conftest.py index e5e74a0271b..ed0c3f64231 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio -import logging +import os import sys import zoneinfo from pathlib import Path @@ -37,10 +37,10 @@ User, ) from telegram.ext import Defaults -from tests.auxil.build_messages import DATE +from tests.auxil.build_messages import DATE, make_message from tests.auxil.ci_bots import BOT_INFO_PROVIDER, JOB_INDEX from tests.auxil.constants import PRIVATE_KEY, TEST_TOPIC_ICON_COLOR, TEST_TOPIC_NAME -from tests.auxil.envvars import GITHUB_ACTIONS, RUN_TEST_OFFICIAL, TEST_WITH_OPT_DEPS +from tests.auxil.envvars import GITHUB_ACTIONS, TEST_WITH_OPT_DEPS, env_var_2_bool from tests.auxil.files import data_file from tests.auxil.networking import NonchalantHttpxRequest from tests.auxil.pytest_classes import PytestBot, make_bot @@ -49,15 +49,6 @@ import pytz -# Don't collect `test_official.py` on Python 3.10- since it uses newer features like X | Y syntax. -# Docs: https://docs.pytest.org/en/7.1.x/example/pythoncollection.html#customizing-test-collection -collect_ignore = [] -if sys.version_info < (3, 10): - if RUN_TEST_OFFICIAL: - logging.warning("Skipping test_official.py since it requires Python 3.10+") - collect_ignore_glob = ["test_official/*.py"] - - # This is here instead of in setup.cfg due to https://github.com/pytest-dev/pytest/issues/8343 def pytest_runtestloop(session: pytest.Session): session.add_marker( @@ -129,6 +120,18 @@ def _disallow_requests_in_without_request_tests(request): ) +@pytest.fixture(scope="module", params=["true", "1", "false", "gibberish", None]) +def PTB_TIMEDELTA(request): + # Here we manually use monkeypatch to give this fixture module scope + monkeypatch = pytest.MonkeyPatch() + if request.param is not None: + monkeypatch.setenv("PTB_TIMEDELTA", request.param) + else: + monkeypatch.delenv("PTB_TIMEDELTA", raising=False) + yield env_var_2_bool(os.getenv("PTB_TIMEDELTA")) + monkeypatch.undo() + + # Redefine the event_loop fixture to have a session scope. Otherwise `bot` fixture can't be # session. See https://github.com/pytest-dev/pytest-asyncio/issues/68 for more details. @pytest.fixture(scope="session") @@ -311,7 +314,7 @@ def false_update(request): scope="session", params=[pytz.timezone, zoneinfo.ZoneInfo] if TEST_WITH_OPT_DEPS else [zoneinfo.ZoneInfo], ) -def _tz_implementation(request): # noqa: PT005 +def _tz_implementation(request): # This fixture is used to parametrize the timezone fixture # This is similar to what @pyttest.mark.parametrize does but for fixtures # However, this is needed only internally for the `tzinfo` fixture, so we keep it private @@ -331,3 +334,13 @@ def timezone(tzinfo): @pytest.fixture def tmp_file(tmp_path) -> Path: return tmp_path / uuid4().hex + + +@pytest.fixture(scope="session") +def dummy_message(): + return make_message("dummy_message") + + +@pytest.fixture(scope="session") +def dummy_message_dict(dummy_message): + return dummy_message.to_dict() diff --git a/tests/docs/admonition_inserter.py b/tests/docs/admonition_inserter.py index 97736f87565..6f5f258c30f 100644 --- a/tests/docs/admonition_inserter.py +++ b/tests/docs/admonition_inserter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,12 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -# This module is intentionally named without "test_" prefix. -# These tests are supposed to be run on GitHub when building docs. -# The tests require Python 3.9+ (just like AdmonitionInserter being tested), -# so they cannot be included in the main suite while older versions of Python are supported. +""" +This module is intentionally named without "test_" prefix. +These tests are supposed to be run on GitHub when building docs. +The tests require Python 3.10+ (just like AdmonitionInserter being tested), +so they cannot be included in the main suite while older versions of Python are supported. +""" import collections.abc @@ -103,6 +105,26 @@ def test_admonitions_dict(self, admonition_inserter): telegram.ResidentialAddress, # mentioned on the second line of docstring of .data ":attr:`telegram.EncryptedPassportElement.data`", ), + ( + "available_in", + telegram.ext.JobQueue, + ":attr:`telegram.ext.CallbackContext.job_queue`", + ), + ( + "available_in", + telegram.ext.Application, + ":attr:`telegram.ext.CallbackContext.application`", + ), + ( + "available_in", + telegram.Bot, + ":attr:`telegram.ext.CallbackContext.bot`", + ), + ( + "available_in", + telegram.Bot, + ":attr:`telegram.ext.Application.bot`", + ), ( "returned_in", telegram.StickerSet, @@ -113,6 +135,11 @@ def test_admonitions_dict(self, admonition_inserter): telegram.ChatMember, ":meth:`telegram.Bot.get_chat_member`", ), + ( + "returned_in", + telegram.GameHighScore, + ":meth:`telegram.Bot.get_game_high_scores`", + ), ( "returned_in", telegram.ChatMemberOwner, @@ -135,6 +162,18 @@ def test_admonitions_dict(self, admonition_inserter): # one of which is with Bot ":meth:`telegram.CallbackQuery.edit_message_caption`", ), + ( + "shortcuts", + telegram.Bot.ban_chat_member, + # ban_member is defined on the private parent class _ChatBase + ":meth:`telegram.Chat.ban_member`", + ), + ( + "shortcuts", + telegram.Bot.ban_chat_member, + # ban_member is defined on the private parent class _ChatBase + ":meth:`telegram.ChatFullInfo.ban_member`", + ), ( "use_in", telegram.InlineQueryResult, @@ -205,9 +244,16 @@ def test_check_presence(self, admonition_inserter, admonition_type, cls, link): "returned_in", telegram.ext.CallbackContext, # -> Application[BT, CCT, UD, CD, BD, JQ]. - # In this case classes inside square brackets must not be parsed + # The type vars are not really part of the return value, so we don't expect them ":meth:`telegram.ext.ApplicationBuilder.build`", ), + ( + "returned_in", + telegram.Bot, + # -> Application[BT, CCT, UD, CD, BD, JQ]. + # The type vars are not really part of the return value, so we don't expect them + ":meth:`telegram.ext.ApplicationBuilder.bot`", + ), ], ) def test_check_absence(self, admonition_inserter, admonition_type, cls, link): diff --git a/tests/ext/__init__.py b/tests/ext/__init__.py index 040bfc78668..c95cb3c9741 100644 --- a/tests/ext/__init__.py +++ b/tests/ext/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/_utils/__init__.py b/tests/ext/_utils/__init__.py index 040bfc78668..c95cb3c9741 100644 --- a/tests/ext/_utils/__init__.py +++ b/tests/ext/_utils/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/_utils/test_networkloop.py b/tests/ext/_utils/test_networkloop.py new file mode 100644 index 00000000000..ba3f64a3e10 --- /dev/null +++ b/tests/ext/_utils/test_networkloop.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains tests for the network_retry_loop function. + +Note: + Most of the retry loop functionality is already covered in test_updater and test_application. + These tests focus specifically on the max_retries behavior for different exception types + and the error callback handling, which were added as part of the bug fix in #5030. +""" + +import pytest + +from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut +from telegram.ext._utils.networkloop import network_retry_loop + + +class TestNetworkRetryLoop: + """Tests for the network_retry_loop function. + + Note: + The general retry loop functionality is extensively tested in test_updater and + test_application. These tests focus on the specific max_retries behavior for + different exception types. + """ + + @pytest.mark.parametrize( + ("exception_class", "exception_args"), + [ + (RetryAfter, (1,)), + (TimedOut, ("Test timeout",)), + ], + ids=["RetryAfter", "TimedOut"], + ) + async def test_exception_respects_max_retries(self, exception_class, exception_args): + """Test that RetryAfter and TimedOut exceptions respect max_retries limit.""" + call_count = 0 + + async def action_with_exception(): + nonlocal call_count + call_count += 1 + raise exception_class(*exception_args) + + with pytest.raises(exception_class): + await network_retry_loop( + action_cb=action_with_exception, + description=f"Test {exception_class.__name__}", + interval=0, + max_retries=2, + ) + + # Should be called 3 times: initial call + 2 retries + assert call_count == 3 + + @pytest.mark.parametrize( + ("exception_class", "exception_args"), + [ + (RetryAfter, (1,)), + (TimedOut, ("Test timeout",)), + ], + ids=["RetryAfter", "TimedOut"], + ) + async def test_exception_with_zero_max_retries(self, exception_class, exception_args): + """Test that RetryAfter and TimedOut with max_retries=0 don't retry.""" + call_count = 0 + + async def action_with_exception(): + nonlocal call_count + call_count += 1 + raise exception_class(*exception_args) + + with pytest.raises(exception_class): + await network_retry_loop( + action_cb=action_with_exception, + description=f"Test {exception_class.__name__} no retries", + interval=0, + max_retries=0, + ) + + # Should be called only once with max_retries=0 + assert call_count == 1 + + async def test_invalid_token_aborts_immediately(self): + """Test that InvalidToken exceptions abort immediately without retries.""" + call_count = 0 + + async def action_with_invalid_token(): + nonlocal call_count + call_count += 1 + raise InvalidToken("Invalid token") + + with pytest.raises(InvalidToken): + await network_retry_loop( + action_cb=action_with_invalid_token, + description="Test InvalidToken", + interval=0, + max_retries=5, + ) + + # Should be called only once, no retries for invalid token + assert call_count == 1 + + async def test_telegram_error_respects_max_retries(self): + """Test that general TelegramError exceptions respect max_retries limit.""" + call_count = 0 + + async def action_with_telegram_error(): + nonlocal call_count + call_count += 1 + raise TelegramError("Test error") + + with pytest.raises(TelegramError): + await network_retry_loop( + action_cb=action_with_telegram_error, + description="Test TelegramError", + interval=0, + max_retries=3, + ) + + # Should be called 4 times: initial call + 3 retries + assert call_count == 4 + + @pytest.mark.parametrize( + ("exception_class", "exception_args"), + [ + (RetryAfter, (1,)), + (TimedOut, ("Test timeout",)), + (InvalidToken, ("Invalid token",)), + ], + ids=["RetryAfter", "TimedOut", "InvalidToken"], + ) + async def test_error_callback_not_called_for_specific_exceptions( + self, exception_class, exception_args + ): + """Test that error callback is not called for RetryAfter, TimedOut, or InvalidToken.""" + error_callback_called = False + + def error_callback(exc): + nonlocal error_callback_called + error_callback_called = True + + async def action_with_exception(): + raise exception_class(*exception_args) + + with pytest.raises(exception_class): + await network_retry_loop( + action_cb=action_with_exception, + on_err_cb=error_callback, + description=f"Test {exception_class.__name__} callback", + interval=0, + max_retries=1, + ) + + assert not error_callback_called + + async def test_error_callback_called_for_telegram_error(self): + """Test that error callback is called for general TelegramError exceptions.""" + error_callback_count = 0 + caught_exception = None + + def error_callback(exc): + nonlocal error_callback_count, caught_exception + error_callback_count += 1 + caught_exception = exc + + async def action_with_telegram_error(): + raise TelegramError("Test error") + + with pytest.raises(TelegramError): + await network_retry_loop( + action_cb=action_with_telegram_error, + on_err_cb=error_callback, + description="Test TelegramError callback", + interval=0, + max_retries=2, + ) + + # Should be called 3 times (initial + 2 retries) + assert error_callback_count == 3 + assert isinstance(caught_exception, TelegramError) + + async def test_success_after_retries(self): + """Test that action succeeds after some retries.""" + call_count = 0 + + async def action_succeeds_on_third_try(): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise TimedOut("Test timeout") + # Success on third try + + await network_retry_loop( + action_cb=action_succeeds_on_third_try, + description="Test success after retries", + interval=0, + max_retries=5, + ) + + assert call_count == 3 + + @pytest.mark.parametrize( + ("exception_class", "exception_args", "success_after"), + [ + (RetryAfter, (0.01,), 5), + (TimedOut, ("Test timeout",), 4), + ], + ids=["RetryAfter", "TimedOut"], + ) + async def test_exception_with_negative_max_retries( + self, exception_class, exception_args, success_after + ): + """Test that exceptions with max_retries=-1 retry indefinitely until success.""" + call_count = 0 + + async def action_succeeds_after_few_tries(): + nonlocal call_count + call_count += 1 + if call_count < success_after: + raise exception_class(*exception_args) + # Success after specified tries + + await network_retry_loop( + action_cb=action_succeeds_after_few_tries, + description=f"Test {exception_class.__name__} infinite retries", + interval=0, + max_retries=-1, + ) + + assert call_count == success_after diff --git a/tests/ext/_utils/test_stack.py b/tests/ext/_utils/test_stack.py index 369098685c0..22a33b9bc5e 100644 --- a/tests/ext/_utils/test_stack.py +++ b/tests/ext/_utils/test_stack.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -80,7 +80,7 @@ def caller_func(): symlink_to(symlink_file, temp_file) sys.path.append(tmp_path.as_posix()) - from caller_link import caller_func + from caller_link import caller_func # noqa: PLC0415 frame = caller_func() assert was_called_by(frame, temp_file) @@ -111,7 +111,7 @@ def outer_func(): symlink_to(symlink_file2, temp_file2) sys.path.append(tmp_path.as_posix()) - from outer_link import outer_func + from outer_link import outer_func # noqa: PLC0415 frame = outer_func() assert was_called_by(frame, temp_file2) diff --git a/tests/ext/_utils/test_trackingdict.py b/tests/ext/_utils/test_trackingdict.py index 0842dc36c63..a5054b2f25b 100644 --- a/tests/ext/_utils/test_trackingdict.py +++ b/tests/ext/_utils/test_trackingdict.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_application.py b/tests/ext/test_application.py index 05b99994838..c26e8b3f32a 100644 --- a/tests/ext/test_application.py +++ b/tests/ext/test_application.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,8 +16,8 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -"""The integration of persistence into the application is tested in test_basepersistence. -""" +"""The integration of persistence into the application is tested in test_basepersistence.""" + import asyncio import functools import inspect @@ -33,12 +33,11 @@ from queue import Queue from random import randrange from threading import Thread -from typing import Optional import pytest from telegram import Bot, Chat, Message, MessageEntity, User -from telegram.error import TelegramError +from telegram.error import InvalidToken, TelegramError from telegram.ext import ( Application, ApplicationBuilder, @@ -59,7 +58,7 @@ from telegram.warnings import PTBDeprecationWarning, PTBUserWarning from tests.auxil.asyncio_helpers import call_after from tests.auxil.build_messages import make_message_update -from tests.auxil.files import PROJECT_ROOT_PATH +from tests.auxil.files import SOURCE_ROOT_PATH from tests.auxil.monkeypatch import empty_get_updates, return_true from tests.auxil.networking import send_webhook_message from tests.auxil.pytest_classes import PytestApplication, PytestUpdater, make_bot @@ -96,7 +95,7 @@ async def error_handler_raise_error(self, update, context): async def callback_increase_count(self, update, context): self.count += 1 - def callback_set_count(self, count, sleep: Optional[float] = None): + def callback_set_count(self, count, sleep: float | None = None): async def callback(update, context): if sleep: await asyncio.sleep(sleep) @@ -1006,9 +1005,9 @@ async def callback(update, context): str(recwarn[0].message) == "ApplicationHandlerStop is not supported with handlers running non-blocking." ) - assert ( - Path(recwarn[0].filename) == PROJECT_ROOT_PATH / "telegram" / "ext" / "_application.py" - ), "incorrect stacklevel!" + assert Path(recwarn[0].filename) == SOURCE_ROOT_PATH / "ext" / "_application.py", ( + "incorrect stacklevel!" + ) async def test_non_blocking_no_error_handler(self, app, caplog): app.add_handler(TypeHandler(object, self.callback_raise_error("Test error"), block=False)) @@ -1079,9 +1078,9 @@ async def error_handler(update, context): str(recwarn[0].message) == "ApplicationHandlerStop is not supported with handlers running non-blocking." ) - assert ( - Path(recwarn[0].filename) == PROJECT_ROOT_PATH / "telegram" / "ext" / "_application.py" - ), "incorrect stacklevel!" + assert Path(recwarn[0].filename) == SOURCE_ROOT_PATH / "ext" / "_application.py", ( + "incorrect stacklevel!" + ) @pytest.mark.parametrize(("block", "expected_output"), [(False, 0), (True, 5)]) async def test_default_block_error_handler(self, bot_info, block, expected_output): @@ -1504,7 +1503,7 @@ def thread_target(): thread.start() with caplog.at_level(logging.DEBUG): app.run_polling(drop_pending_updates=True, close_loop=False) - thread.join() + thread.join(timeout=10) assert len(assertions) == 8 for key, value in assertions.items(): @@ -1516,49 +1515,6 @@ def thread_target(): found_log = True assert found_log - @pytest.mark.parametrize( - "timeout_name", - ["read_timeout", "connect_timeout", "write_timeout", "pool_timeout", "poll_interval"], - ) - @pytest.mark.skipif( - platform.system() == "Windows", - reason="Can't send signals without stopping whole process on windows", - ) - def test_run_polling_timeout_deprecation_warnings( - self, timeout_name, monkeypatch, recwarn, app - ): - def thread_target(): - waited = 0 - while not app.running: - time.sleep(0.05) - waited += 0.05 - if waited > 5: - pytest.fail("App apparently won't start") - - time.sleep(0.05) - - os.kill(os.getpid(), signal.SIGINT) - - monkeypatch.setattr(app.bot, "get_updates", empty_get_updates) - - thread = Thread(target=thread_target) - thread.start() - - kwargs = {timeout_name: 42} - app.run_polling(drop_pending_updates=True, close_loop=False, **kwargs) - thread.join() - - if timeout_name == "poll_interval": - assert len(recwarn) == 0 - return - - assert len(recwarn) == 1 - assert "Setting timeouts via `Application.run_polling` is deprecated." in str( - recwarn[0].message - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__, "wrong stacklevel" - @pytest.mark.skipif( platform.system() == "Windows", reason="Can't send signals without stopping whole process on windows", @@ -1601,7 +1557,7 @@ async def post_init(app: Application) -> None: thread = Thread(target=thread_target) thread.start() app.run_polling(drop_pending_updates=True, close_loop=False) - thread.join() + thread.join(timeout=10) assert events == ["init", "post_init", "start_polling"], "Wrong order of events detected!" @pytest.mark.skipif( @@ -1646,7 +1602,7 @@ async def post_shutdown(app: Application) -> None: thread = Thread(target=thread_target) thread.start() app.run_polling(drop_pending_updates=True, close_loop=False) - thread.join() + thread.join(timeout=10) assert events == [ "updater.shutdown", "shutdown", @@ -1698,7 +1654,7 @@ async def post_stop(app: Application) -> None: thread = Thread(target=thread_target) thread.start() app.run_polling(drop_pending_updates=True, close_loop=False) - thread.join() + thread.join(timeout=10) assert events == [ "updater.stop", "stop", @@ -1747,7 +1703,7 @@ def thread_target(): thread = Thread(target=thread_target) thread.start() app.run_polling(close_loop=False) - thread.join() + thread.join(timeout=10) assert set(self.received.keys()) == set(updater_signature.parameters.keys()) for name, param in updater_signature.parameters.items(): @@ -1759,10 +1715,11 @@ def thread_target(): expected = { name: name for name in updater_signature.parameters if name != "error_callback" } + expected["bootstrap_retries"] = 42 thread = Thread(target=thread_target) thread.start() app.run_polling(close_loop=False, **expected) - thread.join() + thread.join(timeout=10) assert set(self.received.keys()) == set(updater_signature.parameters.keys()) assert self.received.pop("error_callback", None) @@ -1821,7 +1778,7 @@ def thread_target(): drop_pending_updates=True, close_loop=False, ) - thread.join() + thread.join(timeout=10) assert len(assertions) == 7 for key, value in assertions.items(): @@ -1886,7 +1843,7 @@ async def post_init(app: Application) -> None: drop_pending_updates=True, close_loop=False, ) - thread.join() + thread.join(timeout=10) assert events == ["init", "post_init", "start_webhook"], "Wrong order of events detected!" @pytest.mark.skipif( @@ -1942,7 +1899,7 @@ async def post_shutdown(app: Application) -> None: drop_pending_updates=True, close_loop=False, ) - thread.join() + thread.join(timeout=10) assert events == [ "updater.shutdown", "shutdown", @@ -2005,7 +1962,7 @@ async def post_stop(app: Application) -> None: drop_pending_updates=True, close_loop=False, ) - thread.join() + thread.join(timeout=10) assert events == [ "updater.stop", "stop", @@ -2056,7 +2013,7 @@ def thread_target(): thread = Thread(target=thread_target) thread.start() app.run_webhook(close_loop=False) - thread.join() + thread.join(timeout=10) assert set(self.received.keys()) == set(updater_signature.parameters.keys()) - {"self"} for name, param in updater_signature.parameters.items(): @@ -2065,10 +2022,11 @@ def thread_target(): assert self.received[name] == param.default expected = {name: name for name in updater_signature.parameters if name != "self"} + expected["bootstrap_retries"] = 42 thread = Thread(target=thread_target) thread.start() app.run_webhook(close_loop=False, **expected) - thread.join() + thread.join(timeout=10) assert set(self.received.keys()) == set(expected.keys()) assert self.received == expected @@ -2359,8 +2317,100 @@ async def raise_method(*args, **kwargs): for record in recwarn: assert not str(record.message).startswith("Could not add signal handlers for the stop") - @pytest.mark.flaky(3, 1) # loop.call_later will error the test when a flood error is received - def test_signal_handlers(self, app, monkeypatch): + @pytest.mark.parametrize("exception_class", [InvalidToken, TelegramError]) + @pytest.mark.parametrize("retries", [3, 0]) + @pytest.mark.parametrize("method_name", ["run_polling", "run_webhook"]) + async def test_run_polling_webhook_bootstrap_retries( + self, monkeypatch, exception_class, retries, offline_bot, method_name + ): + """This doesn't test all of the internals of the network retry loop. We do that quite + intensively for the `Updater` and here we just want to make sure that the `Application` + does do the retries. + """ + + def thread_target(): + asyncio.set_event_loop(asyncio.new_event_loop()) + app = ( + ApplicationBuilder().bot(offline_bot).application_class(PytestApplication).build() + ) + + async def initialize(*args, **kwargs): + self.count += 1 + raise exception_class(str(self.count)) + + monkeypatch.setattr(app, "initialize", initialize) + method = functools.partial( + getattr(app, method_name), + bootstrap_retries=retries, + close_loop=False, + stop_signals=None, + ) + + if exception_class == InvalidToken: + with pytest.raises(InvalidToken, match="1"): + method() + else: + with pytest.raises(TelegramError, match=str(retries + 1)): + method() + + thread = Thread(target=thread_target) + thread.start() + thread.join(timeout=10) + assert not thread.is_alive(), "Test took to long to run. Aborting" + + @pytest.mark.parametrize("method_name", ["run_polling", "run_webhook"]) + async def test_run_polling_webhook_infinite_bootstrap_retries( + self, monkeypatch, offline_bot, method_name + ): + """Here we simply test that setting `bootstrap_retries=-1` does not lead to the wrong + infinite-loop behavior reported in #4966. Raising an exception on the first call to + `initialize` ensures that a retry actually happens. + """ + + def thread_target(): + asyncio.set_event_loop(asyncio.new_event_loop()) + + async def post_init(application): + application.stop_running() + + app = ( + ApplicationBuilder() + .bot(offline_bot) + .application_class(PytestApplication) + .post_init(post_init) + .build() + ) + + async def do_pass(*args, **kwargs): + pass + + monkeypatch.setattr(app.bot, "initialize", do_pass) + monkeypatch.setattr(app.bot, "delete_webhook", do_pass) + + original_initialize = app.initialize + + async def initialize(*args, **kwargs): + if self.count >= 3: + pytest.fail("Should be called only once. Test failed.") + + self.count += 1 + if self.count == 1: + raise TelegramError("Test Exception") + await original_initialize(*args, **kwargs) + + monkeypatch.setattr(app, "initialize", initialize) + getattr(app, method_name)( + bootstrap_retries=-1, + close_loop=False, + stop_signals=None, + ) + + thread = Thread(target=thread_target) + thread.start() + thread.join(timeout=10) + assert not thread.is_alive(), "Test took to long to run. Aborting" + + def test_signal_handlers(self, offline_bot, monkeypatch): # this test should make sure that signal handlers are set by default on Linux + Mac, # and not on Windows. @@ -2368,19 +2418,30 @@ def test_signal_handlers(self, app, monkeypatch): def signal_handler_test(*args, **kwargs): # args[0] is the signal, [1] the callback - received_signals.append(args[0]) + received_signals.append(args[1]) - loop = asyncio.get_event_loop() + app = ApplicationBuilder().bot(offline_bot).application_class(PytestApplication).build() - monkeypatch.setattr(loop, "add_signal_handler", signal_handler_test) + # Mock the necessary methods to avoid network calls monkeypatch.setattr(app.bot, "get_updates", empty_get_updates) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) - def abort_app(): - raise SystemExit + loop = asyncio.get_event_loop() - loop.call_later(0.6, abort_app) + monkeypatch.setattr(loop.__class__, "add_signal_handler", signal_handler_test) - app.run_polling(close_loop=False) + # Mock initialize to exit quickly after testing signal handler setup + original_initialize = app.initialize + + async def quick_initialize(*args, **kwargs): + await original_initialize(*args, **kwargs) + # Exit quickly by raising an exception after successful initialization + raise TelegramError("Test completed successfully") + + monkeypatch.setattr(app, "initialize", quick_initialize) + + with pytest.raises(TelegramError, match="Test completed successfully"): + app.run_polling(close_loop=False) if platform.system() == "Windows": assert received_signals == [] @@ -2388,8 +2449,8 @@ def abort_app(): assert received_signals == [signal.SIGINT, signal.SIGTERM, signal.SIGABRT] received_signals.clear() - loop.call_later(0.8, abort_app) - app.run_webhook(port=49152, webhook_url="example.com", close_loop=False) + with pytest.raises(TelegramError, match="Test completed successfully"): + app.run_webhook(port=49152, webhook_url="example.com", close_loop=False) if platform.system() == "Windows": assert received_signals == [] @@ -2540,7 +2601,7 @@ async def callback(update, context): close_loop=False, ) - thread.join() + thread.join(timeout=10) assert len(assertions) == 5 for key, value in assertions.items(): @@ -2586,6 +2647,70 @@ async def callback(update, context): assert received_updates == {2} assert len(caplog.records) == 0 + @pytest.mark.parametrize("change_type", ["remove", "add"]) + async def test_process_update_handler_change_groups_during_iteration(self, app, change_type): + run_groups = set() + + async def dummy_callback(_, __, g: int): + run_groups.add(g) + + for group in range(10, 20): + handler = TypeHandler(int, functools.partial(dummy_callback, g=group)) + app.add_handler(handler, group=group) + + async def wait_callback(_, context): + # Trigger a change of the app.handlers dict during the iteration + if change_type == "remove": + context.application.remove_handler(handler, group) + else: + context.application.add_handler( + TypeHandler(int, functools.partial(dummy_callback, g=42)), group=42 + ) + + app.add_handler(TypeHandler(int, wait_callback)) + + async with app: + await app.process_update(1) + + # check that exactly those handlers were called that were configured when + # process_update was called + assert run_groups == set(range(10, 20)) + + async def test_process_update_handler_change_group_during_iteration(self, app): + async def dummy_callback(_, __): + pass + + checked_handlers = set() + + class TrackHandler(TypeHandler): + def __init__(self, name: str, *args, **kwargs): + self.name = name + super().__init__(*args, **kwargs) + + def check_update(self, update: object) -> bool: + checked_handlers.add(self.name) + return super().check_update(update) + + remove_handler = TrackHandler("remove", int, dummy_callback) + add_handler = TrackHandler("add", int, dummy_callback) + + class TriggerHandler(TypeHandler): + def check_update(self, update: object) -> bool: + # Trigger a change of the app.handlers *in the same group* during the iteration + app.remove_handler(remove_handler) + app.add_handler(add_handler) + # return False to ensure that additional handlers in the same group are checked + return False + + app.add_handler(TriggerHandler(str, dummy_callback)) + app.add_handler(remove_handler) + async with app: + await app.process_update("string update") + + # check that exactly those handlers were checked that were configured when + # process_update was called + assert checked_handlers == {"remove"} + async def test_process_error_exception_in_building_context(self, monkeypatch, caplog, app): # Makes sure that exceptions in building the context don't stop the application exception = ValueError("TestException") @@ -2625,3 +2750,92 @@ async def callback(update, context): assert received_errors == {2} assert len(caplog.records) == 0 + + @pytest.mark.parametrize("change_type", ["remove", "add"]) + async def test_process_error_change_during_iteration(self, app, change_type): + called_handlers = set() + + async def dummy_process_error(name: str, *_, **__): + called_handlers.add(name) + + add_error_handler = functools.partial(dummy_process_error, "add_handler") + remove_error_handler = functools.partial(dummy_process_error, "remove_handler") + + async def trigger_change(*_, **__): + if change_type == "remove": + app.remove_error_handler(remove_error_handler) + else: + app.add_error_handler(add_error_handler) + + app.add_error_handler(trigger_change) + app.add_error_handler(remove_error_handler) + async with app: + await app.process_error(update=None, error=None) + + # check that exactly those handlers were checked that were configured when + # add_error_handler was called + assert called_handlers == {"remove_handler"} + + @pytest.mark.skipif( + sys.version_info < (3, 14), + reason="Only relevant for Python 3.14+ where get_event_loop() raises RuntimeError", + ) + def test_run_polling_no_event_loop_python314(self, offline_bot, monkeypatch): + """Test that run_polling works when no event loop exists (Python 3.14+ scenario). + + This simulates the Python 3.14+ behavior where get_event_loop() raises RuntimeError + when there's no current event loop in the main thread. The fix should create a new + event loop in this case. + """ + # Track if our test ran and whether any exceptions occurred + exception_captured = None + + def thread_target(): + nonlocal exception_captured + try: + # Intentionally DON'T set an event loop to simulate Python 3.14 scenario + # Note: the existing test_run_polling_webhook_bootstrap_retries DOES set one + + app = ( + ApplicationBuilder() + .bot(offline_bot) + .application_class(PytestApplication) + .build() + ) + + # Mock the necessary methods to avoid network calls + monkeypatch.setattr(app.bot, "get_updates", empty_get_updates) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) + + # Mock initialize to exit quickly after testing event loop creation + original_initialize = app.initialize + + async def quick_initialize(*args, **kwargs): + await original_initialize(*args, **kwargs) + # Exit quickly by raising an exception after successful initialization + raise TelegramError("Test completed successfully") + + monkeypatch.setattr(app, "initialize", quick_initialize) + + # This should work - the key is that it creates an event loop and doesn't + # raise RuntimeError about no current event loop (Python 3.14+ issue) + with pytest.raises(TelegramError, match="Test completed successfully"): + app.run_polling( + bootstrap_retries=0, + close_loop=True, + stop_signals=None, # Can't use signals in threads + drop_pending_updates=True, + ) + # If we get here, the event loop was created successfully + except Exception as e: + exception_captured = e + + thread = Thread(target=thread_target) + thread.start() + thread.join(timeout=10) + + assert not thread.is_alive(), "Test took too long to run" + + # If there was an unexpected exception, fail the test + if exception_captured: + raise exception_captured diff --git a/tests/ext/test_applicationbuilder.py b/tests/ext/test_applicationbuilder.py index fbf6bf917d4..59fbbd4fb4f 100644 --- a/tests/ext/test_applicationbuilder.py +++ b/tests/ext/test_applicationbuilder.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio +import datetime as dtm import inspect from dataclasses import dataclass from http import HTTPStatus @@ -41,7 +42,6 @@ from telegram.ext._applicationbuilder import _BOT_CHECKS from telegram.ext._baseupdateprocessor import SimpleUpdateProcessor from telegram.request import HTTPXRequest -from telegram.warnings import PTBDeprecationWarning from tests.auxil.constants import PRIVATE_KEY from tests.auxil.envvars import TEST_WITH_OPT_DEPS from tests.auxil.files import data_file @@ -110,7 +110,7 @@ def init_raises_runtime_error(*args, **kwargs): ApplicationBuilder() def test_build_without_token(self, builder): - with pytest.raises(RuntimeError, match="No bot token was set."): + with pytest.raises(RuntimeError, match="No bot token was set\\."): builder.build() def test_build_custom_bot(self, builder, bot): @@ -150,9 +150,7 @@ class Client: assert app.bot.local_mode is False get_updates_client = app.bot._request[0]._client - assert get_updates_client.limits == httpx.Limits( - max_connections=1, max_keepalive_connections=1 - ) + assert get_updates_client.limits == httpx.Limits(max_connections=1) assert get_updates_client.proxy is None assert get_updates_client.timeout == httpx.Timeout( connect=5.0, read=5.0, write=5.0, pool=1.0 @@ -161,7 +159,7 @@ class Client: assert not get_updates_client.http2 client = app.bot.request._client - assert client.limits == httpx.Limits(max_connections=256, max_keepalive_connections=256) + assert client.limits == httpx.Limits(max_connections=256) assert client.proxy is None assert client.timeout == httpx.Timeout(connect=5.0, read=5.0, write=5.0, pool=1.0) assert client.http1 is True @@ -207,7 +205,6 @@ def test_mutually_exclusive_for_bot(self, builder, method, description): "write_timeout", "media_write_timeout", "proxy", - "proxy_url", "socket_options", "bot", "updater", @@ -217,9 +214,8 @@ def test_mutually_exclusive_for_bot(self, builder, method, description): def test_mutually_exclusive_for_request(self, builder, method): builder.request(1) - method_name = method.replace("proxy_url", "proxy") with pytest.raises( - RuntimeError, match=f"`{method_name}` may only be set, if no request instance" + RuntimeError, match=f"`{method}` may only be set, if no request instance" ): getattr(builder, method)(data_file("private.key")) @@ -237,7 +233,6 @@ def test_mutually_exclusive_for_request(self, builder, method): "get_updates_read_timeout", "get_updates_write_timeout", "get_updates_proxy", - "get_updates_proxy_url", "get_updates_socket_options", "get_updates_http_version", "bot", @@ -247,10 +242,9 @@ def test_mutually_exclusive_for_request(self, builder, method): def test_mutually_exclusive_for_get_updates_request(self, builder, method): builder.get_updates_request(1) - method_name = method.replace("proxy_url", "proxy") with pytest.raises( RuntimeError, - match=f"`{method_name}` may only be set, if no get_updates_request instance", + match=f"`{method}` may only be set, if no get_updates_request instance", ): getattr(builder, method)(data_file("private.key")) @@ -267,7 +261,6 @@ def test_mutually_exclusive_for_get_updates_request(self, builder, method): "get_updates_pool_timeout", "get_updates_read_timeout", "get_updates_write_timeout", - "get_updates_proxy_url", "get_updates_proxy", "get_updates_socket_options", "get_updates_http_version", @@ -278,7 +271,6 @@ def test_mutually_exclusive_for_get_updates_request(self, builder, method): "write_timeout", "media_write_timeout", "proxy", - "proxy_url", "socket_options", "http_version", "bot", @@ -290,17 +282,15 @@ def test_mutually_exclusive_for_get_updates_request(self, builder, method): def test_mutually_exclusive_for_updater(self, builder, method): builder.updater(1) - method_name = method.replace("proxy_url", "proxy") with pytest.raises( RuntimeError, - match=f"`{method_name}` may only be set, if no updater", + match=f"`{method}` may only be set, if no updater", ): getattr(builder, method)(data_file("private.key")) builder = ApplicationBuilder() getattr(builder, method)(data_file("private.key")) - method = method.replace("proxy_url", "proxy") with pytest.raises(RuntimeError, match=f"`updater` may only be set, if no {method}"): builder.updater(1) @@ -313,7 +303,6 @@ def test_mutually_exclusive_for_updater(self, builder, method): "get_updates_read_timeout", "get_updates_write_timeout", "get_updates_proxy", - "get_updates_proxy_url", "get_updates_socket_options", "get_updates_http_version", "connection_pool_size", @@ -323,7 +312,6 @@ def test_mutually_exclusive_for_updater(self, builder, method): "write_timeout", "media_write_timeout", "proxy", - "proxy_url", "socket_options", "bot", "http_version", @@ -341,14 +329,11 @@ def test_mutually_non_exclusive_for_updater(self, builder, method): getattr(builder, method)(data_file("private.key")) builder.updater(None) - # We test with bot the new & legacy version to ensure that the legacy version still works - @pytest.mark.parametrize( - ("proxy_method", "get_updates_proxy_method"), - [("proxy", "get_updates_proxy"), ("proxy_url", "get_updates_proxy_url")], - ids=["new", "legacy"], - ) def test_all_bot_args_custom( - self, builder, bot, monkeypatch, proxy_method, get_updates_proxy_method + self, + builder, + bot, + monkeypatch, ): # Only socket_options is tested in a standalone test, since that's easier defaults = Defaults() @@ -359,11 +344,7 @@ def test_all_bot_args_custom( PRIVATE_KEY ).defaults(defaults).arbitrary_callback_data(42).request(request).get_updates_request( get_updates_request - ).rate_limiter( - rate_limiter - ).local_mode( - True - ) + ).rate_limiter(rate_limiter).local_mode(True) built_bot = builder.build().bot # In the following we access some private attributes of bot and request. this is not @@ -403,13 +384,12 @@ def init_httpx_request(self_, *args, **kwargs): builder = ApplicationBuilder().token(bot.token) builder.connection_pool_size(1).connect_timeout(2).pool_timeout(3).read_timeout( 4 - ).write_timeout(5).media_write_timeout(6).http_version("1.1") - getattr(builder, proxy_method)("proxy") + ).write_timeout(5).media_write_timeout(6).http_version("1.1").proxy("proxy") app = builder.build() client = app.bot.request._client assert client.timeout == httpx.Timeout(pool=3, connect=2, read=4, write=5) - assert client.limits == httpx.Limits(max_connections=1, max_keepalive_connections=1) + assert client.limits == httpx.Limits(max_connections=1) assert client.proxy == "proxy" assert client.http1 is True assert client.http2 is False @@ -421,15 +401,12 @@ def init_httpx_request(self_, *args, **kwargs): 2 ).get_updates_pool_timeout(3).get_updates_read_timeout(4).get_updates_write_timeout( 5 - ).get_updates_http_version( - "1.1" - ) - getattr(builder, get_updates_proxy_method)("get_updates_proxy") + ).get_updates_http_version("1.1").get_updates_proxy("get_updates_proxy") app = builder.build() client = app.bot._request[0]._client assert client.timeout == httpx.Timeout(pool=3, connect=2, read=4, write=5) - assert client.limits == httpx.Limits(max_connections=1, max_keepalive_connections=1) + assert client.limits == httpx.Limits(max_connections=1) assert client.proxy == "get_updates_proxy" assert client.http1 is True assert client.http2 is False @@ -440,7 +417,6 @@ def test_custom_socket_options(self, builder, monkeypatch, bot): httpx_request_init = HTTPXRequest.__init__ def init_transport(*args, **kwargs): - nonlocal httpx_request_kwargs # This is called once for request and once for get_updates_request, so we make # it a list httpx_request_kwargs.append(kwargs.copy()) @@ -585,31 +561,18 @@ def test_no_job_queue(self, bot, builder): assert isinstance(app.update_queue, asyncio.Queue) assert isinstance(app.updater, Updater) - def test_proxy_url_deprecation_warning(self, bot, builder, recwarn): - builder.token(bot.token).proxy_url("proxy_url") - assert len(recwarn) == 1 - assert "`ApplicationBuilder.proxy_url` is deprecated" in str(recwarn[0].message) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__, "wrong stacklevel" - - def test_get_updates_proxy_url_deprecation_warning(self, bot, builder, recwarn): - builder.token(bot.token).get_updates_proxy_url("get_updates_proxy_url") - assert len(recwarn) == 1 - assert "`ApplicationBuilder.get_updates_proxy_url` is deprecated" in str( - recwarn[0].message - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__, "wrong stacklevel" - @pytest.mark.parametrize( ("read_timeout", "timeout", "expected"), [ (None, None, 0), (1, None, 1), (None, 1, 1), + (None, dtm.timedelta(seconds=1), 1), (DEFAULT_NONE, None, 10), (DEFAULT_NONE, 1, 11), + (DEFAULT_NONE, dtm.timedelta(seconds=1), 11), (1, 2, 3), + (1, dtm.timedelta(seconds=2), 3), ], ) async def test_get_updates_read_timeout_value_passing( diff --git a/tests/ext/test_basehandler.py b/tests/ext/test_basehandler.py index cd5a1f552c7..6279a946190 100644 --- a/tests/ext/test_basehandler.py +++ b/tests/ext/test_basehandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_basepersistence.py b/tests/ext/test_basepersistence.py index 42be9c62e03..39891c777e5 100644 --- a/tests/ext/test_basepersistence.py +++ b/tests/ext/test_basepersistence.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -21,12 +21,13 @@ import copy import enum import functools +import itertools import logging import sys import time from http import HTTPStatus from pathlib import Path -from typing import NamedTuple, Optional +from typing import NamedTuple import pytest @@ -73,7 +74,7 @@ class TrackingPersistence(BasePersistence): def __init__( self, - store_data: Optional[PersistenceInput] = None, + store_data: PersistenceInput | None = None, update_interval: float = 60, fill_data: bool = False, ): @@ -222,20 +223,20 @@ def build_handler(cls, state: HandlerStates, callback=None): class PappInput(NamedTuple): - bot_data: Optional[bool] = None - chat_data: Optional[bool] = None - user_data: Optional[bool] = None - callback_data: Optional[bool] = None + bot_data: bool | None = None + chat_data: bool | None = None + user_data: bool | None = None + callback_data: bool | None = None conversations: bool = True update_interval: float = None fill_data: bool = False def build_papp( - bot_info: Optional[dict] = None, - token: Optional[str] = None, - store_data: Optional[dict] = None, - update_interval: Optional[float] = None, + bot_info: dict | None = None, + token: str | None = None, + store_data: dict | None = None, + update_interval: float | None = None, fill_data: bool = False, ) -> Application: store_data = PersistenceInput(**(store_data or {})) @@ -319,7 +320,7 @@ class TestBasePersistence: """Tests basic behavior of BasePersistence and (most importantly) the integration of persistence into the Application.""" - def job_callback(self, chat_id: Optional[int] = None): + def job_callback(self, chat_id: int | None = None): async def callback(context): if context.user_data: context.user_data["key"] = "value" @@ -338,7 +339,7 @@ async def callback(context): return callback - def handler_callback(self, chat_id: Optional[int] = None, sleep: Optional[float] = None): + def handler_callback(self, chat_id: int | None = None, sleep: float | None = None): async def callback(update, context): if sleep: await asyncio.sleep(sleep) @@ -404,7 +405,7 @@ def test_update_interval_immutable(self, papp): @default_papp def test_set_bot_error(self, papp): - with pytest.raises(TypeError, match="when using telegram.ext.ExtBot"): + with pytest.raises(TypeError, match="when using telegram\\.ext\\.ExtBot"): papp.persistence.set_bot(Bot(papp.bot.token)) # just making sure that setting an ExtBoxt without callback_data_cache doesn't raise an @@ -419,7 +420,7 @@ def __init__(self): self.store_data = PersistenceInput(False, False, False, False) with pytest.raises( - TypeError, match="persistence must be based on telegram.ext.BasePersistence" + TypeError, match="persistence must be based on telegram\\.ext\\.BasePersistence" ): ApplicationBuilder().bot(bot).persistence(MyPersistence()).build() @@ -639,7 +640,7 @@ async def update_persistence(*args, **kwargs): await papp.stop() # Make assertions before calling shutdown, as that calls update_persistence again! - diffs = [j - i for i, j in zip(call_times[:-1], call_times[1:])] + diffs = [j - i for i, j in itertools.pairwise(call_times)] assert sum(diffs) / len(diffs) == pytest.approx( papp.persistence.update_interval, rel=1e-1 ) diff --git a/tests/ext/test_baseupdateprocessor.py b/tests/ext/test_baseupdateprocessor.py index 0b8da2574cc..c8f06fe24a8 100644 --- a/tests/ext/test_baseupdateprocessor.py +++ b/tests/ext/test_baseupdateprocessor.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,6 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """Here we run tests directly with SimpleUpdateProcessor because that's easier than providing dummy implementations for SimpleUpdateProcessor and we want to test SimpleUpdateProcessor anyway.""" + import asyncio import pytest @@ -164,3 +165,33 @@ async def shutdown(*args, **kwargs): pass assert self.test_flag == "shutdown" + + async def test_current_concurrent_updates(self, mock_processor): + async def callback(event: asyncio.Event): + await event.wait() + + events = {i: asyncio.Event() for i in range(10)} + coroutines = {i: callback(event) for i, event in events.items()} + + process_tasks = [ + asyncio.create_task(mock_processor.process_update(Update(i), coroutines[i])) + for i in range(10) + ] + await asyncio.sleep(0.01) + + assert mock_processor.current_concurrent_updates == mock_processor.max_concurrent_updates + for i in range(5): + events[i].set() + + await asyncio.sleep(0.01) + assert mock_processor.current_concurrent_updates == mock_processor.max_concurrent_updates + + for i in range(5, 10): + events[i].set() + await asyncio.sleep(0.01) + assert ( + mock_processor.current_concurrent_updates + == mock_processor.max_concurrent_updates - (i - 4) + ) + + await asyncio.gather(*process_tasks) diff --git a/tests/ext/test_businessconnectionhandler.py b/tests/ext/test_businessconnectionhandler.py index e8e8f77bdf9..07ac3417c34 100644 --- a/tests/ext/test_businessconnectionhandler.py +++ b/tests/ext/test_businessconnectionhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,6 +23,7 @@ from telegram import ( Bot, + BusinessBotRights, BusinessConnection, CallbackQuery, Chat, @@ -81,8 +82,8 @@ def business_connection(bot): user_chat_id=1, user=User(1, "name", username="user_a", is_bot=False), date=dtm.datetime.now(tz=UTC), - can_reply=True, is_enabled=True, + rights=BusinessBotRights(can_reply=True), ) bc.set_bot(bot) return bc diff --git a/tests/ext/test_businessmessagesdeletedhandler.py b/tests/ext/test_businessmessagesdeletedhandler.py index 77f6442de24..f002f50c55f 100644 --- a/tests/ext/test_businessmessagesdeletedhandler.py +++ b/tests/ext/test_businessmessagesdeletedhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_callbackcontext.py b/tests/ext/test_callbackcontext.py index d74f2473d63..8ff88792a2b 100644 --- a/tests/ext/test_callbackcontext.py +++ b/tests/ext/test_callbackcontext.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -200,12 +200,12 @@ def test_drop_callback_data_exception(self, bot, app, raw_bot): callback_context = CallbackContext.from_update(update, app) - with pytest.raises(RuntimeError, match="This telegram.ext.ExtBot instance does not"): + with pytest.raises(RuntimeError, match="This telegram\\.ext\\.ExtBot instance does not"): callback_context.drop_callback_data(None) try: app.bot = raw_bot - with pytest.raises(RuntimeError, match="telegram.Bot does not allow for"): + with pytest.raises(RuntimeError, match="telegram\\.Bot does not allow for"): callback_context.drop_callback_data(None) finally: app.bot = bot diff --git a/tests/ext/test_callbackdatacache.py b/tests/ext/test_callbackdatacache.py index 084a6b1887e..380d594e253 100644 --- a/tests/ext/test_callbackdatacache.py +++ b/tests/ext/test_callbackdatacache.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -68,9 +68,9 @@ def test_slot_behaviour(self): keyboard_data = _KeyboardData("uuid") for attr in keyboard_data.__slots__: assert getattr(keyboard_data, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(keyboard_data)) == len( - set(mro_slots(keyboard_data)) - ), "duplicate slot" + assert len(mro_slots(keyboard_data)) == len(set(mro_slots(keyboard_data))), ( + "duplicate slot" + ) @pytest.mark.skipif( @@ -86,9 +86,9 @@ def test_slot_behaviour(self, callback_data_cache): else attr ) assert getattr(callback_data_cache, at, "err") != "err", f"got extra slot '{at}'" - assert len(mro_slots(callback_data_cache)) == len( - set(mro_slots(callback_data_cache)) - ), "duplicate slot" + assert len(mro_slots(callback_data_cache)) == len(set(mro_slots(callback_data_cache))), ( + "duplicate slot" + ) @pytest.mark.parametrize("maxsize", [1, 5, 2048]) def test_init_maxsize(self, maxsize, bot): @@ -320,7 +320,7 @@ def test_drop_data_missing_data(self, callback_data_cache): data=out.inline_keyboard[0][1].callback_data, ) - with pytest.raises(KeyError, match="CallbackQuery was not found in cache."): + with pytest.raises(KeyError, match="CallbackQuery was not found in cache\\."): callback_data_cache.drop_data(callback_query) callback_data_cache.process_callback_query(callback_query) diff --git a/tests/ext/test_callbackqueryhandler.py b/tests/ext/test_callbackqueryhandler.py index 653b2c8333a..9d998b6e1c4 100644 --- a/tests/ext/test_callbackqueryhandler.py +++ b/tests/ext/test_callbackqueryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_chatboosthandler.py b/tests/ext/test_chatboosthandler.py index 5e0d1c07a81..8b567415d6d 100644 --- a/tests/ext/test_chatboosthandler.py +++ b/tests/ext/test_chatboosthandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -132,7 +132,7 @@ async def cb_chat_boost_any(self, update, context): ) @pytest.mark.parametrize( - argnames=["allowed_types", "cb", "expected"], + argnames=("allowed_types", "cb", "expected"), argvalues=[ (ChatBoostHandler.CHAT_BOOST, "cb_chat_boost_updated", (True, False)), (ChatBoostHandler.REMOVED_CHAT_BOOST, "cb_chat_boost_removed", (False, True)), diff --git a/tests/ext/test_chatjoinrequesthandler.py b/tests/ext/test_chatjoinrequesthandler.py index e4f8b2db81c..4a5ea46bcea 100644 --- a/tests/ext/test_chatjoinrequesthandler.py +++ b/tests/ext/test_chatjoinrequesthandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_chatmemberhandler.py b/tests/ext/test_chatmemberhandler.py index 7846d3607b1..addaeabacc4 100644 --- a/tests/ext/test_chatmemberhandler.py +++ b/tests/ext/test_chatmemberhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -115,7 +115,7 @@ async def callback(self, update, context): ) @pytest.mark.parametrize( - argnames=["allowed_types", "expected"], + argnames=("allowed_types", "expected"), argvalues=[ (ChatMemberHandler.MY_CHAT_MEMBER, (True, False)), (ChatMemberHandler.CHAT_MEMBER, (False, True)), @@ -145,7 +145,7 @@ async def test_chat_member_types( assert self.test_flag == result_2 @pytest.mark.parametrize( - argnames=["allowed_types", "chat_id", "expected"], + argnames=("allowed_types", "chat_id", "expected"), argvalues=[ (ChatMemberHandler.MY_CHAT_MEMBER, None, (True, False)), (ChatMemberHandler.CHAT_MEMBER, None, (False, True)), diff --git a/tests/ext/test_choseninlineresulthandler.py b/tests/ext/test_choseninlineresulthandler.py index 947732caf89..24984e380e5 100644 --- a/tests/ext/test_choseninlineresulthandler.py +++ b/tests/ext/test_choseninlineresulthandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_commandhandler.py b/tests/ext/test_commandhandler.py index f256091904e..154298f853c 100644 --- a/tests/ext/test_commandhandler.py +++ b/tests/ext/test_commandhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_contexttypes.py b/tests/ext/test_contexttypes.py index a2509d81e4b..91240952344 100644 --- a/tests/ext/test_contexttypes.py +++ b/tests/ext/test_contexttypes.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_conversationhandler.py b/tests/ext/test_conversationhandler.py index 7d8b7ddb946..ea0dc580754 100644 --- a/tests/ext/test_conversationhandler.py +++ b/tests/ext/test_conversationhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,9 +17,11 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Persistence of conversations is tested in test_basepersistence.py""" + import asyncio import functools import logging +from copy import copy from pathlib import Path from warnings import filterwarnings @@ -60,7 +62,7 @@ ) from telegram.warnings import PTBUserWarning from tests.auxil.build_messages import make_command_message -from tests.auxil.files import PROJECT_ROOT_PATH +from tests.auxil.files import SOURCE_ROOT_PATH from tests.auxil.pytest_classes import PytestBot, make_bot from tests.auxil.slots import mro_slots @@ -307,8 +309,6 @@ def test_repr_no_truncation(self): ) def test_repr_with_truncation(self): - from copy import copy - states = copy(self.drinking_states) # there are exactly 3 drinking states. adding one more to make sure it's truncated states["extra_to_be_truncated"] = [CommandHandler("foo", self.start)] @@ -725,7 +725,7 @@ async def callback(_, __): assert recwarn[0].category is PTBUserWarning assert ( Path(recwarn[0].filename) - == PROJECT_ROOT_PATH / "telegram" / "ext" / "_handlers" / "conversationhandler.py" + == SOURCE_ROOT_PATH / "ext" / "_handlers" / "conversationhandler.py" ), "wrong stacklevel!" assert ( str(recwarn[0].message) @@ -1105,11 +1105,7 @@ async def test_no_running_job_queue_warning(self, app, bot, user1, recwarn, jq): assert warning.category is PTBUserWarning assert ( Path(warning.filename) - == PROJECT_ROOT_PATH - / "telegram" - / "ext" - / "_handlers" - / "conversationhandler.py" + == SOURCE_ROOT_PATH / "ext" / "_handlers" / "conversationhandler.py" ), "wrong stacklevel!" # now set app.job_queue back to it's original value @@ -1427,10 +1423,9 @@ def timeout(*args, **kwargs): assert len(recwarn) == 1 assert str(recwarn[0].message).startswith("ApplicationHandlerStop in TIMEOUT") assert recwarn[0].category is PTBUserWarning - assert ( - Path(recwarn[0].filename) - == PROJECT_ROOT_PATH / "telegram" / "ext" / "_jobqueue.py" - ), "wrong stacklevel!" + assert Path(recwarn[0].filename) == SOURCE_ROOT_PATH / "ext" / "_jobqueue.py", ( + "wrong stacklevel!" + ) await app.stop() @@ -1438,7 +1433,7 @@ async def test_conversation_handler_timeout_update_and_context(self, app, bot, u context = None async def start_callback(u, c): - nonlocal context, self + nonlocal context context = c return await self.start(u, c) @@ -1459,7 +1454,6 @@ async def start_callback(u, c): update = Update(update_id=0, message=message) async def timeout_callback(u, c): - nonlocal update, context assert u is update assert c is context diff --git a/tests/ext/test_defaults.py b/tests/ext/test_defaults.py index dc852aba19f..31d9d9e126c 100644 --- a/tests/ext/test_defaults.py +++ b/tests/ext/test_defaults.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -22,7 +22,7 @@ import pytest -from telegram import LinkPreviewOptions, User +from telegram import User from telegram.ext import Defaults from telegram.warnings import PTBDeprecationWarning from tests.auxil.envvars import TEST_WITH_OPT_DEPS @@ -31,7 +31,7 @@ class TestDefaults: def test_slot_behaviour(self): - a = Defaults(parse_mode="HTML", quote=True) + a = Defaults(parse_mode="HTML", do_quote=True) for attr in a.__slots__: assert getattr(a, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(a)) == len(set(mro_slots(a))), "duplicate slot" @@ -42,7 +42,7 @@ def test_utc(self): @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="pytz not installed") def test_pytz_deprecation(self, recwarn): - import pytz + import pytz # noqa: PLC0415 with pytest.warns(PTBDeprecationWarning, match="pytz") as record: Defaults(tzinfo=pytz.timezone("Europe/Berlin")) @@ -63,8 +63,8 @@ def test_equality(self): c = Defaults(parse_mode="HTML", do_quote=True, protect_content=True) d = Defaults(parse_mode="HTML", protect_content=True) e = User(123, "test_user", False) - f = Defaults(parse_mode="HTML", disable_web_page_preview=True) - g = Defaults(parse_mode="HTML", disable_web_page_preview=True) + f = Defaults(parse_mode="HTML", block=True) + g = Defaults(parse_mode="HTML", block=True) assert a == b assert hash(a) == hash(b) @@ -81,29 +81,3 @@ def test_equality(self): assert f == g assert hash(f) == hash(g) - - def test_mutually_exclusive(self): - with pytest.raises(ValueError, match="mutually exclusive"): - Defaults(disable_web_page_preview=True, link_preview_options=LinkPreviewOptions(False)) - with pytest.raises(ValueError, match="mutually exclusive"): - Defaults(quote=True, do_quote=False) - - def test_deprecation_warning_for_disable_web_page_preview(self): - with pytest.warns( - PTBDeprecationWarning, match="`Defaults.disable_web_page_preview` is " - ) as record: - Defaults(disable_web_page_preview=True) - - assert record[0].filename == __file__, "wrong stacklevel!" - - assert Defaults(disable_web_page_preview=True).link_preview_options.is_disabled is True - assert Defaults(disable_web_page_preview=False).disable_web_page_preview is False - - def test_deprecation_warning_for_quote(self): - with pytest.warns(PTBDeprecationWarning, match="`Defaults.quote` is ") as record: - Defaults(quote=True) - - assert record[0].filename == __file__, "wrong stacklevel!" - - assert Defaults(quote=True).do_quote is True - assert Defaults(quote=False).quote is False diff --git a/tests/ext/test_dictpersistence.py b/tests/ext/test_dictpersistence.py index e7cca10354e..a6a4fb4494b 100644 --- a/tests/ext/test_dictpersistence.py +++ b/tests/ext/test_dictpersistence.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index b7655dd4ddf..b01670806a1 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,6 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import datetime as dtm import inspect +import platform import re import pytest @@ -157,9 +158,9 @@ def test__all__(self): ) } actual = set(filters.__all__) - assert ( - actual == expected - ), f"Members {expected - actual} were not listed in constants.__all__" + assert actual == expected, ( + f"Members {expected - actual} were not listed in constants.__all__" + ) def test_filters_all(self, update): assert filters.ALL.check_update(update) @@ -711,7 +712,9 @@ def test_filters_document_type(self, update): assert not filters.Document.WAV.check_update(update) assert not filters.Document.AUDIO.check_update(update) - update.message.document.mime_type = "audio/x-wav" + update.message.document.mime_type = ( + "audio/x-wav" if int(platform.python_version_tuple()[1]) < 14 else "audio/vnd.wave" + ) assert filters.Document.WAV.check_update(update) assert filters.Document.AUDIO.check_update(update) assert not filters.Document.XML.check_update(update) @@ -1068,11 +1071,6 @@ def test_filters_status_update(self, update): assert filters.StatusUpdate.WRITE_ACCESS_ALLOWED.check_update(update) update.message.write_access_allowed = None - update.message.api_kwargs = {"user_shared": "user_shared"} - assert filters.StatusUpdate.ALL.check_update(update) - assert filters.StatusUpdate.USER_SHARED.check_update(update) - update.message.api_kwargs = {} - update.message.users_shared = "users_shared" assert filters.StatusUpdate.ALL.check_update(update) assert filters.StatusUpdate.USERS_SHARED.check_update(update) @@ -1103,7 +1101,67 @@ def test_filters_status_update(self, update): assert filters.StatusUpdate.REFUNDED_PAYMENT.check_update(update) update.message.refunded_payment = None - def test_filters_forwarded(self, update, message_origin_user): + update.message.suggested_post_approval_failed = "suggested_post_approval_failed" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.SUGGESTED_POST_APPROVAL_FAILED.check_update(update) + update.message.suggested_post_approval_failed = None + + update.message.suggested_post_approved = "suggested_post_approved" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.SUGGESTED_POST_APPROVED.check_update(update) + update.message.suggested_post_approved = None + + update.message.suggested_post_declined = "suggested_post_declined" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.SUGGESTED_POST_DECLINED.check_update(update) + update.message.suggested_post_declined = None + + update.message.suggested_post_paid = "suggested_post_paid" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.SUGGESTED_POST_PAID.check_update(update) + update.message.suggested_post_paid = None + + update.message.suggested_post_refunded = "suggested_post_refunded" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.SUGGESTED_POST_REFUNDED.check_update(update) + update.message.suggested_post_refunded = None + + update.message.gift = "gift" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.GIFT.check_update(update) + update.message.gift = None + + update.message.unique_gift = "unique_gift" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.UNIQUE_GIFT.check_update(update) + update.message.unique_gift = None + + update.message.gift_upgrade_sent = "gift_upgrade_sent" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.GIFT_UPGRADE_SENT.check_update(update) + update.message.gift_upgrade_sent = None + + update.message.paid_message_price_changed = "paid_message_price_changed" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.PAID_MESSAGE_PRICE_CHANGED.check_update(update) + update.message.paid_message_price_changed = None + + update.message.direct_message_price_changed = "direct_message_price_changed" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED.check_update(update) + update.message.direct_message_price_changed = None + + update.message.checklist_tasks_added = "checklist_tasks_added" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.CHECKLIST_TASKS_ADDED.check_update(update) + update.message.checklist_tasks_added = None + + update.message.checklist_tasks_done = "checklist_tasks_done" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.CHECKLIST_TASKS_DONE.check_update(update) + update.message.checklist_tasks_done = None + + def test_filters_forwarded(self, update): assert filters.FORWARDED.check_update(update) update.message.forward_origin = MessageOriginHiddenUser(dtm.datetime.utcnow(), 1) assert filters.FORWARDED.check_update(update) @@ -1334,15 +1392,12 @@ def test_filters_chat_allow_empty(self, update): def test_filters_chat_id(self, update): assert not filters.Chat(chat_id=1).check_update(update) - assert filters.CHAT.check_update(update) update.message.chat.id = 1 assert filters.Chat(chat_id=1).check_update(update) - assert filters.CHAT.check_update(update) update.message.chat.id = 2 assert filters.Chat(chat_id=[1, 2]).check_update(update) assert not filters.Chat(chat_id=[3, 4]).check_update(update) update.message.chat = None - assert not filters.CHAT.check_update(update) assert not filters.Chat(chat_id=[3, 4]).check_update(update) def test_filters_chat_username(self, update): @@ -1471,6 +1526,11 @@ def test_filters_chat_repr(self): with pytest.raises(RuntimeError, match="Cannot set name"): f.name = "foo" + def test_filters_forum(self, update): + assert not filters.FORUM.check_update(update) + update.message.chat.is_forum = True + assert filters.FORUM.check_update(update) + def test_filters_forwarded_from_init(self): with pytest.raises(RuntimeError, match="in conjunction with"): filters.ForwardedFrom(chat_id=1, username="chat") @@ -2083,6 +2143,11 @@ def test_filters_successful_payment(self, update): update.message.successful_payment = "test" assert filters.SUCCESSFUL_PAYMENT.check_update(update) + def test_filters_suggested_post_info(self, update): + assert not filters.SUGGESTED_POST_INFO.check_update(update) + update.message.suggested_post_info = "test" + assert filters.SUGGESTED_POST_INFO.check_update(update) + def test_filters_successful_payment_payloads(self, update): assert not filters.SuccessfulPayment(("custom-payload",)).check_update(update) assert not filters.SuccessfulPayment().check_update(update) @@ -2782,3 +2847,17 @@ def test_filters_sender_boost_count(self, update): update.message.sender_boost_count = "test" assert filters.SENDER_BOOST_COUNT.check_update(update) assert str(filters.SENDER_BOOST_COUNT) == "filters.SENDER_BOOST_COUNT" + + def test_filters_checklist(self, update): + assert not filters.CHECKLIST.check_update(update) + + update.message.checklist = "test" + assert filters.CHECKLIST.check_update(update) + assert str(filters.CHECKLIST) == "filters.CHECKLIST" + + def test_filters_direct_messages(self, update): + assert not filters.DIRECT_MESSAGES.check_update(update) + + update.message.chat.is_direct_messages = True + assert filters.DIRECT_MESSAGES.check_update(update) + assert str(filters.DIRECT_MESSAGES) == "filters.DIRECT_MESSAGES" diff --git a/tests/ext/test_inlinequeryhandler.py b/tests/ext/test_inlinequeryhandler.py index 24aa69f01f6..a1dd87fdc35 100644 --- a/tests/ext/test_inlinequeryhandler.py +++ b/tests/ext/test_inlinequeryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -152,6 +152,28 @@ async def test_context_pattern(self, app, inline_query): update.inline_query.query = "not_a_match" assert not handler.check_update(update) + @pytest.mark.parametrize( + ("query", "expected_result"), + [ + pytest.param("", True, id="empty string"), + pytest.param("not empty", False, id="non_empty_string"), + ], + ) + async def test_empty_inline_query_pattern(self, app, query, expected_result): + handler = InlineQueryHandler(self.callback, pattern=r"^$") + app.add_handler(handler) + + update = Update( + update_id=0, + inline_query=InlineQuery( + id="id", from_user=User(1, "test", False), query=query, offset="" + ), + ) + + async with app: + await app.process_update(update) + assert self.test_flag == expected_result + @pytest.mark.parametrize("chat_types", [[Chat.SENDER], [Chat.SENDER, Chat.SUPERGROUP], []]) @pytest.mark.parametrize( ("chat_type", "result"), [(Chat.SENDER, True), (Chat.CHANNEL, False), (None, False)] diff --git a/tests/ext/test_jobqueue.py b/tests/ext/test_jobqueue.py index 57bac18b342..50a78f5f3dc 100644 --- a/tests/ext/test_jobqueue.py +++ b/tests/ext/test_jobqueue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -27,7 +27,14 @@ import pytest -from telegram.ext import ApplicationBuilder, CallbackContext, ContextTypes, Defaults, Job, JobQueue +from telegram.ext import ( + ApplicationBuilder, + CallbackContext, + ContextTypes, + Defaults, + Job, + JobQueue, +) from tests.auxil.envvars import GITHUB_ACTIONS, TEST_WITH_OPT_DEPS from tests.auxil.pytest_classes import make_bot from tests.auxil.slots import mro_slots @@ -187,6 +194,49 @@ async def test_job_with_data(self, job_queue): await asyncio.sleep(0.2) assert self.result == 5 + def test_callback_name_without_name_attribute(self, app): + """Test that callable class instances work as job callbacks (issue #4992)""" + + class CallableJob: + async def __call__(self, context: ContextTypes.DEFAULT_TYPE): + pass + + jq = JobQueue() + jq.set_application(app) + + # This should not raise AttributeError + job_instance = CallableJob() + + # Test with run_once + job = jq.run_once(job_instance, 10) + # The job name should be the class name, not raise AttributeError + assert job.name == "CallableJob", f"Expected 'CallableJob', got '{job.name}'" + + # Test with run_repeating (the method from the issue) + job2 = jq.run_repeating(job_instance, 0.5) + assert job2.name == "CallableJob", f"Expected 'CallableJob', got '{job2.name}'" + + # Test with run_monthly + when = dtm.time(12, 0, 0) + job3 = jq.run_monthly(job_instance, when, 1) + assert job3.name == "CallableJob", f"Expected 'CallableJob', got '{job3.name}'" + + # Test with run_daily + job4 = jq.run_daily(job_instance, when) + assert job4.name == "CallableJob", f"Expected 'CallableJob', got '{job4.name}'" + + # Test with run_custom + job5 = jq.run_custom( + job_instance, + {"trigger": "date", "run_date": dtm.datetime.now() + dtm.timedelta(seconds=10)}, + ) + assert job5.name == "CallableJob", f"Expected 'CallableJob', got '{job5.name}'" + + # Test Job.__repr__ uses the correct name + assert "callback=CallableJob" in repr(job), ( + f"repr should contain 'callback=CallableJob', got: {job!r}" + ) + async def test_run_repeating(self, job_queue): job_queue.run_repeating(self.job_run_once, 0.1) await asyncio.sleep(0.25) @@ -629,7 +679,7 @@ async def callback(context): async def test_attribute_error(self): job = Job(self.job_run_once) with pytest.raises( - AttributeError, match="nor 'apscheduler.job.Job' has attribute 'error'" + AttributeError, match="nor 'apscheduler\\.job\\.Job' has attribute 'error'" ): job.error diff --git a/tests/ext/test_messagehandler.py b/tests/ext/test_messagehandler.py index 824d9ec2edc..aeb2e1b0391 100644 --- a/tests/ext/test_messagehandler.py +++ b/tests/ext/test_messagehandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_messagereactionhandler.py b/tests/ext/test_messagereactionhandler.py index e22e78230ad..7de7f135ea6 100644 --- a/tests/ext/test_messagereactionhandler.py +++ b/tests/ext/test_messagereactionhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -173,7 +173,7 @@ async def test_context(self, app, message_reaction_update, message_reaction_coun assert self.test_flag @pytest.mark.parametrize( - argnames=["allowed_types", "expected"], + argnames=("allowed_types", "expected"), argvalues=[ (MessageReactionHandler.MESSAGE_REACTION_UPDATED, (True, False)), (MessageReactionHandler.MESSAGE_REACTION_COUNT_UPDATED, (False, True)), @@ -201,7 +201,7 @@ async def test_message_reaction_types( assert self.test_flag == result_2 @pytest.mark.parametrize( - argnames=["allowed_types", "kwargs"], + argnames=("allowed_types", "kwargs"), argvalues=[ (MessageReactionHandler.MESSAGE_REACTION_COUNT_UPDATED, {"user_username": "user"}), (MessageReactionHandler.MESSAGE_REACTION, {"user_id": 123}), @@ -210,12 +210,12 @@ async def test_message_reaction_types( ) async def test_username_with_anonymous_reaction(self, app, allowed_types, kwargs): with pytest.raises( - ValueError, match="You can not filter for users and include anonymous reactions." + ValueError, match="You can not filter for users and include anonymous reactions\\." ): MessageReactionHandler(self.callback, message_reaction_types=allowed_types, **kwargs) @pytest.mark.parametrize( - argnames=["chat_id", "expected"], + argnames=("chat_id", "expected"), argvalues=[(1, True), ([1], True), (2, False), ([2], False)], ) async def test_with_chat_ids( @@ -226,8 +226,8 @@ async def test_with_chat_ids( assert handler.check_update(message_reaction_count_update) == expected @pytest.mark.parametrize( - argnames=["chat_username"], - argvalues=[("group_a",), ("@group_a",), (["group_a"],), (["@group_a"],)], + argnames="chat_username", + argvalues=["group_a", "@group_a", ["group_a"], ["@group_a"]], ids=["group_a", "@group_a", "['group_a']", "['@group_a']"], ) async def test_with_chat_usernames( @@ -247,7 +247,7 @@ async def test_with_chat_usernames( message_reaction_count_update.message_reaction_count.chat.username = None @pytest.mark.parametrize( - argnames=["user_id", "expected"], + argnames=("user_id", "expected"), argvalues=[(1, True), ([1], True), (2, False), ([2], False)], ) async def test_with_user_ids( @@ -262,8 +262,8 @@ async def test_with_user_ids( assert not handler.check_update(message_reaction_count_update) @pytest.mark.parametrize( - argnames=["user_username"], - argvalues=[("user_a",), ("@user_a",), (["user_a"],), (["@user_a"],)], + argnames="user_username", + argvalues=["user_a", "@user_a", ["user_a"], ["@user_a"]], ids=["user_a", "@user_a", "['user_a']", "['@user_a']"], ) async def test_with_user_usernames( diff --git a/tests/ext/test_paidmediapurchasedhandler.py b/tests/ext/test_paidmediapurchasedhandler.py index f3fb728d20f..059c8e264ca 100644 --- a/tests/ext/test_paidmediapurchasedhandler.py +++ b/tests/ext/test_paidmediapurchasedhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_picklepersistence.py b/tests/ext/test_picklepersistence.py index 5ce998c9018..655d2047ae8 100644 --- a/tests/ext/test_picklepersistence.py +++ b/tests/ext/test_picklepersistence.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -28,7 +28,7 @@ from telegram import Chat, Message, TelegramObject, Update, User from telegram.ext import ContextTypes, PersistenceInput, PicklePersistence from telegram.warnings import PTBUserWarning -from tests.auxil.files import PROJECT_ROOT_PATH +from tests.auxil.files import SOURCE_ROOT_PATH from tests.auxil.pytest_classes import make_bot from tests.auxil.slots import mro_slots @@ -898,10 +898,9 @@ async def test_custom_pickler_unpickler_simple( assert len(recwarn) == 1 assert recwarn[-1].category is PTBUserWarning assert str(recwarn[-1].message).startswith("Unknown bot instance found.") - assert ( - Path(recwarn[-1].filename) - == PROJECT_ROOT_PATH / "telegram" / "ext" / "_picklepersistence.py" - ), "wrong stacklevel!" + assert Path(recwarn[-1].filename) == SOURCE_ROOT_PATH / "ext" / "_picklepersistence.py", ( + "wrong stacklevel!" + ) pp = PicklePersistence("pickletest", single_file=False, on_flush=False) pp.set_bot(bot) assert (await pp.get_chat_data())[12345]["unknown_bot_in_user"]._bot is None diff --git a/tests/ext/test_pollanswerhandler.py b/tests/ext/test_pollanswerhandler.py index 680f597e5dc..8900f466486 100644 --- a/tests/ext/test_pollanswerhandler.py +++ b/tests/ext/test_pollanswerhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_pollhandler.py b/tests/ext/test_pollhandler.py index c44e4db8652..cb2864822fe 100644 --- a/tests/ext/test_pollhandler.py +++ b/tests/ext/test_pollhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_precheckoutqueryhandler.py b/tests/ext/test_precheckoutqueryhandler.py index 5f00ce407bf..ab558c499be 100644 --- a/tests/ext/test_precheckoutqueryhandler.py +++ b/tests/ext/test_precheckoutqueryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_prefixhandler.py b/tests/ext/test_prefixhandler.py index 7b2c0d29c5f..f12592fa98f 100644 --- a/tests/ext/test_prefixhandler.py +++ b/tests/ext/test_prefixhandler.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_ratelimiter.py b/tests/ext/test_ratelimiter.py index dd88f7cb1cf..084ffb6f5ab 100644 --- a/tests/ext/test_ratelimiter.py +++ b/tests/ext/test_ratelimiter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -21,11 +21,14 @@ We mostly test on directly on AIORateLimiter here, b/c BaseRateLimiter doesn't contain anything notable """ + import asyncio import datetime as dtm +import itertools import json import platform import time +from collections import Counter from http import HTTPStatus import pytest @@ -52,7 +55,7 @@ class TestBaseRateLimiter: request_received = None async def test_no_rate_limiter(self, bot): - with pytest.raises(ValueError, match="if a `ExtBot.rate_limiter` is set"): + with pytest.raises(ValueError, match="if a `ExtBot\\.rate_limiter` is set"): await bot.send_message(chat_id=42, text="test", rate_limit_args="something") async def test_argument_passing(self, bot_info, monkeypatch, bot): @@ -84,6 +87,10 @@ async def initialize(self) -> None: async def shutdown(self) -> None: pass + @property + def read_timeout(self): + return 1 + async def do_request(self, *args, **kwargs): if TestBaseRateLimiter.request_received is None: TestBaseRateLimiter.request_received = [] @@ -148,7 +155,9 @@ async def do_request(self, *args, **kwargs): @pytest.mark.flaky(10, 1) # Timings aren't quite perfect class TestAIORateLimiter: count = 0 + apb_count = 0 call_times = [] + apb_call_times = [] class CountRequest(BaseRequest): def __init__(self, retry_after=None): @@ -160,9 +169,21 @@ async def initialize(self) -> None: async def shutdown(self) -> None: pass + @property + def read_timeout(self): + return 1 + async def do_request(self, *args, **kwargs): - TestAIORateLimiter.count += 1 - TestAIORateLimiter.call_times.append(time.time()) + request_data = kwargs.get("request_data") + allow_paid_broadcast = request_data.parameters.get("allow_paid_broadcast", False) + + if allow_paid_broadcast: + TestAIORateLimiter.apb_count += 1 + TestAIORateLimiter.apb_call_times.append(time.time()) + else: + TestAIORateLimiter.count += 1 + TestAIORateLimiter.call_times.append(time.time()) + if self.retry_after: raise RetryAfter(retry_after=1) @@ -190,10 +211,10 @@ async def do_request(self, *args, **kwargs): @pytest.fixture(autouse=True) def _reset(self): - self.count = 0 TestAIORateLimiter.count = 0 - self.call_times = [] TestAIORateLimiter.call_times = [] + TestAIORateLimiter.apb_count = 0 + TestAIORateLimiter.apb_call_times = [] @pytest.mark.parametrize("max_retries", [0, 1, 4]) async def test_max_retries(self, bot, max_retries): @@ -214,7 +235,7 @@ async def test_max_retries(self, bot, max_retries): times = TestAIORateLimiter.call_times if len(times) <= 1: return - delays = [j - i for i, j in zip(times[:-1], times[1:])] + delays = [j - i for i, j in itertools.pairwise(times)] assert delays == pytest.approx([1.1 for _ in range(max_retries)], rel=0.05) async def test_delay_all_pending_on_retry(self, bot): @@ -358,3 +379,58 @@ async def test_group_caching(self, bot, intermediate): finally: TestAIORateLimiter.count = 0 TestAIORateLimiter.call_times = [] + + async def test_allow_paid_broadcast(self, bot): + try: + rl_bot = ExtBot( + token=bot.token, + request=self.CountRequest(retry_after=None), + rate_limiter=AIORateLimiter(), + ) + + async with rl_bot: + apb_tasks = {} + non_apb_tasks = {} + for i in range(3000): + apb_tasks[i] = asyncio.create_task( + rl_bot.send_message(chat_id=-1, text="test", allow_paid_broadcast=True) + ) + + number = 2 + for i in range(number): + non_apb_tasks[i] = asyncio.create_task( + rl_bot.send_message(chat_id=-1, text="test") + ) + non_apb_tasks[i + number] = asyncio.create_task( + rl_bot.send_message(chat_id=-1, text="test", allow_paid_broadcast=False) + ) + + await asyncio.sleep(0.1) + # We expect 5 non-apb requests: + # 1: `get_me` from `async with rl_bot` + # 2-5: `send_message` + assert TestAIORateLimiter.count == 5 + assert sum(1 for task in non_apb_tasks.values() if task.done()) == 4 + + # ~2 second after start + # We do the checks once all apb_tasks are done as apparently getting the timings + # right to check after 1 second is hard + await asyncio.sleep(2.1 - 0.1) + assert all(task.done() for task in apb_tasks.values()) + + apb_call_times = [ + ct - TestAIORateLimiter.apb_call_times[0] + for ct in TestAIORateLimiter.apb_call_times + ] + apb_call_times_dict = Counter(map(int, apb_call_times)) + + # We expect ~2000 apb requests after the first second + # 2000 (>>1000), since we have a floating window logic such that an initial + # burst is allowed that is hard to measure in the tests + assert apb_call_times_dict[0] <= 2000 + assert apb_call_times_dict[0] + apb_call_times_dict[1] < 3000 + assert sum(apb_call_times_dict.values()) == 3000 + + finally: + # cleanup + await asyncio.gather(*apb_tasks.values(), *non_apb_tasks.values()) diff --git a/tests/ext/test_shippingqueryhandler.py b/tests/ext/test_shippingqueryhandler.py index 0be8fe57205..90e51c68a91 100644 --- a/tests/ext/test_shippingqueryhandler.py +++ b/tests/ext/test_shippingqueryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_stringcommandhandler.py b/tests/ext/test_stringcommandhandler.py index 32109a07622..14bd8e4e126 100644 --- a/tests/ext/test_stringcommandhandler.py +++ b/tests/ext/test_stringcommandhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_stringregexhandler.py b/tests/ext/test_stringregexhandler.py index 8896177849a..421038f7a45 100644 --- a/tests/ext/test_stringregexhandler.py +++ b/tests/ext/test_stringregexhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_typehandler.py b/tests/ext/test_typehandler.py index 8a1eed8475c..952a72d075c 100644 --- a/tests/ext/test_typehandler.py +++ b/tests/ext/test_typehandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_updater.py b/tests/ext/test_updater.py index 9fdc8e4a769..bbe1d3dc320 100644 --- a/tests/ext/test_updater.py +++ b/tests/ext/test_updater.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio +import datetime as dtm import logging import platform from collections import defaultdict @@ -27,7 +28,6 @@ import pytest from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup, Update -from telegram._utils.defaultvalue import DEFAULT_NONE from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut from telegram.ext import ExtBot, InvalidCallbackData, Updater from tests.auxil.build_messages import make_message, make_message_update @@ -295,11 +295,7 @@ async def test_polling_mark_updates_as_read(self, monkeypatch, updater, caplog): tracking_flag = False received_kwargs = {} expected_kwargs = { - "timeout": 0, - "read_timeout": "read_timeout", - "connect_timeout": "connect_timeout", - "write_timeout": "write_timeout", - "pool_timeout": "pool_timeout", + "timeout": dtm.timedelta(seconds=0), "allowed_updates": "allowed_updates", } @@ -378,7 +374,6 @@ async def get_updates(*args, **kwargs): assert log_found async def test_polling_mark_updates_as_read_failure(self, monkeypatch, updater, caplog): - monkeypatch.setattr(updater.bot, "get_updates", empty_get_updates) async with updater: @@ -403,7 +398,6 @@ async def test_polling_mark_updates_as_read_failure(self, monkeypatch, updater, assert log_found async def test_start_polling_already_running(self, updater, monkeypatch): - monkeypatch.setattr(updater.bot, "get_updates", empty_get_updates) async with updater: @@ -421,11 +415,7 @@ async def test_start_polling_get_updates_parameters(self, updater, monkeypatch): on_stop_flag = False expected = { - "timeout": 10, - "read_timeout": DEFAULT_NONE, - "write_timeout": DEFAULT_NONE, - "connect_timeout": DEFAULT_NONE, - "pool_timeout": DEFAULT_NONE, + "timeout": dtm.timedelta(seconds=10), "allowed_updates": None, "api_kwargs": None, } @@ -465,22 +455,14 @@ async def get_updates(*args, **kwargs): on_stop_flag = False expected = { - "timeout": 42, - "read_timeout": 43, - "write_timeout": 44, - "connect_timeout": 45, - "pool_timeout": 46, + "timeout": dtm.timedelta(seconds=42), "allowed_updates": ["message"], "api_kwargs": None, } await update_queue.put(Update(update_id=2)) await updater.start_polling( - timeout=42, - read_timeout=43, - write_timeout=44, - connect_timeout=45, - pool_timeout=46, + timeout=dtm.timedelta(seconds=42), allowed_updates=["message"], ) await update_queue.join() @@ -867,7 +849,7 @@ async def test_unix_webhook_mutually_exclusive_params(self, updater): ) async def test_no_unix(self, updater): async with updater: - with pytest.raises(RuntimeError, match="binding unix sockets."): + with pytest.raises(RuntimeError, match="binding unix sockets\\."): await updater.start_webhook(unix="DoesntMatter", webhook_url="TOKEN") async def test_start_webhook_already_running(self, updater, monkeypatch): @@ -972,7 +954,6 @@ async def test_webhook_arbitrary_callback_data( monkeypatch.setattr(updater.bot, "set_webhook", return_true) try: - ip = "127.0.0.1" port = randrange(1024, 49152) # Select random port @@ -1014,7 +995,6 @@ async def test_webhook_arbitrary_callback_data( updater.bot.callback_data_cache.clear_callback_queries() async def test_webhook_invalid_ssl(self, monkeypatch, updater): - ip = "127.0.0.1" port = randrange(1024, 49152) # Select random port async with updater: @@ -1086,7 +1066,6 @@ async def set_webhook(*args, **kwargs): ) async def test_webhook_invalid_posts(self, updater, monkeypatch): - ip = "127.0.0.1" port = randrange(1024, 49152) @@ -1162,3 +1141,52 @@ def de_json_fails(*args, **kwargs): await updater.stop() assert not updater.running + + @pytest.mark.parametrize("method_name", ["start_polling", "start_webhook"]) + async def test_infinite_bootstrap_retries(self, updater, monkeypatch, method_name): + """Here we simply test that setting `bootstrap_retries=-1` does not lead to the wrong + infinite-loop behavior reported in #4966. Raising an exception on the first call to + `set/delete_webhook` ensures that a retry actually happens. + """ + + original_delete_webhook = updater.bot.delete_webhook + original_set_webhook = updater.bot.set_webhook + counts = {"delete": 0, "set": 0} + + def patch_builder(func, name): + async def wrapped(*args, **kwargs): + if counts[name] >= 3: + pytest.fail("Should be called only once. Test failed.") + counts[name] += 1 + if counts[name] == 1: + raise TelegramError("1") + return await func(*args, **kwargs) + + return wrapped + + async def get_updates(*args, **kwargs): + return [] + + monkeypatch.setattr( + updater.bot, "delete_webhook", patch_builder(original_delete_webhook, "delete") + ) + monkeypatch.setattr(updater.bot, "set_webhook", patch_builder(original_set_webhook, "set")) + monkeypatch.setattr(updater.bot, "get_updates", get_updates) + + kwargs = {"bootstrap_retries": -1} + if method_name == "start_webhook": + kwargs.update( + { + "listen": "127.0.0.1", + "port": randrange(1024, 49152), + } + ) + + async with updater: + task = asyncio.create_task(getattr(updater, method_name)(**kwargs)) + try: + await asyncio.wait_for(task, timeout=10) + except TimeoutError: + pytest.fail(f"{method_name} did not succeed within the timeout. Aborting.") + finally: + await updater.stop() diff --git a/tests/request/__init__.py b/tests/request/__init__.py index 040bfc78668..c95cb3c9741 100644 --- a/tests/request/__init__.py +++ b/tests/request/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/request/test_request.py b/tests/request/test_request.py index 2c35cf5fccc..a0d71544aa3 100644 --- a/tests/request/test_request.py +++ b/tests/request/test_request.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,14 +18,16 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """Here we run tests directly with HTTPXRequest because that's easier than providing dummy implementations for BaseRequest and we want to test HTTPXRequest anyway.""" + import asyncio +import datetime as dtm import json import logging from collections import defaultdict -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine from dataclasses import dataclass from http import HTTPStatus -from typing import Any, Callable +from typing import Any import httpx import pytest @@ -45,10 +47,9 @@ TelegramError, TimedOut, ) -from telegram.request import BaseRequest, RequestData +from telegram.request import RequestData from telegram.request._httpxrequest import HTTPXRequest from telegram.request._requestparameter import RequestParameter -from telegram.warnings import PTBDeprecationWarning from tests.auxil.envvars import TEST_WITH_OPT_DEPS from tests.auxil.files import data_file from tests.auxil.networking import NonchalantHttpxRequest @@ -245,7 +246,7 @@ async def test_chat_migrated(self, monkeypatch, httpx_request: HTTPXRequest): assert exc_info.value.new_chat_id == 123 - async def test_retry_after(self, monkeypatch, httpx_request: HTTPXRequest): + async def test_retry_after(self, monkeypatch, httpx_request: HTTPXRequest, PTB_TIMEDELTA): server_response = b'{"ok": "False", "parameters": {"retry_after": 42}}' monkeypatch.setattr( @@ -254,10 +255,12 @@ async def test_retry_after(self, monkeypatch, httpx_request: HTTPXRequest): mocker_factory(response=server_response, return_code=HTTPStatus.BAD_REQUEST), ) - with pytest.raises(RetryAfter, match="Retry in 42") as exc_info: + with pytest.raises( + RetryAfter, match="Retry in " + "0:00:42" if PTB_TIMEDELTA else "42" + ) as exc_info: await httpx_request.post(None, None, None) - assert exc_info.value.retry_after == 42 + assert exc_info.value.retry_after == (dtm.timdelta(seconds=42) if PTB_TIMEDELTA else 42) async def test_unknown_request_params(self, monkeypatch, httpx_request: HTTPXRequest): server_response = b'{"ok": "False", "parameters": {"unknown": "42"}}' @@ -270,7 +273,7 @@ async def test_unknown_request_params(self, monkeypatch, httpx_request: HTTPXReq with pytest.raises( BadRequest, - match="{'unknown': '42'}", + match="\\{'unknown': '42'\\}", ): await httpx_request.post(None, None, None) @@ -317,10 +320,14 @@ async def test_error_description(self, monkeypatch, httpx_request: HTTPXRequest, (-1, NetworkError), ], ) + @pytest.mark.parametrize("description", ["Test Message", None]) async def test_special_errors( - self, monkeypatch, httpx_request: HTTPXRequest, code, exception_class + self, monkeypatch, httpx_request: HTTPXRequest, code, exception_class, description ): - server_response = b'{"ok": "False", "description": "Test Message"}' + server_response_json = {"ok": False} + if description: + server_response_json["description"] = description + server_response = json.dumps(server_response_json).encode(TextEncoding.UTF_8) monkeypatch.setattr( httpx_request, @@ -328,7 +335,25 @@ async def test_special_errors( mocker_factory(response=server_response, return_code=code), ) - with pytest.raises(exception_class, match="Test Message"): + if not description and code not in list(HTTPStatus): + match = f"Unknown HTTPError.*{code}" + else: + match = description or str(code.value) + + with pytest.raises(exception_class, match=match): + await httpx_request.post("", None, None) + + async def test_error_parsing_payload(self, monkeypatch, httpx_request: HTTPXRequest): + """Test that we raise an error if the payload is not a valid JSON.""" + server_response = b"invalid_json" + + monkeypatch.setattr( + httpx_request, + "do_request", + mocker_factory(response=server_response, return_code=HTTPStatus.BAD_GATEWAY), + ) + + with pytest.raises(TelegramError, match=r"502.*\. Parsing.*b'invalid_json' failed"): await httpx_request.post("", None, None) @pytest.mark.parametrize( @@ -390,76 +415,6 @@ async def make_assertion(*args, **kwargs): ) assert self.test_flag == (1, 2, 3, 4) - def test_read_timeout_not_implemented(self): - class SimpleRequest(BaseRequest): - async def do_request(self, *args, **kwargs): - raise httpx.ReadTimeout("read timeout") - - async def initialize(self) -> None: - pass - - async def shutdown(self) -> None: - pass - - with pytest.raises(NotImplementedError): - SimpleRequest().read_timeout - - @pytest.mark.parametrize("media", [True, False]) - async def test_timeout_propagation_write_timeout( - self, monkeypatch, media, input_media_photo, recwarn # noqa: F811 - ): - class CustomRequest(BaseRequest): - async def initialize(self_) -> None: - pass - - async def shutdown(self_) -> None: - pass - - async def do_request(self_, *args, **kwargs) -> tuple[int, bytes]: - self.test_flag = ( - kwargs.get("read_timeout"), - kwargs.get("connect_timeout"), - kwargs.get("write_timeout"), - kwargs.get("pool_timeout"), - ) - return HTTPStatus.OK, b'{"ok": "True", "result": {}}' - - custom_request = CustomRequest() - data = {"string": "string", "int": 1, "float": 1.0} - if media: - data["media"] = input_media_photo - request_data = RequestData( - parameters=[RequestParameter.from_input(key, value) for key, value in data.items()], - ) - - # First make sure that custom timeouts are always respected - await custom_request.post( - "url", request_data, read_timeout=1, connect_timeout=2, write_timeout=3, pool_timeout=4 - ) - assert self.test_flag == (1, 2, 3, 4) - - # Now also ensure that the default timeout for media requests is 20 seconds - await custom_request.post("url", request_data) - assert self.test_flag == ( - DEFAULT_NONE, - DEFAULT_NONE, - 20 if media else DEFAULT_NONE, - DEFAULT_NONE, - ) - - print("warnings") - for entry in recwarn: - print(entry.message) - if media: - assert len(recwarn) == 1 - assert "will default to `BaseRequest.DEFAULT_NONE` instead of 20" in str( - recwarn[0].message - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__ - else: - assert len(recwarn) == 0 - @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="No need to run this twice") class TestHTTPXRequestWithoutRequest: @@ -469,9 +424,7 @@ class TestHTTPXRequestWithoutRequest: def _reset(self): self.test_flag = None - # We parametrize this to make sure that the legacy `proxy_url` argument is still supported - @pytest.mark.parametrize("proxy_argument", ["proxy", "proxy_url"]) - def test_init(self, monkeypatch, proxy_argument): + def test_init(self, monkeypatch): @dataclass class Client: timeout: object @@ -486,38 +439,22 @@ class Client: request = HTTPXRequest() assert request._client.timeout == httpx.Timeout(connect=5.0, read=5.0, write=5.0, pool=1.0) assert request._client.proxy is None - assert request._client.limits == httpx.Limits( - max_connections=1, max_keepalive_connections=1 - ) + assert request._client.limits == httpx.Limits(max_connections=256) assert request._client.http1 is True assert not request._client.http2 - kwargs = { - "connection_pool_size": 42, - proxy_argument: "proxy", - "connect_timeout": 43, - "read_timeout": 44, - "write_timeout": 45, - "pool_timeout": 46, - } - request = HTTPXRequest(**kwargs) - assert request._client.proxy == "proxy" - assert request._client.limits == httpx.Limits( - max_connections=42, max_keepalive_connections=42 + request = HTTPXRequest( + connection_pool_size=42, + proxy="proxy", + connect_timeout=43, + read_timeout=44, + write_timeout=45, + pool_timeout=46, ) + assert request._client.proxy == "proxy" + assert request._client.limits == httpx.Limits(max_connections=42) assert request._client.timeout == httpx.Timeout(connect=43, read=44, write=45, pool=46) - def test_proxy_mutually_exclusive(self): - with pytest.raises(ValueError, match="mutually exclusive"): - HTTPXRequest(proxy="proxy", proxy_url="proxy_url") - - def test_proxy_url_deprecation_warning(self, recwarn): - HTTPXRequest(proxy_url="http://127.0.0.1:3128") - assert len(recwarn) == 1 - assert recwarn[0].category is PTBDeprecationWarning - assert "`proxy_url` is deprecated" in str(recwarn[0].message) - assert recwarn[0].filename == __file__, "incorrect stacklevel" - async def test_multiple_inits_and_shutdowns(self, monkeypatch): self.test_flag = defaultdict(int) @@ -651,7 +588,10 @@ async def make_assertion(self, **kwargs): assert code == HTTPStatus.OK async def test_do_request_params_with_data( - self, monkeypatch, httpx_request, mixed_rqs # noqa: F811 + self, + monkeypatch, + httpx_request, + mixed_rqs, # noqa: F811 ): async def make_assertion(self, **kwargs): method_assertion = kwargs.get("method") == "method" @@ -728,7 +668,11 @@ async def request(_, **kwargs): @pytest.mark.parametrize("media", [True, False]) async def test_do_request_write_timeout( - self, monkeypatch, media, httpx_request, input_media_photo, recwarn # noqa: F811 + self, + monkeypatch, + media, + httpx_request, + input_media_photo, # noqa: F811 ): async def request(_, **kwargs): self.test_flag = kwargs.get("timeout") @@ -753,13 +697,13 @@ async def request(_, **kwargs): await httpx_request.post("url", request_data) assert self.test_flag == httpx.Timeout(read=5, connect=5, write=20 if media else 5, pool=1) - # Just for double-checking, since warnings are issued for implementations of BaseRequest - # other than HTTPXRequest - assert len(recwarn) == 0 - @pytest.mark.parametrize("init", [True, False]) async def test_setting_media_write_timeout( - self, monkeypatch, init, input_media_photo, recwarn # noqa: F811 + self, + monkeypatch, + init, + input_media_photo, # noqa: F811 + recwarn, ): httpx_request = HTTPXRequest(media_write_timeout=42) if init else HTTPXRequest() diff --git a/tests/request/test_requestdata.py b/tests/request/test_requestdata.py index 805a629fd3c..c66380f1c65 100644 --- a/tests/request/test_requestdata.py +++ b/tests/request/test_requestdata.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/request/test_requestparameter.py b/tests/request/test_requestparameter.py index c124e5281fc..4870a48fc04 100644 --- a/tests/request/test_requestparameter.py +++ b/tests/request/test_requestparameter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -21,7 +21,17 @@ import pytest -from telegram import InputFile, InputMediaPhoto, InputMediaVideo, InputSticker, MessageEntity +from telegram import ( + InputFile, + InputMediaPhoto, + InputMediaVideo, + InputProfilePhotoAnimated, + InputProfilePhotoStatic, + InputSticker, + InputStoryContentPhoto, + InputStoryContentVideo, + MessageEntity, +) from telegram.constants import ChatType from telegram.request._requestparameter import RequestParameter from tests.auxil.files import data_file @@ -83,6 +93,7 @@ def test_multiple_multipart_data(self): (ChatType.PRIVATE, "private"), (MessageEntity("type", 1, 1), {"type": "type", "offset": 1, "length": 1}), (dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5), 1573431976), + (dtm.timedelta(days=42), 42 * 24 * 60 * 60), ( [ True, @@ -100,6 +111,19 @@ def test_from_input_no_media(self, value, expected_value): assert request_parameter.value == expected_value assert request_parameter.input_files is None + @pytest.mark.parametrize( + ("value", "expected_type", "expected_value"), + [ + (dtm.timedelta(seconds=1), int, 1), + (dtm.timedelta(milliseconds=1), float, 0.001), + ], + ) + def test_from_input_timedelta(self, value, expected_type, expected_value): + request_parameter = RequestParameter.from_input("key", value) + assert request_parameter.value == expected_value + assert request_parameter.input_files is None + assert isinstance(request_parameter.value, expected_type) + def test_from_input_inputfile(self): inputfile_1 = InputFile("data1", filename="inputfile_1", attach=True) inputfile_2 = InputFile("data2", filename="inputfile_2") @@ -162,6 +186,42 @@ def test_from_input_inputmedia_without_attach(self): assert request_parameter.value == {"type": "video"} assert request_parameter.input_files == [input_media.media, input_media.thumbnail] + def test_from_input_profile_photo_static(self): + input_profile_photo = InputProfilePhotoStatic(data_file("telegram.jpg").read_bytes()) + expected = input_profile_photo.to_dict() + expected.update({"photo": input_profile_photo.photo.attach_uri}) + request_parameter = RequestParameter.from_input("key", input_profile_photo) + assert request_parameter.value == expected + assert request_parameter.input_files == [input_profile_photo.photo] + + def test_from_input_profile_photo_animated(self): + input_profile_photo = InputProfilePhotoAnimated( + data_file("telegram2.mp4").read_bytes(), + main_frame_timestamp=dtm.timedelta(seconds=42, milliseconds=43), + ) + expected = input_profile_photo.to_dict() + expected.update({"animation": input_profile_photo.animation.attach_uri}) + request_parameter = RequestParameter.from_input("key", input_profile_photo) + assert request_parameter.value == expected + assert request_parameter.input_files == [input_profile_photo.animation] + + @pytest.mark.parametrize( + ("cls", "args"), + [ + (InputProfilePhotoStatic, (data_file("telegram.jpg"),)), + ( + InputProfilePhotoAnimated, + (data_file("telegram2.mp4"), dtm.timedelta(seconds=42, milliseconds=43)), + ), + ], + ) + def test_from_input_profile_photo_local_files(self, cls, args): + input_profile_photo = cls(*args) + expected = input_profile_photo.to_dict() + requested = RequestParameter.from_input("key", input_profile_photo) + assert requested.value == expected + assert requested.input_files is None + def test_from_input_inputsticker(self): input_sticker = InputSticker(data_file("telegram.png").read_bytes(), ["emoji"], "static") expected = input_sticker.to_dict() @@ -170,6 +230,36 @@ def test_from_input_inputsticker(self): assert request_parameter.value == expected assert request_parameter.input_files == [input_sticker.sticker] + def test_from_input_story_content_photo(self): + input_story_content_photo = InputStoryContentPhoto(data_file("telegram.jpg").read_bytes()) + expected = input_story_content_photo.to_dict() + expected.update({"photo": input_story_content_photo.photo.attach_uri}) + request_parameter = RequestParameter.from_input("key", input_story_content_photo) + assert request_parameter.value == expected + assert request_parameter.input_files == [input_story_content_photo.photo] + + def test_from_input_story_content_video(self): + input_story_content_video = InputStoryContentVideo(data_file("telegram2.mp4").read_bytes()) + expected = input_story_content_video.to_dict() + expected.update({"video": input_story_content_video.video.attach_uri}) + request_parameter = RequestParameter.from_input("key", input_story_content_video) + assert request_parameter.value == expected + assert request_parameter.input_files == [input_story_content_video.video] + + @pytest.mark.parametrize( + ("cls", "arg"), + [ + (InputStoryContentPhoto, data_file("telegram.jpg")), + (InputStoryContentVideo, data_file("telegram2.mp4")), + ], + ) + def test_from_input_story_content_local_files(self, cls, arg): + input_story_content = cls(arg) + expected = input_story_content.to_dict() + requested = RequestParameter.from_input("key", input_story_content) + assert requested.value == expected + assert requested.input_files is None + def test_from_input_str_and_bytes(self): input_str = "test_input" request_parameter = RequestParameter.from_input("input", input_str) diff --git a/tests/test_birthdate.py b/tests/test_birthdate.py index e14608abba9..8a2d4d240ec 100644 --- a/tests/test_birthdate.py +++ b/tests/test_birthdate.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_bot.py b/tests/test_bot.py index fe0307a86cb..9d299faf6fa 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -38,11 +38,11 @@ BotDescription, BotName, BotShortDescription, - BusinessConnection, CallbackQuery, Chat, ChatAdministratorRights, ChatFullInfo, + ChatInviteLink, ChatPermissions, Dice, InlineKeyboardButton, @@ -65,6 +65,7 @@ MenuButtonWebApp, Message, MessageEntity, + OwnedGifts, Poll, PollOption, PreparedInlineMessage, @@ -75,10 +76,13 @@ ShippingOption, StarTransaction, StarTransactions, + SuggestedPostParameters, + SuggestedPostPrice, Update, User, WebAppInfo, ) +from telegram._payment.stars.staramount import StarAmount from telegram._utils.datetime import UTC, from_timestamp, localize, to_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.strings import to_camel_case @@ -90,11 +94,11 @@ ParseMode, ReactionEmoji, ) -from telegram.error import BadRequest, EndPointNotFound, InvalidToken +from telegram.error import BadRequest, EndPointNotFound, InvalidToken, TimedOut from telegram.ext import ExtBot, InvalidCallbackData from telegram.helpers import escape_markdown from telegram.request import BaseRequest, HTTPXRequest, RequestData -from telegram.warnings import PTBDeprecationWarning, PTBUserWarning +from telegram.warnings import PTBUserWarning from tests.auxil.bot_method_checks import check_defaults_handling from tests.auxil.ci_bots import FALLBACKS from tests.auxil.envvars import GITHUB_ACTIONS @@ -104,6 +108,7 @@ from tests.auxil.slots import mro_slots from .auxil.build_messages import make_message +from .auxil.dummy_objects import get_dummy_object @pytest.fixture @@ -191,7 +196,7 @@ def bot_methods(ext_bot=True, include_camel_case=False, include_do_api_request=F ids.append(f"{cls.__name__}.{name}") return pytest.mark.parametrize( - argnames="bot_class, bot_method_name,bot_method", argvalues=arg_values, ids=ids + argnames=("bot_class", "bot_method_name", "bot_method"), argvalues=arg_values, ids=ids ) @@ -233,7 +238,9 @@ def _reset(self): @pytest.mark.parametrize("bot_class", [Bot, ExtBot]) def test_slot_behaviour(self, bot_class, offline_bot): - inst = bot_class(offline_bot.token) + inst = bot_class( + offline_bot.token, request=OfflineRequest(1), get_updates_request=OfflineRequest(1) + ) for attr in inst.__slots__: assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" @@ -242,6 +249,71 @@ async def test_no_token_passed(self): with pytest.raises(InvalidToken, match="You must pass the token"): Bot("") + def test_base_url_parsing_basic(self, caplog): + with caplog.at_level(logging.DEBUG): + bot = Bot( + token="!!Test String!!", + base_url="base/", + base_file_url="base/", + request=OfflineRequest(1), + get_updates_request=OfflineRequest(1), + ) + + assert bot.base_url == "base/!!Test String!!" + assert bot.base_file_url == "base/!!Test String!!" + + assert len(caplog.records) >= 2 + messages = [record.getMessage() for record in caplog.records] + assert "Set Bot API URL: base/!!Test String!!" in messages + assert "Set Bot API File URL: base/!!Test String!!" in messages + + @pytest.mark.parametrize( + "insert_key", ["token", "TOKEN", "bot_token", "BOT_TOKEN", "bot-token", "BOT-TOKEN"] + ) + def test_base_url_parsing_string_format(self, insert_key, caplog): + string = f"{{{insert_key}}}" + + with caplog.at_level(logging.DEBUG): + bot = Bot( + token="!!Test String!!", + base_url=string, + base_file_url=string, + request=OfflineRequest(1), + get_updates_request=OfflineRequest(1), + ) + + assert bot.base_url == "!!Test String!!" + assert bot.base_file_url == "!!Test String!!" + + assert len(caplog.records) >= 2 + messages = [record.getMessage() for record in caplog.records] + assert "Set Bot API URL: !!Test String!!" in messages + assert "Set Bot API File URL: !!Test String!!" in messages + + with pytest.raises(KeyError, match="unsupported insertion: unknown"): + Bot("token", base_url="{unknown}{token}") + + def test_base_url_parsing_callable(self, caplog): + def build_url(_: str) -> str: + return "!!Test String!!" + + with caplog.at_level(logging.DEBUG): + bot = Bot( + token="some-token", + base_url=build_url, + base_file_url=build_url, + request=OfflineRequest(1), + get_updates_request=OfflineRequest(1), + ) + + assert bot.base_url == "!!Test String!!" + assert bot.base_file_url == "!!Test String!!" + + assert len(caplog.records) >= 2 + messages = [record.getMessage() for record in caplog.records] + assert "Set Bot API URL: !!Test String!!" in messages + assert "Set Bot API File URL: !!Test String!!" in messages + async def test_repr(self): offline_bot = Bot(token="some_token", base_file_url="") assert repr(offline_bot) == "Bot[token=some_token]" @@ -302,6 +374,59 @@ async def shutdown(*args, **kwargs): assert self.received["init"] == 2 assert self.received["shutdown"] == 2 + async def test_initialize_with_get_me_failure_then_success(self, offline_bot, monkeypatch): + """Test that bot can recover from get_me failure during initialization.""" + get_me_call_count = 0 + request_init_count = 0 + + test_bot = PytestBot(token=offline_bot.token, request=OfflineRequest()) + original_get_me = test_bot.get_me + original_request_init = test_bot.request.initialize + + async def failing_then_succeeding_get_me(*args, **kwargs): + nonlocal get_me_call_count + get_me_call_count += 1 + if get_me_call_count == 1: + # First call fails + raise TimedOut("Test timeout") + # Subsequent calls succeed + return await original_get_me(*args, **kwargs) + + async def counting_request_init(*args, **kwargs): + nonlocal request_init_count + request_init_count += 1 + await original_request_init(*args, **kwargs) + + monkeypatch.setattr(test_bot, "get_me", failing_then_succeeding_get_me) + monkeypatch.setattr(test_bot.request, "initialize", counting_request_init) + + try: + # First initialize attempt should fail due to get_me timeout + with pytest.raises(TimedOut): + await test_bot.initialize() + + # Request initialization should have been called (once per initialize call) + assert request_init_count == 1 + # get_me should have been called once and failed + assert get_me_call_count == 1 + + # Second initialize attempt should succeed + await test_bot.initialize() + # Request initialization should not be called again (still 1) + assert request_init_count == 1 + # get_me should have been called a second time and succeeded + assert get_me_call_count == 2 + # Verify bot is now accessible + assert test_bot.bot.id == offline_bot.id + + # Third initialize attempt should be a no-op (both flags already True) + await test_bot.initialize() + # Neither should be called again + assert request_init_count == 1 + assert get_me_call_count == 2 + finally: + await test_bot.shutdown() + async def test_context_manager(self, monkeypatch, offline_bot): async def initialize(): self.test_flag = ["initialize"] @@ -333,6 +458,21 @@ async def shutdown(): assert self.test_flag == "stop" + async def test_shutdown_at_error_in_request_in_init(self, monkeypatch, offline_bot): + async def get_me_error(): + raise httpx.HTTPError("BadRequest wrong token sry :(") + + async def shutdown(*args): + self.test_flag = "stop" + + monkeypatch.setattr(offline_bot, "get_me", get_me_error) + monkeypatch.setattr(offline_bot, "shutdown", shutdown) + + async with offline_bot: + pass + + assert self.test_flag == "stop" + async def test_equality(self): async with ( make_bot(token=FALLBACKS[0]["token"]) as a, @@ -407,9 +547,10 @@ def test_bot_deepcopy_error(self, offline_bot): ("cls", "logger_name"), [(Bot, "telegram.Bot"), (ExtBot, "telegram.ext.ExtBot")] ) async def test_bot_method_logging(self, offline_bot: PytestExtBot, cls, logger_name, caplog): + instance = cls(offline_bot.token) # Second argument makes sure that we ignore logs from e.g. httpx with caplog.at_level(logging.DEBUG, logger="telegram"): - await cls(offline_bot.token).get_me() + await instance.get_me() # Only for stabilizing this test- if len(caplog.records) == 4: for idx, record in enumerate(caplog.records): @@ -458,7 +599,7 @@ def test_api_kwargs_and_timeouts_present(self, bot_class, bot_method_name, bot_m if bot_method_name.replace("_", "").lower() != "getupdates" and bot_class is ExtBot: assert rate_arg in param_names, f"{bot_method} is missing the parameter `{rate_arg}`" - @bot_methods(ext_bot=False) + @bot_methods() async def test_defaults_handling( self, bot_class, @@ -487,17 +628,11 @@ async def test_defaults_handling( Finally, there are some tests for Defaults.{parse_mode, quote, allow_sending_without_reply} at the appropriate places, as those are the only things we can actually check. """ - # Mocking get_me within check_defaults_handling messes with the cached values like - # Bot.{bot, username, id, …}` unless we return the expected User object. - return_value = ( - offline_bot.bot if bot_method_name.lower().replace("_", "") == "getme" else None - ) - # Check that ExtBot does the right thing bot_method = getattr(offline_bot, bot_method_name) raw_bot_method = getattr(raw_bot, bot_method_name) - assert await check_defaults_handling(bot_method, offline_bot, return_value=return_value) - assert await check_defaults_handling(raw_bot_method, raw_bot, return_value=return_value) + assert await check_defaults_handling(bot_method, offline_bot) + assert await check_defaults_handling(raw_bot_method, raw_bot) @pytest.mark.parametrize( ("name", "method"), inspect.getmembers(Bot, predicate=inspect.isfunction) @@ -517,9 +652,9 @@ def test_ext_bot_signature(self, name, method): signature = inspect.signature(method) ext_signature = inspect.signature(getattr(ExtBot, name)) - assert ( - ext_signature.return_annotation == signature.return_annotation - ), f"Wrong return annotation for method {name}" + assert ext_signature.return_annotation == signature.return_annotation, ( + f"Wrong return annotation for method {name}" + ) assert ( set(signature.parameters) == set(ext_signature.parameters) - global_extra_args - extra_args_per_method[name] @@ -527,15 +662,15 @@ def test_ext_bot_signature(self, name, method): for param_name, param in signature.parameters.items(): if param_name in different_hints_per_method[name]: continue - assert ( - param.annotation == ext_signature.parameters[param_name].annotation - ), f"Wrong annotation for parameter {param_name} of method {name}" - assert ( - param.default == ext_signature.parameters[param_name].default - ), f"Wrong default value for parameter {param_name} of method {name}" - assert ( - param.kind == ext_signature.parameters[param_name].kind - ), f"Wrong parameter kind for parameter {param_name} of method {name}" + assert param.annotation == ext_signature.parameters[param_name].annotation, ( + f"Wrong annotation for parameter {param_name} of method {name}" + ) + assert param.default == ext_signature.parameters[param_name].default, ( + f"Wrong default value for parameter {param_name} of method {name}" + ) + assert param.kind == ext_signature.parameters[param_name].kind, ( + f"Wrong parameter kind for parameter {param_name} of method {name}" + ) async def test_unknown_kwargs(self, offline_bot, monkeypatch): async def post(url, request_data: RequestData, *args, **kwargs): @@ -617,7 +752,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): @pytest.mark.parametrize( "default_bot", - [{"parse_mode": "Markdown", "disable_web_page_preview": True}], + [{"parse_mode": "Markdown", "link_preview_options": LinkPreviewOptions(is_disabled=True)}], indirect=True, ) @pytest.mark.parametrize( @@ -723,11 +858,14 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): # TODO: Needs improvement. We need incoming inline query to test answer. @pytest.mark.parametrize("button_type", ["start", "web_app"]) - async def test_answer_inline_query(self, monkeypatch, offline_bot, raw_bot, button_type): + @pytest.mark.parametrize("cache_time", [74, dtm.timedelta(seconds=74)]) + async def test_answer_inline_query( + self, monkeypatch, offline_bot, raw_bot, button_type, cache_time + ): # For now just test that our internals pass the correct data async def make_assertion(url, request_data: RequestData, *args, **kwargs): expected = { - "cache_time": 300, + "cache_time": 74, "results": [ { "title": "first", @@ -807,7 +945,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): assert await bot_type.answer_inline_query( 1234, results=results, - cache_time=300, + cache_time=cache_time, is_personal=True, next_offset="42", button=button, @@ -909,7 +1047,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): @pytest.mark.parametrize( "default_bot", - [{"parse_mode": "Markdown", "disable_web_page_preview": True}], + [{"parse_mode": "Markdown", "link_preview_options": LinkPreviewOptions(is_disabled=True)}], indirect=True, ) async def test_answer_inline_query_default_parse_mode(self, monkeypatch, default_bot): @@ -1263,21 +1401,22 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): assert await offline_bot.set_chat_administrator_custom_title(2, 32, "custom_title") # TODO: Needs improvement. Need an incoming callbackquery to test - async def test_answer_callback_query(self, monkeypatch, offline_bot): + @pytest.mark.parametrize("cache_time", [74, dtm.timedelta(seconds=74)]) + async def test_answer_callback_query(self, monkeypatch, offline_bot, cache_time): # For now just test that our internals pass the correct data async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.parameters == { "callback_query_id": 23, "show_alert": True, "url": "no_url", - "cache_time": 1, + "cache_time": 74, "text": "answer", } monkeypatch.setattr(offline_bot.request, "post", make_assertion) assert await offline_bot.answer_callback_query( - 23, text="answer", show_alert=True, url="no_url", cache_time=1 + 23, text="answer", show_alert=True, url="no_url", cache_time=cache_time ) @pytest.mark.parametrize("drop_pending_updates", [True, False]) @@ -1339,6 +1478,61 @@ async def make_assertion(*args, **_): "SoSecretToken", ) + async def test_send_message_draft(self, offline_bot, monkeypatch): + entities = [ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ] + + async def make_assertions(*args, **kwargs): + params = kwargs.get("request_data").parameters + assert params.get("chat_id") == 123 + assert params.get("draft_id") == 1 + assert params.get("text") == "test test" + assert params.get("message_thread_id") == 9 + assert params.get("parse_mode") == "markdown" + assert params.get("entities") == [e.to_dict() for e in entities] + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertions) + assert await offline_bot.send_message_draft( + chat_id=123, + draft_id=1, + text="test test", + message_thread_id=9, + parse_mode="markdown", + entities=entities, + ) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, "Markdown"), ("HTML", "HTML"), (None, None)], + ) + async def test_send_message_draft_default_parse_mode( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("parse_mode") == expected_value + return True + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "chat_id": 123, + "draft_id": 1, + "text": "test test", + "message_thread_id": 9, + "entities": [ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ], + } + if passed_value is not DEFAULT_NONE: + kwargs["parse_mode"] = passed_value + + await default_bot.send_message_draft(**kwargs) + # TODO: Needs improvement. Need incoming shipping queries to test async def test_answer_shipping_query_ok(self, monkeypatch, offline_bot): # For now just test that our internals pass the correct data @@ -1432,7 +1626,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): ) @pytest.mark.parametrize("local_mode", [True, False]) - async def test_set_chat_photo_local_files(self, monkeypatch, offline_bot, chat_id, local_mode): + async def test_set_chat_photo_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: offline_bot._local_mode = local_mode # For just test that the correct paths are passed as we have no local Bot API set up @@ -1547,6 +1743,7 @@ async def post(url, request_data: RequestData, *args, **kwargs): == [MessageEntity(MessageEntity.BOLD, 0, 4).to_dict()], data["protect_content"] is True, data["message_thread_id"] == 1, + data["video_start_timestamp"] == 999, ] ): pytest.fail("I got wrong parameters in post") @@ -1558,6 +1755,7 @@ async def post(url, request_data: RequestData, *args, **kwargs): from_chat_id=chat_id, message_id=media_message.message_id, caption=caption, + video_start_timestamp=999, caption_entities=[MessageEntity(MessageEntity.BOLD, 0, 4)], parse_mode=ParseMode.HTML, reply_to_message_id=media_message.message_id, @@ -1628,7 +1826,7 @@ async def test_arbitrary_callback_data_pinned_message_reply_to_message( message = Message( 1, dtm.datetime.utcnow(), - None, + get_dummy_object(Chat), reply_markup=offline_bot.callback_data_cache.process_keyboard(reply_markup), ) message._unfreeze() @@ -1642,7 +1840,7 @@ async def post(*args, **kwargs): message_type: Message( 1, dtm.datetime.utcnow(), - None, + get_dummy_object(Chat), pinned_message=message, reply_to_message=Message.de_json(message.to_dict(), offline_bot), ) @@ -1785,7 +1983,7 @@ async def test_arbitrary_callback_data_via_bot( message = Message( 1, dtm.datetime.utcnow(), - None, + get_dummy_object(Chat), reply_markup=reply_markup, via_bot=bot.bot if self_sender else User(1, "first", False), ) @@ -2185,6 +2383,10 @@ async def do_request(self_, *args, **kwargs) -> tuple[int, bytes]: ) return HTTPStatus.OK, b'{"ok": "True", "result": {}}' + @property + def read_timeout(self): + return 1 + custom_request = CustomRequest() offline_bot = Bot(offline_bot.token, request=custom_request) @@ -2199,7 +2401,7 @@ async def do_request(self_, *args, **kwargs) -> tuple[int, bytes]: assert test_flag == ( DEFAULT_NONE, DEFAULT_NONE, - 20, + DEFAULT_NONE, DEFAULT_NONE, ) @@ -2228,14 +2430,32 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): api_kwargs={"chat_id": 2, "user_id": 32, "until_date": until_timestamp}, ) - async def test_business_connection_id_argument(self, offline_bot, monkeypatch): + async def test_business_connection_id_argument( + self, offline_bot, monkeypatch, dummy_message_dict + ): """We can't connect to a business acc, so we just test that the correct data is passed. We also can't test every single method easily, so we just test a few. Our linting will catch any unused args with the others.""" + return_values = asyncio.Queue() + await return_values.put(dummy_message_dict) + await return_values.put( + Poll( + id="42", + question="question", + options=[PollOption("option", 0)], + total_voter_count=5, + is_closed=True, + is_anonymous=True, + type="regular", + allows_multiple_answers=False, + ).to_dict() + ) + await return_values.put(True) + await return_values.put(True) async def make_assertion(url, request_data: RequestData, *args, **kwargs): assert request_data.parameters.get("business_connection_id") == 42 - return {} + return await return_values.get() monkeypatch.setattr(offline_bot.request, "post", make_assertion) @@ -2264,25 +2484,31 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): monkeypatch.setattr(offline_bot.request, "post", make_assertion) assert await offline_bot.send_message(2, "text", allow_paid_broadcast=42) - async def test_get_business_connection(self, offline_bot, monkeypatch): - bci = "42" - user = User(1, "first", False) - user_chat_id = 1 - date = dtm.datetime.utcnow() - can_reply = True - is_enabled = True - bc = BusinessConnection(bci, user, user_chat_id, date, can_reply, is_enabled).to_json() + async def test_direct_messages_topic_id_argument(self, offline_bot, monkeypatch): + """We can't test every single method easily, so we just test one. Our linting will catch + any unused args with the others.""" + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return request_data.parameters.get("direct_messages_topic_id") == 42 - async def do_request(*args, **kwargs): - data = kwargs.get("request_data") - obj = data.parameters.get("business_connection_id") - if obj == bci: - return 200, f'{{"ok": true, "result": {bc}}}'.encode() - return 400, b'{"ok": false, "result": []}' + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_message(2, "text", direct_messages_topic_id=42) - monkeypatch.setattr(offline_bot.request, "do_request", do_request) - obj = await offline_bot.get_business_connection(business_connection_id=bci) - assert isinstance(obj, BusinessConnection) + async def test_suggested_post_parameters_argument(self, offline_bot, monkeypatch): + """We can't test every single method easily, so we just test one. Our linting will catch + any unused args with the others.""" + suggested_post_parameters = SuggestedPostParameters(price=SuggestedPostPrice("TON", 10)) + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return ( + request_data.parameters.get("suggested_post_parameters") + == suggested_post_parameters.to_dict() + ) + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_message( + 2, "text", suggested_post_parameters=suggested_post_parameters + ) async def test_send_chat_action_all_args(self, bot, chat_id, monkeypatch): async def make_assertion(*args, **_): @@ -2297,6 +2523,61 @@ async def make_assertion(*args, **_): monkeypatch.setattr(bot, "_post", make_assertion) assert await bot.send_chat_action(chat_id, "action", 1, 3) + async def test_gift_premium_subscription_all_args(self, bot, monkeypatch): + # can't make actual request so we just test that the correct data is passed + async def make_assertion(*args, **_): + kwargs = args[1] + return ( + kwargs.get("user_id") == 12 + and kwargs.get("month_count") == 3 + and kwargs.get("star_count") == 1000 + and kwargs.get("text") == "test text" + and kwargs.get("text_parse_mode") == "Markdown" + and kwargs.get("text_entities") + == [ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 11), + ] + ) + + monkeypatch.setattr(bot, "_post", make_assertion) + assert await bot.gift_premium_subscription( + user_id=12, + month_count=3, + star_count=1000, + text="test text", + text_parse_mode="Markdown", + text_entities=[ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 11), + ], + ) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, "Markdown"), ("HTML", "HTML"), (None, None)], + ) + async def test_gift_premium_subscription_default_parse_mode( + self, default_bot, monkeypatch, passed_value, expected_value + ): + # can't make actual request so we just test that the correct data is passed + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("text_parse_mode") == expected_value + return True + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "user_id": 123, + "month_count": 3, + "star_count": 1000, + "text": "text", + } + if passed_value is not DEFAULT_NONE: + kwargs["text_parse_mode"] = passed_value + + assert await default_bot.gift_premium_subscription(**kwargs) + async def test_refund_star_payment(self, offline_bot, monkeypatch): # can't make actual request so we just test that the correct data is passed async def make_assertion(url, request_data: RequestData, *args, **kwargs): @@ -2348,6 +2629,9 @@ async def test_create_chat_subscription_invite_link( async def make_assertion(url, request_data: RequestData, *args, **kwargs): assert request_data.parameters.get("subscription_period") == 2592000 assert request_data.parameters.get("subscription_price") == 6 + return ChatInviteLink( + "https://t.me/joinchat/invite_link", User(1, "first", False), False, False, False + ).to_dict() monkeypatch.setattr(offline_bot.request, "post", make_assertion) @@ -2428,6 +2712,128 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): await offline_bot.remove_chat_verification(1234) + async def test_get_my_star_balance(self, offline_bot, monkeypatch): + sa = StarAmount(1000).to_json() + + async def do_request(url, request_data: RequestData, *args, **kwargs): + assert not request_data.parameters + return 200, f'{{"ok": true, "result": {sa}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", do_request) + obj = await offline_bot.get_my_star_balance() + assert isinstance(obj, StarAmount) + + async def test_approve_suggested_post(self, offline_bot, monkeypatch): + "No way to test this without receiving suggested posts" + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + data = request_data.json_parameters + chat_id = data.get("chat_id") == "1234" + message_id = data.get("message_id") == "5678" + send_date = data.get("send_date", "1577887200") == "1577887200" + return chat_id and message_id and send_date + + until = from_timestamp(1577887200) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + assert await offline_bot.approve_suggested_post(1234, 5678, 1577887200) + assert await offline_bot.approve_suggested_post(1234, 5678, until) + + async def test_approve_suggested_post_with_tz(self, monkeypatch, tz_bot): + until = dtm.datetime(2020, 1, 11, 16, 13) + until_timestamp = to_timestamp(until, tzinfo=tz_bot.defaults.tzinfo) + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + data = request_data.parameters + chat_id = data["chat_id"] == 2 + message_id = data["message_id"] == 32 + until_date = data.get("until_date", until_timestamp) == until_timestamp + return chat_id and message_id and until_date + + monkeypatch.setattr(tz_bot.request, "post", make_assertion) + + assert await tz_bot.approve_suggested_post(2, 32) + assert await tz_bot.approve_suggested_post(2, 32, send_date=until) + assert await tz_bot.approve_suggested_post(2, 32, send_date=until_timestamp) + + async def test_decline_suggested_post(self, offline_bot, monkeypatch): + "No way to test this without receiving suggested posts" + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("chat_id") == 1234 + assert request_data.parameters.get("message_id") == 5678 + assert request_data.parameters.get("comment") == "declined" + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + await offline_bot.decline_suggested_post(1234, 5678, "declined") + + async def test_get_user_gifts_parameter_passing(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + for param in ( + "user_id", + "exclude_unlimited", + "exclude_limited_upgradable", + "exclude_limited_non_upgradable", + "exclude_from_blockchain", + "exclude_unique", + "sort_by_price", + "offset", + "limit", + ): + assert request_data.parameters.get(param) == param + + return OwnedGifts(0, [], "null").to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + await offline_bot.get_user_gifts( + user_id="user_id", + exclude_unlimited="exclude_unlimited", + exclude_limited_upgradable="exclude_limited_upgradable", + exclude_limited_non_upgradable="exclude_limited_non_upgradable", + exclude_from_blockchain="exclude_from_blockchain", + exclude_unique="exclude_unique", + sort_by_price="sort_by_price", + offset="offset", + limit="limit", + ) + + async def test_get_chat_gifts_parameter_passing(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + for param in ( + "chat_id", + "exclude_saved", + "exclude_unsaved", + "exclude_unlimited", + "exclude_limited_upgradable", + "exclude_limited_non_upgradable", + "exclude_from_blockchain", + "exclude_unique", + "sort_by_price", + "offset", + "limit", + ): + assert request_data.parameters.get(param) == param + + return OwnedGifts(0, [], "null").to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + await offline_bot.get_chat_gifts( + chat_id="chat_id", + exclude_saved="exclude_saved", + exclude_unsaved="exclude_unsaved", + exclude_unlimited="exclude_unlimited", + exclude_limited_upgradable="exclude_limited_upgradable", + exclude_limited_non_upgradable="exclude_limited_non_upgradable", + exclude_from_blockchain="exclude_from_blockchain", + exclude_unique="exclude_unique", + sort_by_price="sort_by_price", + offset="offset", + limit="limit", + ) + class TestBotWithRequest: """ @@ -2441,7 +2847,7 @@ class TestBotWithRequest: # No need to duplicate here. async def test_invalid_token_server_response(self): - with pytest.raises(InvalidToken, match="The token `12` was rejected by the server."): + with pytest.raises(InvalidToken, match="The token `12` was rejected by the server\\."): async with ExtBot(token="12"): pass @@ -2696,7 +3102,9 @@ async def test_send_and_stop_poll(self, bot, super_group_id, reply_markup): assert quiz_task.done() @pytest.mark.parametrize( - ("open_period", "close_date"), [(5, None), (None, True)], ids=["open_period", "close_date"] + ("open_period", "close_date"), + [(5, None), (dtm.timedelta(seconds=5), None), (None, True)], + ids=["open_period", "open_period-dtm", "close_date"], ) async def test_send_open_period(self, bot, super_group_id, open_period, close_date): question = "Is this a test?" @@ -3118,53 +3526,27 @@ async def test_edit_reply_markup_inline(self): pass # TODO: Actually send updates to the test bot so this can be tested properly - async def test_get_updates(self, bot): + @pytest.mark.parametrize("timeout", [1, dtm.timedelta(seconds=1)]) + async def test_get_updates(self, bot, timeout): await bot.delete_webhook() # make sure there is no webhook set if webhook tests failed - updates = await bot.get_updates(timeout=1) + updates = await bot.get_updates(timeout=timeout) assert isinstance(updates, tuple) if updates: assert isinstance(updates[0], Update) - @pytest.mark.parametrize("bot_class", [Bot, ExtBot]) - async def test_get_updates_read_timeout_deprecation_warning( - self, bot, recwarn, monkeypatch, bot_class - ): - # Using the normal HTTPXRequest should not issue any warnings - await bot.get_updates() - assert len(recwarn) == 0 - - # Now let's test deprecation warning when using get_updates for other BaseRequest - # subclasses (we just monkeypatch the existing HTTPXRequest for this) - read_timeout = None - - async def catch_timeouts(*args, **kwargs): - nonlocal read_timeout - read_timeout = kwargs.get("read_timeout") - return HTTPStatus.OK, b'{"ok": "True", "result": {}}' - - monkeypatch.setattr(HTTPXRequest, "read_timeout", BaseRequest.read_timeout) - monkeypatch.setattr(HTTPXRequest, "do_request", catch_timeouts) - - bot = bot_class(get_updates_request=HTTPXRequest(), token=bot.token) - await bot.get_updates() - - assert len(recwarn) == 1 - assert "does not override the property `read_timeout`" in str(recwarn[0].message) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__, "wrong stacklevel" - - assert read_timeout == 2 - @pytest.mark.parametrize( ("read_timeout", "timeout", "expected"), [ (None, None, 0), (1, None, 1), (None, 1, 1), + (None, dtm.timedelta(seconds=1), 1), (DEFAULT_NONE, None, 10), (DEFAULT_NONE, 1, 11), + (DEFAULT_NONE, dtm.timedelta(seconds=1), 11), (1, 2, 3), + (1, dtm.timedelta(seconds=2), 3), ], ) async def test_get_updates_read_timeout_value_passing( @@ -3418,6 +3800,7 @@ async def test_promote_chat_member(self, bot, channel_id, monkeypatch): can_post_stories=True, can_edit_stories=True, can_delete_stories=True, + can_manage_direct_messages=True, ) # Test that we pass the correct params to TG @@ -3441,6 +3824,7 @@ async def make_assertion(*args, **_): and data.get("can_post_stories") == 13 and data.get("can_edit_stories") == 14 and data.get("can_delete_stories") == 15 + and data.get("can_manage_direct_messages") == 16 ) monkeypatch.setattr(bot, "_post", make_assertion) @@ -3462,6 +3846,7 @@ async def make_assertion(*args, **_): can_post_stories=13, can_edit_stories=14, can_delete_stories=15, + can_manage_direct_messages=16, ) async def test_export_chat_invite_link(self, bot, channel_id): @@ -3625,7 +4010,7 @@ async def test_decline_chat_join_request(self, bot, chat_id, channel_id): # # The error message Hide_requester_missing started showing up instead of # User_already_participant. Don't know why … - with pytest.raises(BadRequest, match="User_already_participant|Hide_requester_missing"): + with pytest.raises(BadRequest, match=r"User_already_participant|Hide_requester_missing"): await bot.decline_chat_join_request(chat_id=channel_id, user_id=chat_id) async def test_set_chat_photo(self, bot, channel_id): @@ -4116,7 +4501,7 @@ async def test_replace_callback_data_stop_poll_and_repl_to_message(self, cdc_bot ] ) await poll_message.stop_poll(reply_markup=reply_markup) - helper_message = await poll_message.reply_text("temp", quote=True) + helper_message = await poll_message.reply_text("temp", do_quote=True) message = helper_message.reply_to_message inline_keyboard = message.reply_markup.inline_keyboard @@ -4379,7 +4764,7 @@ async def test_do_api_request_list_return_type(self, bot, chat_id, return_type): assert isinstance(entry, dict) result = Message.de_list(result, bot) - for message, file_name in zip(result, ("text_file.txt", "local_file.txt")): + for message, file_name in zip(result, ("text_file.txt", "local_file.txt"), strict=False): assert isinstance(message, Message) assert message.chat_id == int(chat_id) out = BytesIO() @@ -4398,13 +4783,14 @@ async def test_get_star_transactions(self, bot): assert isinstance(transactions, StarTransactions) assert len(transactions.transactions) == 0 + @pytest.mark.parametrize("subscription_period", [2592000, dtm.timedelta(days=30)]) async def test_create_edit_chat_subscription_link( - self, bot, subscription_channel_id, channel_id + self, bot, subscription_channel_id, channel_id, subscription_period ): sub_link = await bot.create_chat_subscription_invite_link( subscription_channel_id, name="sub_name", - subscription_period=2592000, + subscription_period=subscription_period, subscription_price=13, ) assert sub_link.name == "sub_name" @@ -4417,3 +4803,94 @@ async def test_create_edit_chat_subscription_link( assert edited_link.name == "sub_name_2" assert sub_link.subscription_period == 2592000 assert sub_link.subscription_price == 13 + + async def test_get_my_star_balance(self, bot): + balance = await bot.get_my_star_balance() + assert isinstance(balance, StarAmount) + assert balance.amount == 0 + + async def test_get_user_gifts_basic(self, bot): + gifts = await bot.get_user_gifts(bot.bot.id) + assert isinstance(gifts, OwnedGifts) + assert gifts.total_count == 0 + + async def test_get_chat_gifts_basic(self, bot, chat_id): + gifts = await bot.get_chat_gifts(chat_id) + assert isinstance(gifts, OwnedGifts) + assert gifts.total_count == 0 + + async def test_initialize_tracks_requests_and_bot_separately(self, offline_bot, monkeypatch): + """Test that requests and bot user are initialized separately and only once.""" + request_init_count = 0 + get_me_call_count = 0 + + async def counting_request_init(*args, **kwargs): + nonlocal request_init_count + request_init_count += 1 + + original_get_me = offline_bot.get_me + + async def counting_get_me(*args, **kwargs): + nonlocal get_me_call_count + get_me_call_count += 1 + return await original_get_me(*args, **kwargs) + + test_bot = PytestBot(token=offline_bot.token, request=OfflineRequest()) + monkeypatch.setattr(test_bot.request, "initialize", counting_request_init) + monkeypatch.setattr(test_bot, "get_me", counting_get_me) + + try: + # First initialization + await test_bot.initialize() + assert request_init_count == 1 + assert get_me_call_count == 1 + + # Second initialization should not call either again + await test_bot.initialize() + assert request_init_count == 1 + assert get_me_call_count == 1 + finally: + await test_bot.shutdown() + + async def test_shutdown_allows_reinitialization(self, offline_bot, monkeypatch): + """Test that after shutdown, bot can be reinitialized.""" + request_init_count = 0 + request_shutdown_count = 0 + get_me_call_count = 0 + + async def counting_request_init(*args, **kwargs): + nonlocal request_init_count + request_init_count += 1 + + async def counting_request_shutdown(*args, **kwargs): + nonlocal request_shutdown_count + request_shutdown_count += 1 + + original_get_me = offline_bot.get_me + + async def counting_get_me(*args, **kwargs): + nonlocal get_me_call_count + get_me_call_count += 1 + return await original_get_me(*args, **kwargs) + + test_bot = PytestBot(token=offline_bot.token, request=OfflineRequest()) + monkeypatch.setattr(test_bot.request, "initialize", counting_request_init) + monkeypatch.setattr(test_bot.request, "shutdown", counting_request_shutdown) + monkeypatch.setattr(test_bot, "get_me", counting_get_me) + + try: + # First initialization + await test_bot.initialize() + assert request_init_count == 1 + assert get_me_call_count == 1 + + # Shutdown + await test_bot.shutdown() + assert request_shutdown_count == 1 + + # Re-initialize should call everything again + await test_bot.initialize() + assert request_init_count == 2 + assert get_me_call_count == 2 + finally: + await test_bot.shutdown() diff --git a/tests/test_botcommand.py b/tests/test_botcommand.py index 7dd2070c098..4ecceb796b8 100644 --- a/tests/test_botcommand.py +++ b/tests/test_botcommand.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -45,8 +45,6 @@ def test_de_json(self, offline_bot): assert bot_command.command == self.command assert bot_command.description == self.description - assert BotCommand.de_json(None, offline_bot) is None - def test_to_dict(self, bot_command): bot_command_dict = bot_command.to_dict() diff --git a/tests/test_botcommandscope.py b/tests/test_botcommandscope.py index 2acafaeb93b..6b520d53192 100644 --- a/tests/test_botcommandscope.py +++ b/tests/test_botcommandscope.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,7 +16,6 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -from copy import deepcopy import pytest @@ -35,149 +34,336 @@ from tests.auxil.slots import mro_slots -@pytest.fixture(scope="module", params=["str", "int"]) -def chat_id(request): - if request.param == "str": - return "@supergroupusername" - return 43 - - -@pytest.fixture( - scope="class", - params=[ - BotCommandScope.DEFAULT, - BotCommandScope.ALL_PRIVATE_CHATS, - BotCommandScope.ALL_GROUP_CHATS, - BotCommandScope.ALL_CHAT_ADMINISTRATORS, - BotCommandScope.CHAT, - BotCommandScope.CHAT_ADMINISTRATORS, - BotCommandScope.CHAT_MEMBER, - ], -) -def scope_type(request): - return request.param - - -@pytest.fixture( - scope="module", - params=[ - BotCommandScopeDefault, - BotCommandScopeAllPrivateChats, - BotCommandScopeAllGroupChats, - BotCommandScopeAllChatAdministrators, - BotCommandScopeChat, - BotCommandScopeChatAdministrators, - BotCommandScopeChatMember, - ], - ids=[ - BotCommandScope.DEFAULT, - BotCommandScope.ALL_PRIVATE_CHATS, - BotCommandScope.ALL_GROUP_CHATS, - BotCommandScope.ALL_CHAT_ADMINISTRATORS, - BotCommandScope.CHAT, - BotCommandScope.CHAT_ADMINISTRATORS, - BotCommandScope.CHAT_MEMBER, - ], -) -def scope_class(request): - return request.param - - -@pytest.fixture( - scope="module", - params=[ - (BotCommandScopeDefault, BotCommandScope.DEFAULT), - (BotCommandScopeAllPrivateChats, BotCommandScope.ALL_PRIVATE_CHATS), - (BotCommandScopeAllGroupChats, BotCommandScope.ALL_GROUP_CHATS), - (BotCommandScopeAllChatAdministrators, BotCommandScope.ALL_CHAT_ADMINISTRATORS), - (BotCommandScopeChat, BotCommandScope.CHAT), - (BotCommandScopeChatAdministrators, BotCommandScope.CHAT_ADMINISTRATORS), - (BotCommandScopeChatMember, BotCommandScope.CHAT_MEMBER), - ], - ids=[ - BotCommandScope.DEFAULT, - BotCommandScope.ALL_PRIVATE_CHATS, - BotCommandScope.ALL_GROUP_CHATS, - BotCommandScope.ALL_CHAT_ADMINISTRATORS, - BotCommandScope.CHAT, - BotCommandScope.CHAT_ADMINISTRATORS, - BotCommandScope.CHAT_MEMBER, - ], -) -def scope_class_and_type(request): - return request.param +@pytest.fixture +def bot_command_scope(): + return BotCommandScope(BotCommandScopeTestBase.type) -@pytest.fixture(scope="module") -def bot_command_scope(scope_class_and_type, chat_id): - # we use de_json here so that we don't have to worry about which class needs which arguments - return scope_class_and_type[0].de_json( - {"type": scope_class_and_type[1], "chat_id": chat_id, "user_id": 42}, bot=None - ) +class BotCommandScopeTestBase: + type = BotCommandScopeType.DEFAULT + chat_id = 123456789 + user_id = 987654321 -# All the scope types are very similar, so we test everything via parametrization -class TestBotCommandScopeWithoutRequest: +class TestBotCommandScopeWithoutRequest(BotCommandScopeTestBase): def test_slot_behaviour(self, bot_command_scope): - for attr in bot_command_scope.__slots__: - assert getattr(bot_command_scope, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(bot_command_scope)) == len( - set(mro_slots(bot_command_scope)) - ), "duplicate slot" - - def test_de_json(self, offline_bot, scope_class_and_type, chat_id): - cls = scope_class_and_type[0] - type_ = scope_class_and_type[1] - - assert cls.de_json({}, offline_bot) is None - - json_dict = {"type": type_, "chat_id": chat_id, "user_id": 42} - bot_command_scope = BotCommandScope.de_json(json_dict, offline_bot) - assert set(bot_command_scope.api_kwargs.keys()) == {"chat_id", "user_id"} - set( - cls.__slots__ + inst = bot_command_scope + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_type_enum_conversion(self, bot_command_scope): + assert type(BotCommandScope("default").type) is BotCommandScopeType + assert BotCommandScope("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + transaction_partner = BotCommandScope.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "unknown" + + @pytest.mark.parametrize( + ("bcs_type", "subclass"), + [ + ("all_private_chats", BotCommandScopeAllPrivateChats), + ("all_chat_administrators", BotCommandScopeAllChatAdministrators), + ("all_group_chats", BotCommandScopeAllGroupChats), + ("chat", BotCommandScopeChat), + ("chat_administrators", BotCommandScopeChatAdministrators), + ("chat_member", BotCommandScopeChatMember), + ("default", BotCommandScopeDefault), + ], + ) + def test_de_json_subclass(self, offline_bot, bcs_type, subclass): + json_dict = { + "type": bcs_type, + "chat_id": self.chat_id, + "user_id": self.user_id, + } + bcs = BotCommandScope.de_json(json_dict, offline_bot) + + assert type(bcs) is subclass + assert set(bcs.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert bcs.type == bcs_type + + def test_to_dict(self, bot_command_scope): + data = bot_command_scope.to_dict() + assert data == {"type": "default"} + + def test_equality(self, bot_command_scope): + a = bot_command_scope + b = BotCommandScope(self.type) + c = BotCommandScope("unknown") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def bot_command_scope_all_private_chats(): + return BotCommandScopeAllPrivateChats() + + +class TestBotCommandScopeAllPrivateChatsWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.ALL_PRIVATE_CHATS + + def test_slot_behaviour(self, bot_command_scope_all_private_chats): + inst = bot_command_scope_all_private_chats + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + transaction_partner = BotCommandScopeAllPrivateChats.de_json({}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "all_private_chats" + + def test_to_dict(self, bot_command_scope_all_private_chats): + assert bot_command_scope_all_private_chats.to_dict() == { + "type": bot_command_scope_all_private_chats.type + } + + def test_equality(self, bot_command_scope_all_private_chats): + a = bot_command_scope_all_private_chats + b = BotCommandScopeAllPrivateChats() + c = Dice(5, "test") + d = BotCommandScopeDefault() + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def bot_command_scope_all_chat_administrators(): + return BotCommandScopeAllChatAdministrators() + + +class TestBotCommandScopeAllChatAdministratorsWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.ALL_CHAT_ADMINISTRATORS + + def test_slot_behaviour(self, bot_command_scope_all_chat_administrators): + inst = bot_command_scope_all_chat_administrators + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + transaction_partner = BotCommandScopeAllChatAdministrators.de_json({}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "all_chat_administrators" + + def test_to_dict(self, bot_command_scope_all_chat_administrators): + assert bot_command_scope_all_chat_administrators.to_dict() == { + "type": bot_command_scope_all_chat_administrators.type + } + + def test_equality(self, bot_command_scope_all_chat_administrators): + a = bot_command_scope_all_chat_administrators + b = BotCommandScopeAllChatAdministrators() + c = Dice(5, "test") + d = BotCommandScopeDefault() + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def bot_command_scope_all_group_chats(): + return BotCommandScopeAllGroupChats() + + +class TestBotCommandScopeAllGroupChatsWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.ALL_GROUP_CHATS + + def test_slot_behaviour(self, bot_command_scope_all_group_chats): + inst = bot_command_scope_all_group_chats + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + transaction_partner = BotCommandScopeAllGroupChats.de_json({}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "all_group_chats" + + def test_to_dict(self, bot_command_scope_all_group_chats): + assert bot_command_scope_all_group_chats.to_dict() == { + "type": bot_command_scope_all_group_chats.type + } + + def test_equality(self, bot_command_scope_all_group_chats): + a = bot_command_scope_all_group_chats + b = BotCommandScopeAllGroupChats() + c = Dice(5, "test") + d = BotCommandScopeDefault() + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def bot_command_scope_chat(): + return BotCommandScopeChat(TestBotCommandScopeChatWithoutRequest.chat_id) + + +class TestBotCommandScopeChatWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.CHAT + + def test_slot_behaviour(self, bot_command_scope_chat): + inst = bot_command_scope_chat + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + transaction_partner = BotCommandScopeChat.de_json({"chat_id": self.chat_id}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "chat" + assert transaction_partner.chat_id == self.chat_id + + def test_to_dict(self, bot_command_scope_chat): + assert bot_command_scope_chat.to_dict() == { + "type": bot_command_scope_chat.type, + "chat_id": self.chat_id, + } + + def test_equality(self, bot_command_scope_chat): + a = bot_command_scope_chat + b = BotCommandScopeChat(self.chat_id) + c = BotCommandScopeChat(self.chat_id + 1) + d = Dice(5, "test") + e = BotCommandScopeDefault() + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture +def bot_command_scope_chat_administrators(): + return BotCommandScopeChatAdministrators( + TestBotCommandScopeChatAdministratorsWithoutRequest.chat_id + ) + + +class TestBotCommandScopeChatAdministratorsWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.CHAT_ADMINISTRATORS + + def test_slot_behaviour(self, bot_command_scope_chat_administrators): + inst = bot_command_scope_chat_administrators + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + transaction_partner = BotCommandScopeChatAdministrators.de_json( + {"chat_id": self.chat_id}, offline_bot ) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "chat_administrators" + assert transaction_partner.chat_id == self.chat_id + + def test_to_dict(self, bot_command_scope_chat_administrators): + assert bot_command_scope_chat_administrators.to_dict() == { + "type": bot_command_scope_chat_administrators.type, + "chat_id": self.chat_id, + } + + def test_equality(self, bot_command_scope_chat_administrators): + a = bot_command_scope_chat_administrators + b = BotCommandScopeChatAdministrators(self.chat_id) + c = BotCommandScopeChatAdministrators(self.chat_id + 1) + d = Dice(5, "test") + e = BotCommandScopeDefault() - assert isinstance(bot_command_scope, BotCommandScope) - assert isinstance(bot_command_scope, cls) - assert bot_command_scope.type == type_ - if "chat_id" in cls.__slots__: - assert bot_command_scope.chat_id == chat_id - if "user_id" in cls.__slots__: - assert bot_command_scope.user_id == 42 + assert a == b + assert hash(a) == hash(b) - def test_de_json_invalid_type(self, offline_bot): - json_dict = {"type": "invalid", "chat_id": chat_id, "user_id": 42} - bot_command_scope = BotCommandScope.de_json(json_dict, offline_bot) + assert a != c + assert hash(a) != hash(c) - assert type(bot_command_scope) is BotCommandScope - assert bot_command_scope.type == "invalid" + assert a != d + assert hash(a) != hash(d) - def test_de_json_subclass(self, scope_class, offline_bot, chat_id): - """This makes sure that e.g. BotCommandScopeDefault(data) never returns a - BotCommandScopeChat instance.""" - json_dict = {"type": "invalid", "chat_id": chat_id, "user_id": 42} - assert type(scope_class.de_json(json_dict, offline_bot)) is scope_class + assert a != e + assert hash(a) != hash(e) - def test_to_dict(self, bot_command_scope): - bot_command_scope_dict = bot_command_scope.to_dict() - assert isinstance(bot_command_scope_dict, dict) - assert bot_command_scope["type"] == bot_command_scope.type - if hasattr(bot_command_scope, "chat_id"): - assert bot_command_scope["chat_id"] == bot_command_scope.chat_id - if hasattr(bot_command_scope, "user_id"): - assert bot_command_scope["user_id"] == bot_command_scope.user_id +@pytest.fixture +def bot_command_scope_chat_member(): + return BotCommandScopeChatMember( + TestBotCommandScopeChatMemberWithoutRequest.chat_id, + TestBotCommandScopeChatMemberWithoutRequest.user_id, + ) - def test_type_enum_conversion(self): - assert type(BotCommandScope("default").type) is BotCommandScopeType - assert BotCommandScope("unknown").type == "unknown" - def test_equality(self, bot_command_scope, offline_bot): - a = BotCommandScope("base_type") - b = BotCommandScope("base_type") - c = bot_command_scope - d = deepcopy(bot_command_scope) - e = Dice(4, "emoji") +class TestBotCommandScopeChatMemberWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.CHAT_MEMBER + + def test_slot_behaviour(self, bot_command_scope_chat_member): + inst = bot_command_scope_chat_member + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + transaction_partner = BotCommandScopeChatMember.de_json( + {"chat_id": self.chat_id, "user_id": self.user_id}, offline_bot + ) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "chat_member" + assert transaction_partner.chat_id == self.chat_id + assert transaction_partner.user_id == self.user_id + + def test_to_dict(self, bot_command_scope_chat_member): + assert bot_command_scope_chat_member.to_dict() == { + "type": bot_command_scope_chat_member.type, + "chat_id": self.chat_id, + "user_id": self.user_id, + } + + def test_equality(self, bot_command_scope_chat_member): + a = bot_command_scope_chat_member + b = BotCommandScopeChatMember(self.chat_id, self.user_id) + c = BotCommandScopeChatMember(self.chat_id + 1, self.user_id) + d = BotCommandScopeChatMember(self.chat_id, self.user_id + 1) + e = Dice(5, "test") + f = BotCommandScopeDefault() assert a == b assert hash(a) == hash(b) @@ -191,24 +377,43 @@ def test_equality(self, bot_command_scope, offline_bot): assert a != e assert hash(a) != hash(e) - assert c == d - assert hash(c) == hash(d) + assert a != f + assert hash(a) != hash(f) + - assert c != e - assert hash(c) != hash(e) +@pytest.fixture +def bot_command_scope_default(): + return BotCommandScopeDefault() - if hasattr(c, "chat_id"): - json_dict = c.to_dict() - json_dict["chat_id"] = 0 - f = c.__class__.de_json(json_dict, offline_bot) - assert c != f - assert hash(c) != hash(f) +class TestBotCommandScopeDefaultWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.DEFAULT - if hasattr(c, "user_id"): - json_dict = c.to_dict() - json_dict["user_id"] = 0 - g = c.__class__.de_json(json_dict, offline_bot) + def test_slot_behaviour(self, bot_command_scope_default): + inst = bot_command_scope_default + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - assert c != g - assert hash(c) != hash(g) + def test_de_json(self, offline_bot): + transaction_partner = BotCommandScopeDefault.de_json({}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "default" + + def test_to_dict(self, bot_command_scope_default): + assert bot_command_scope_default.to_dict() == {"type": bot_command_scope_default.type} + + def test_equality(self, bot_command_scope_default): + a = bot_command_scope_default + b = BotCommandScopeDefault() + c = Dice(5, "test") + d = BotCommandScopeChatMember(123, 456) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_botdescription.py b/tests/test_botdescription.py index e5826154741..142545c3419 100644 --- a/tests/test_botdescription.py +++ b/tests/test_botdescription.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -41,9 +41,9 @@ class TestBotDescriptionWithoutRequest(BotDescriptionTestBase): def test_slot_behaviour(self, bot_description): for attr in bot_description.__slots__: assert getattr(bot_description, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(bot_description)) == len( - set(mro_slots(bot_description)) - ), "duplicate slot" + assert len(mro_slots(bot_description)) == len(set(mro_slots(bot_description))), ( + "duplicate slot" + ) def test_to_dict(self, bot_description): bot_description_dict = bot_description.to_dict() diff --git a/tests/test_botname.py b/tests/test_botname.py index 2fb69b11246..289e07e1add 100644 --- a/tests/test_botname.py +++ b/tests/test_botname.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_business.py b/tests/test_business.py deleted file mode 100644 index d8e976a9144..00000000000 --- a/tests/test_business.py +++ /dev/null @@ -1,412 +0,0 @@ -#!/usr/bin/env python -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 -# Leandro Toledo de Souza -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser Public License for more details. -# -# You should have received a copy of the GNU Lesser Public License -# along with this program. If not, see [http://www.gnu.org/licenses/]. -import datetime as dtm - -import pytest - -from telegram import ( - BusinessConnection, - BusinessIntro, - BusinessLocation, - BusinessMessagesDeleted, - BusinessOpeningHours, - BusinessOpeningHoursInterval, - Chat, - Location, - Sticker, - User, -) -from telegram._utils.datetime import UTC, to_timestamp -from tests.auxil.slots import mro_slots - - -class BusinessTestBase: - id_ = "123" - user = User(123, "test_user", False) - user_chat_id = 123 - date = dtm.datetime.now(tz=UTC).replace(microsecond=0) - can_reply = True - is_enabled = True - message_ids = (123, 321) - business_connection_id = "123" - chat = Chat(123, "test_chat") - title = "Business Title" - message = "Business description" - sticker = Sticker("sticker_id", "unique_id", 50, 50, True, False, Sticker.REGULAR) - address = "address" - location = Location(-23.691288, 46.788279) - opening_minute = 0 - closing_minute = 60 - time_zone_name = "Country/City" - opening_hours = [ - BusinessOpeningHoursInterval(opening, opening + 60) for opening in (0, 24 * 60) - ] - - -@pytest.fixture(scope="module") -def business_connection(): - return BusinessConnection( - BusinessTestBase.id_, - BusinessTestBase.user, - BusinessTestBase.user_chat_id, - BusinessTestBase.date, - BusinessTestBase.can_reply, - BusinessTestBase.is_enabled, - ) - - -@pytest.fixture(scope="module") -def business_messages_deleted(): - return BusinessMessagesDeleted( - BusinessTestBase.business_connection_id, - BusinessTestBase.chat, - BusinessTestBase.message_ids, - ) - - -@pytest.fixture(scope="module") -def business_intro(): - return BusinessIntro( - BusinessTestBase.title, - BusinessTestBase.message, - BusinessTestBase.sticker, - ) - - -@pytest.fixture(scope="module") -def business_location(): - return BusinessLocation( - BusinessTestBase.address, - BusinessTestBase.location, - ) - - -@pytest.fixture(scope="module") -def business_opening_hours_interval(): - return BusinessOpeningHoursInterval( - BusinessTestBase.opening_minute, - BusinessTestBase.closing_minute, - ) - - -@pytest.fixture(scope="module") -def business_opening_hours(): - return BusinessOpeningHours( - BusinessTestBase.time_zone_name, - BusinessTestBase.opening_hours, - ) - - -class TestBusinessConnectionWithoutRequest(BusinessTestBase): - def test_slots(self, business_connection): - bc = business_connection - for attr in bc.__slots__: - assert getattr(bc, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(bc)) == len(set(mro_slots(bc))), "duplicate slot" - - def test_de_json(self): - json_dict = { - "id": self.id_, - "user": self.user.to_dict(), - "user_chat_id": self.user_chat_id, - "date": to_timestamp(self.date), - "can_reply": self.can_reply, - "is_enabled": self.is_enabled, - } - bc = BusinessConnection.de_json(json_dict, None) - assert bc.id == self.id_ - assert bc.user == self.user - assert bc.user_chat_id == self.user_chat_id - assert bc.date == self.date - assert bc.can_reply == self.can_reply - assert bc.is_enabled == self.is_enabled - assert bc.api_kwargs == {} - assert isinstance(bc, BusinessConnection) - - def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): - json_dict = { - "id": self.id_, - "user": self.user.to_dict(), - "user_chat_id": self.user_chat_id, - "date": to_timestamp(self.date), - "can_reply": self.can_reply, - "is_enabled": self.is_enabled, - } - chat_bot = BusinessConnection.de_json(json_dict, offline_bot) - chat_bot_raw = BusinessConnection.de_json(json_dict, raw_bot) - chat_bot_tz = BusinessConnection.de_json(json_dict, tz_bot) - - # comparing utcoffsets because comparing tzinfo objects is not reliable - date_offset = chat_bot_tz.date.utcoffset() - date_offset_tz = tz_bot.defaults.tzinfo.utcoffset(chat_bot_tz.date.replace(tzinfo=None)) - - assert chat_bot.date.tzinfo == UTC - assert chat_bot_raw.date.tzinfo == UTC - assert date_offset_tz == date_offset - - def test_to_dict(self, business_connection): - bc_dict = business_connection.to_dict() - assert isinstance(bc_dict, dict) - assert bc_dict["id"] == self.id_ - assert bc_dict["user"] == self.user.to_dict() - assert bc_dict["user_chat_id"] == self.user_chat_id - assert bc_dict["date"] == to_timestamp(self.date) - assert bc_dict["can_reply"] == self.can_reply - assert bc_dict["is_enabled"] == self.is_enabled - - def test_equality(self): - bc1 = BusinessConnection( - self.id_, self.user, self.user_chat_id, self.date, self.can_reply, self.is_enabled - ) - bc2 = BusinessConnection( - self.id_, self.user, self.user_chat_id, self.date, self.can_reply, self.is_enabled - ) - bc3 = BusinessConnection( - "321", self.user, self.user_chat_id, self.date, self.can_reply, self.is_enabled - ) - - assert bc1 == bc2 - assert hash(bc1) == hash(bc2) - - assert bc1 != bc3 - assert hash(bc1) != hash(bc3) - - -class TestBusinessMessagesDeleted(BusinessTestBase): - def test_slots(self, business_messages_deleted): - bmd = business_messages_deleted - for attr in bmd.__slots__: - assert getattr(bmd, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(bmd)) == len(set(mro_slots(bmd))), "duplicate slot" - - def test_to_dict(self, business_messages_deleted): - bmd_dict = business_messages_deleted.to_dict() - assert isinstance(bmd_dict, dict) - assert bmd_dict["message_ids"] == list(self.message_ids) - assert bmd_dict["business_connection_id"] == self.business_connection_id - assert bmd_dict["chat"] == self.chat.to_dict() - - def test_de_json(self): - json_dict = { - "business_connection_id": self.business_connection_id, - "chat": self.chat.to_dict(), - "message_ids": self.message_ids, - } - bmd = BusinessMessagesDeleted.de_json(json_dict, None) - assert bmd.business_connection_id == self.business_connection_id - assert bmd.chat == self.chat - assert bmd.message_ids == self.message_ids - assert bmd.api_kwargs == {} - assert isinstance(bmd, BusinessMessagesDeleted) - - def test_equality(self): - bmd1 = BusinessMessagesDeleted(self.business_connection_id, self.chat, self.message_ids) - bmd2 = BusinessMessagesDeleted(self.business_connection_id, self.chat, self.message_ids) - bmd3 = BusinessMessagesDeleted("1", Chat(4, "random"), [321, 123]) - - assert bmd1 == bmd2 - assert hash(bmd1) == hash(bmd2) - - assert bmd1 != bmd3 - assert hash(bmd1) != hash(bmd3) - - -class TestBusinessIntroWithoutRequest(BusinessTestBase): - def test_slot_behaviour(self, business_intro): - intro = business_intro - for attr in intro.__slots__: - assert getattr(intro, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(intro)) == len(set(mro_slots(intro))), "duplicate slot" - - def test_to_dict(self, business_intro): - intro_dict = business_intro.to_dict() - assert isinstance(intro_dict, dict) - assert intro_dict["title"] == self.title - assert intro_dict["message"] == self.message - assert intro_dict["sticker"] == self.sticker.to_dict() - - def test_de_json(self): - json_dict = { - "title": self.title, - "message": self.message, - "sticker": self.sticker.to_dict(), - } - intro = BusinessIntro.de_json(json_dict, None) - assert intro.title == self.title - assert intro.message == self.message - assert intro.sticker == self.sticker - assert intro.api_kwargs == {} - assert isinstance(intro, BusinessIntro) - - def test_equality(self): - intro1 = BusinessIntro(self.title, self.message, self.sticker) - intro2 = BusinessIntro(self.title, self.message, self.sticker) - intro3 = BusinessIntro("Other Business", self.message, self.sticker) - - assert intro1 == intro2 - assert hash(intro1) == hash(intro2) - assert intro1 is not intro2 - - assert intro1 != intro3 - assert hash(intro1) != hash(intro3) - - -class TestBusinessLocationWithoutRequest(BusinessTestBase): - def test_slot_behaviour(self, business_location): - inst = business_location - for attr in inst.__slots__: - assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - - def test_to_dict(self, business_location): - blc_dict = business_location.to_dict() - assert isinstance(blc_dict, dict) - assert blc_dict["address"] == self.address - assert blc_dict["location"] == self.location.to_dict() - - def test_de_json(self): - json_dict = { - "address": self.address, - "location": self.location.to_dict(), - } - blc = BusinessLocation.de_json(json_dict, None) - assert blc.address == self.address - assert blc.location == self.location - assert blc.api_kwargs == {} - assert isinstance(blc, BusinessLocation) - - def test_equality(self): - blc1 = BusinessLocation(self.address, self.location) - blc2 = BusinessLocation(self.address, self.location) - blc3 = BusinessLocation("Other Address", self.location) - - assert blc1 == blc2 - assert hash(blc1) == hash(blc2) - assert blc1 is not blc2 - - assert blc1 != blc3 - assert hash(blc1) != hash(blc3) - - -class TestBusinessOpeningHoursIntervalWithoutRequest(BusinessTestBase): - def test_slot_behaviour(self, business_opening_hours_interval): - inst = business_opening_hours_interval - for attr in inst.__slots__: - assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - - def test_to_dict(self, business_opening_hours_interval): - bohi_dict = business_opening_hours_interval.to_dict() - assert isinstance(bohi_dict, dict) - assert bohi_dict["opening_minute"] == self.opening_minute - assert bohi_dict["closing_minute"] == self.closing_minute - - def test_de_json(self): - json_dict = { - "opening_minute": self.opening_minute, - "closing_minute": self.closing_minute, - } - bohi = BusinessOpeningHoursInterval.de_json(json_dict, None) - assert bohi.opening_minute == self.opening_minute - assert bohi.closing_minute == self.closing_minute - assert bohi.api_kwargs == {} - assert isinstance(bohi, BusinessOpeningHoursInterval) - - def test_equality(self): - bohi1 = BusinessOpeningHoursInterval(self.opening_minute, self.closing_minute) - bohi2 = BusinessOpeningHoursInterval(self.opening_minute, self.closing_minute) - bohi3 = BusinessOpeningHoursInterval(61, 100) - - assert bohi1 == bohi2 - assert hash(bohi1) == hash(bohi2) - assert bohi1 is not bohi2 - - assert bohi1 != bohi3 - assert hash(bohi1) != hash(bohi3) - - @pytest.mark.parametrize( - ("opening_minute", "expected"), - [ # openings per docstring - (8 * 60, (0, 8, 0)), - (24 * 60, (1, 0, 0)), - (6 * 24 * 60, (6, 0, 0)), - ], - ) - def test_opening_time(self, opening_minute, expected): - bohi = BusinessOpeningHoursInterval(opening_minute, -0) - - opening_time = bohi.opening_time - assert opening_time == expected - - cached = bohi.opening_time - assert cached is opening_time - - @pytest.mark.parametrize( - ("closing_minute", "expected"), - [ # closings per docstring - (20 * 60 + 30, (0, 20, 30)), - (2 * 24 * 60 - 1, (1, 23, 59)), - (7 * 24 * 60 - 2, (6, 23, 58)), - ], - ) - def test_closing_time(self, closing_minute, expected): - bohi = BusinessOpeningHoursInterval(-0, closing_minute) - - closing_time = bohi.closing_time - assert closing_time == expected - - cached = bohi.closing_time - assert cached is closing_time - - -class TestBusinessOpeningHoursWithoutRequest(BusinessTestBase): - def test_slot_behaviour(self, business_opening_hours): - inst = business_opening_hours - for attr in inst.__slots__: - assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - - def test_to_dict(self, business_opening_hours): - boh_dict = business_opening_hours.to_dict() - assert isinstance(boh_dict, dict) - assert boh_dict["time_zone_name"] == self.time_zone_name - assert boh_dict["opening_hours"] == [opening.to_dict() for opening in self.opening_hours] - - def test_de_json(self): - json_dict = { - "time_zone_name": self.time_zone_name, - "opening_hours": [opening.to_dict() for opening in self.opening_hours], - } - boh = BusinessOpeningHours.de_json(json_dict, None) - assert boh.time_zone_name == self.time_zone_name - assert boh.opening_hours == tuple(self.opening_hours) - assert boh.api_kwargs == {} - assert isinstance(boh, BusinessOpeningHours) - - def test_equality(self): - boh1 = BusinessOpeningHours(self.time_zone_name, self.opening_hours) - boh2 = BusinessOpeningHours(self.time_zone_name, self.opening_hours) - boh3 = BusinessOpeningHours("Other/Timezone", self.opening_hours) - - assert boh1 == boh2 - assert hash(boh1) == hash(boh2) - assert boh1 is not boh2 - - assert boh1 != boh3 - assert hash(boh1) != hash(boh3) diff --git a/tests/test_business_classes.py b/tests/test_business_classes.py new file mode 100644 index 00000000000..8994243d1e6 --- /dev/null +++ b/tests/test_business_classes.py @@ -0,0 +1,781 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm +from zoneinfo import ZoneInfo + +import pytest + +from telegram import ( + BusinessConnection, + BusinessIntro, + BusinessLocation, + BusinessMessagesDeleted, + BusinessOpeningHours, + BusinessOpeningHoursInterval, + Chat, + Location, + Sticker, + User, +) +from telegram._business import BusinessBotRights +from telegram._utils.datetime import UTC, to_timestamp +from tests.auxil.slots import mro_slots + + +class BusinessTestBase: + id_ = "123" + user = User(123, "test_user", False) + user_chat_id = 123 + date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + can_change_gift_settings = True + can_convert_gifts_to_stars = True + can_delete_all_messages = True + can_delete_sent_messages = True + can_edit_bio = True + can_edit_name = True + can_edit_profile_photo = True + can_edit_username = True + can_manage_stories = True + can_read_messages = True + can_reply = True + can_transfer_and_upgrade_gifts = True + can_transfer_stars = True + can_view_gifts_and_stars = True + is_enabled = True + message_ids = (123, 321) + business_connection_id = "123" + chat = Chat(123, "test_chat") + title = "Business Title" + message = "Business description" + sticker = Sticker("sticker_id", "unique_id", 50, 50, True, False, Sticker.REGULAR) + address = "address" + location = Location(-23.691288, 46.788279) + opening_minute = 0 + closing_minute = 60 + time_zone_name = "Country/City" + opening_hours = [ + BusinessOpeningHoursInterval(opening, opening + 60) for opening in (0, 24 * 60) + ] + + +@pytest.fixture(scope="module") +def business_bot_rights(): + return BusinessBotRights( + can_change_gift_settings=BusinessTestBase.can_change_gift_settings, + can_convert_gifts_to_stars=BusinessTestBase.can_convert_gifts_to_stars, + can_delete_all_messages=BusinessTestBase.can_delete_all_messages, + can_delete_sent_messages=BusinessTestBase.can_delete_sent_messages, + can_edit_bio=BusinessTestBase.can_edit_bio, + can_edit_name=BusinessTestBase.can_edit_name, + can_edit_profile_photo=BusinessTestBase.can_edit_profile_photo, + can_edit_username=BusinessTestBase.can_edit_username, + can_manage_stories=BusinessTestBase.can_manage_stories, + can_read_messages=BusinessTestBase.can_read_messages, + can_reply=BusinessTestBase.can_reply, + can_transfer_and_upgrade_gifts=BusinessTestBase.can_transfer_and_upgrade_gifts, + can_transfer_stars=BusinessTestBase.can_transfer_stars, + can_view_gifts_and_stars=BusinessTestBase.can_view_gifts_and_stars, + ) + + +@pytest.fixture(scope="module") +def business_connection(business_bot_rights): + return BusinessConnection( + BusinessTestBase.id_, + BusinessTestBase.user, + BusinessTestBase.user_chat_id, + BusinessTestBase.date, + BusinessTestBase.is_enabled, + rights=business_bot_rights, + ) + + +@pytest.fixture(scope="module") +def business_messages_deleted(): + return BusinessMessagesDeleted( + BusinessTestBase.business_connection_id, + BusinessTestBase.chat, + BusinessTestBase.message_ids, + ) + + +@pytest.fixture(scope="module") +def business_intro(): + return BusinessIntro( + BusinessTestBase.title, + BusinessTestBase.message, + BusinessTestBase.sticker, + ) + + +@pytest.fixture(scope="module") +def business_location(): + return BusinessLocation( + BusinessTestBase.address, + BusinessTestBase.location, + ) + + +@pytest.fixture(scope="module") +def business_opening_hours_interval(): + return BusinessOpeningHoursInterval( + BusinessTestBase.opening_minute, + BusinessTestBase.closing_minute, + ) + + +@pytest.fixture(scope="module") +def business_opening_hours(): + return BusinessOpeningHours( + BusinessTestBase.time_zone_name, + BusinessTestBase.opening_hours, + ) + + +class TestBusinessBotRightsWithoutRequest(BusinessTestBase): + def test_slot_behaviour(self, business_bot_rights): + inst = business_bot_rights + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_to_dict(self, business_bot_rights): + rights_dict = business_bot_rights.to_dict() + + assert isinstance(rights_dict, dict) + assert rights_dict["can_reply"] is self.can_reply + assert rights_dict["can_read_messages"] is self.can_read_messages + assert rights_dict["can_delete_sent_messages"] is self.can_delete_sent_messages + assert rights_dict["can_delete_all_messages"] is self.can_delete_all_messages + assert rights_dict["can_edit_name"] is self.can_edit_name + assert rights_dict["can_edit_bio"] is self.can_edit_bio + assert rights_dict["can_edit_profile_photo"] is self.can_edit_profile_photo + assert rights_dict["can_edit_username"] is self.can_edit_username + assert rights_dict["can_change_gift_settings"] is self.can_change_gift_settings + assert rights_dict["can_view_gifts_and_stars"] is self.can_view_gifts_and_stars + assert rights_dict["can_convert_gifts_to_stars"] is self.can_convert_gifts_to_stars + assert rights_dict["can_transfer_and_upgrade_gifts"] is self.can_transfer_and_upgrade_gifts + assert rights_dict["can_transfer_stars"] is self.can_transfer_stars + assert rights_dict["can_manage_stories"] is self.can_manage_stories + + def test_de_json(self): + json_dict = { + "can_reply": self.can_reply, + "can_read_messages": self.can_read_messages, + "can_delete_sent_messages": self.can_delete_sent_messages, + "can_delete_all_messages": self.can_delete_all_messages, + "can_edit_name": self.can_edit_name, + "can_edit_bio": self.can_edit_bio, + "can_edit_profile_photo": self.can_edit_profile_photo, + "can_edit_username": self.can_edit_username, + "can_change_gift_settings": self.can_change_gift_settings, + "can_view_gifts_and_stars": self.can_view_gifts_and_stars, + "can_convert_gifts_to_stars": self.can_convert_gifts_to_stars, + "can_transfer_and_upgrade_gifts": self.can_transfer_and_upgrade_gifts, + "can_transfer_stars": self.can_transfer_stars, + "can_manage_stories": self.can_manage_stories, + } + + rights = BusinessBotRights.de_json(json_dict, None) + assert rights.can_reply is self.can_reply + assert rights.can_read_messages is self.can_read_messages + assert rights.can_delete_sent_messages is self.can_delete_sent_messages + assert rights.can_delete_all_messages is self.can_delete_all_messages + assert rights.can_edit_name is self.can_edit_name + assert rights.can_edit_bio is self.can_edit_bio + assert rights.can_edit_profile_photo is self.can_edit_profile_photo + assert rights.can_edit_username is self.can_edit_username + assert rights.can_change_gift_settings is self.can_change_gift_settings + assert rights.can_view_gifts_and_stars is self.can_view_gifts_and_stars + assert rights.can_convert_gifts_to_stars is self.can_convert_gifts_to_stars + assert rights.can_transfer_and_upgrade_gifts is self.can_transfer_and_upgrade_gifts + assert rights.can_transfer_stars is self.can_transfer_stars + assert rights.can_manage_stories is self.can_manage_stories + assert rights.api_kwargs == {} + assert isinstance(rights, BusinessBotRights) + + def test_equality(self): + rights1 = BusinessBotRights( + can_reply=self.can_reply, + ) + + rights2 = BusinessBotRights( + can_reply=True, + ) + + rights3 = BusinessBotRights( + can_reply=True, + can_read_messages=self.can_read_messages, + ) + + assert rights1 == rights2 + assert hash(rights1) == hash(rights2) + assert rights1 is not rights2 + + assert rights1 != rights3 + assert hash(rights1) != hash(rights3) + + +class TestBusinessConnectionWithoutRequest(BusinessTestBase): + def test_slots(self, business_connection): + bc = business_connection + for attr in bc.__slots__: + assert getattr(bc, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(bc)) == len(set(mro_slots(bc))), "duplicate slot" + + def test_de_json(self, business_bot_rights): + json_dict = { + "id": self.id_, + "user": self.user.to_dict(), + "user_chat_id": self.user_chat_id, + "date": to_timestamp(self.date), + "is_enabled": self.is_enabled, + "rights": business_bot_rights.to_dict(), + } + bc = BusinessConnection.de_json(json_dict, None) + assert bc.id == self.id_ + assert bc.user == self.user + assert bc.user_chat_id == self.user_chat_id + assert bc.date == self.date + assert bc.is_enabled == self.is_enabled + assert bc.rights == business_bot_rights + assert bc.api_kwargs == {} + assert isinstance(bc, BusinessConnection) + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot, business_bot_rights): + json_dict = { + "id": self.id_, + "user": self.user.to_dict(), + "user_chat_id": self.user_chat_id, + "date": to_timestamp(self.date), + "is_enabled": self.is_enabled, + "rights": business_bot_rights.to_dict(), + } + chat_bot = BusinessConnection.de_json(json_dict, offline_bot) + chat_bot_raw = BusinessConnection.de_json(json_dict, raw_bot) + chat_bot_tz = BusinessConnection.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing tzinfo objects is not reliable + date_offset = chat_bot_tz.date.utcoffset() + date_offset_tz = tz_bot.defaults.tzinfo.utcoffset(chat_bot_tz.date.replace(tzinfo=None)) + + assert chat_bot.date.tzinfo == UTC + assert chat_bot_raw.date.tzinfo == UTC + assert date_offset_tz == date_offset + + def test_to_dict(self, business_connection, business_bot_rights): + bc_dict = business_connection.to_dict() + assert isinstance(bc_dict, dict) + assert bc_dict["id"] == self.id_ + assert bc_dict["user"] == self.user.to_dict() + assert bc_dict["user_chat_id"] == self.user_chat_id + assert bc_dict["date"] == to_timestamp(self.date) + assert bc_dict["is_enabled"] == self.is_enabled + assert bc_dict["rights"] == business_bot_rights.to_dict() + + def test_equality(self, business_bot_rights): + bc1 = BusinessConnection( + self.id_, + self.user, + self.user_chat_id, + self.date, + self.is_enabled, + rights=business_bot_rights, + ) + bc2 = BusinessConnection( + self.id_, + self.user, + self.user_chat_id, + self.date, + self.is_enabled, + rights=business_bot_rights, + ) + bc3 = BusinessConnection( + "321", + self.user, + self.user_chat_id, + self.date, + self.is_enabled, + rights=business_bot_rights, + ) + bc4 = BusinessConnection( + self.id_, + self.user, + self.user_chat_id, + self.date, + self.is_enabled, + rights=BusinessBotRights(), + ) + + assert bc1 == bc2 + assert hash(bc1) == hash(bc2) + + assert bc1 != bc3 + assert hash(bc1) != hash(bc3) + + assert bc1 != bc4 + assert hash(bc1) != hash(bc4) + + +class TestBusinessMessagesDeleted(BusinessTestBase): + def test_slots(self, business_messages_deleted): + bmd = business_messages_deleted + for attr in bmd.__slots__: + assert getattr(bmd, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(bmd)) == len(set(mro_slots(bmd))), "duplicate slot" + + def test_to_dict(self, business_messages_deleted): + bmd_dict = business_messages_deleted.to_dict() + assert isinstance(bmd_dict, dict) + assert bmd_dict["message_ids"] == list(self.message_ids) + assert bmd_dict["business_connection_id"] == self.business_connection_id + assert bmd_dict["chat"] == self.chat.to_dict() + + def test_de_json(self): + json_dict = { + "business_connection_id": self.business_connection_id, + "chat": self.chat.to_dict(), + "message_ids": self.message_ids, + } + bmd = BusinessMessagesDeleted.de_json(json_dict, None) + assert bmd.business_connection_id == self.business_connection_id + assert bmd.chat == self.chat + assert bmd.message_ids == self.message_ids + assert bmd.api_kwargs == {} + assert isinstance(bmd, BusinessMessagesDeleted) + + def test_equality(self): + bmd1 = BusinessMessagesDeleted(self.business_connection_id, self.chat, self.message_ids) + bmd2 = BusinessMessagesDeleted(self.business_connection_id, self.chat, self.message_ids) + bmd3 = BusinessMessagesDeleted("1", Chat(4, "random"), [321, 123]) + + assert bmd1 == bmd2 + assert hash(bmd1) == hash(bmd2) + + assert bmd1 != bmd3 + assert hash(bmd1) != hash(bmd3) + + +class TestBusinessIntroWithoutRequest(BusinessTestBase): + def test_slot_behaviour(self, business_intro): + intro = business_intro + for attr in intro.__slots__: + assert getattr(intro, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(intro)) == len(set(mro_slots(intro))), "duplicate slot" + + def test_to_dict(self, business_intro): + intro_dict = business_intro.to_dict() + assert isinstance(intro_dict, dict) + assert intro_dict["title"] == self.title + assert intro_dict["message"] == self.message + assert intro_dict["sticker"] == self.sticker.to_dict() + + def test_de_json(self): + json_dict = { + "title": self.title, + "message": self.message, + "sticker": self.sticker.to_dict(), + } + intro = BusinessIntro.de_json(json_dict, None) + assert intro.title == self.title + assert intro.message == self.message + assert intro.sticker == self.sticker + assert intro.api_kwargs == {} + assert isinstance(intro, BusinessIntro) + + def test_equality(self): + intro1 = BusinessIntro(self.title, self.message, self.sticker) + intro2 = BusinessIntro(self.title, self.message, self.sticker) + intro3 = BusinessIntro("Other Business", self.message, self.sticker) + + assert intro1 == intro2 + assert hash(intro1) == hash(intro2) + assert intro1 is not intro2 + + assert intro1 != intro3 + assert hash(intro1) != hash(intro3) + + +class TestBusinessLocationWithoutRequest(BusinessTestBase): + def test_slot_behaviour(self, business_location): + inst = business_location + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_to_dict(self, business_location): + blc_dict = business_location.to_dict() + assert isinstance(blc_dict, dict) + assert blc_dict["address"] == self.address + assert blc_dict["location"] == self.location.to_dict() + + def test_de_json(self): + json_dict = { + "address": self.address, + "location": self.location.to_dict(), + } + blc = BusinessLocation.de_json(json_dict, None) + assert blc.address == self.address + assert blc.location == self.location + assert blc.api_kwargs == {} + assert isinstance(blc, BusinessLocation) + + def test_equality(self): + blc1 = BusinessLocation(self.address, self.location) + blc2 = BusinessLocation(self.address, self.location) + blc3 = BusinessLocation("Other Address", self.location) + + assert blc1 == blc2 + assert hash(blc1) == hash(blc2) + assert blc1 is not blc2 + + assert blc1 != blc3 + assert hash(blc1) != hash(blc3) + + +class TestBusinessOpeningHoursIntervalWithoutRequest(BusinessTestBase): + def test_slot_behaviour(self, business_opening_hours_interval): + inst = business_opening_hours_interval + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_to_dict(self, business_opening_hours_interval): + bohi_dict = business_opening_hours_interval.to_dict() + assert isinstance(bohi_dict, dict) + assert bohi_dict["opening_minute"] == self.opening_minute + assert bohi_dict["closing_minute"] == self.closing_minute + + def test_de_json(self): + json_dict = { + "opening_minute": self.opening_minute, + "closing_minute": self.closing_minute, + } + bohi = BusinessOpeningHoursInterval.de_json(json_dict, None) + assert bohi.opening_minute == self.opening_minute + assert bohi.closing_minute == self.closing_minute + assert bohi.api_kwargs == {} + assert isinstance(bohi, BusinessOpeningHoursInterval) + + def test_equality(self): + bohi1 = BusinessOpeningHoursInterval(self.opening_minute, self.closing_minute) + bohi2 = BusinessOpeningHoursInterval(self.opening_minute, self.closing_minute) + bohi3 = BusinessOpeningHoursInterval(61, 100) + + assert bohi1 == bohi2 + assert hash(bohi1) == hash(bohi2) + assert bohi1 is not bohi2 + + assert bohi1 != bohi3 + assert hash(bohi1) != hash(bohi3) + + @pytest.mark.parametrize( + ("opening_minute", "expected"), + [ # openings per docstring + (8 * 60, (0, 8, 0)), + (24 * 60, (1, 0, 0)), + (6 * 24 * 60, (6, 0, 0)), + ], + ) + def test_opening_time(self, opening_minute, expected): + bohi = BusinessOpeningHoursInterval(opening_minute, -0) + + opening_time = bohi.opening_time + assert opening_time == expected + + cached = bohi.opening_time + assert cached is opening_time + + @pytest.mark.parametrize( + ("closing_minute", "expected"), + [ # closings per docstring + (20 * 60 + 30, (0, 20, 30)), + (2 * 24 * 60 - 1, (1, 23, 59)), + (7 * 24 * 60 - 2, (6, 23, 58)), + ], + ) + def test_closing_time(self, closing_minute, expected): + bohi = BusinessOpeningHoursInterval(-0, closing_minute) + + closing_time = bohi.closing_time + assert closing_time == expected + + cached = bohi.closing_time + assert cached is closing_time + + +class TestBusinessOpeningHoursWithoutRequest(BusinessTestBase): + def test_slot_behaviour(self, business_opening_hours): + inst = business_opening_hours + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_to_dict(self, business_opening_hours): + boh_dict = business_opening_hours.to_dict() + assert isinstance(boh_dict, dict) + assert boh_dict["time_zone_name"] == self.time_zone_name + assert boh_dict["opening_hours"] == [opening.to_dict() for opening in self.opening_hours] + + def test_de_json(self): + json_dict = { + "time_zone_name": self.time_zone_name, + "opening_hours": [opening.to_dict() for opening in self.opening_hours], + } + boh = BusinessOpeningHours.de_json(json_dict, None) + assert boh.time_zone_name == self.time_zone_name + assert boh.opening_hours == tuple(self.opening_hours) + assert boh.api_kwargs == {} + assert isinstance(boh, BusinessOpeningHours) + + def test_equality(self): + boh1 = BusinessOpeningHours(self.time_zone_name, self.opening_hours) + boh2 = BusinessOpeningHours(self.time_zone_name, self.opening_hours) + boh3 = BusinessOpeningHours("Other/Timezone", self.opening_hours) + + assert boh1 == boh2 + assert hash(boh1) == hash(boh2) + assert boh1 is not boh2 + + assert boh1 != boh3 + assert hash(boh1) != hash(boh3) + + class TestBusinessOpeningHoursGetOpeningHoursForDayWithoutRequest: + @pytest.fixture + def sample_opening_hours(self): + # Monday 8am-8:30pm (480-1230) + # Tuesday 24 hours (1440-2879) + # Sunday 12am-11:58pm (8640-10078) + intervals = [ + BusinessOpeningHoursInterval(480, 1230), # Monday 8am-8:30pm + BusinessOpeningHoursInterval(1440, 2879), # Tuesday 24 hours + BusinessOpeningHoursInterval(8640, 10078), # Sunday 12am-11:58pm + ] + return BusinessOpeningHours(time_zone_name="UTC", opening_hours=intervals) + + def test_monday_opening_hours(self, sample_opening_hours): + # Test for Monday + test_date = dtm.date(2023, 11, 6) # Monday + time_zone = ZoneInfo("UTC") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 6, 8, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 6, 20, 30, tzinfo=time_zone), + ), + ) + + assert result == expected + + def test_tuesday_24_hours(self, sample_opening_hours): + # Test for Tuesday (24 hours) + test_date = dtm.date(2023, 11, 7) # Tuesday + time_zone = ZoneInfo("UTC") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 7, 0, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 7, 23, 59, tzinfo=time_zone), + ), + ) + + assert result == expected + + def test_sunday_opening_hours(self, sample_opening_hours): + # Test for Sunday + test_date = dtm.date(2023, 11, 12) # Sunday + time_zone = ZoneInfo("UTC") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 12, 0, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 12, 23, 58, tzinfo=time_zone), + ), + ) + + assert result == expected + + def test_day_with_no_opening_hours(self, sample_opening_hours): + # Test for Wednesday (no opening hours defined) + test_date = dtm.date(2023, 11, 8) # Wednesday + time_zone = ZoneInfo("UTC") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + assert result == () + + def test_multiple_intervals_same_day(self): + # Test with multiple intervals on the same day + intervals = [ + # unsorted on purpose to check that the sorting works (even though this is + # currently undocumented behaviour) + BusinessOpeningHoursInterval(900, 1230), # Monday 3pm-8:30pm + BusinessOpeningHoursInterval(480, 720), # Monday 8am-12pm + ] + opening_hours = BusinessOpeningHours(time_zone_name="UTC", opening_hours=intervals) + + test_date = dtm.date(2023, 11, 6) # Monday + time_zone = ZoneInfo("UTC") + result = opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 6, 8, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 6, 12, 0, tzinfo=time_zone), + ), + ( + dtm.datetime(2023, 11, 6, 15, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 6, 20, 30, tzinfo=time_zone), + ), + ) + + assert result == expected + + @pytest.mark.parametrize("input_type", [str, ZoneInfo]) + def test_timezone_conversion(self, sample_opening_hours, input_type): + # Test that timezone is properly applied + test_date = dtm.date(2023, 11, 6) # Monday + time_zone = input_type("America/New_York") + zone_info = ZoneInfo("America/New_York") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 6, 3, 0, tzinfo=zone_info), + dtm.datetime(2023, 11, 6, 15, 30, tzinfo=zone_info), + ), + ) + + assert result == expected + assert result[0][0].tzinfo == zone_info + assert result[0][1].tzinfo == zone_info + + def test_timezone_conversation_changing_date(self): + # test for the edge case where the returned time is on a different date in the target + # timezone than in the business timezone + intervals = [ + BusinessOpeningHoursInterval(60, 120), # Monday 1am-2am UTC + ] + opening_hours = BusinessOpeningHours(time_zone_name="UTC", opening_hours=intervals) + test_date = dtm.date(2023, 11, 6) # Monday + time_zone = ZoneInfo("America/New_York") # UTC-5, so 1am UTC is 8pm previous day + result = opening_hours.get_opening_hours_for_day(test_date, time_zone) + expected = ( + ( + dtm.datetime(2023, 11, 5, 20, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 5, 21, 0, tzinfo=time_zone), + ), + ) + assert result == expected + + def test_no_timezone_provided(self, sample_opening_hours): + # Test when no timezone is provided + test_date = dtm.date(2023, 11, 6) # Monday + result = sample_opening_hours.get_opening_hours_for_day(test_date) + + expected = ( + ( + dtm.datetime( + 2023, + 11, + 6, + 8, + 0, + tzinfo=ZoneInfo(sample_opening_hours.time_zone_name), + ), + dtm.datetime( + 2023, + 11, + 6, + 20, + 30, + tzinfo=ZoneInfo(sample_opening_hours.time_zone_name), + ), + ), + ) + + assert result == expected + + class TestBusinessOpeningHoursIsOpenWithoutRequest: + @pytest.fixture + def sample_opening_hours(self): + # Monday 8am-8:30pm (480-1230) + # Tuesday 24 hours (1440-2879) + # Sunday 12am-11:59pm (8640-10079) + intervals = [ + BusinessOpeningHoursInterval(480, 1230), # Monday 8am-8:30pm UTC + BusinessOpeningHoursInterval(1440, 2879), # Tuesday 24 hours UTC + BusinessOpeningHoursInterval(8640, 10079), # Sunday 12am-11:59pm UTC + ] + return BusinessOpeningHours(time_zone_name="UTC", opening_hours=intervals) + + def test_is_open_during_business_hours(self, sample_opening_hours): + # Monday 10am UTC (within 8am-8:30pm) + dt = dtm.datetime(2023, 11, 6, 10, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is True + + def test_is_open_at_opening_time(self, sample_opening_hours): + # Monday exactly 8am UTC + dt = dtm.datetime(2023, 11, 6, 8, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is True + + def test_is_closed_at_closing_time(self, sample_opening_hours): + # Monday exactly 8:30pm UTC (closing time is exclusive) + dt = dtm.datetime(2023, 11, 6, 20, 30, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is False + + def test_is_closed_outside_business_hours(self, sample_opening_hours): + # Monday 7am UTC (before opening) + dt = dtm.datetime(2023, 11, 6, 7, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is False + + def test_is_open_24h_day(self, sample_opening_hours): + # Tuesday 3am UTC (24h opening) + dt = dtm.datetime(2023, 11, 7, 3, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is True + + def test_is_closed_on_day_with_no_hours(self, sample_opening_hours): + # Wednesday (no opening hours) + dt = dtm.datetime(2023, 11, 8, 12, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is False + + def test_timezone_conversion(self, sample_opening_hours): + # Monday 5am EDT is 10am UTC (should be open) + dt = dtm.datetime(2023, 11, 6, 5, 0, tzinfo=ZoneInfo("America/New_York")) + assert sample_opening_hours.is_open(dt) is True + + # Monday 2am EDT is 7am UTC (should be closed) + dt = dtm.datetime(2023, 11, 6, 2, 0, tzinfo=ZoneInfo("America/New_York")) + assert sample_opening_hours.is_open(dt) is False + + def test_naive_datetime_uses_business_timezone(self, sample_opening_hours): + # Naive datetime - should be interpreted as UTC (business timezone) + dt = dtm.datetime(2023, 11, 6, 10, 0) # 10am naive + assert sample_opening_hours.is_open(dt) is True + + def test_boundary_conditions(self, sample_opening_hours): + # Sunday 11:58pm UTC (should be open) + dt = dtm.datetime(2023, 11, 12, 23, 58, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is True + + # Sunday 11:59pm UTC (should be closed) + dt = dtm.datetime(2023, 11, 12, 23, 59, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is False diff --git a/tests/test_business_methods.py b/tests/test_business_methods.py new file mode 100644 index 00000000000..43a666eb07e --- /dev/null +++ b/tests/test_business_methods.py @@ -0,0 +1,847 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + +import pytest + +from telegram import ( + BusinessBotRights, + BusinessConnection, + Chat, + InputProfilePhotoStatic, + InputStoryContentPhoto, + MessageEntity, + StarAmount, + Story, + StoryAreaTypeLink, + StoryAreaTypeUniqueGift, + User, +) +from telegram._files._inputstorycontent import InputStoryContentVideo +from telegram._files.sticker import Sticker +from telegram._gifts import AcceptedGiftTypes, Gift +from telegram._inline.inlinekeyboardbutton import InlineKeyboardButton +from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup +from telegram._inputchecklist import InputChecklist, InputChecklistTask +from telegram._message import Message +from telegram._ownedgift import OwnedGiftRegular, OwnedGifts +from telegram._reply import ReplyParameters +from telegram._utils.datetime import UTC +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram.constants import InputProfilePhotoType, InputStoryContentType +from tests.auxil.files import data_file + + +class BusinessMethodsTestBase: + bci = "42" + + +class TestBusinessMethodsWithoutRequest(BusinessMethodsTestBase): + async def test_get_business_connection(self, offline_bot, monkeypatch): + user = User(1, "first", False) + user_chat_id = 1 + date = dtm.datetime.utcnow() + rights = BusinessBotRights(can_reply=True) + is_enabled = True + bc = BusinessConnection( + self.bci, + user, + user_chat_id, + date, + is_enabled, + rights=rights, + ).to_json() + + async def do_request(*args, **kwargs): + data = kwargs.get("request_data") + obj = data.parameters.get("business_connection_id") + if obj == self.bci: + return 200, f'{{"ok": true, "result": {bc}}}'.encode() + return 400, b'{"ok": false, "result": []}' + + monkeypatch.setattr(offline_bot.request, "do_request", do_request) + obj = await offline_bot.get_business_connection(business_connection_id=self.bci) + assert isinstance(obj, BusinessConnection) + + @pytest.mark.parametrize("bool_param", [True, False, None]) + async def test_get_business_account_gifts(self, offline_bot, monkeypatch, bool_param): + offset = 50 + limit = 50 + owned_gifts = OwnedGifts( + total_count=1, + gifts=[ + OwnedGiftRegular( + gift=Gift( + id="id1", + sticker=Sticker( + "file_id", "file_unique_id", 512, 512, False, False, "regular" + ), + star_count=5, + ), + send_date=dtm.datetime.now(tz=UTC).replace(microsecond=0), + owned_gift_id="some_id_1", + ) + ], + ).to_json() + + async def do_request_and_make_assertions(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("exclude_unsaved") is bool_param + assert data.get("exclude_saved") is bool_param + assert data.get("exclude_unlimited") is bool_param + assert data.get("exclude_limited_upgradable") is bool_param + assert data.get("exclude_limited_non_upgradable") is bool_param + assert data.get("exclude_unique") is bool_param + assert data.get("exclude_from_blockchain") is bool_param + assert data.get("sort_by_price") is bool_param + assert data.get("offset") == offset + assert data.get("limit") == limit + + return 200, f'{{"ok": true, "result": {owned_gifts}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", do_request_and_make_assertions) + obj = await offline_bot.get_business_account_gifts( + business_connection_id=self.bci, + exclude_unsaved=bool_param, + exclude_saved=bool_param, + exclude_unlimited=bool_param, + exclude_limited_upgradable=bool_param, + exclude_limited_non_upgradable=bool_param, + exclude_unique=bool_param, + exclude_from_blockchain=bool_param, + sort_by_price=bool_param, + offset=offset, + limit=limit, + ) + assert isinstance(obj, OwnedGifts) + + async def test_get_business_account_star_balance(self, offline_bot, monkeypatch): + star_amount_json = StarAmount(amount=100, nanostar_amount=356).to_json() + + async def do_request(*args, **kwargs): + data = kwargs.get("request_data") + obj = data.parameters.get("business_connection_id") + if obj == self.bci: + return 200, f'{{"ok": true, "result": {star_amount_json}}}'.encode() + return 400, b'{"ok": false, "result": []}' + + monkeypatch.setattr(offline_bot.request, "do_request", do_request) + obj = await offline_bot.get_business_account_star_balance(business_connection_id=self.bci) + assert isinstance(obj, StarAmount) + + async def test_read_business_message(self, offline_bot, monkeypatch): + chat_id = 43 + message_id = 44 + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("chat_id") == chat_id + assert data.get("message_id") == message_id + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.read_business_message( + business_connection_id=self.bci, chat_id=chat_id, message_id=message_id + ) + + async def test_delete_business_messages(self, offline_bot, monkeypatch): + message_ids = [1, 2, 3] + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("message_ids") == message_ids + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.delete_business_messages( + business_connection_id=self.bci, message_ids=message_ids + ) + + @pytest.mark.parametrize("last_name", [None, "last_name"]) + async def test_set_business_account_name(self, offline_bot, monkeypatch, last_name): + first_name = "Test Business Account" + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("first_name") == first_name + assert data.get("last_name") == last_name + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_business_account_name( + business_connection_id=self.bci, first_name=first_name, last_name=last_name + ) + + @pytest.mark.parametrize("username", ["username", None]) + async def test_set_business_account_username(self, offline_bot, monkeypatch, username): + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("username") == username + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_business_account_username( + business_connection_id=self.bci, username=username + ) + + @pytest.mark.parametrize("bio", ["bio", None]) + async def test_set_business_account_bio(self, offline_bot, monkeypatch, bio): + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("bio") == bio + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_business_account_bio(business_connection_id=self.bci, bio=bio) + + async def test_set_business_account_gift_settings(self, offline_bot, monkeypatch): + show_gift_button = True + accepted_gift_types = AcceptedGiftTypes(True, True, True, True, True) + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").json_parameters + assert data.get("business_connection_id") == self.bci + assert data.get("show_gift_button") == "true" + assert data.get("accepted_gift_types") == accepted_gift_types.to_json() + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_business_account_gift_settings( + business_connection_id=self.bci, + show_gift_button=show_gift_button, + accepted_gift_types=accepted_gift_types, + ) + + async def test_convert_gift_to_stars(self, offline_bot, monkeypatch): + owned_gift_id = "some_id" + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("owned_gift_id") == owned_gift_id + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.convert_gift_to_stars( + business_connection_id=self.bci, + owned_gift_id=owned_gift_id, + ) + + @pytest.mark.parametrize("keep_original_details", [True, None]) + @pytest.mark.parametrize("star_count", [100, None]) + async def test_upgrade_gift(self, offline_bot, monkeypatch, keep_original_details, star_count): + owned_gift_id = "some_id" + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("owned_gift_id") == owned_gift_id + assert data.get("keep_original_details") is keep_original_details + assert data.get("star_count") == star_count + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.upgrade_gift( + business_connection_id=self.bci, + owned_gift_id=owned_gift_id, + keep_original_details=keep_original_details, + star_count=star_count, + ) + + @pytest.mark.parametrize("star_count", [100, None]) + async def test_transfer_gift(self, offline_bot, monkeypatch, star_count): + owned_gift_id = "some_id" + new_owner_chat_id = 123 + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("owned_gift_id") == owned_gift_id + assert data.get("new_owner_chat_id") == new_owner_chat_id + assert data.get("star_count") == star_count + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.transfer_gift( + business_connection_id=self.bci, + owned_gift_id=owned_gift_id, + new_owner_chat_id=new_owner_chat_id, + star_count=star_count, + ) + + async def test_transfer_business_account_stars(self, offline_bot, monkeypatch): + star_count = 100 + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("star_count") == star_count + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.transfer_business_account_stars( + business_connection_id=self.bci, + star_count=star_count, + ) + + @pytest.mark.parametrize("is_public", [True, False, None, DEFAULT_NONE]) + async def test_set_business_account_profile_photo(self, offline_bot, monkeypatch, is_public): + async def make_assertion(*args, **kwargs): + request_data = kwargs.get("request_data") + params = request_data.parameters + assert params.get("business_connection_id") == self.bci + if is_public is DEFAULT_NONE: + assert "is_public" not in params + else: + assert params.get("is_public") == is_public + + assert (photo_dict := params.get("photo")).get("type") == InputProfilePhotoType.STATIC + assert (photo_attach := photo_dict["photo"]).startswith("attach://") + assert isinstance( + request_data.multipart_data.get(photo_attach.removeprefix("attach://")), tuple + ) + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "photo": InputProfilePhotoStatic( + photo=data_file("telegram.jpg").read_bytes(), + ), + } + if is_public is not DEFAULT_NONE: + kwargs["is_public"] = is_public + + assert await offline_bot.set_business_account_profile_photo(**kwargs) + + async def test_set_business_account_profile_photo_local_file(self, offline_bot, monkeypatch): + async def make_assertion(*args, **kwargs): + request_data = kwargs.get("request_data") + params = request_data.parameters + assert params.get("business_connection_id") == self.bci + + assert (photo_dict := params.get("photo")).get("type") == InputProfilePhotoType.STATIC + assert photo_dict["photo"] == data_file("telegram.jpg").as_uri() + assert not request_data.multipart_data + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "photo": InputProfilePhotoStatic( + photo=data_file("telegram.jpg"), + ), + } + + assert await offline_bot.set_business_account_profile_photo(**kwargs) + + @pytest.mark.parametrize("is_public", [True, False, None, DEFAULT_NONE]) + async def test_remove_business_account_profile_photo( + self, offline_bot, monkeypatch, is_public + ): + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + if is_public is DEFAULT_NONE: + assert "is_public" not in data + else: + assert data.get("is_public") == is_public + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + kwargs = {"business_connection_id": self.bci} + if is_public is not DEFAULT_NONE: + kwargs["is_public"] = is_public + + assert await offline_bot.remove_business_account_profile_photo(**kwargs) + + @pytest.mark.parametrize("active_period", [dtm.timedelta(seconds=30), 30]) + async def test_post_story_all_args(self, offline_bot, monkeypatch, active_period): + content = InputStoryContentPhoto(photo=data_file("telegram.jpg").read_bytes()) + caption = "test caption" + caption_entities = [ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 11), + ] + parse_mode = "Markdown" + areas = [StoryAreaTypeLink("http_url"), StoryAreaTypeUniqueGift("unique_gift_name")] + post_to_chat_page = True + protect_content = True + json_story = Story(chat=Chat(123, "private"), id=123).to_json() + + async def do_request_and_make_assertions(*args, **kwargs): + request_data = kwargs.get("request_data") + params = kwargs.get("request_data").parameters + assert params.get("business_connection_id") == self.bci + assert params.get("active_period") == 30 + assert params.get("caption") == caption + assert params.get("caption_entities") == [e.to_dict() for e in caption_entities] + assert params.get("parse_mode") == parse_mode + assert params.get("areas") == [area.to_dict() for area in areas] + assert params.get("post_to_chat_page") is post_to_chat_page + assert params.get("protect_content") is protect_content + + assert (content_dict := params.get("content")).get( + "type" + ) == InputStoryContentType.PHOTO + assert (photo_attach := content_dict["photo"]).startswith("attach://") + assert isinstance( + request_data.multipart_data.get(photo_attach.removeprefix("attach://")), tuple + ) + + return 200, f'{{"ok": true, "result": {json_story}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", do_request_and_make_assertions) + obj = await offline_bot.post_story( + business_connection_id=self.bci, + content=content, + active_period=active_period, + caption=caption, + caption_entities=caption_entities, + parse_mode=parse_mode, + areas=areas, + post_to_chat_page=post_to_chat_page, + protect_content=protect_content, + ) + assert isinstance(obj, Story) + + @pytest.mark.parametrize("active_period", [dtm.timedelta(seconds=30), 30]) + async def test_post_story_local_file(self, offline_bot, monkeypatch, active_period): + json_story = Story(chat=Chat(123, "private"), id=123).to_json() + + async def make_assertion(*args, **kwargs): + request_data = kwargs.get("request_data") + params = request_data.parameters + assert params.get("business_connection_id") == self.bci + + assert (content_dict := params.get("content")).get( + "type" + ) == InputStoryContentType.PHOTO + assert content_dict["photo"] == data_file("telegram.jpg").as_uri() + assert not request_data.multipart_data + + return 200, f'{{"ok": true, "result": {json_story}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "content": InputStoryContentPhoto( + photo=data_file("telegram.jpg"), + ), + "active_period": active_period, + } + + assert await offline_bot.post_story(**kwargs) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, "Markdown"), ("HTML", "HTML"), (None, None)], + ) + async def test_post_story_default_parse_mode( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("parse_mode") == expected_value + return Story(chat=Chat(123, "private"), id=123).to_dict() + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "content": InputStoryContentPhoto(photo=data_file("telegram.jpg").read_bytes()), + "active_period": dtm.timedelta(seconds=20), + "caption": "caption", + } + if passed_value is not DEFAULT_NONE: + kwargs["parse_mode"] = passed_value + + await default_bot.post_story(**kwargs) + + @pytest.mark.parametrize("default_bot", [{"protect_content": True}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, True), (False, False), (None, None)], + ) + async def test_post_story_default_protect_content( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("protect_content") == expected_value + return Story(chat=Chat(123, "private"), id=123).to_dict() + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "content": InputStoryContentPhoto(bytes("photo", encoding="utf-8")), + "active_period": dtm.timedelta(seconds=20), + } + if passed_value is not DEFAULT_NONE: + kwargs["protect_content"] = passed_value + + await default_bot.post_story(**kwargs) + + @pytest.mark.parametrize( + ("argument", "expected"), + [(4, 4), (4.0, 4), (dtm.timedelta(seconds=4), 4), (4.5, 4.5)], + ) + async def test_post_story_float_time_period( + self, offline_bot, monkeypatch, argument, expected + ): + # We test that whole number conversion works properly. Only tested here but + # relevant for some other methods too (e.g bot.set_business_account_profile_photo) + async def make_assertion(url, request_data, *args, **kwargs): + data = request_data.parameters + content = data["content"] + + assert content["duration"] == expected + assert type(content["duration"]) is type(expected) + assert content["cover_frame_timestamp"] == expected + assert type(content["cover_frame_timestamp"]) is type(expected) + + return Story(chat=Chat(123, "private"), id=123).to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "content": InputStoryContentVideo( + video=data_file("telegram.mp4"), + duration=argument, + cover_frame_timestamp=argument, + ), + "active_period": dtm.timedelta(seconds=20), + } + + assert await offline_bot.post_story(**kwargs) + + async def test_edit_story_all_args(self, offline_bot, monkeypatch): + story_id = 1234 + content = InputStoryContentPhoto(photo=data_file("telegram.jpg").read_bytes()) + caption = "test caption" + caption_entities = [ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 11), + ] + parse_mode = "Markdown" + areas = [StoryAreaTypeLink("http_url"), StoryAreaTypeUniqueGift("unique_gift_name")] + json_story = Story(chat=Chat(123, "private"), id=123).to_json() + + async def do_request_and_make_assertions(*args, **kwargs): + request_data = kwargs.get("request_data") + params = kwargs.get("request_data").parameters + assert params.get("business_connection_id") == self.bci + assert params.get("story_id") == story_id + assert params.get("caption") == caption + assert params.get("caption_entities") == [e.to_dict() for e in caption_entities] + assert params.get("parse_mode") == parse_mode + assert params.get("areas") == [area.to_dict() for area in areas] + + assert (content_dict := params.get("content")).get( + "type" + ) == InputStoryContentType.PHOTO + assert (photo_attach := content_dict["photo"]).startswith("attach://") + assert isinstance( + request_data.multipart_data.get(photo_attach.removeprefix("attach://")), tuple + ) + + return 200, f'{{"ok": true, "result": {json_story}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", do_request_and_make_assertions) + obj = await offline_bot.edit_story( + business_connection_id=self.bci, + story_id=story_id, + content=content, + caption=caption, + caption_entities=caption_entities, + parse_mode=parse_mode, + areas=areas, + ) + assert isinstance(obj, Story) + + async def test_edit_story_local_file(self, offline_bot, monkeypatch): + json_story = Story(chat=Chat(123, "private"), id=123).to_json() + + async def make_assertion(*args, **kwargs): + request_data = kwargs.get("request_data") + params = request_data.parameters + assert params.get("business_connection_id") == self.bci + + assert (content_dict := params.get("content")).get( + "type" + ) == InputStoryContentType.PHOTO + assert content_dict["photo"] == data_file("telegram.jpg").as_uri() + assert not request_data.multipart_data + + return 200, f'{{"ok": true, "result": {json_story}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "story_id": 1234, + "content": InputStoryContentPhoto( + photo=data_file("telegram.jpg"), + ), + } + + assert await offline_bot.edit_story(**kwargs) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, "Markdown"), ("HTML", "HTML"), (None, None)], + ) + async def test_edit_story_default_parse_mode( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("parse_mode") == expected_value + return Story(chat=Chat(123, "private"), id=123).to_dict() + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "story_id": 1234, + "content": InputStoryContentPhoto(photo=data_file("telegram.jpg").read_bytes()), + "caption": "caption", + } + if passed_value is not DEFAULT_NONE: + kwargs["parse_mode"] = passed_value + + await default_bot.edit_story(**kwargs) + + async def test_delete_story(self, offline_bot, monkeypatch): + story_id = 123 + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("story_id") == story_id + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.delete_story(business_connection_id=self.bci, story_id=story_id) + + async def test_send_checklist_all_args(self, offline_bot, monkeypatch): + chat_id = 123 + checklist = InputChecklist( + title="My Checklist", + tasks=[InputChecklistTask(1, "Task 1"), InputChecklistTask(2, "Task 2")], + ) + disable_notification = True + protect_content = False + message_effect_id = 42 + reply_parameters = ReplyParameters(23, chat_id, allow_sending_without_reply=True) + reply_markup = InlineKeyboardMarkup( + [[InlineKeyboardButton(text="test", callback_data="test2")]] + ) + json_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="test").to_json() + + async def make_assertions(*args, **kwargs): + params = kwargs.get("request_data").parameters + assert params.get("business_connection_id") == self.bci + assert params.get("chat_id") == chat_id + assert params.get("checklist") == checklist.to_dict() + assert params.get("disable_notification") is disable_notification + assert params.get("protect_content") is protect_content + assert params.get("message_effect_id") == message_effect_id + assert params.get("reply_parameters") == reply_parameters.to_dict() + assert params.get("reply_markup") == reply_markup.to_dict() + + return 200, f'{{"ok": true, "result": {json_message}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", make_assertions) + obj = await offline_bot.send_checklist( + business_connection_id=self.bci, + chat_id=chat_id, + checklist=checklist, + disable_notification=disable_notification, + protect_content=protect_content, + message_effect_id=message_effect_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + ) + assert isinstance(obj, Message) + + @pytest.mark.parametrize("default_bot", [{"disable_notification": True}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, True), (False, False), (None, None)], + ) + async def test_send_checklist_default_disable_notification( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("disable_notification") is expected_value + return Message(1, dtm.datetime.now(), Chat(1, ""), text="test").to_dict() + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "chat_id": 123, + "checklist": InputChecklist( + title="My Checklist", + tasks=[InputChecklistTask(1, "Task 1")], + ), + } + if passed_value is not DEFAULT_NONE: + kwargs["disable_notification"] = passed_value + + await default_bot.send_checklist(**kwargs) + + @pytest.mark.parametrize("default_bot", [{"protect_content": True}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, True), (False, False), (None, None)], + ) + async def test_send_checklist_default_protect_content( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("protect_content") is expected_value + return Message(1, dtm.datetime.now(), Chat(1, ""), text="test").to_dict() + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "chat_id": 123, + "checklist": InputChecklist( + title="My Checklist", + tasks=[InputChecklistTask(1, "Task 1")], + ), + } + if passed_value is not DEFAULT_NONE: + kwargs["protect_content"] = passed_value + + await default_bot.send_checklist(**kwargs) + + async def test_send_checklist_mutually_exclusive_reply_parameters(self, offline_bot): + """Test that reply_to_message_id and allow_sending_without_reply are mutually exclusive + with reply_parameters.""" + with pytest.raises(ValueError, match="`reply_to_message_id` and"): + await offline_bot.send_checklist( + self.bci, + 123, + InputChecklist(title="My Checklist", tasks=[InputChecklistTask(1, "Task 1")]), + reply_to_message_id=1, + reply_parameters=True, + ) + + with pytest.raises(ValueError, match="`allow_sending_without_reply` and"): + await offline_bot.send_checklist( + self.bci, + 123, + InputChecklist(title="My Checklist", tasks=[InputChecklistTask(1, "Task 1")]), + allow_sending_without_reply=True, + reply_parameters=True, + ) + + async def test_edit_message_checklist_all_args(self, offline_bot, monkeypatch): + chat_id = 123 + message_id = 45 + checklist = InputChecklist( + title="My Checklist", + tasks=[InputChecklistTask(1, "Task 1"), InputChecklistTask(2, "Task 2")], + ) + reply_markup = InlineKeyboardMarkup( + [[InlineKeyboardButton(text="test", callback_data="test2")]] + ) + json_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="test").to_json() + + async def make_assertions(*args, **kwargs): + params = kwargs.get("request_data").parameters + assert params.get("business_connection_id") == self.bci + assert params.get("chat_id") == chat_id + assert params.get("message_id") == message_id + assert params.get("checklist") == checklist.to_dict() + assert params.get("reply_markup") == reply_markup.to_dict() + + return 200, f'{{"ok": true, "result": {json_message}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", make_assertions) + obj = await offline_bot.edit_message_checklist( + business_connection_id=self.bci, + chat_id=chat_id, + message_id=message_id, + checklist=checklist, + reply_markup=reply_markup, + ) + assert isinstance(obj, Message) + + async def test_repost_story(self, offline_bot, monkeypatch): + """No way to test this without stories""" + + async def make_assertion(url, request_data, *args, **kwargs): + for param in ( + "business_connection_id", + "from_chat_id", + "from_story_id", + "active_period", + "post_to_chat_page", + "protect_content", + ): + assert request_data.parameters.get(param) == param + return Story(chat=Chat(id=1, type=Chat.PRIVATE), id=42).to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + story = await offline_bot.repost_story( + business_connection_id="business_connection_id", + from_chat_id="from_chat_id", + from_story_id="from_story_id", + active_period="active_period", + post_to_chat_page="post_to_chat_page", + protect_content="protect_content", + ) + assert story.chat.id == 1 + assert story.id == 42 + + @pytest.mark.parametrize("default_bot", [{"protect_content": True}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, True), (False, False), (None, None)], + ) + async def test_repost_story_default_protect_content( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("protect_content") == expected_value + return Story(chat=Chat(123, "private"), id=123).to_dict() + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "from_chat_id": 123, + "from_story_id": 456, + "active_period": dtm.timedelta(seconds=20), + } + if passed_value is not DEFAULT_NONE: + kwargs["protect_content"] = passed_value + + await default_bot.repost_story(**kwargs) diff --git a/tests/test_callbackquery.py b/tests/test_callbackquery.py index 97ef9b2a627..d45f171ef8a 100644 --- a/tests/test_callbackquery.py +++ b/tests/test_callbackquery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -21,7 +21,17 @@ import pytest -from telegram import Audio, Bot, CallbackQuery, Chat, InaccessibleMessage, Message, User +from telegram import ( + Audio, + Bot, + CallbackQuery, + Chat, + InaccessibleMessage, + InputChecklist, + InputChecklistTask, + Message, + User, +) from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -230,6 +240,43 @@ async def make_assertion(*_, **kwargs): assert await callback_query.edit_message_caption(caption="new caption") assert await callback_query.edit_message_caption("new caption") + async def test_edit_message_checklist(self, monkeypatch, callback_query): + checklist = InputChecklist(title="My Checklist", tasks=[InputChecklistTask(1, "Task 1")]) + + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.edit_message_checklist(checklist) + return + + if callback_query.inline_message_id: + pytest.skip("Can't edit inline messages") + + async def make_assertion(*_, **kwargs): + chat_id = kwargs["chat_id"] == callback_query.message.chat_id + message_id = kwargs["message_id"] == callback_query.message.message_id + caption = kwargs["checklist"] == checklist + return chat_id and message_id and caption + + assert check_shortcut_signature( + CallbackQuery.edit_message_checklist, + Bot.edit_message_checklist, + ["chat_id", "message_id", "business_connection_id"], + [], + ) + assert await check_shortcut_call( + callback_query.edit_message_checklist, + callback_query.get_bot(), + "edit_message_checklist", + shortcut_kwargs=["chat_id", "message_id", "business_connection_id"], + ) + assert await check_defaults_handling( + callback_query.edit_message_checklist, callback_query.get_bot() + ) + + monkeypatch.setattr(callback_query.get_bot(), "edit_message_checklist", make_assertion) + assert await callback_query.edit_message_checklist(checklist=checklist) + assert await callback_query.edit_message_checklist(checklist) + async def test_edit_message_reply_markup(self, monkeypatch, callback_query): if isinstance(callback_query.message, InaccessibleMessage): with pytest.raises(TypeError, match="inaccessible message"): @@ -529,11 +576,14 @@ async def make_assertion(*args, **kwargs): assert check_shortcut_signature( CallbackQuery.copy_message, Bot.copy_message, - ["message_id", "from_chat_id"], + ["message_id", "from_chat_id", "direct_messages_topic_id"], [], ) assert await check_shortcut_call( - callback_query.copy_message, callback_query.get_bot(), "copy_message" + callback_query.copy_message, + callback_query.get_bot(), + "copy_message", + shortcut_kwargs=["direct_messages_topic_id"], ) assert await check_defaults_handling(callback_query.copy_message, callback_query.get_bot()) diff --git a/tests/test_chat.py b/tests/test_chat.py index e39281ee010..624711da263 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,7 +20,15 @@ import pytest -from telegram import Bot, Chat, ChatPermissions, ReactionTypeEmoji, User +from telegram import ( + Bot, + Chat, + ChatPermissions, + InputChecklist, + InputChecklistTask, + ReactionTypeEmoji, + User, +) from telegram.constants import ChatAction, ChatType, ReactionEmoji from telegram.helpers import escape_markdown from tests.auxil.bot_method_checks import ( @@ -41,6 +49,7 @@ def chat(bot): is_forum=True, first_name=ChatTestBase.first_name, last_name=ChatTestBase.last_name, + is_direct_messages=ChatTestBase.is_direct_messages, ) chat.set_bot(bot) chat._unfreeze() @@ -55,6 +64,7 @@ class ChatTestBase: is_forum = True first_name = "first" last_name = "last" + is_direct_messages = True class TestChatWithoutRequest(ChatTestBase): @@ -72,6 +82,7 @@ def test_de_json(self, offline_bot): "is_forum": self.is_forum, "first_name": self.first_name, "last_name": self.last_name, + "is_direct_messages": self.is_direct_messages, } chat = Chat.de_json(json_dict, offline_bot) @@ -82,6 +93,7 @@ def test_de_json(self, offline_bot): assert chat.is_forum == self.is_forum assert chat.first_name == self.first_name assert chat.last_name == self.last_name + assert chat.is_direct_messages == self.is_direct_messages def test_to_dict(self, chat): chat_dict = chat.to_dict() @@ -94,6 +106,7 @@ def test_to_dict(self, chat): assert chat_dict["is_forum"] == chat.is_forum assert chat_dict["first_name"] == chat.first_name assert chat_dict["last_name"] == chat.last_name + assert chat_dict["is_direct_messages"] == chat.is_direct_messages def test_enum_init(self): chat = Chat(id=1, type="foo") @@ -286,7 +299,7 @@ async def test_unban_member(self, monkeypatch, chat, only_if_banned): async def make_assertion(*_, **kwargs): chat_id = kwargs["chat_id"] == chat.id user_id = kwargs["user_id"] == 42 - o_i_b = kwargs.get("only_if_banned", None) == only_if_banned + o_i_b = kwargs.get("only_if_banned") == only_if_banned return chat_id and user_id and o_i_b assert check_shortcut_signature(Chat.unban_member, Bot.unban_chat_member, ["chat_id"], []) @@ -333,7 +346,7 @@ async def test_promote_member(self, monkeypatch, chat, is_anonymous): async def make_assertion(*_, **kwargs): chat_id = kwargs["chat_id"] == chat.id user_id = kwargs["user_id"] == 42 - o_i_b = kwargs.get("is_anonymous", None) == is_anonymous + o_i_b = kwargs.get("is_anonymous") == is_anonymous return chat_id and user_id and o_i_b assert check_shortcut_signature( @@ -353,7 +366,7 @@ async def test_restrict_member(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): chat_id = kwargs["chat_id"] == chat.id user_id = kwargs["user_id"] == 42 - o_i_b = kwargs.get("permissions", None) == permissions + o_i_b = kwargs.get("permissions") == permissions return chat_id and user_id and o_i_b assert check_shortcut_signature( @@ -511,6 +524,21 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "send_message", make_assertion) assert await chat.send_message(text="test") + async def test_instance_method_send_message_draft(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id and kwargs["text"] == "test" + + assert check_shortcut_signature( + Chat.send_message_draft, Bot.send_message_draft, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.send_message_draft, chat.get_bot(), "send_message_draft" + ) + assert await check_defaults_handling(chat.send_message_draft, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "send_message_draft", make_assertion) + assert await chat.send_message_draft(draft_id=1, text="test") + async def test_instance_method_send_media_group(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id and kwargs["media"] == "test_media_group" @@ -579,6 +607,23 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "send_dice", make_assertion) assert await chat.send_dice(emoji="test_dice") + async def test_instance_method_send_checklist(self, monkeypatch, chat): + checklist = InputChecklist(title="My Checklist", tasks=[InputChecklistTask(1, "Task 1")]) + + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["business_connection_id"] == "123" + and kwargs["checklist"] == checklist + ) + + assert check_shortcut_signature(Chat.send_checklist, Bot.send_checklist, ["chat_id"], []) + assert await check_shortcut_call(chat.send_checklist, chat.get_bot(), "send_checklist") + assert await check_defaults_handling(chat.send_checklist, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "send_checklist", make_assertion) + assert await chat.send_checklist("123", checklist) + async def test_instance_method_send_game(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id and kwargs["game_short_name"] == "test_game" @@ -610,9 +655,9 @@ async def make_assertion(*_, **kwargs): "title", "description", "payload", - "provider_token", "currency", "prices", + "provider_token", ) async def test_instance_method_send_location(self, monkeypatch, chat): @@ -1312,7 +1357,7 @@ async def make_assertion(*_, **kwargs): ) async def test_instance_method_send_gift(self, monkeypatch, chat): - async def make_assertion(*_, **kwargs): + async def make_assertion_private(*_, **kwargs): return ( kwargs["user_id"] == chat.id and kwargs["gift_id"] == "gift_id" @@ -1321,11 +1366,31 @@ async def make_assertion(*_, **kwargs): and kwargs["text_entities"] == "text_entities" ) - assert check_shortcut_signature(Chat.send_gift, Bot.send_gift, ["user_id"], []) - assert await check_shortcut_call(chat.send_gift, chat.get_bot(), "send_gift") + async def make_assertion_channel(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["gift_id"] == "gift_id" + and kwargs["text"] == "text" + and kwargs["text_parse_mode"] == "text_parse_mode" + and kwargs["text_entities"] == "text_entities" + ) + + assert check_shortcut_signature(Chat.send_gift, Bot.send_gift, ["user_id", "chat_id"], []) + assert await check_shortcut_call( + chat.send_gift, chat.get_bot(), "send_gift", ["user_id", "chat_id"] + ) assert await check_defaults_handling(chat.send_gift, chat.get_bot()) - monkeypatch.setattr(chat.get_bot(), "send_gift", make_assertion) + monkeypatch.setattr(chat.get_bot(), "send_gift", make_assertion_private) + chat.type = chat.PRIVATE + assert await chat.send_gift( + gift_id="gift_id", + text="text", + text_parse_mode="text_parse_mode", + text_entities="text_entities", + ) + monkeypatch.setattr(chat.get_bot(), "send_gift", make_assertion_channel) + chat.type = chat.CHANNEL assert await chat.send_gift( gift_id="gift_id", text="text", @@ -1333,6 +1398,28 @@ async def make_assertion(*_, **kwargs): text_entities="text_entities", ) + @pytest.mark.parametrize("star_count", [100, None]) + async def test_instance_method_transfer_gift(self, monkeypatch, chat, star_count): + async def make_assertion(*_, **kwargs): + return ( + kwargs["new_owner_chat_id"] == chat.id + and kwargs["owned_gift_id"] == "owned_gift_id" + and kwargs["star_count"] == star_count + ) + + assert check_shortcut_signature( + Chat.transfer_gift, Bot.transfer_gift, ["new_owner_chat_id"], [] + ) + assert await check_shortcut_call(chat.transfer_gift, chat.get_bot(), "transfer_gift") + assert await check_defaults_handling(chat.transfer_gift, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "transfer_gift", make_assertion) + assert await chat.transfer_gift( + owned_gift_id="owned_gift_id", + star_count=star_count, + business_connection_id="business_connection_id", + ) + async def test_instance_method_verify_chat(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return ( @@ -1364,6 +1451,103 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "remove_chat_verification", make_assertion) assert await chat.remove_verification() + async def test_instance_method_read_business_message(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["business_connection_id"] == "business_connection_id" + and kwargs["message_id"] == "message_id" + ) + + assert check_shortcut_signature( + Chat.read_business_message, Bot.read_business_message, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.read_business_message, chat.get_bot(), "read_business_message" + ) + assert await check_defaults_handling(chat.read_business_message, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "read_business_message", make_assertion) + assert await chat.read_business_message( + message_id="message_id", business_connection_id="business_connection_id" + ) + + async def test_instance_method_approve_suggested_post(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["message_id"] == "message_id" + and kwargs["send_date"] == "send_date" + ) + + assert check_shortcut_signature( + Chat.approve_suggested_post, Bot.approve_suggested_post, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.approve_suggested_post, chat.get_bot(), "approve_suggested_post" + ) + assert await check_defaults_handling(chat.approve_suggested_post, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "approve_suggested_post", make_assertion) + assert await chat.approve_suggested_post(message_id="message_id", send_date="send_date") + + async def test_instance_method_decline_suggested_post(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["message_id"] == "message_id" + and kwargs["comment"] == "comment" + ) + + assert check_shortcut_signature( + Chat.decline_suggested_post, Bot.decline_suggested_post, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.decline_suggested_post, chat.get_bot(), "decline_suggested_post" + ) + assert await check_defaults_handling(chat.decline_suggested_post, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "decline_suggested_post", make_assertion) + assert await chat.decline_suggested_post(message_id="message_id", comment="comment") + + async def test_instance_method_repost_story(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["from_chat_id"] == chat.id + + assert check_shortcut_signature( + Chat.repost_story, + Bot.repost_story, + [ + "from_chat_id", + ], + additional_kwargs=[], + ) + assert await check_shortcut_call( + chat.repost_story, + chat.get_bot(), + "repost_story", + shortcut_kwargs=["from_chat_id"], + ) + assert await check_defaults_handling(chat.repost_story, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "repost_story", make_assertion) + assert await chat.repost_story( + business_connection_id="bcid", + from_story_id=123, + active_period=3600, + ) + + async def test_instance_method_get_gifts(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id + + assert check_shortcut_signature(Chat.get_gifts, Bot.get_chat_gifts, ["chat_id"], []) + assert await check_shortcut_call(chat.get_gifts, chat.get_bot(), "get_chat_gifts") + assert await check_defaults_handling(chat.get_gifts, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "get_chat_gifts", make_assertion) + assert await chat.get_gifts() + def test_mention_html(self): chat = Chat(id=1, type="foo") with pytest.raises(TypeError, match="Can not create a mention to a private group chat"): diff --git a/tests/test_chatadministratorrights.py b/tests/test_chatadministratorrights.py index c93f23d4bcd..f7a9ba09272 100644 --- a/tests/test_chatadministratorrights.py +++ b/tests/test_chatadministratorrights.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -40,6 +40,7 @@ def chat_admin_rights(): can_post_stories=True, can_edit_stories=True, can_delete_stories=True, + can_manage_direct_messages=True, ) @@ -67,6 +68,7 @@ def test_de_json(self, offline_bot, chat_admin_rights): "can_post_stories": True, "can_edit_stories": True, "can_delete_stories": True, + "can_manage_direct_messages": True, } chat_administrator_rights_de = ChatAdministratorRights.de_json(json_dict, offline_bot) assert chat_administrator_rights_de.api_kwargs == {} @@ -93,6 +95,7 @@ def test_to_dict(self, chat_admin_rights): assert admin_rights_dict["can_post_stories"] == car.can_post_stories assert admin_rights_dict["can_edit_stories"] == car.can_edit_stories assert admin_rights_dict["can_delete_stories"] == car.can_delete_stories + assert admin_rights_dict["can_manage_direct_messages"] == car.can_manage_direct_messages def test_equality(self): a = ChatAdministratorRights( @@ -143,6 +146,7 @@ def test_all_rights(self): True, True, True, + True, ) t = ChatAdministratorRights.all_rights() # if the dirs are the same, the attributes will all be there @@ -168,6 +172,7 @@ def test_no_rights(self): False, False, False, + False, ) t = ChatAdministratorRights.no_rights() # if the dirs are the same, the attributes will all be there diff --git a/tests/test_chatbackground.py b/tests/test_chatbackground.py index 33e257d3e83..b5a4ebc7b43 100644 --- a/tests/test_chatbackground.py +++ b/tests/test_chatbackground.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,9 +16,6 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -import inspect -from copy import deepcopy -from typing import Union import pytest @@ -32,205 +29,306 @@ BackgroundTypeFill, BackgroundTypePattern, BackgroundTypeWallpaper, + ChatBackground, Dice, Document, ) +from telegram.constants import BackgroundFillType, BackgroundTypeType from tests.auxil.slots import mro_slots -ignored = ["self", "api_kwargs"] + +@pytest.fixture +def background_fill(): + return BackgroundFill(BackgroundFillTestBase.type) -class BFDefaults: - color = 0 - top_color = 1 - bottom_color = 2 +class BackgroundFillTestBase: + type = BackgroundFill.SOLID + color = 42 + top_color = 43 + bottom_color = 44 rotation_angle = 45 - colors = [0, 1, 2] + colors = [46, 47, 48, 49] -def background_fill_solid(): - return BackgroundFillSolid(BFDefaults.color) +class TestBackgroundFillWithoutRequest(BackgroundFillTestBase): + def test_slots(self, background_fill): + inst = background_fill + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_type_enum_conversion(self, background_fill): + assert type(BackgroundFill("solid").type) is BackgroundFillType + assert BackgroundFill("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + transaction_partner = BackgroundFill.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "unknown" + + @pytest.mark.parametrize( + ("bf_type", "subclass"), + [ + ("solid", BackgroundFillSolid), + ("gradient", BackgroundFillGradient), + ("freeform_gradient", BackgroundFillFreeformGradient), + ], + ) + def test_de_json_subclass(self, offline_bot, bf_type, subclass): + json_dict = { + "type": bf_type, + "color": self.color, + "top_color": self.top_color, + "bottom_color": self.bottom_color, + "rotation_angle": self.rotation_angle, + "colors": self.colors, + } + bf = BackgroundFill.de_json(json_dict, offline_bot) + + assert type(bf) is subclass + assert set(bf.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert bf.type == bf_type + + def test_to_dict(self, background_fill): + assert background_fill.to_dict() == {"type": background_fill.type} + + def test_equality(self, background_fill): + a = background_fill + b = BackgroundFill(self.type) + c = BackgroundFill("unknown") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) +@pytest.fixture def background_fill_gradient(): return BackgroundFillGradient( - BFDefaults.top_color, BFDefaults.bottom_color, BFDefaults.rotation_angle + TestBackgroundFillGradientWithoutRequest.top_color, + TestBackgroundFillGradientWithoutRequest.bottom_color, + TestBackgroundFillGradientWithoutRequest.rotation_angle, ) -def background_fill_freeform_gradient(): - return BackgroundFillFreeformGradient(BFDefaults.colors) +class TestBackgroundFillGradientWithoutRequest(BackgroundFillTestBase): + type = BackgroundFill.GRADIENT + + def test_slots(self, background_fill_gradient): + inst = background_fill_gradient + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + def test_de_json(self, offline_bot): + data = { + "top_color": self.top_color, + "bottom_color": self.bottom_color, + "rotation_angle": self.rotation_angle, + } + transaction_partner = BackgroundFillGradient.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "gradient" + + def test_to_dict(self, background_fill_gradient): + assert background_fill_gradient.to_dict() == { + "type": background_fill_gradient.type, + "top_color": self.top_color, + "bottom_color": self.bottom_color, + "rotation_angle": self.rotation_angle, + } + + def test_equality(self, background_fill_gradient): + a = background_fill_gradient + b = BackgroundFillGradient( + self.top_color, + self.bottom_color, + self.rotation_angle, + ) + c = BackgroundFillGradient( + self.top_color + 1, + self.bottom_color + 1, + self.rotation_angle + 1, + ) + d = Dice(5, "test") -class BTDefaults: - document = Document(1, 2) - fill = BackgroundFillSolid(color=0) - dark_theme_dimming = 20 - is_blurred = True - is_moving = False - intensity = 90 - is_inverted = False - theme_name = "ice" + assert a == b + assert hash(a) == hash(b) + assert a != c + assert hash(a) != hash(c) -def background_type_fill(): - return BackgroundTypeFill(BTDefaults.fill, BTDefaults.dark_theme_dimming) + assert a != d + assert hash(a) != hash(d) -def background_type_wallpaper(): - return BackgroundTypeWallpaper( - BTDefaults.document, - BTDefaults.dark_theme_dimming, - BTDefaults.is_blurred, - BTDefaults.is_moving, +@pytest.fixture +def background_fill_freeform_gradient(): + return BackgroundFillFreeformGradient( + TestBackgroundFillFreeformGradientWithoutRequest.colors, ) -def background_type_pattern(): - return BackgroundTypePattern( - BTDefaults.document, - BTDefaults.fill, - BTDefaults.intensity, - BTDefaults.is_inverted, - BTDefaults.is_moving, - ) +class TestBackgroundFillFreeformGradientWithoutRequest(BackgroundFillTestBase): + type = BackgroundFill.FREEFORM_GRADIENT + + def test_slots(self, background_fill_freeform_gradient): + inst = background_fill_freeform_gradient + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + def test_de_json(self, offline_bot): + data = {"colors": self.colors} + transaction_partner = BackgroundFillFreeformGradient.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "freeform_gradient" -def background_type_chat_theme(): - return BackgroundTypeChatTheme(BTDefaults.theme_name) - - -def make_json_dict( - instance: Union[BackgroundType, BackgroundFill], include_optional_args: bool = False -) -> dict: - """Used to make the json dict which we use for testing de_json. Similar to iter_args()""" - json_dict = {"type": instance.type} - sig = inspect.signature(instance.__class__.__init__) - - for param in sig.parameters.values(): - if param.name in ignored: # ignore irrelevant params - continue - - val = getattr(instance, param.name) - # Compulsory args- - if param.default is inspect.Parameter.empty: - if hasattr(val, "to_dict"): # convert the user object or any future ones to dict. - val = val.to_dict() - json_dict[param.name] = val - - # If we want to test all args (for de_json)- - elif param.default is not inspect.Parameter.empty and include_optional_args: - json_dict[param.name] = val - return json_dict - - -def iter_args( - instance: Union[BackgroundType, BackgroundFill], - de_json_inst: Union[BackgroundType, BackgroundFill], - include_optional: bool = False, -): - """ - We accept both the regular instance and de_json created instance and iterate over them for - easy one line testing later one. - """ - yield instance.type, de_json_inst.type # yield this here cause it's not available in sig. - - sig = inspect.signature(instance.__class__.__init__) - for param in sig.parameters.values(): - if param.name in ignored: - continue - inst_at, json_at = getattr(instance, param.name), getattr(de_json_inst, param.name) - if ( - param.default is not inspect.Parameter.empty and include_optional - ) or param.default is inspect.Parameter.empty: - yield inst_at, json_at + def test_to_dict(self, background_fill_freeform_gradient): + assert background_fill_freeform_gradient.to_dict() == { + "type": background_fill_freeform_gradient.type, + "colors": self.colors, + } + + def test_equality(self, background_fill_freeform_gradient): + a = background_fill_freeform_gradient + b = BackgroundFillFreeformGradient(self.colors) + c = BackgroundFillFreeformGradient([color + 1 for color in self.colors]) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) @pytest.fixture -def background_type(request): - return request.param() - - -@pytest.mark.parametrize( - "background_type", - [ - background_type_fill, - background_type_wallpaper, - background_type_pattern, - background_type_chat_theme, - ], - indirect=True, -) -class TestBackgroundTypeWithoutRequest: - def test_slot_behaviour(self, background_type): - inst = background_type +def background_fill_solid(): + return BackgroundFillSolid(TestBackgroundFillSolidWithoutRequest.color) + + +class TestBackgroundFillSolidWithoutRequest(BackgroundFillTestBase): + type = BackgroundFill.SOLID + + def test_slots(self, background_fill_solid): + inst = background_fill_solid for attr in inst.__slots__: assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - def test_de_json_required_args(self, offline_bot, background_type): - cls = background_type.__class__ - assert cls.de_json({}, offline_bot) is None + def test_de_json(self, offline_bot): + data = {"color": self.color} + transaction_partner = BackgroundFillSolid.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "solid" + + def test_to_dict(self, background_fill_solid): + assert background_fill_solid.to_dict() == { + "type": background_fill_solid.type, + "color": self.color, + } - json_dict = make_json_dict(background_type) - const_background_type = BackgroundType.de_json(json_dict, offline_bot) - assert const_background_type.api_kwargs == {} + def test_equality(self, background_fill_solid): + a = background_fill_solid + b = BackgroundFillSolid(self.color) + c = BackgroundFillSolid(self.color + 1) + d = Dice(5, "test") - assert isinstance(const_background_type, BackgroundType) - assert isinstance(const_background_type, cls) - for bg_type_at, const_bg_type_at in iter_args(background_type, const_background_type): - assert bg_type_at == const_bg_type_at + assert a == b + assert hash(a) == hash(b) - def test_de_json_all_args(self, offline_bot, background_type): - json_dict = make_json_dict(background_type, include_optional_args=True) - const_background_type = BackgroundType.de_json(json_dict, offline_bot) + assert a != c + assert hash(a) != hash(c) - assert const_background_type.api_kwargs == {} + assert a != d + assert hash(a) != hash(d) - assert isinstance(const_background_type, BackgroundType) - assert isinstance(const_background_type, background_type.__class__) - for bg_type_at, const_bg_type_at in iter_args( - background_type, const_background_type, True - ): - assert bg_type_at == const_bg_type_at - def test_de_json_invalid_type(self, background_type, offline_bot): - json_dict = {"type": "invalid", "theme_name": BTDefaults.theme_name} - background_type = BackgroundType.de_json(json_dict, offline_bot) +@pytest.fixture +def background_type(): + return BackgroundType(BackgroundTypeTestBase.type) - assert type(background_type) is BackgroundType - assert background_type.type == "invalid" - def test_de_json_subclass(self, background_type, offline_bot, chat_id): - """This makes sure that e.g. BackgroundTypeFill(data, offline_bot) never returns a - BackgroundTypeWallpaper instance.""" - cls = background_type.__class__ - json_dict = make_json_dict(background_type, True) - assert type(cls.de_json(json_dict, offline_bot)) is cls +class BackgroundTypeTestBase: + type = BackgroundType.WALLPAPER + fill = BackgroundFillSolid(42) + dark_theme_dimming = 43 + document = Document("file_id", "file_unique_id", "file_name", 42) + is_blurred = True + is_moving = True + intensity = 45 + is_inverted = True + theme_name = "test theme name" - def test_to_dict(self, background_type): - bg_type_dict = background_type.to_dict() - assert isinstance(bg_type_dict, dict) - assert bg_type_dict["type"] == background_type.type +class TestBackgroundTypeWithoutRequest(BackgroundTypeTestBase): + def test_slots(self, background_type): + inst = background_type + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - for slot in background_type.__slots__: # additional verification for the optional args - if slot in ("fill", "document"): - assert (getattr(background_type, slot)).to_dict() == bg_type_dict[slot] - continue - assert getattr(background_type, slot) == bg_type_dict[slot] + def test_type_enum_conversion(self, background_type): + assert type(BackgroundType("wallpaper").type) is BackgroundTypeType + assert BackgroundType("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + transaction_partner = BackgroundType.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "unknown" + + @pytest.mark.parametrize( + ("bt_type", "subclass"), + [ + ("wallpaper", BackgroundTypeWallpaper), + ("fill", BackgroundTypeFill), + ("pattern", BackgroundTypePattern), + ("chat_theme", BackgroundTypeChatTheme), + ], + ) + def test_de_json_subclass(self, offline_bot, bt_type, subclass): + json_dict = { + "type": bt_type, + "fill": self.fill.to_dict(), + "dark_theme_dimming": self.dark_theme_dimming, + "document": self.document.to_dict(), + "is_blurred": self.is_blurred, + "is_moving": self.is_moving, + "intensity": self.intensity, + "is_inverted": self.is_inverted, + "theme_name": self.theme_name, + } + bt = BackgroundType.de_json(json_dict, offline_bot) + + assert type(bt) is subclass + assert set(bt.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert bt.type == bt_type + + def test_to_dict(self, background_type): + assert background_type.to_dict() == {"type": background_type.type} def test_equality(self, background_type): - a = BackgroundType(type="type") - b = BackgroundType(type="type") - c = background_type - d = deepcopy(background_type) - e = Dice(4, "emoji") - sig = inspect.signature(background_type.__class__.__init__) - params = [ - "random" for param in sig.parameters.values() if param.name not in [*ignored, "type"] - ] - f = background_type.__class__(*params) + a = background_type + b = BackgroundType(self.type) + c = BackgroundType("unknown") + d = Dice(5, "test") assert a == b assert hash(a) == hash(b) @@ -241,103 +339,218 @@ def test_equality(self, background_type): assert a != d assert hash(a) != hash(d) - assert a != e - assert hash(a) != hash(e) - assert c == d - assert hash(c) == hash(d) +@pytest.fixture +def background_type_fill(): + return BackgroundTypeFill( + fill=TestBackgroundTypeFillWithoutRequest.fill, + dark_theme_dimming=TestBackgroundTypeFillWithoutRequest.dark_theme_dimming, + ) + + +class TestBackgroundTypeFillWithoutRequest(BackgroundTypeTestBase): + type = BackgroundType.FILL + + def test_slots(self, background_type_fill): + inst = background_type_fill + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + data = {"fill": self.fill.to_dict(), "dark_theme_dimming": self.dark_theme_dimming} + transaction_partner = BackgroundTypeFill.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "fill" + + def test_to_dict(self, background_type_fill): + assert background_type_fill.to_dict() == { + "type": background_type_fill.type, + "fill": self.fill.to_dict(), + "dark_theme_dimming": self.dark_theme_dimming, + } + + def test_equality(self, background_type_fill): + a = background_type_fill + b = BackgroundTypeFill(self.fill, self.dark_theme_dimming) + c = BackgroundTypeFill(BackgroundFillSolid(43), 44) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) - assert c != e - assert hash(c) != hash(e) + assert a != c + assert hash(a) != hash(c) - assert f != c - assert hash(f) != hash(c) + assert a != d + assert hash(a) != hash(d) @pytest.fixture -def background_fill(request): - return request.param() - - -@pytest.mark.parametrize( - "background_fill", - [ - background_fill_solid, - background_fill_gradient, - background_fill_freeform_gradient, - ], - indirect=True, -) -class TestBackgroundFillWithoutRequest: - def test_slot_behaviour(self, background_fill): - inst = background_fill +def background_type_pattern(): + return BackgroundTypePattern( + TestBackgroundTypePatternWithoutRequest.document, + TestBackgroundTypePatternWithoutRequest.fill, + TestBackgroundTypePatternWithoutRequest.intensity, + TestBackgroundTypePatternWithoutRequest.is_inverted, + TestBackgroundTypePatternWithoutRequest.is_moving, + ) + + +class TestBackgroundTypePatternWithoutRequest(BackgroundTypeTestBase): + type = BackgroundType.PATTERN + + def test_slots(self, background_type_pattern): + inst = background_type_pattern for attr in inst.__slots__: assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - def test_de_json_required_args(self, offline_bot, background_fill): - cls = background_fill.__class__ - assert cls.de_json({}, offline_bot) is None + def test_de_json(self, offline_bot): + data = { + "document": self.document.to_dict(), + "fill": self.fill.to_dict(), + "intensity": self.intensity, + "is_inverted": self.is_inverted, + "is_moving": self.is_moving, + } + transaction_partner = BackgroundTypePattern.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "pattern" + + def test_to_dict(self, background_type_pattern): + assert background_type_pattern.to_dict() == { + "type": background_type_pattern.type, + "document": self.document.to_dict(), + "fill": self.fill.to_dict(), + "intensity": self.intensity, + "is_inverted": self.is_inverted, + "is_moving": self.is_moving, + } + + def test_equality(self, background_type_pattern): + a = background_type_pattern + b = BackgroundTypePattern( + self.document, + self.fill, + self.intensity, + ) + c = BackgroundTypePattern( + Document("other", "other", "file_name", 43), + False, + False, + 44, + ) + d = Dice(5, "test") - json_dict = make_json_dict(background_fill) - const_background_fill = BackgroundFill.de_json(json_dict, offline_bot) - assert const_background_fill.api_kwargs == {} + assert a == b + assert hash(a) == hash(b) - assert isinstance(const_background_fill, BackgroundFill) - assert isinstance(const_background_fill, cls) - for bg_fill_at, const_bg_fill_at in iter_args(background_fill, const_background_fill): - assert bg_fill_at == const_bg_fill_at + assert a != c + assert hash(a) != hash(c) - def test_de_json_all_args(self, offline_bot, background_fill): - json_dict = make_json_dict(background_fill, include_optional_args=True) - const_background_fill = BackgroundFill.de_json(json_dict, offline_bot) + assert a != d + assert hash(a) != hash(d) - assert const_background_fill.api_kwargs == {} - assert isinstance(const_background_fill, BackgroundFill) - assert isinstance(const_background_fill, background_fill.__class__) - for bg_fill_at, const_bg_fill_at in iter_args( - background_fill, const_background_fill, True - ): - assert bg_fill_at == const_bg_fill_at +@pytest.fixture +def background_type_chat_theme(): + return BackgroundTypeChatTheme( + TestBackgroundTypeChatThemeWithoutRequest.theme_name, + ) - def test_de_json_invalid_type(self, background_fill, offline_bot): - json_dict = {"type": "invalid", "theme_name": BTDefaults.theme_name} - background_fill = BackgroundFill.de_json(json_dict, offline_bot) - assert type(background_fill) is BackgroundFill - assert background_fill.type == "invalid" +class TestBackgroundTypeChatThemeWithoutRequest(BackgroundTypeTestBase): + type = BackgroundType.CHAT_THEME - def test_de_json_subclass(self, background_fill, offline_bot): - """This makes sure that e.g. BackgroundFillSolid(data, offline_bot) never returns a - BackgroundFillGradient instance.""" - cls = background_fill.__class__ - json_dict = make_json_dict(background_fill, True) - assert type(cls.de_json(json_dict, offline_bot)) is cls + def test_slots(self, background_type_chat_theme): + inst = background_type_chat_theme + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - def test_to_dict(self, background_fill): - bg_fill_dict = background_fill.to_dict() + def test_de_json(self, offline_bot): + data = {"theme_name": self.theme_name} + transaction_partner = BackgroundTypeChatTheme.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "chat_theme" - assert isinstance(bg_fill_dict, dict) - assert bg_fill_dict["type"] == background_fill.type + def test_to_dict(self, background_type_chat_theme): + assert background_type_chat_theme.to_dict() == { + "type": background_type_chat_theme.type, + "theme_name": self.theme_name, + } - for slot in background_fill.__slots__: # additional verification for the optional args - if slot == "colors": - assert getattr(background_fill, slot) == tuple(bg_fill_dict[slot]) - continue - assert getattr(background_fill, slot) == bg_fill_dict[slot] + def test_equality(self, background_type_chat_theme): + a = background_type_chat_theme + b = BackgroundTypeChatTheme(self.theme_name) + c = BackgroundTypeChatTheme("other") + d = Dice(5, "test") - def test_equality(self, background_fill): - a = BackgroundFill(type="type") - b = BackgroundFill(type="type") - c = background_fill - d = deepcopy(background_fill) - e = Dice(4, "emoji") - sig = inspect.signature(background_fill.__class__.__init__) - params = [ - "random" for param in sig.parameters.values() if param.name not in [*ignored, "type"] - ] - f = background_fill.__class__(*params) + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def background_type_wallpaper(): + return BackgroundTypeWallpaper( + TestBackgroundTypeWallpaperWithoutRequest.document, + TestBackgroundTypeWallpaperWithoutRequest.dark_theme_dimming, + TestBackgroundTypeWallpaperWithoutRequest.is_blurred, + TestBackgroundTypeWallpaperWithoutRequest.is_moving, + ) + + +class TestBackgroundTypeWallpaperWithoutRequest(BackgroundTypeTestBase): + type = BackgroundType.WALLPAPER + + def test_slots(self, background_type_wallpaper): + inst = background_type_wallpaper + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + data = { + "document": self.document.to_dict(), + "dark_theme_dimming": self.dark_theme_dimming, + "is_blurred": self.is_blurred, + "is_moving": self.is_moving, + } + transaction_partner = BackgroundTypeWallpaper.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "wallpaper" + + def test_to_dict(self, background_type_wallpaper): + assert background_type_wallpaper.to_dict() == { + "type": background_type_wallpaper.type, + "document": self.document.to_dict(), + "dark_theme_dimming": self.dark_theme_dimming, + "is_blurred": self.is_blurred, + "is_moving": self.is_moving, + } + + def test_equality(self, background_type_wallpaper): + a = background_type_wallpaper + b = BackgroundTypeWallpaper( + self.document, + self.dark_theme_dimming, + self.is_blurred, + self.is_moving, + ) + c = BackgroundTypeWallpaper( + Document("other", "other", "file_name", 43), + 44, + False, + False, + ) + d = Dice(5, "test") assert a == b assert hash(a) == hash(b) @@ -348,14 +561,43 @@ def test_equality(self, background_fill): assert a != d assert hash(a) != hash(d) - assert a != e - assert hash(a) != hash(e) - assert c == d - assert hash(c) == hash(d) +@pytest.fixture +def chat_background(): + return ChatBackground(ChatBackgroundTestBase.type) + + +class ChatBackgroundTestBase: + type = BackgroundTypeFill(BackgroundFillSolid(42), 43) + + +class TestChatBackgroundWithoutRequest(ChatBackgroundTestBase): + def test_slots(self, chat_background): + inst = chat_background + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + data = {"type": self.type.to_dict()} + transaction_partner = ChatBackground.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == self.type - assert c != e - assert hash(c) != hash(e) + def test_to_dict(self, chat_background): + assert chat_background.to_dict() == {"type": chat_background.type.to_dict()} - assert f != c - assert hash(f) != hash(c) + def test_equality(self, chat_background): + a = chat_background + b = ChatBackground(self.type) + c = ChatBackground(BackgroundTypeFill(BackgroundFillSolid(43), 44)) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_chatboost.py b/tests/test_chatboost.py index 60692289b83..578bc4f0824 100644 --- a/tests/test_chatboost.py +++ b/tests/test_chatboost.py @@ -1,5 +1,5 @@ # python-telegram-bot - a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # by the python-telegram-bot contributors # # This program is free software: you can redistribute it and/or modify @@ -16,8 +16,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import datetime as dtm -import inspect -from copy import deepcopy import pytest @@ -38,200 +36,180 @@ from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import ChatBoostSources from telegram.request import RequestData +from tests.auxil.dummy_objects import get_dummy_object_json_dict from tests.auxil.slots import mro_slots class ChatBoostDefaults: + source = ChatBoostSource.PREMIUM chat_id = 1 boost_id = "2" giveaway_message_id = 3 is_unclaimed = False chat = Chat(1, "group") user = User(1, "user", False) - date = to_timestamp(dtm.datetime.utcnow()) + date = dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0) default_source = ChatBoostSourcePremium(user) prize_star_count = 99 + boost = ChatBoost( + boost_id=boost_id, + add_date=date, + expiration_date=date, + source=default_source, + ) @pytest.fixture(scope="module") -def chat_boost_removed(): - return ChatBoostRemoved( - chat=ChatBoostDefaults.chat, - boost_id=ChatBoostDefaults.boost_id, - remove_date=ChatBoostDefaults.date, - source=ChatBoostDefaults.default_source, +def chat_boost_source(): + return ChatBoostSource( + source=ChatBoostDefaults.source, ) -@pytest.fixture(scope="module") -def chat_boost(): - return ChatBoost( - boost_id=ChatBoostDefaults.boost_id, - add_date=ChatBoostDefaults.date, - expiration_date=ChatBoostDefaults.date, - source=ChatBoostDefaults.default_source, - ) +class TestChatBoostSourceWithoutRequest(ChatBoostDefaults): + def test_slot_behaviour(self, chat_boost_source): + inst = chat_boost_source + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + def test_type_enum_conversion(self, chat_boost_source): + assert type(ChatBoostSource("premium").source) is ChatBoostSources + assert ChatBoostSource("unknown").source == "unknown" -@pytest.fixture(scope="module") -def chat_boost_updated(chat_boost): - return ChatBoostUpdated( - chat=ChatBoostDefaults.chat, - boost=chat_boost, + def test_de_json(self, offline_bot): + json_dict = { + "source": "unknown", + } + cbs = ChatBoostSource.de_json(json_dict, offline_bot) + + assert cbs.api_kwargs == {} + assert cbs.source == "unknown" + + @pytest.mark.parametrize( + ("cb_source", "subclass"), + [ + ("premium", ChatBoostSourcePremium), + ("gift_code", ChatBoostSourceGiftCode), + ("giveaway", ChatBoostSourceGiveaway), + ], ) + def test_de_json_subclass(self, offline_bot, cb_source, subclass): + json_dict = { + "source": cb_source, + "user": ChatBoostDefaults.user.to_dict(), + "giveaway_message_id": ChatBoostDefaults.giveaway_message_id, + } + cbs = ChatBoostSource.de_json(json_dict, offline_bot) + assert type(cbs) is subclass + assert set(cbs.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "source" + } + assert cbs.source == cb_source -def chat_boost_source_gift_code(): - return ChatBoostSourceGiftCode( - user=ChatBoostDefaults.user, - ) + def test_to_dict(self, chat_boost_source): + chat_boost_source_dict = chat_boost_source.to_dict() + assert isinstance(chat_boost_source_dict, dict) + assert chat_boost_source_dict["source"] == chat_boost_source.source -def chat_boost_source_giveaway(): - return ChatBoostSourceGiveaway( - user=ChatBoostDefaults.user, - giveaway_message_id=ChatBoostDefaults.giveaway_message_id, - is_unclaimed=ChatBoostDefaults.is_unclaimed, - prize_star_count=ChatBoostDefaults.prize_star_count, - ) + def test_equality(self, chat_boost_source): + a = chat_boost_source + b = ChatBoostSource(source=ChatBoostDefaults.source) + c = ChatBoostSource(source="unknown") + d = Dice(5, "test") + assert a == b + assert hash(a) == hash(b) -def chat_boost_source_premium(): - return ChatBoostSourcePremium( - user=ChatBoostDefaults.user, - ) + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) @pytest.fixture(scope="module") -def user_chat_boosts(chat_boost): - return UserChatBoosts( - boosts=[chat_boost], +def chat_boost_source_premium(): + return ChatBoostSourcePremium( + user=TestChatBoostSourcePremiumWithoutRequest.user, ) -@pytest.fixture -def chat_boost_source(request): - return request.param() +class TestChatBoostSourcePremiumWithoutRequest(ChatBoostDefaults): + source = ChatBoostSources.PREMIUM + def test_slot_behaviour(self, chat_boost_source_premium): + inst = chat_boost_source_premium + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" -ignored = ["self", "api_kwargs"] + def test_de_json(self, offline_bot): + json_dict = { + "user": self.user.to_dict(), + } + cbsp = ChatBoostSourcePremium.de_json(json_dict, offline_bot) + assert cbsp.api_kwargs == {} + assert cbsp.user == self.user -def make_json_dict(instance: ChatBoostSource, include_optional_args: bool = False) -> dict: - """Used to make the json dict which we use for testing de_json. Similar to iter_args()""" - json_dict = {"source": instance.source} - sig = inspect.signature(instance.__class__.__init__) + def test_to_dict(self, chat_boost_source_premium): + chat_boost_source_premium_dict = chat_boost_source_premium.to_dict() - for param in sig.parameters.values(): - if param.name in ignored: # ignore irrelevant params - continue + assert isinstance(chat_boost_source_premium_dict, dict) + assert chat_boost_source_premium_dict["source"] == self.source + assert chat_boost_source_premium_dict["user"] == self.user.to_dict() - val = getattr(instance, param.name) - if hasattr(val, "to_dict"): # convert the user object or any future ones to dict. - val = val.to_dict() - json_dict[param.name] = val + def test_equality(self, chat_boost_source_premium): + a = chat_boost_source_premium + b = ChatBoostSourcePremium(user=self.user) + c = Dice(5, "test") - return json_dict + assert a == b + assert hash(a) == hash(b) + assert a != c + assert hash(a) != hash(c) -def iter_args( - instance: ChatBoostSource, de_json_inst: ChatBoostSource, include_optional: bool = False -): - """ - We accept both the regular instance and de_json created instance and iterate over them for - easy one line testing later one. - """ - yield instance.source, de_json_inst.source # yield this here cause it's not available in sig. - sig = inspect.signature(instance.__class__.__init__) - for param in sig.parameters.values(): - if param.name in ignored: - continue - inst_at, json_at = getattr(instance, param.name), getattr(de_json_inst, param.name) - if isinstance(json_at, dtm.datetime): # Convert dtm to int - json_at = to_timestamp(json_at) - if ( - param.default is not inspect.Parameter.empty and include_optional - ) or param.default is inspect.Parameter.empty: - yield inst_at, json_at +@pytest.fixture(scope="module") +def chat_boost_source_gift_code(): + return ChatBoostSourceGiftCode( + user=TestChatBoostSourceGiftCodeWithoutRequest.user, + ) -@pytest.mark.parametrize( - "chat_boost_source", - [ - chat_boost_source_gift_code, - chat_boost_source_giveaway, - chat_boost_source_premium, - ], - indirect=True, -) -class TestChatBoostSourceTypesWithoutRequest: - def test_slot_behaviour(self, chat_boost_source): - inst = chat_boost_source +class TestChatBoostSourceGiftCodeWithoutRequest(ChatBoostDefaults): + source = ChatBoostSources.GIFT_CODE + + def test_slot_behaviour(self, chat_boost_source_gift_code): + inst = chat_boost_source_gift_code for attr in inst.__slots__: assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - def test_de_json_required_args(self, offline_bot, chat_boost_source): - cls = chat_boost_source.__class__ - assert cls.de_json({}, offline_bot) is None - assert ChatBoost.de_json({}, offline_bot) is None - - json_dict = make_json_dict(chat_boost_source) - const_boost_source = ChatBoostSource.de_json(json_dict, offline_bot) - assert const_boost_source.api_kwargs == {} - - assert isinstance(const_boost_source, ChatBoostSource) - assert isinstance(const_boost_source, cls) - for chat_mem_type_at, const_chat_mem_at in iter_args( - chat_boost_source, const_boost_source - ): - assert chat_mem_type_at == const_chat_mem_at - - def test_de_json_all_args(self, offline_bot, chat_boost_source): - json_dict = make_json_dict(chat_boost_source, include_optional_args=True) - const_boost_source = ChatBoostSource.de_json(json_dict, offline_bot) - assert const_boost_source.api_kwargs == {} - - assert isinstance(const_boost_source, ChatBoostSource) - assert isinstance(const_boost_source, chat_boost_source.__class__) - for c_mem_type_at, const_c_mem_at in iter_args( - chat_boost_source, const_boost_source, True - ): - assert c_mem_type_at == const_c_mem_at - - def test_de_json_invalid_source(self, chat_boost_source, offline_bot): - json_dict = {"source": "invalid"} - chat_boost_source = ChatBoostSource.de_json(json_dict, offline_bot) - - assert type(chat_boost_source) is ChatBoostSource - assert chat_boost_source.source == "invalid" - - def test_de_json_subclass(self, chat_boost_source, offline_bot): - """This makes sure that e.g. ChatBoostSourcePremium(data, offline_bot) never returns a - ChatBoostSourceGiftCode instance.""" - cls = chat_boost_source.__class__ - json_dict = make_json_dict(chat_boost_source, True) - assert type(cls.de_json(json_dict, offline_bot)) is cls + def test_de_json(self, offline_bot): + json_dict = { + "user": self.user.to_dict(), + } + cbsgc = ChatBoostSourceGiftCode.de_json(json_dict, offline_bot) - def test_to_dict(self, chat_boost_source): - chat_boost_dict = chat_boost_source.to_dict() + assert cbsgc.api_kwargs == {} + assert cbsgc.user == self.user - assert isinstance(chat_boost_dict, dict) - assert chat_boost_dict["source"] == chat_boost_source.source - assert chat_boost_dict["user"] == chat_boost_source.user.to_dict() + def test_to_dict(self, chat_boost_source_gift_code): + chat_boost_source_gift_code_dict = chat_boost_source_gift_code.to_dict() - for slot in chat_boost_source.__slots__: # additional verification for the optional args - if slot == "user": # we already test "user" above: - continue - assert getattr(chat_boost_source, slot) == chat_boost_dict[slot] + assert isinstance(chat_boost_source_gift_code_dict, dict) + assert chat_boost_source_gift_code_dict["source"] == self.source + assert chat_boost_source_gift_code_dict["user"] == self.user.to_dict() - def test_equality(self, chat_boost_source): - a = ChatBoostSource(source="status") - b = ChatBoostSource(source="status") - c = chat_boost_source - d = deepcopy(chat_boost_source) - e = Dice(4, "emoji") + def test_equality(self, chat_boost_source_gift_code): + a = chat_boost_source_gift_code + b = ChatBoostSourceGiftCode(user=self.user) + c = Dice(5, "test") assert a == b assert hash(a) == hash(b) @@ -239,23 +217,63 @@ def test_equality(self, chat_boost_source): assert a != c assert hash(a) != hash(c) - assert a != d - assert hash(a) != hash(d) - assert a != e - assert hash(a) != hash(e) +@pytest.fixture(scope="module") +def chat_boost_source_giveaway(): + return ChatBoostSourceGiveaway( + user=TestChatBoostSourceGiveawayWithoutRequest.user, + giveaway_message_id=TestChatBoostSourceGiveawayWithoutRequest.giveaway_message_id, + ) + - assert c == d - assert hash(c) == hash(d) +class TestChatBoostSourceGiveawayWithoutRequest(ChatBoostDefaults): + source = ChatBoostSources.GIVEAWAY - assert c != e - assert hash(c) != hash(e) + def test_slot_behaviour(self, chat_boost_source_giveaway): + inst = chat_boost_source_giveaway + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - def test_enum_init(self, chat_boost_source): - cbs = ChatBoostSource(source="foo") - assert cbs.source == "foo" - cbs = ChatBoostSource(source="premium") - assert cbs.source == ChatBoostSources.PREMIUM + def test_de_json(self, offline_bot): + json_dict = { + "user": self.user.to_dict(), + "giveaway_message_id": self.giveaway_message_id, + } + cbsg = ChatBoostSourceGiveaway.de_json(json_dict, offline_bot) + + assert cbsg.api_kwargs == {} + assert cbsg.user == self.user + assert cbsg.giveaway_message_id == self.giveaway_message_id + + def test_to_dict(self, chat_boost_source_giveaway): + chat_boost_source_giveaway_dict = chat_boost_source_giveaway.to_dict() + + assert isinstance(chat_boost_source_giveaway_dict, dict) + assert chat_boost_source_giveaway_dict["source"] == self.source + assert chat_boost_source_giveaway_dict["user"] == self.user.to_dict() + assert chat_boost_source_giveaway_dict["giveaway_message_id"] == self.giveaway_message_id + + def test_equality(self, chat_boost_source_giveaway): + a = chat_boost_source_giveaway + b = ChatBoostSourceGiveaway(user=self.user, giveaway_message_id=self.giveaway_message_id) + c = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + +@pytest.fixture(scope="module") +def chat_boost(): + return ChatBoost( + boost_id=ChatBoostDefaults.boost_id, + add_date=ChatBoostDefaults.date, + expiration_date=ChatBoostDefaults.date, + source=ChatBoostDefaults.default_source, + ) class TestChatBoostWithoutRequest(ChatBoostDefaults): @@ -267,30 +285,24 @@ def test_slot_behaviour(self, chat_boost): def test_de_json(self, offline_bot, chat_boost): json_dict = { - "boost_id": "2", - "add_date": self.date, - "expiration_date": self.date, + "boost_id": self.boost_id, + "add_date": to_timestamp(self.date), + "expiration_date": to_timestamp(self.date), "source": self.default_source.to_dict(), } cb = ChatBoost.de_json(json_dict, offline_bot) - assert isinstance(cb, ChatBoost) - assert isinstance(cb.add_date, dtm.datetime) - assert isinstance(cb.expiration_date, dtm.datetime) - assert isinstance(cb.source, ChatBoostSource) - with cb._unfrozen(): - cb.add_date = to_timestamp(cb.add_date) - cb.expiration_date = to_timestamp(cb.expiration_date) - - # We don't compare cbu.boost to self.boost because we have to update the _id_attrs (sigh) - for slot in cb.__slots__: - assert getattr(cb, slot) == getattr(chat_boost, slot), f"attribute {slot} differs" + assert cb.api_kwargs == {} + assert cb.boost_id == self.boost_id + assert (cb.add_date) == self.date + assert (cb.expiration_date) == self.date + assert cb.source == self.default_source def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): json_dict = { "boost_id": "2", - "add_date": self.date, - "expiration_date": self.date, + "add_date": to_timestamp(self.date), + "expiration_date": to_timestamp(self.date), "source": self.default_source.to_dict(), } @@ -311,8 +323,8 @@ def test_to_dict(self, chat_boost): assert isinstance(chat_boost_dict, dict) assert chat_boost_dict["boost_id"] == chat_boost.boost_id - assert chat_boost_dict["add_date"] == chat_boost.add_date - assert chat_boost_dict["expiration_date"] == chat_boost.expiration_date + assert chat_boost_dict["add_date"] == to_timestamp(chat_boost.add_date) + assert chat_boost_dict["expiration_date"] == to_timestamp(chat_boost.expiration_date) assert chat_boost_dict["source"] == chat_boost.source.to_dict() def test_equality(self): @@ -342,6 +354,14 @@ def test_equality(self): assert hash(a) != hash(c) +@pytest.fixture(scope="module") +def chat_boost_updated(chat_boost): + return ChatBoostUpdated( + chat=ChatBoostDefaults.chat, + boost=chat_boost, + ) + + class TestChatBoostUpdatedWithoutRequest(ChatBoostDefaults): def test_slot_behaviour(self, chat_boost_updated): inst = chat_boost_updated @@ -352,25 +372,13 @@ def test_slot_behaviour(self, chat_boost_updated): def test_de_json(self, offline_bot, chat_boost): json_dict = { "chat": self.chat.to_dict(), - "boost": { - "boost_id": "2", - "add_date": self.date, - "expiration_date": self.date, - "source": self.default_source.to_dict(), - }, + "boost": self.boost.to_dict(), } cbu = ChatBoostUpdated.de_json(json_dict, offline_bot) - assert isinstance(cbu, ChatBoostUpdated) + assert cbu.api_kwargs == {} assert cbu.chat == self.chat - # We don't compare cbu.boost to chat_boost because we have to update the _id_attrs (sigh) - with cbu.boost._unfrozen(): - cbu.boost.add_date = to_timestamp(cbu.boost.add_date) - cbu.boost.expiration_date = to_timestamp(cbu.boost.expiration_date) - for slot in cbu.boost.__slots__: # Assumes _id_attrs are same as slots - assert getattr(cbu.boost, slot) == getattr(chat_boost, slot), f"attr {slot} differs" - - # no need to test localization since that is already tested in the above class. + assert cbu.boost == self.boost def test_to_dict(self, chat_boost_updated): chat_boost_updated_dict = chat_boost_updated.to_dict() @@ -415,6 +423,16 @@ def test_equality(self): assert hash(a) != hash(c) +@pytest.fixture(scope="module") +def chat_boost_removed(): + return ChatBoostRemoved( + chat=ChatBoostDefaults.chat, + boost_id=ChatBoostDefaults.boost_id, + remove_date=ChatBoostDefaults.date, + source=ChatBoostDefaults.default_source, + ) + + class TestChatBoostRemovedWithoutRequest(ChatBoostDefaults): def test_slot_behaviour(self, chat_boost_removed): inst = chat_boost_removed @@ -425,23 +443,23 @@ def test_slot_behaviour(self, chat_boost_removed): def test_de_json(self, offline_bot, chat_boost_removed): json_dict = { "chat": self.chat.to_dict(), - "boost_id": "2", - "remove_date": self.date, + "boost_id": self.boost_id, + "remove_date": to_timestamp(self.date), "source": self.default_source.to_dict(), } cbr = ChatBoostRemoved.de_json(json_dict, offline_bot) - assert isinstance(cbr, ChatBoostRemoved) + assert cbr.api_kwargs == {} assert cbr.chat == self.chat assert cbr.boost_id == self.boost_id - assert to_timestamp(cbr.remove_date) == self.date + assert cbr.remove_date == self.date assert cbr.source == self.default_source def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): json_dict = { "chat": self.chat.to_dict(), - "boost_id": "2", - "remove_date": self.date, + "boost_id": self.boost_id, + "remove_date": to_timestamp(self.date), "source": self.default_source.to_dict(), } @@ -463,7 +481,9 @@ def test_to_dict(self, chat_boost_removed): assert isinstance(chat_boost_removed_dict, dict) assert chat_boost_removed_dict["chat"] == chat_boost_removed.chat.to_dict() assert chat_boost_removed_dict["boost_id"] == chat_boost_removed.boost_id - assert chat_boost_removed_dict["remove_date"] == chat_boost_removed.remove_date + assert chat_boost_removed_dict["remove_date"] == to_timestamp( + chat_boost_removed.remove_date + ) assert chat_boost_removed_dict["source"] == chat_boost_removed.source.to_dict() def test_equality(self): @@ -493,6 +513,13 @@ def test_equality(self): assert hash(a) != hash(c) +@pytest.fixture(scope="module") +def user_chat_boosts(chat_boost): + return UserChatBoosts( + boosts=[chat_boost], + ) + + class TestUserChatBoostsWithoutRequest(ChatBoostDefaults): def test_slot_behaviour(self, user_chat_boosts): inst = user_chat_boosts @@ -503,22 +530,13 @@ def test_slot_behaviour(self, user_chat_boosts): def test_de_json(self, offline_bot, user_chat_boosts): json_dict = { "boosts": [ - { - "boost_id": "2", - "add_date": self.date, - "expiration_date": self.date, - "source": self.default_source.to_dict(), - } + self.boost.to_dict(), ] } ucb = UserChatBoosts.de_json(json_dict, offline_bot) - assert isinstance(ucb, UserChatBoosts) - assert isinstance(ucb.boosts[0], ChatBoost) - assert ucb.boosts[0].boost_id == self.boost_id - assert to_timestamp(ucb.boosts[0].add_date) == self.date - assert to_timestamp(ucb.boosts[0].expiration_date) == self.date - assert ucb.boosts[0].source == self.default_source + assert ucb.api_kwargs == {} + assert ucb.boosts[0] == self.boost def test_to_dict(self, user_chat_boosts): user_chat_boosts_dict = user_chat_boosts.to_dict() @@ -534,7 +552,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): user_id = data["user_id"] == "2" if not all((chat_id, user_id)): pytest.fail("I got wrong parameters in post") - return data + return get_dummy_object_json_dict(UserChatBoosts) monkeypatch.setattr(offline_bot.request, "post", make_assertion) diff --git a/tests/test_chatfullinfo.py b/tests/test_chatfullinfo.py index 11373567c9f..e52a71b9999 100644 --- a/tests/test_chatfullinfo.py +++ b/tests/test_chatfullinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -33,9 +33,13 @@ Location, ReactionTypeCustomEmoji, ReactionTypeEmoji, + UniqueGiftColors, + UserRating, ) +from telegram._gifts import AcceptedGiftTypes from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import ReactionEmoji +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -46,12 +50,14 @@ def chat_full_info(bot): type=ChatFullInfoTestBase.type_, accent_color_id=ChatFullInfoTestBase.accent_color_id, max_reaction_count=ChatFullInfoTestBase.max_reaction_count, + accepted_gift_types=ChatFullInfoTestBase.accepted_gift_types, title=ChatFullInfoTestBase.title, username=ChatFullInfoTestBase.username, sticker_set_name=ChatFullInfoTestBase.sticker_set_name, can_set_sticker_set=ChatFullInfoTestBase.can_set_sticker_set, permissions=ChatFullInfoTestBase.permissions, slow_mode_delay=ChatFullInfoTestBase.slow_mode_delay, + message_auto_delete_time=ChatFullInfoTestBase.message_auto_delete_time, bio=ChatFullInfoTestBase.bio, linked_chat_id=ChatFullInfoTestBase.linked_chat_id, location=ChatFullInfoTestBase.location, @@ -83,6 +89,11 @@ def chat_full_info(bot): first_name=ChatFullInfoTestBase.first_name, last_name=ChatFullInfoTestBase.last_name, can_send_paid_media=ChatFullInfoTestBase.can_send_paid_media, + is_direct_messages=ChatFullInfoTestBase.is_direct_messages, + parent_chat=ChatFullInfoTestBase.parent_chat, + rating=ChatFullInfoTestBase.rating, + unique_gift_colors=ChatFullInfoTestBase.unique_gift_colors, + paid_message_star_count=ChatFullInfoTestBase.paid_message_star_count, ) chat.set_bot(bot) chat._unfreeze() @@ -103,7 +114,8 @@ class ChatFullInfoTestBase: can_change_info=False, can_invite_users=True, ) - slow_mode_delay = 30 + slow_mode_delay = dtm.timedelta(seconds=30) + message_auto_delete_time = dtm.timedelta(60) bio = "I'm a Barbie Girl in a Barbie World" linked_chat_id = 11880 location = ChatLocation(Location(123, 456), "Barbie World") @@ -140,6 +152,19 @@ class ChatFullInfoTestBase: first_name = "first_name" last_name = "last_name" can_send_paid_media = True + accepted_gift_types = AcceptedGiftTypes(True, True, True, True, True) + is_direct_messages = True + parent_chat = Chat(4, "channel", "channel") + rating = UserRating(level=1, rating=2, current_level_rating=3, next_level_rating=4) + unique_gift_colors = UniqueGiftColors( + model_custom_emoji_id="model_custom_emoji_id", + symbol_custom_emoji_id="symbol_custom_emoji_id", + light_theme_main_color=0xFF5733, + light_theme_other_colors=[0x33FF57, 0x3357FF], + dark_theme_main_color=0xC70039, + dark_theme_other_colors=[0x900C3F, 0x581845], + ) + paid_message_star_count = 1234 class TestChatFullInfoWithoutRequest(ChatFullInfoTestBase): @@ -158,10 +183,12 @@ def test_de_json(self, offline_bot): "accent_color_id": self.accent_color_id, "max_reaction_count": self.max_reaction_count, "username": self.username, + "accepted_gift_types": self.accepted_gift_types.to_dict(), "sticker_set_name": self.sticker_set_name, "can_set_sticker_set": self.can_set_sticker_set, "permissions": self.permissions.to_dict(), - "slow_mode_delay": self.slow_mode_delay, + "slow_mode_delay": self.slow_mode_delay.total_seconds(), + "message_auto_delete_time": self.message_auto_delete_time.total_seconds(), "bio": self.bio, "business_intro": self.business_intro.to_dict(), "business_location": self.business_location.to_dict(), @@ -193,16 +220,25 @@ def test_de_json(self, offline_bot): "first_name": self.first_name, "last_name": self.last_name, "can_send_paid_media": self.can_send_paid_media, + "is_direct_messages": self.is_direct_messages, + "parent_chat": self.parent_chat.to_dict(), + "rating": self.rating.to_dict(), + "unique_gift_colors": self.unique_gift_colors.to_dict(), + "paid_message_star_count": self.paid_message_star_count, } + cfi = ChatFullInfo.de_json(json_dict, offline_bot) + assert cfi.api_kwargs == {} assert cfi.id == self.id_ assert cfi.title == self.title assert cfi.type == self.type_ assert cfi.username == self.username + assert cfi.accepted_gift_types == self.accepted_gift_types assert cfi.sticker_set_name == self.sticker_set_name assert cfi.can_set_sticker_set == self.can_set_sticker_set assert cfi.permissions == self.permissions - assert cfi.slow_mode_delay == self.slow_mode_delay + assert cfi._slow_mode_delay == self.slow_mode_delay + assert cfi._message_auto_delete_time == self.message_auto_delete_time assert cfi.bio == self.bio assert cfi.business_intro == self.business_intro assert cfi.business_location == self.business_location @@ -238,6 +274,11 @@ def test_de_json(self, offline_bot): assert cfi.last_name == self.last_name assert cfi.max_reaction_count == self.max_reaction_count assert cfi.can_send_paid_media == self.can_send_paid_media + assert cfi.is_direct_messages == self.is_direct_messages + assert cfi.parent_chat == self.parent_chat + assert cfi.rating == self.rating + assert cfi.unique_gift_colors == self.unique_gift_colors + assert cfi.paid_message_star_count == self.paid_message_star_count def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): json_dict = { @@ -245,6 +286,7 @@ def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): "type": self.type_, "accent_color_id": self.accent_color_id, "max_reaction_count": self.max_reaction_count, + "accepted_gift_types": self.accepted_gift_types.to_dict(), "emoji_status_expiration_date": to_timestamp(self.emoji_status_expiration_date), } cfi_bot = ChatFullInfo.de_json(json_dict, offline_bot) @@ -271,7 +313,10 @@ def test_to_dict(self, chat_full_info): assert cfi_dict["type"] == cfi.type assert cfi_dict["username"] == cfi.username assert cfi_dict["permissions"] == cfi.permissions.to_dict() - assert cfi_dict["slow_mode_delay"] == cfi.slow_mode_delay + assert cfi_dict["slow_mode_delay"] == int(self.slow_mode_delay.total_seconds()) + assert cfi_dict["message_auto_delete_time"] == int( + self.message_auto_delete_time.total_seconds() + ) assert cfi_dict["bio"] == cfi.bio assert cfi_dict["business_intro"] == cfi.business_intro.to_dict() assert cfi_dict["business_location"] == cfi.business_location.to_dict() @@ -312,8 +357,43 @@ def test_to_dict(self, chat_full_info): assert cfi_dict["first_name"] == cfi.first_name assert cfi_dict["last_name"] == cfi.last_name assert cfi_dict["can_send_paid_media"] == cfi.can_send_paid_media + assert cfi_dict["accepted_gift_types"] == cfi.accepted_gift_types.to_dict() assert cfi_dict["max_reaction_count"] == cfi.max_reaction_count + assert cfi_dict["is_direct_messages"] == cfi.is_direct_messages + assert cfi_dict["parent_chat"] == cfi.parent_chat.to_dict() + assert cfi_dict["rating"] == cfi.rating.to_dict() + assert cfi_dict["unique_gift_colors"] == cfi.unique_gift_colors.to_dict() + assert cfi_dict["paid_message_star_count"] == cfi.paid_message_star_count + + def test_time_period_properties(self, PTB_TIMEDELTA, chat_full_info): + cfi = chat_full_info + if PTB_TIMEDELTA: + assert cfi.slow_mode_delay == self.slow_mode_delay + assert isinstance(cfi.slow_mode_delay, dtm.timedelta) + + assert cfi.message_auto_delete_time == self.message_auto_delete_time + assert isinstance(cfi.message_auto_delete_time, dtm.timedelta) + else: + assert cfi.slow_mode_delay == int(self.slow_mode_delay.total_seconds()) + assert isinstance(cfi.slow_mode_delay, int) + + assert cfi.message_auto_delete_time == int( + self.message_auto_delete_time.total_seconds() + ) + assert isinstance(cfi.message_auto_delete_time, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, chat_full_info): + chat_full_info.slow_mode_delay + chat_full_info.message_auto_delete_time + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 2 + for i, attr in enumerate(["slow_mode_delay", "message_auto_delete_time"]): + assert f"`{attr}` will be of type `datetime.timedelta`" in str(recwarn[i].message) + assert recwarn[i].category is PTBDeprecationWarning def test_always_tuples_attributes(self): cfi = ChatFullInfo( @@ -321,6 +401,7 @@ def test_always_tuples_attributes(self): type=Chat.PRIVATE, accent_color_id=1, max_reaction_count=2, + accepted_gift_types=self.accepted_gift_types, ) assert isinstance(cfi.active_usernames, tuple) assert cfi.active_usernames == () diff --git a/tests/test_chatinvitelink.py b/tests/test_chatinvitelink.py index 55cfc5763a9..36acfbffbab 100644 --- a/tests/test_chatinvitelink.py +++ b/tests/test_chatinvitelink.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -22,6 +22,7 @@ from telegram import ChatInviteLink, User from telegram._utils.datetime import UTC, to_timestamp +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -56,7 +57,7 @@ class ChatInviteLinkTestBase: member_limit = 42 name = "LinkName" pending_join_request_count = 42 - subscription_period = 43 + subscription_period = dtm.timedelta(seconds=43) subscription_price = 44 @@ -95,7 +96,7 @@ def test_de_json_all_args(self, offline_bot, creator): "member_limit": self.member_limit, "name": self.name, "pending_join_request_count": str(self.pending_join_request_count), - "subscription_period": self.subscription_period, + "subscription_period": int(self.subscription_period.total_seconds()), "subscription_price": self.subscription_price, } @@ -112,7 +113,7 @@ def test_de_json_all_args(self, offline_bot, creator): assert invite_link.member_limit == self.member_limit assert invite_link.name == self.name assert invite_link.pending_join_request_count == self.pending_join_request_count - assert invite_link.subscription_period == self.subscription_period + assert invite_link._subscription_period == self.subscription_period assert invite_link.subscription_price == self.subscription_price def test_de_json_localization(self, tz_bot, offline_bot, raw_bot, creator): @@ -154,9 +155,32 @@ def test_to_dict(self, invite_link): assert invite_link_dict["member_limit"] == self.member_limit assert invite_link_dict["name"] == self.name assert invite_link_dict["pending_join_request_count"] == self.pending_join_request_count - assert invite_link_dict["subscription_period"] == self.subscription_period + assert invite_link_dict["subscription_period"] == int( + self.subscription_period.total_seconds() + ) + assert isinstance(invite_link_dict["subscription_period"], int) assert invite_link_dict["subscription_price"] == self.subscription_price + def test_time_period_properties(self, PTB_TIMEDELTA, invite_link): + if PTB_TIMEDELTA: + assert invite_link.subscription_period == self.subscription_period + assert isinstance(invite_link.subscription_period, dtm.timedelta) + else: + assert invite_link.subscription_period == int(self.subscription_period.total_seconds()) + assert isinstance(invite_link.subscription_period, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, invite_link): + invite_link.subscription_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`subscription_period` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = ChatInviteLink("link", User(1, "", False), True, True, True) b = ChatInviteLink("link", User(1, "", False), True, True, True) diff --git a/tests/test_chatjoinrequest.py b/tests/test_chatjoinrequest.py index e94bd7dff4a..85c94e5852f 100644 --- a/tests/test_chatjoinrequest.py +++ b/tests/test_chatjoinrequest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_chatlocation.py b/tests/test_chatlocation.py index 5ab90d26532..e0ba62d3cd3 100644 --- a/tests/test_chatlocation.py +++ b/tests/test_chatlocation.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_chatmember.py b/tests/test_chatmember.py index 6249fccfe08..2ef55b067f5 100644 --- a/tests/test_chatmember.py +++ b/tests/test_chatmember.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -34,260 +34,450 @@ User, ) from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import ChatMemberStatus from tests.auxil.slots import mro_slots -ignored = ["self", "api_kwargs"] - - -class CMDefaults: - user = User(1, "First name", False) - custom_title: str = "PTB" - is_anonymous: bool = True - until_date: dtm.datetime = to_timestamp(dtm.datetime.utcnow()) - can_be_edited: bool = False - can_change_info: bool = True - can_post_messages: bool = True - can_edit_messages: bool = True - can_delete_messages: bool = True - can_invite_users: bool = True - can_restrict_members: bool = True - can_pin_messages: bool = True - can_promote_members: bool = True - can_send_messages: bool = True - can_send_media_messages: bool = True - can_send_polls: bool = True - can_send_other_messages: bool = True - can_add_web_page_previews: bool = True - is_member: bool = True - can_manage_chat: bool = True - can_manage_video_chats: bool = True - can_manage_topics: bool = True - can_send_audios: bool = True - can_send_documents: bool = True - can_send_photos: bool = True - can_send_videos: bool = True - can_send_video_notes: bool = True - can_send_voice_notes: bool = True - can_post_stories: bool = True - can_edit_stories: bool = True - can_delete_stories: bool = True +@pytest.fixture +def chat_member(): + return ChatMember(ChatMemberTestBase.user, ChatMemberTestBase.status) + + +class ChatMemberTestBase: + status = ChatMemberStatus.MEMBER + user = User(1, "test_user", is_bot=False) + is_anonymous = True + custom_title = "test_title" + can_be_edited = True + can_manage_chat = True + can_delete_messages = True + can_manage_video_chats = True + can_restrict_members = True + can_promote_members = True + can_change_info = True + can_invite_users = True + can_post_messages = True + can_edit_messages = True + can_pin_messages = True + can_post_stories = True + can_edit_stories = True + can_delete_stories = True + can_manage_topics = True + until_date = dtm.datetime.now(UTC).replace(microsecond=0) + can_send_polls = True + can_send_other_messages = True + can_add_web_page_previews = True + can_send_audios = True + can_send_documents = True + can_send_photos = True + can_send_videos = True + can_send_video_notes = True + can_send_voice_notes = True + can_send_messages = True + is_member = True + can_manage_direct_messages = True + + +class TestChatMemberWithoutRequest(ChatMemberTestBase): + def test_slot_behaviour(self, chat_member): + inst = chat_member + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" -def chat_member_owner(): - return ChatMemberOwner(CMDefaults.user, CMDefaults.is_anonymous, CMDefaults.custom_title) + def test_status_enum_conversion(self, chat_member): + assert type(ChatMember(ChatMemberTestBase.user, "member").status) is ChatMemberStatus + assert ChatMember(ChatMemberTestBase.user, "unknown").status == "unknown" + + def test_de_json(self, offline_bot): + data = {"status": "unknown", "user": self.user.to_dict()} + chat_member = ChatMember.de_json(data, offline_bot) + assert chat_member.api_kwargs == {} + assert chat_member.status == "unknown" + assert chat_member.user == self.user + + @pytest.mark.parametrize( + ("status", "subclass"), + [ + ("administrator", ChatMemberAdministrator), + ("kicked", ChatMemberBanned), + ("left", ChatMemberLeft), + ("member", ChatMemberMember), + ("creator", ChatMemberOwner), + ("restricted", ChatMemberRestricted), + ], + ) + def test_de_json_subclass(self, offline_bot, status, subclass): + json_dict = { + "status": status, + "user": self.user.to_dict(), + "is_anonymous": self.is_anonymous, + "is_member": self.is_member, + "until_date": to_timestamp(self.until_date), + **{name: value for name, value in inspect.getmembers(self) if name.startswith("can_")}, + } + chat_member = ChatMember.de_json(json_dict, offline_bot) + + assert type(chat_member) is subclass + assert set(chat_member.api_kwargs.keys()) == set(json_dict.keys()) - set( + subclass.__slots__ + ) - {"status", "user"} + assert chat_member.user == self.user + + def test_to_dict(self, chat_member): + assert chat_member.to_dict() == { + "status": chat_member.status, + "user": chat_member.user.to_dict(), + } + def test_equality(self, chat_member): + a = chat_member + b = ChatMember(self.user, self.status) + c = ChatMember(self.user, "unknown") + d = ChatMember(User(2, "test_bot", is_bot=True), self.status) + e = Dice(5, "test") + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture def chat_member_administrator(): return ChatMemberAdministrator( - CMDefaults.user, - CMDefaults.can_be_edited, - CMDefaults.is_anonymous, - CMDefaults.can_manage_chat, - CMDefaults.can_delete_messages, - CMDefaults.can_manage_video_chats, - CMDefaults.can_restrict_members, - CMDefaults.can_promote_members, - CMDefaults.can_change_info, - CMDefaults.can_invite_users, - CMDefaults.can_post_stories, - CMDefaults.can_edit_stories, - CMDefaults.can_delete_stories, - CMDefaults.can_post_messages, - CMDefaults.can_edit_messages, - CMDefaults.can_pin_messages, - CMDefaults.can_manage_topics, - CMDefaults.custom_title, + TestChatMemberAdministratorWithoutRequest.user, + TestChatMemberAdministratorWithoutRequest.can_be_edited, + TestChatMemberAdministratorWithoutRequest.can_change_info, + TestChatMemberAdministratorWithoutRequest.can_delete_messages, + TestChatMemberAdministratorWithoutRequest.can_delete_stories, + TestChatMemberAdministratorWithoutRequest.can_edit_messages, + TestChatMemberAdministratorWithoutRequest.can_edit_stories, + TestChatMemberAdministratorWithoutRequest.can_invite_users, + TestChatMemberAdministratorWithoutRequest.can_manage_chat, + TestChatMemberAdministratorWithoutRequest.can_manage_topics, + TestChatMemberAdministratorWithoutRequest.can_manage_video_chats, + TestChatMemberAdministratorWithoutRequest.can_pin_messages, + TestChatMemberAdministratorWithoutRequest.can_post_messages, + TestChatMemberAdministratorWithoutRequest.can_post_stories, + TestChatMemberAdministratorWithoutRequest.can_promote_members, + TestChatMemberAdministratorWithoutRequest.can_restrict_members, + TestChatMemberAdministratorWithoutRequest.custom_title, + TestChatMemberAdministratorWithoutRequest.is_anonymous, + TestChatMemberAdministratorWithoutRequest.can_manage_direct_messages, ) -def chat_member_member(): - return ChatMemberMember(CMDefaults.user, until_date=CMDefaults.until_date) +class TestChatMemberAdministratorWithoutRequest(ChatMemberTestBase): + status = ChatMemberStatus.ADMINISTRATOR + def test_slot_behaviour(self, chat_member_administrator): + inst = chat_member_administrator + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" -def chat_member_restricted(): - return ChatMemberRestricted( - CMDefaults.user, - CMDefaults.is_member, - CMDefaults.can_change_info, - CMDefaults.can_invite_users, - CMDefaults.can_pin_messages, - CMDefaults.can_send_messages, - CMDefaults.can_send_polls, - CMDefaults.can_send_other_messages, - CMDefaults.can_add_web_page_previews, - CMDefaults.can_manage_topics, - CMDefaults.until_date, - CMDefaults.can_send_audios, - CMDefaults.can_send_documents, - CMDefaults.can_send_photos, - CMDefaults.can_send_videos, - CMDefaults.can_send_video_notes, - CMDefaults.can_send_voice_notes, + def test_de_json(self, offline_bot): + data = { + "user": self.user.to_dict(), + "can_be_edited": self.can_be_edited, + "can_change_info": self.can_change_info, + "can_delete_messages": self.can_delete_messages, + "can_delete_stories": self.can_delete_stories, + "can_edit_messages": self.can_edit_messages, + "can_edit_stories": self.can_edit_stories, + "can_invite_users": self.can_invite_users, + "can_manage_chat": self.can_manage_chat, + "can_manage_topics": self.can_manage_topics, + "can_manage_video_chats": self.can_manage_video_chats, + "can_pin_messages": self.can_pin_messages, + "can_post_messages": self.can_post_messages, + "can_post_stories": self.can_post_stories, + "can_promote_members": self.can_promote_members, + "can_restrict_members": self.can_restrict_members, + "custom_title": self.custom_title, + "is_anonymous": self.is_anonymous, + "can_manage_direct_messages": self.can_manage_direct_messages, + } + chat_member = ChatMemberAdministrator.de_json(data, offline_bot) + + assert type(chat_member) is ChatMemberAdministrator + assert chat_member.api_kwargs == {} + + assert chat_member.user == self.user + assert chat_member.can_be_edited == self.can_be_edited + assert chat_member.can_change_info == self.can_change_info + assert chat_member.can_delete_messages == self.can_delete_messages + assert chat_member.can_delete_stories == self.can_delete_stories + assert chat_member.can_edit_messages == self.can_edit_messages + assert chat_member.can_edit_stories == self.can_edit_stories + assert chat_member.can_invite_users == self.can_invite_users + assert chat_member.can_manage_chat == self.can_manage_chat + assert chat_member.can_manage_topics == self.can_manage_topics + assert chat_member.can_manage_video_chats == self.can_manage_video_chats + assert chat_member.can_pin_messages == self.can_pin_messages + assert chat_member.can_post_messages == self.can_post_messages + assert chat_member.can_post_stories == self.can_post_stories + assert chat_member.can_promote_members == self.can_promote_members + assert chat_member.can_restrict_members == self.can_restrict_members + assert chat_member.custom_title == self.custom_title + assert chat_member.is_anonymous == self.is_anonymous + assert chat_member.can_manage_direct_messages == self.can_manage_direct_messages + + def test_to_dict(self, chat_member_administrator): + assert chat_member_administrator.to_dict() == { + "status": chat_member_administrator.status, + "user": chat_member_administrator.user.to_dict(), + "can_be_edited": chat_member_administrator.can_be_edited, + "can_change_info": chat_member_administrator.can_change_info, + "can_delete_messages": chat_member_administrator.can_delete_messages, + "can_delete_stories": chat_member_administrator.can_delete_stories, + "can_edit_messages": chat_member_administrator.can_edit_messages, + "can_edit_stories": chat_member_administrator.can_edit_stories, + "can_invite_users": chat_member_administrator.can_invite_users, + "can_manage_chat": chat_member_administrator.can_manage_chat, + "can_manage_topics": chat_member_administrator.can_manage_topics, + "can_manage_video_chats": chat_member_administrator.can_manage_video_chats, + "can_pin_messages": chat_member_administrator.can_pin_messages, + "can_post_messages": chat_member_administrator.can_post_messages, + "can_post_stories": chat_member_administrator.can_post_stories, + "can_promote_members": chat_member_administrator.can_promote_members, + "can_restrict_members": chat_member_administrator.can_restrict_members, + "custom_title": chat_member_administrator.custom_title, + "is_anonymous": chat_member_administrator.is_anonymous, + "can_manage_direct_messages": chat_member_administrator.can_manage_direct_messages, + } + + def test_equality(self, chat_member_administrator): + a = chat_member_administrator + b = ChatMemberAdministrator( + User(1, "test_user", is_bot=False), + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + ) + c = ChatMemberAdministrator( + User(1, "test_user", is_bot=False), + False, + False, + False, + False, + False, + False, + False, + False, + False, + False, + False, + False, + False, + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def chat_member_banned(): + return ChatMemberBanned( + TestChatMemberBannedWithoutRequest.user, + TestChatMemberBannedWithoutRequest.until_date, ) +class TestChatMemberBannedWithoutRequest(ChatMemberTestBase): + status = ChatMemberStatus.BANNED + + def test_slot_behaviour(self, chat_member_banned): + inst = chat_member_banned + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + data = { + "user": self.user.to_dict(), + "until_date": to_timestamp(self.until_date), + } + chat_member = ChatMemberBanned.de_json(data, offline_bot) + + assert type(chat_member) is ChatMemberBanned + assert chat_member.api_kwargs == {} + + assert chat_member.user == self.user + assert chat_member.until_date == self.until_date + + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): + json_dict = { + "user": self.user.to_dict(), + "until_date": to_timestamp(self.until_date), + } + + cmb_raw = ChatMemberBanned.de_json(json_dict, raw_bot) + cmb_bot = ChatMemberBanned.de_json(json_dict, offline_bot) + cmb_bot_tz = ChatMemberBanned.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + cmb_bot_tz_offset = cmb_bot_tz.until_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + cmb_bot_tz.until_date.replace(tzinfo=None) + ) + + assert cmb_raw.until_date.tzinfo == UTC + assert cmb_bot.until_date.tzinfo == UTC + assert cmb_bot_tz_offset == tz_bot_offset + + def test_to_dict(self, chat_member_banned): + assert chat_member_banned.to_dict() == { + "status": chat_member_banned.status, + "user": chat_member_banned.user.to_dict(), + "until_date": to_timestamp(chat_member_banned.until_date), + } + + def test_equality(self, chat_member_banned): + a = chat_member_banned + b = ChatMemberBanned( + User(1, "test_user", is_bot=False), dtm.datetime.now(UTC).replace(microsecond=0) + ) + c = ChatMemberBanned( + User(2, "test_bot", is_bot=True), dtm.datetime.now(UTC).replace(microsecond=0) + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture def chat_member_left(): - return ChatMemberLeft(CMDefaults.user) + return ChatMemberLeft(TestChatMemberLeftWithoutRequest.user) -def chat_member_banned(): - return ChatMemberBanned(CMDefaults.user, CMDefaults.until_date) - - -def make_json_dict(instance: ChatMember, include_optional_args: bool = False) -> dict: - """Used to make the json dict which we use for testing de_json. Similar to iter_args()""" - json_dict = {"status": instance.status} - sig = inspect.signature(instance.__class__.__init__) - - for param in sig.parameters.values(): - if param.name in ignored: # ignore irrelevant params - continue - - val = getattr(instance, param.name) - # Compulsory args- - if param.default is inspect.Parameter.empty: - if hasattr(val, "to_dict"): # convert the user object or any future ones to dict. - val = val.to_dict() - json_dict[param.name] = val - - # If we want to test all args (for de_json) - # or if the param is optional but for backwards compatability - elif ( - param.default is not inspect.Parameter.empty - and include_optional_args - or param.name in ["can_delete_stories", "can_post_stories", "can_edit_stories"] - ): - json_dict[param.name] = val - return json_dict - - -def iter_args(instance: ChatMember, de_json_inst: ChatMember, include_optional: bool = False): - """ - We accept both the regular instance and de_json created instance and iterate over them for - easy one line testing later one. - """ - yield instance.status, de_json_inst.status # yield this here cause it's not available in sig. - - sig = inspect.signature(instance.__class__.__init__) - for param in sig.parameters.values(): - if param.name in ignored: - continue - inst_at, json_at = getattr(instance, param.name), getattr(de_json_inst, param.name) - if isinstance(json_at, dtm.datetime): # Convert dtm to int - json_at = to_timestamp(json_at) - if ( - param.default is not inspect.Parameter.empty and include_optional - ) or param.default is inspect.Parameter.empty: - yield inst_at, json_at +class TestChatMemberLeftWithoutRequest(ChatMemberTestBase): + status = ChatMemberStatus.LEFT + + def test_slot_behaviour(self, chat_member_left): + inst = chat_member_left + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + data = {"user": self.user.to_dict()} + chat_member = ChatMemberLeft.de_json(data, offline_bot) + + assert type(chat_member) is ChatMemberLeft + assert chat_member.api_kwargs == {} + + assert chat_member.user == self.user + + def test_to_dict(self, chat_member_left): + assert chat_member_left.to_dict() == { + "status": chat_member_left.status, + "user": chat_member_left.user.to_dict(), + } + + def test_equality(self, chat_member_left): + a = chat_member_left + b = ChatMemberLeft(User(1, "test_user", is_bot=False)) + c = ChatMemberLeft(User(2, "test_bot", is_bot=True)) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) @pytest.fixture -def chat_member_type(request): - return request.param() - - -@pytest.mark.parametrize( - "chat_member_type", - [ - chat_member_owner, - chat_member_administrator, - chat_member_member, - chat_member_restricted, - chat_member_left, - chat_member_banned, - ], - indirect=True, -) -class TestChatMemberTypesWithoutRequest: - def test_slot_behaviour(self, chat_member_type): - inst = chat_member_type +def chat_member_member(): + return ChatMemberMember(TestChatMemberMemberWithoutRequest.user) + + +class TestChatMemberMemberWithoutRequest(ChatMemberTestBase): + status = ChatMemberStatus.MEMBER + + def test_slot_behaviour(self, chat_member_member): + inst = chat_member_member for attr in inst.__slots__: assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - def test_de_json_required_args(self, offline_bot, chat_member_type): - cls = chat_member_type.__class__ - assert cls.de_json({}, offline_bot) is None - - json_dict = make_json_dict(chat_member_type) - const_chat_member = ChatMember.de_json(json_dict, offline_bot) - assert const_chat_member.api_kwargs == {} - - assert isinstance(const_chat_member, ChatMember) - assert isinstance(const_chat_member, cls) - for chat_mem_type_at, const_chat_mem_at in iter_args(chat_member_type, const_chat_member): - assert chat_mem_type_at == const_chat_mem_at - - def test_de_json_all_args(self, offline_bot, chat_member_type): - json_dict = make_json_dict(chat_member_type, include_optional_args=True) - const_chat_member = ChatMember.de_json(json_dict, offline_bot) - assert const_chat_member.api_kwargs == {} - - assert isinstance(const_chat_member, ChatMember) - assert isinstance(const_chat_member, chat_member_type.__class__) - for c_mem_type_at, const_c_mem_at in iter_args(chat_member_type, const_chat_member, True): - assert c_mem_type_at == const_c_mem_at - - def test_de_json_chatmemberbanned_localization( - self, chat_member_type, tz_bot, offline_bot, raw_bot - ): - # We only test two classes because the other three don't have datetimes in them. - if isinstance( - chat_member_type, (ChatMemberBanned, ChatMemberRestricted, ChatMemberMember) - ): - json_dict = make_json_dict(chat_member_type, include_optional_args=True) - chatmember_raw = ChatMember.de_json(json_dict, raw_bot) - chatmember_bot = ChatMember.de_json(json_dict, offline_bot) - chatmember_tz = ChatMember.de_json(json_dict, tz_bot) - - # comparing utcoffsets because comparing timezones is unpredicatable - chatmember_offset = chatmember_tz.until_date.utcoffset() - tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( - chatmember_tz.until_date.replace(tzinfo=None) - ) - - assert chatmember_raw.until_date.tzinfo == UTC - assert chatmember_bot.until_date.tzinfo == UTC - assert chatmember_offset == tz_bot_offset - - def test_de_json_invalid_status(self, chat_member_type, offline_bot): - json_dict = {"status": "invalid", "user": CMDefaults.user.to_dict()} - chat_member_type = ChatMember.de_json(json_dict, offline_bot) - - assert type(chat_member_type) is ChatMember - assert chat_member_type.status == "invalid" - - def test_de_json_subclass(self, chat_member_type, offline_bot, chat_id): - """This makes sure that e.g. ChatMemberAdministrator(data, offline_bot) never returns a - ChatMemberBanned instance.""" - cls = chat_member_type.__class__ - json_dict = make_json_dict(chat_member_type, True) - assert type(cls.de_json(json_dict, offline_bot)) is cls - - def test_to_dict(self, chat_member_type): - chat_member_dict = chat_member_type.to_dict() - - assert isinstance(chat_member_dict, dict) - assert chat_member_dict["status"] == chat_member_type.status - assert chat_member_dict["user"] == chat_member_type.user.to_dict() - - for slot in chat_member_type.__slots__: # additional verification for the optional args - assert getattr(chat_member_type, slot) == chat_member_dict[slot] - - def test_chat_member_restricted_api_kwargs(self, chat_member_type): - json_dict = make_json_dict(chat_member_restricted()) - json_dict["can_send_media_messages"] = "can_send_media_messages" - chat_member_restricted_instance = ChatMember.de_json(json_dict, None) - assert chat_member_restricted_instance.api_kwargs == { - "can_send_media_messages": "can_send_media_messages", + def test_de_json(self, offline_bot): + data = {"user": self.user.to_dict(), "until_date": to_timestamp(self.until_date)} + chat_member = ChatMemberMember.de_json(data, offline_bot) + + assert type(chat_member) is ChatMemberMember + assert chat_member.api_kwargs == {} + + assert chat_member.user == self.user + assert chat_member.until_date == self.until_date + + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): + json_dict = { + "user": self.user.to_dict(), + "until_date": to_timestamp(self.until_date), } - def test_equality(self, chat_member_type): - a = ChatMember(status="status", user=CMDefaults.user) - b = ChatMember(status="status", user=CMDefaults.user) - c = chat_member_type - d = deepcopy(chat_member_type) - e = Dice(4, "emoji") + cmm_raw = ChatMemberMember.de_json(json_dict, raw_bot) + cmm_bot = ChatMemberMember.de_json(json_dict, offline_bot) + cmm_bot_tz = ChatMemberMember.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + cmm_bot_tz_offset = cmm_bot_tz.until_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + cmm_bot_tz.until_date.replace(tzinfo=None) + ) + + assert cmm_raw.until_date.tzinfo == UTC + assert cmm_bot.until_date.tzinfo == UTC + assert cmm_bot_tz_offset == tz_bot_offset + + def test_to_dict(self, chat_member_member): + assert chat_member_member.to_dict() == { + "status": chat_member_member.status, + "user": chat_member_member.user.to_dict(), + } + + def test_equality(self, chat_member_member): + a = chat_member_member + b = ChatMemberMember(User(1, "test_user", is_bot=False)) + c = ChatMemberMember(User(2, "test_bot", is_bot=True)) + d = Dice(5, "test") assert a == b assert hash(a) == hash(b) @@ -298,11 +488,209 @@ def test_equality(self, chat_member_type): assert a != d assert hash(a) != hash(d) - assert a != e - assert hash(a) != hash(e) - assert c == d - assert hash(c) == hash(d) +@pytest.fixture +def chat_member_owner(): + return ChatMemberOwner( + TestChatMemberOwnerWithoutRequest.user, + TestChatMemberOwnerWithoutRequest.is_anonymous, + TestChatMemberOwnerWithoutRequest.custom_title, + ) + + +class TestChatMemberOwnerWithoutRequest(ChatMemberTestBase): + status = ChatMemberStatus.OWNER + + def test_slot_behaviour(self, chat_member_owner): + inst = chat_member_owner + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - assert c != e - assert hash(c) != hash(e) + def test_de_json(self, offline_bot): + data = { + "user": self.user.to_dict(), + "is_anonymous": self.is_anonymous, + "custom_title": self.custom_title, + } + chat_member = ChatMemberOwner.de_json(data, offline_bot) + + assert type(chat_member) is ChatMemberOwner + assert chat_member.api_kwargs == {} + + assert chat_member.user == self.user + assert chat_member.is_anonymous == self.is_anonymous + assert chat_member.custom_title == self.custom_title + + def test_to_dict(self, chat_member_owner): + assert chat_member_owner.to_dict() == { + "status": chat_member_owner.status, + "user": chat_member_owner.user.to_dict(), + "is_anonymous": chat_member_owner.is_anonymous, + "custom_title": chat_member_owner.custom_title, + } + + def test_equality(self, chat_member_owner): + a = chat_member_owner + b = ChatMemberOwner(User(1, "test_user", is_bot=False), True, "test_title") + c = ChatMemberOwner(User(1, "test_user", is_bot=False), False, "test_title") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def chat_member_restricted(): + return ChatMemberRestricted( + user=TestChatMemberRestrictedWithoutRequest.user, + can_add_web_page_previews=TestChatMemberRestrictedWithoutRequest.can_add_web_page_previews, + can_change_info=TestChatMemberRestrictedWithoutRequest.can_change_info, + can_invite_users=TestChatMemberRestrictedWithoutRequest.can_invite_users, + can_manage_topics=TestChatMemberRestrictedWithoutRequest.can_manage_topics, + can_pin_messages=TestChatMemberRestrictedWithoutRequest.can_pin_messages, + can_send_audios=TestChatMemberRestrictedWithoutRequest.can_send_audios, + can_send_documents=TestChatMemberRestrictedWithoutRequest.can_send_documents, + can_send_messages=TestChatMemberRestrictedWithoutRequest.can_send_messages, + can_send_other_messages=TestChatMemberRestrictedWithoutRequest.can_send_other_messages, + can_send_photos=TestChatMemberRestrictedWithoutRequest.can_send_photos, + can_send_polls=TestChatMemberRestrictedWithoutRequest.can_send_polls, + can_send_video_notes=TestChatMemberRestrictedWithoutRequest.can_send_video_notes, + can_send_videos=TestChatMemberRestrictedWithoutRequest.can_send_videos, + can_send_voice_notes=TestChatMemberRestrictedWithoutRequest.can_send_voice_notes, + is_member=TestChatMemberRestrictedWithoutRequest.is_member, + until_date=TestChatMemberRestrictedWithoutRequest.until_date, + ) + + +class TestChatMemberRestrictedWithoutRequest(ChatMemberTestBase): + status = ChatMemberStatus.RESTRICTED + + def test_slot_behaviour(self, chat_member_restricted): + inst = chat_member_restricted + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + data = { + "user": self.user.to_dict(), + "can_add_web_page_previews": self.can_add_web_page_previews, + "can_change_info": self.can_change_info, + "can_invite_users": self.can_invite_users, + "can_manage_topics": self.can_manage_topics, + "can_pin_messages": self.can_pin_messages, + "can_send_audios": self.can_send_audios, + "can_send_documents": self.can_send_documents, + "can_send_messages": self.can_send_messages, + "can_send_other_messages": self.can_send_other_messages, + "can_send_photos": self.can_send_photos, + "can_send_polls": self.can_send_polls, + "can_send_video_notes": self.can_send_video_notes, + "can_send_videos": self.can_send_videos, + "can_send_voice_notes": self.can_send_voice_notes, + "is_member": self.is_member, + "until_date": to_timestamp(self.until_date), + # legacy argument + "can_send_media_messages": False, + } + chat_member = ChatMemberRestricted.de_json(data, offline_bot) + + assert type(chat_member) is ChatMemberRestricted + assert chat_member.api_kwargs == {"can_send_media_messages": False} + + assert chat_member.user == self.user + assert chat_member.can_add_web_page_previews == self.can_add_web_page_previews + assert chat_member.can_change_info == self.can_change_info + assert chat_member.can_invite_users == self.can_invite_users + assert chat_member.can_manage_topics == self.can_manage_topics + assert chat_member.can_pin_messages == self.can_pin_messages + assert chat_member.can_send_audios == self.can_send_audios + assert chat_member.can_send_documents == self.can_send_documents + assert chat_member.can_send_messages == self.can_send_messages + assert chat_member.can_send_other_messages == self.can_send_other_messages + assert chat_member.can_send_photos == self.can_send_photos + assert chat_member.can_send_polls == self.can_send_polls + assert chat_member.can_send_video_notes == self.can_send_video_notes + assert chat_member.can_send_videos == self.can_send_videos + assert chat_member.can_send_voice_notes == self.can_send_voice_notes + assert chat_member.is_member == self.is_member + assert chat_member.until_date == self.until_date + + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot, chat_member_restricted): + json_dict = chat_member_restricted.to_dict() + + cmr_raw = ChatMemberRestricted.de_json(json_dict, raw_bot) + cmr_bot = ChatMemberRestricted.de_json(json_dict, offline_bot) + cmr_bot_tz = ChatMemberRestricted.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + cmr_bot_tz_offset = cmr_bot_tz.until_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + cmr_bot_tz.until_date.replace(tzinfo=None) + ) + + assert cmr_raw.until_date.tzinfo == UTC + assert cmr_bot.until_date.tzinfo == UTC + assert cmr_bot_tz_offset == tz_bot_offset + + def test_to_dict(self, chat_member_restricted): + assert chat_member_restricted.to_dict() == { + "status": chat_member_restricted.status, + "user": chat_member_restricted.user.to_dict(), + "can_add_web_page_previews": chat_member_restricted.can_add_web_page_previews, + "can_change_info": chat_member_restricted.can_change_info, + "can_invite_users": chat_member_restricted.can_invite_users, + "can_manage_topics": chat_member_restricted.can_manage_topics, + "can_pin_messages": chat_member_restricted.can_pin_messages, + "can_send_audios": chat_member_restricted.can_send_audios, + "can_send_documents": chat_member_restricted.can_send_documents, + "can_send_messages": chat_member_restricted.can_send_messages, + "can_send_other_messages": chat_member_restricted.can_send_other_messages, + "can_send_photos": chat_member_restricted.can_send_photos, + "can_send_polls": chat_member_restricted.can_send_polls, + "can_send_video_notes": chat_member_restricted.can_send_video_notes, + "can_send_videos": chat_member_restricted.can_send_videos, + "can_send_voice_notes": chat_member_restricted.can_send_voice_notes, + "is_member": chat_member_restricted.is_member, + "until_date": to_timestamp(chat_member_restricted.until_date), + } + + def test_equality(self, chat_member_restricted): + a = chat_member_restricted + b = deepcopy(chat_member_restricted) + c = ChatMemberRestricted( + User(1, "test_user", is_bot=False), + False, + False, + False, + False, + False, + False, + False, + False, + False, + self.until_date, + False, + False, + False, + False, + False, + False, + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_chatmemberupdated.py b/tests/test_chatmemberupdated.py index 515c3a4d76b..ec76fd2fce7 100644 --- a/tests/test_chatmemberupdated.py +++ b/tests/test_chatmemberupdated.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_chatpermissions.py b/tests/test_chatpermissions.py index b69fd125412..54de0bc00f1 100644 --- a/tests/test_chatpermissions.py +++ b/tests/test_chatpermissions.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_checklists.py b/tests/test_checklists.py new file mode 100644 index 00000000000..5395a900e35 --- /dev/null +++ b/tests/test_checklists.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + +import pytest + +from telegram import ( + Chat, + Checklist, + ChecklistTask, + ChecklistTasksAdded, + ChecklistTasksDone, + Dice, + MessageEntity, + User, +) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import ZERO_DATE +from tests.auxil.build_messages import make_message +from tests.auxil.slots import mro_slots + + +class ChecklistTaskTestBase: + id = 42 + text = "here is a text" + text_entities = [ + MessageEntity(type="bold", offset=0, length=4), + MessageEntity(type="italic", offset=5, length=2), + ] + completed_by_user = User(id=1, first_name="Test", last_name="User", is_bot=False) + completed_by_chat = Chat(id=-100, type=Chat.SUPERGROUP, title="Test Chat") + completion_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + + +@pytest.fixture(scope="module") +def checklist_task(): + return ChecklistTask( + id=ChecklistTaskTestBase.id, + text=ChecklistTaskTestBase.text, + text_entities=ChecklistTaskTestBase.text_entities, + completed_by_user=ChecklistTaskTestBase.completed_by_user, + completed_by_chat=ChecklistTaskTestBase.completed_by_chat, + completion_date=ChecklistTaskTestBase.completion_date, + ) + + +class TestChecklistTaskWithoutRequest(ChecklistTaskTestBase): + def test_slot_behaviour(self, checklist_task): + for attr in checklist_task.__slots__: + assert getattr(checklist_task, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(checklist_task)) == len(set(mro_slots(checklist_task))), ( + "duplicate slot" + ) + + def test_to_dict(self, checklist_task): + clt_dict = checklist_task.to_dict() + assert isinstance(clt_dict, dict) + assert clt_dict["id"] == self.id + assert clt_dict["text"] == self.text + assert clt_dict["text_entities"] == [entity.to_dict() for entity in self.text_entities] + assert clt_dict["completed_by_user"] == self.completed_by_user.to_dict() + assert clt_dict["completed_by_chat"] == self.completed_by_chat.to_dict() + assert clt_dict["completion_date"] == to_timestamp(self.completion_date) + + def test_de_json(self, offline_bot): + json_dict = { + "id": self.id, + "text": self.text, + "text_entities": [entity.to_dict() for entity in self.text_entities], + "completed_by_user": self.completed_by_user.to_dict(), + "completed_by_chat": self.completed_by_chat.to_dict(), + "completion_date": to_timestamp(self.completion_date), + } + clt = ChecklistTask.de_json(json_dict, offline_bot) + assert isinstance(clt, ChecklistTask) + assert clt.id == self.id + assert clt.text == self.text + assert clt.text_entities == tuple(self.text_entities) + assert clt.completed_by_user == self.completed_by_user + assert clt.completed_by_chat == self.completed_by_chat + assert clt.completion_date == self.completion_date + assert clt.api_kwargs == {} + + def test_de_json_required_fields(self, offline_bot): + json_dict = { + "id": self.id, + "text": self.text, + } + clt = ChecklistTask.de_json(json_dict, offline_bot) + assert isinstance(clt, ChecklistTask) + assert clt.id == self.id + assert clt.text == self.text + assert clt.text_entities == () + assert clt.completed_by_user is None + assert clt.completion_date is None + assert clt.api_kwargs == {} + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "id": self.id, + "text": self.text, + "completion_date": to_timestamp(self.completion_date), + } + clt_bot = ChecklistTask.de_json(json_dict, offline_bot) + clt_bot_raw = ChecklistTask.de_json(json_dict, raw_bot) + clt_bot_tz = ChecklistTask.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing tzinfo objects is not reliable + completion_date_offset = clt_bot_tz.completion_date.utcoffset() + completion_date_offset_tz = tz_bot.defaults.tzinfo.utcoffset( + clt_bot_tz.completion_date.replace(tzinfo=None) + ) + + assert clt_bot.completion_date.tzinfo == UTC + assert clt_bot_raw.completion_date.tzinfo == UTC + assert completion_date_offset_tz == completion_date_offset + + @pytest.mark.parametrize( + ("completion_date", "expected"), + [ + (None, None), + (0, ZERO_DATE), + (1735689600, dtm.datetime(2025, 1, 1, tzinfo=UTC)), + ], + ) + def test_de_json_completion_date(self, offline_bot, completion_date, expected): + json_dict = { + "id": self.id, + "text": self.text, + "completion_date": completion_date, + } + clt = ChecklistTask.de_json(json_dict, offline_bot) + assert isinstance(clt, ChecklistTask) + assert clt.completion_date == expected + + def test_parse_entity(self, checklist_task): + assert checklist_task.parse_entity(checklist_task.text_entities[0]) == "here" + + def test_parse_entities(self, checklist_task): + assert checklist_task.parse_entities(MessageEntity.BOLD) == { + checklist_task.text_entities[0]: "here" + } + assert checklist_task.parse_entities() == { + checklist_task.text_entities[0]: "here", + checklist_task.text_entities[1]: "is", + } + + def test_equality(self, checklist_task): + clt1 = checklist_task + clt2 = ChecklistTask( + id=self.id, + text="other text", + ) + clt3 = ChecklistTask( + id=self.id + 1, + text=self.text, + ) + clt4 = Dice(value=1, emoji="🎲") + + assert clt1 == clt2 + assert hash(clt1) == hash(clt2) + + assert clt1 != clt3 + assert hash(clt1) != hash(clt3) + + assert clt1 != clt4 + assert hash(clt1) != hash(clt4) + + +class ChecklistTestBase: + title = "Checklist Title" + title_entities = [ + MessageEntity(type="bold", offset=0, length=9), + MessageEntity(type="italic", offset=10, length=5), + ] + tasks = [ + ChecklistTask( + id=1, + text="Task 1", + ), + ChecklistTask( + id=2, + text="Task 2", + ), + ] + others_can_add_tasks = True + others_can_mark_tasks_as_done = False + + +@pytest.fixture(scope="module") +def checklist(): + return Checklist( + title=ChecklistTestBase.title, + title_entities=ChecklistTestBase.title_entities, + tasks=ChecklistTestBase.tasks, + others_can_add_tasks=ChecklistTestBase.others_can_add_tasks, + others_can_mark_tasks_as_done=ChecklistTestBase.others_can_mark_tasks_as_done, + ) + + +class TestChecklistWithoutRequest(ChecklistTestBase): + def test_slot_behaviour(self, checklist): + for attr in checklist.__slots__: + assert getattr(checklist, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(checklist)) == len(set(mro_slots(checklist))), "duplicate slot" + + def test_to_dict(self, checklist): + cl_dict = checklist.to_dict() + assert isinstance(cl_dict, dict) + assert cl_dict["title"] == self.title + assert cl_dict["title_entities"] == [entity.to_dict() for entity in self.title_entities] + assert cl_dict["tasks"] == [task.to_dict() for task in self.tasks] + assert cl_dict["others_can_add_tasks"] is self.others_can_add_tasks + assert cl_dict["others_can_mark_tasks_as_done"] is self.others_can_mark_tasks_as_done + + def test_de_json(self, offline_bot): + json_dict = { + "title": self.title, + "title_entities": [entity.to_dict() for entity in self.title_entities], + "tasks": [task.to_dict() for task in self.tasks], + "others_can_add_tasks": self.others_can_add_tasks, + "others_can_mark_tasks_as_done": self.others_can_mark_tasks_as_done, + } + cl = Checklist.de_json(json_dict, offline_bot) + assert isinstance(cl, Checklist) + assert cl.title == self.title + assert cl.title_entities == tuple(self.title_entities) + assert cl.tasks == tuple(self.tasks) + assert cl.others_can_add_tasks is self.others_can_add_tasks + assert cl.others_can_mark_tasks_as_done is self.others_can_mark_tasks_as_done + assert cl.api_kwargs == {} + + def test_de_json_required_fields(self, offline_bot): + json_dict = { + "title": self.title, + "tasks": [task.to_dict() for task in self.tasks], + } + cl = Checklist.de_json(json_dict, offline_bot) + assert isinstance(cl, Checklist) + assert cl.title == self.title + assert cl.title_entities == () + assert cl.tasks == tuple(self.tasks) + assert not cl.others_can_add_tasks + assert not cl.others_can_mark_tasks_as_done + + def test_parse_entity(self, checklist): + assert checklist.parse_entity(checklist.title_entities[0]) == "Checklist" + assert checklist.parse_entity(checklist.title_entities[1]) == "Title" + + def test_parse_entities(self, checklist): + assert checklist.parse_entities(MessageEntity.BOLD) == { + checklist.title_entities[0]: "Checklist" + } + assert checklist.parse_entities() == { + checklist.title_entities[0]: "Checklist", + checklist.title_entities[1]: "Title", + } + + def test_equality(self, checklist, checklist_task): + cl1 = checklist + cl2 = Checklist( + title=self.title + " other", + tasks=[ChecklistTask(id=1, text="something"), ChecklistTask(id=2, text="something")], + ) + cl3 = Checklist( + title=self.title + " other", + tasks=[ChecklistTask(id=42, text="Task 2")], + ) + cl4 = checklist_task + + assert cl1 == cl2 + assert hash(cl1) == hash(cl2) + + assert cl1 != cl3 + assert hash(cl1) != hash(cl3) + + assert cl1 != cl4 + assert hash(cl1) != hash(cl4) + + +class ChecklistTasksDoneTestBase: + checklist_message = make_message("Checklist message") + marked_as_done_task_ids = [1, 2, 3] + marked_as_not_done_task_ids = [4, 5] + + +@pytest.fixture(scope="module") +def checklist_tasks_done(): + return ChecklistTasksDone( + checklist_message=ChecklistTasksDoneTestBase.checklist_message, + marked_as_done_task_ids=ChecklistTasksDoneTestBase.marked_as_done_task_ids, + marked_as_not_done_task_ids=ChecklistTasksDoneTestBase.marked_as_not_done_task_ids, + ) + + +class TestChecklistTasksDoneWithoutRequest(ChecklistTasksDoneTestBase): + def test_slot_behaviour(self, checklist_tasks_done): + for attr in checklist_tasks_done.__slots__: + assert getattr(checklist_tasks_done, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(checklist_tasks_done)) == len(set(mro_slots(checklist_tasks_done))), ( + "duplicate slot" + ) + + def test_to_dict(self, checklist_tasks_done): + cltd_dict = checklist_tasks_done.to_dict() + assert isinstance(cltd_dict, dict) + assert cltd_dict["checklist_message"] == self.checklist_message.to_dict() + assert cltd_dict["marked_as_done_task_ids"] == self.marked_as_done_task_ids + assert cltd_dict["marked_as_not_done_task_ids"] == self.marked_as_not_done_task_ids + + def test_de_json(self, offline_bot): + json_dict = { + "checklist_message": self.checklist_message.to_dict(), + "marked_as_done_task_ids": self.marked_as_done_task_ids, + "marked_as_not_done_task_ids": self.marked_as_not_done_task_ids, + } + cltd = ChecklistTasksDone.de_json(json_dict, offline_bot) + assert isinstance(cltd, ChecklistTasksDone) + assert cltd.checklist_message == self.checklist_message + assert cltd.marked_as_done_task_ids == tuple(self.marked_as_done_task_ids) + assert cltd.marked_as_not_done_task_ids == tuple(self.marked_as_not_done_task_ids) + assert cltd.api_kwargs == {} + + def test_de_json_required_fields(self, offline_bot): + cltd = ChecklistTasksDone.de_json({}, offline_bot) + assert isinstance(cltd, ChecklistTasksDone) + assert cltd.checklist_message is None + assert cltd.marked_as_done_task_ids == () + assert cltd.marked_as_not_done_task_ids == () + assert cltd.api_kwargs == {} + + def test_equality(self, checklist_tasks_done): + cltd1 = checklist_tasks_done + cltd2 = ChecklistTasksDone( + checklist_message=None, + marked_as_done_task_ids=[1, 2, 3], + marked_as_not_done_task_ids=[4, 5], + ) + cltd3 = ChecklistTasksDone( + checklist_message=make_message("Checklist message"), + marked_as_done_task_ids=[1, 2, 3], + ) + cltd4 = make_message("Not a checklist tasks done") + + assert cltd1 == cltd2 + assert hash(cltd1) == hash(cltd2) + + assert cltd1 != cltd3 + assert hash(cltd1) != hash(cltd3) + + assert cltd1 != cltd4 + assert hash(cltd1) != hash(cltd4) + + +class ChecklistTasksAddedTestBase: + checklist_message = make_message("Checklist message") + tasks = [ + ChecklistTask(id=1, text="Task 1"), + ChecklistTask(id=2, text="Task 2"), + ChecklistTask(id=3, text="Task 3"), + ] + + +@pytest.fixture(scope="module") +def checklist_tasks_added(): + return ChecklistTasksAdded( + checklist_message=ChecklistTasksAddedTestBase.checklist_message, + tasks=ChecklistTasksAddedTestBase.tasks, + ) + + +class TestChecklistTasksAddedWithoutRequest(ChecklistTasksAddedTestBase): + def test_slot_behaviour(self, checklist_tasks_added): + for attr in checklist_tasks_added.__slots__: + assert getattr(checklist_tasks_added, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(checklist_tasks_added)) == len( + set(mro_slots(checklist_tasks_added)) + ), "duplicate slot" + + def test_to_dict(self, checklist_tasks_added): + clta_dict = checklist_tasks_added.to_dict() + assert isinstance(clta_dict, dict) + assert clta_dict["checklist_message"] == self.checklist_message.to_dict() + assert clta_dict["tasks"] == [task.to_dict() for task in self.tasks] + + def test_de_json(self, offline_bot): + json_dict = { + "checklist_message": self.checklist_message.to_dict(), + "tasks": [task.to_dict() for task in self.tasks], + } + clta = ChecklistTasksAdded.de_json(json_dict, offline_bot) + assert isinstance(clta, ChecklistTasksAdded) + assert clta.checklist_message == self.checklist_message + assert clta.tasks == tuple(self.tasks) + assert clta.api_kwargs == {} + + def test_de_json_required_fields(self, offline_bot): + clta = ChecklistTasksAdded.de_json( + {"tasks": [task.to_dict() for task in self.tasks]}, offline_bot + ) + assert isinstance(clta, ChecklistTasksAdded) + assert clta.checklist_message is None + assert clta.tasks == tuple(self.tasks) + assert clta.api_kwargs == {} + + def test_equality(self, checklist_tasks_added): + clta1 = checklist_tasks_added + clta2 = ChecklistTasksAdded( + checklist_message=None, + tasks=[ + ChecklistTask(id=1, text="Other Task 1"), + ChecklistTask(id=2, text="Other Task 2"), + ChecklistTask(id=3, text="Other Task 3"), + ], + ) + clta3 = ChecklistTasksAdded( + checklist_message=make_message("Checklist message"), + tasks=[ChecklistTask(id=1, text="Task 1")], + ) + clta4 = make_message("Not a checklist tasks added") + + assert clta1 == clta2 + assert hash(clta1) == hash(clta2) + + assert clta1 != clta3 + assert hash(clta1) != hash(clta3) + + assert clta1 != clta4 + assert hash(clta1) != hash(clta4) diff --git a/tests/test_choseninlineresult.py b/tests/test_choseninlineresult.py index 0659b586172..f5b1ce99274 100644 --- a/tests/test_choseninlineresult.py +++ b/tests/test_choseninlineresult.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_constants.py b/tests/test_constants.py index 3cd9e56e7ab..5611b5f80c7 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -62,9 +62,9 @@ def test__all__(self): ) } actual = set(constants.__all__) - assert ( - actual == expected - ), f"Members {expected - actual} were not listed in constants.__all__" + assert actual == expected, ( + f"Members {expected - actual} were not listed in constants.__all__" + ) def test_message_attachment_type(self): assert all( @@ -182,6 +182,7 @@ def is_type_attribute(name: str) -> bool: "caption", "chat", "chat_id", + "direct_messages_topic", "effective_attachment", "entities", "from_user", @@ -203,6 +204,9 @@ def is_type_attribute(name: str) -> bool: "via_bot", "is_from_offline", "show_caption_above_media", + "paid_star_count", + "is_paid_post", + "reply_to_checklist_task_id", } @pytest.mark.parametrize( @@ -225,9 +229,9 @@ def test_message_type_completeness(self, attribute): @pytest.mark.parametrize("member", constants.MessageType) def test_message_type_completeness_reverse(self, member): - assert self.is_type_attribute( - member.value - ), f"Additional member {member} in MessageType that should not be a message type" + assert self.is_type_attribute(member.value), ( + f"Additional member {member} in MessageType that should not be a message type" + ) @pytest.mark.parametrize("member", constants.MessageAttachmentType) def test_message_attachment_type_completeness(self, member): diff --git a/tests/test_copytextbutton.py b/tests/test_copytextbutton.py index c571b485b4c..537a302e694 100644 --- a/tests/test_copytextbutton.py +++ b/tests/test_copytextbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -36,9 +36,9 @@ class TestCopyTextButtonWithoutRequest(CopyTextButtonTestBase): def test_slot_behaviour(self, copy_text_button): for attr in copy_text_button.__slots__: assert getattr(copy_text_button, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(copy_text_button)) == len( - set(mro_slots(copy_text_button)) - ), "duplicate slot" + assert len(mro_slots(copy_text_button)) == len(set(mro_slots(copy_text_button))), ( + "duplicate slot" + ) def test_de_json(self, offline_bot): json_dict = {"text": self.text} @@ -46,7 +46,6 @@ def test_de_json(self, offline_bot): assert copy_text_button.api_kwargs == {} assert copy_text_button.text == self.text - assert CopyTextButton.de_json(None, offline_bot) is None def test_to_dict(self, copy_text_button): copy_text_button_dict = copy_text_button.to_dict() diff --git a/tests/test_dice.py b/tests/test_dice.py index e0f5f89c972..bab02522f9f 100644 --- a/tests/test_dice.py +++ b/tests/test_dice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -46,7 +46,6 @@ def test_de_json(self, offline_bot, emoji): assert dice.value == self.value assert dice.emoji == emoji - assert Dice.de_json(None, offline_bot) is None def test_to_dict(self, dice): dice_dict = dice.to_dict() diff --git a/tests/test_directmessagepricechanged.py b/tests/test_directmessagepricechanged.py new file mode 100644 index 00000000000..58dd722b4b9 --- /dev/null +++ b/tests/test_directmessagepricechanged.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object for testing a Direct Message Price.""" + +from typing import TYPE_CHECKING + +import pytest + +from telegram import DirectMessagePriceChanged, User +from tests.auxil.slots import mro_slots + +if TYPE_CHECKING: + from telegram._utils.types import JSONDict + + +@pytest.fixture +def direct_message_price_changed(): + return DirectMessagePriceChanged( + are_direct_messages_enabled=DirectMessagePriceChangedTestBase.are_direct_messages_enabled, + direct_message_star_count=DirectMessagePriceChangedTestBase.direct_message_star_count, + ) + + +class DirectMessagePriceChangedTestBase: + are_direct_messages_enabled: bool = True + direct_message_star_count: int = 100 + + +class TestDirectMessagePriceChangedWithoutRequest(DirectMessagePriceChangedTestBase): + def test_slot_behaviour(self, direct_message_price_changed): + action = direct_message_price_changed + for attr in action.__slots__: + assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict: JSONDict = { + "are_direct_messages_enabled": self.are_direct_messages_enabled, + "direct_message_star_count": self.direct_message_star_count, + } + dmpc = DirectMessagePriceChanged.de_json(json_dict, offline_bot) + assert dmpc.api_kwargs == {} + + assert dmpc.are_direct_messages_enabled == self.are_direct_messages_enabled + assert dmpc.direct_message_star_count == self.direct_message_star_count + + def test_to_dict(self, direct_message_price_changed): + dmpc_dict = direct_message_price_changed.to_dict() + assert dmpc_dict["are_direct_messages_enabled"] == self.are_direct_messages_enabled + assert dmpc_dict["direct_message_star_count"] == self.direct_message_star_count + + def test_equality(self, direct_message_price_changed): + dmpc1 = direct_message_price_changed + dmpc2 = DirectMessagePriceChanged( + are_direct_messages_enabled=self.are_direct_messages_enabled, + direct_message_star_count=self.direct_message_star_count, + ) + assert dmpc1 == dmpc2 + assert hash(dmpc1) == hash(dmpc2) + + dmpc3 = DirectMessagePriceChanged( + are_direct_messages_enabled=False, + direct_message_star_count=self.direct_message_star_count, + ) + assert dmpc1 != dmpc3 + assert hash(dmpc1) != hash(dmpc3) + + not_a_dmpc = User(id=1, first_name="wrong", is_bot=False) + assert dmpc1 != not_a_dmpc + assert hash(dmpc1) != hash(not_a_dmpc) diff --git a/tests/test_directmessagestopic.py b/tests/test_directmessagestopic.py new file mode 100644 index 00000000000..53161478b5c --- /dev/null +++ b/tests/test_directmessagestopic.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains the TestDirectMessagesTopic class.""" + +import pytest + +from telegram import DirectMessagesTopic, User +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def direct_messages_topic(offline_bot): + dmt = DirectMessagesTopic( + topic_id=DirectMessagesTopicTestBase.topic_id, + user=DirectMessagesTopicTestBase.user, + ) + dmt.set_bot(offline_bot) + dmt._unfreeze() + return dmt + + +class DirectMessagesTopicTestBase: + topic_id = 12345 + user = User(id=67890, is_bot=False, first_name="Test") + + +class TestDirectMessagesTopicWithoutRequest(DirectMessagesTopicTestBase): + def test_slot_behaviour(self, direct_messages_topic): + cfi = direct_messages_topic + for attr in cfi.__slots__: + assert getattr(cfi, attr, "err") != "err", f"got extra slot '{attr}'" + + assert len(mro_slots(cfi)) == len(set(mro_slots(cfi))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "topic_id": self.topic_id, + "user": self.user.to_dict(), + } + + dmt = DirectMessagesTopic.de_json(json_dict, offline_bot) + assert dmt.topic_id == self.topic_id + assert dmt.user == self.user + assert dmt.api_kwargs == {} + + def test_to_dict(self, direct_messages_topic): + dmt = direct_messages_topic + dmt_dict = dmt.to_dict() + + assert isinstance(dmt_dict, dict) + assert dmt_dict["topic_id"] == dmt.topic_id + assert dmt_dict["user"] == dmt.user.to_dict() + + def test_equality(self, direct_messages_topic): + dmt_1 = direct_messages_topic + dmt_2 = DirectMessagesTopic( + topic_id=dmt_1.topic_id, + user=dmt_1.user, + ) + assert dmt_1 == dmt_2 + assert hash(dmt_1) == hash(dmt_2) + + random = User(id=99999, is_bot=False, first_name="Random") + assert random != dmt_2 + assert hash(random) != hash(dmt_2) + + dmt_3 = DirectMessagesTopic( + topic_id=8371, + user=dmt_1.user, + ) + assert dmt_1 != dmt_3 + assert hash(dmt_1) != hash(dmt_3) diff --git a/tests/test_enum_types.py b/tests/test_enum_types.py index ef3729061bf..aa9f4c17696 100644 --- a/tests/test_enum_types.py +++ b/tests/test_enum_types.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,13 +25,13 @@ telegram_ext_root = telegram_root / "ext" exclude_dirs = { # We touch passport stuff only if strictly necessary. - telegram_root - / "_passport", + telegram_root / "_passport", } exclude_patterns = { re.compile(re.escape("self.type: ReactionType = type")), re.compile(re.escape("self.type: BackgroundType = type")), + re.compile(re.escape("self.type: StoryAreaType = type")), } diff --git a/tests/test_error.py b/tests/test_error.py index 9fd0ba707fc..a6eadc0e2f1 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,6 +16,7 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm import pickle from collections import defaultdict @@ -35,28 +36,29 @@ TimedOut, ) from telegram.ext import InvalidCallbackData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots class TestErrors: def test_telegram_error(self): - with pytest.raises(TelegramError, match="^test message$"): + with pytest.raises(TelegramError, match=r"^test message$"): raise TelegramError("test message") - with pytest.raises(TelegramError, match="^Test message$"): + with pytest.raises(TelegramError, match=r"^Test message$"): raise TelegramError("Error: test message") - with pytest.raises(TelegramError, match="^Test message$"): + with pytest.raises(TelegramError, match=r"^Test message$"): raise TelegramError("[Error]: test message") - with pytest.raises(TelegramError, match="^Test message$"): + with pytest.raises(TelegramError, match=r"^Test message$"): raise TelegramError("Bad Request: test message") def test_unauthorized(self): with pytest.raises(Forbidden, match="test message"): raise Forbidden("test message") - with pytest.raises(Forbidden, match="^Test message$"): + with pytest.raises(Forbidden, match=r"^Test message$"): raise Forbidden("Error: test message") - with pytest.raises(Forbidden, match="^Test message$"): + with pytest.raises(Forbidden, match=r"^Test message$"): raise Forbidden("[Error]: test message") - with pytest.raises(Forbidden, match="^Test message$"): + with pytest.raises(Forbidden, match=r"^Test message$"): raise Forbidden("Bad Request: test message") def test_invalid_token(self): @@ -66,25 +68,25 @@ def test_invalid_token(self): def test_network_error(self): with pytest.raises(NetworkError, match="test message"): raise NetworkError("test message") - with pytest.raises(NetworkError, match="^Test message$"): + with pytest.raises(NetworkError, match=r"^Test message$"): raise NetworkError("Error: test message") - with pytest.raises(NetworkError, match="^Test message$"): + with pytest.raises(NetworkError, match=r"^Test message$"): raise NetworkError("[Error]: test message") - with pytest.raises(NetworkError, match="^Test message$"): + with pytest.raises(NetworkError, match=r"^Test message$"): raise NetworkError("Bad Request: test message") def test_bad_request(self): with pytest.raises(BadRequest, match="test message"): raise BadRequest("test message") - with pytest.raises(BadRequest, match="^Test message$"): + with pytest.raises(BadRequest, match=r"^Test message$"): raise BadRequest("Error: test message") - with pytest.raises(BadRequest, match="^Test message$"): + with pytest.raises(BadRequest, match=r"^Test message$"): raise BadRequest("[Error]: test message") - with pytest.raises(BadRequest, match="^Test message$"): + with pytest.raises(BadRequest, match=r"^Test message$"): raise BadRequest("Bad Request: test message") def test_timed_out(self): - with pytest.raises(TimedOut, match="^Timed out$"): + with pytest.raises(TimedOut, match=r"^Timed out$"): raise TimedOut def test_chat_migrated(self): @@ -92,12 +94,31 @@ def test_chat_migrated(self): raise ChatMigrated(1234) assert e.value.new_chat_id == 1234 - def test_retry_after(self): - with pytest.raises(RetryAfter, match="Flood control exceeded. Retry in 12 seconds"): - raise RetryAfter(12) + @pytest.mark.parametrize("retry_after", [12, dtm.timedelta(seconds=12)]) + def test_retry_after(self, PTB_TIMEDELTA, retry_after): + if PTB_TIMEDELTA: + with pytest.raises(RetryAfter, match="Flood control exceeded\\. Retry in 0:00:12"): + raise (exception := RetryAfter(retry_after)) + assert type(exception.retry_after) is dtm.timedelta + else: + with pytest.raises(RetryAfter, match="Flood control exceeded\\. Retry in 12 seconds"): + raise (exception := RetryAfter(retry_after)) + assert type(exception.retry_after) is int + + def test_retry_after_int_deprecated(self, PTB_TIMEDELTA, recwarn): + retry_after = RetryAfter(12).retry_after + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + assert type(retry_after) is dtm.timedelta + else: + assert len(recwarn) == 1 + assert "`retry_after` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + assert type(retry_after) is int def test_conflict(self): - with pytest.raises(Conflict, match="Something something."): + with pytest.raises(Conflict, match="Something something\\."): raise Conflict("Something something.") @pytest.mark.parametrize( @@ -111,6 +132,7 @@ def test_conflict(self): (TimedOut(), ["message"]), (ChatMigrated(1234), ["message", "new_chat_id"]), (RetryAfter(12), ["message", "retry_after"]), + (RetryAfter(dtm.timedelta(seconds=12)), ["message", "retry_after"]), (Conflict("test message"), ["message"]), (PassportDecryptionError("test message"), ["message"]), (InvalidCallbackData("test data"), ["callback_data"]), @@ -136,7 +158,7 @@ def test_errors_pickling(self, exception, attributes): (BadRequest("test message")), (TimedOut()), (ChatMigrated(1234)), - (RetryAfter(12)), + (RetryAfter(dtm.timedelta(seconds=12))), (Conflict("test message")), (PassportDecryptionError("test message")), (InvalidCallbackData("test data")), @@ -181,15 +203,19 @@ def make_assertion(cls): make_assertion(TelegramError) - def test_string_representations(self): + def test_string_representations(self, PTB_TIMEDELTA): """We just randomly test a few of the subclasses - should suffice""" e = TelegramError("This is a message") assert repr(e) == "TelegramError('This is a message')" assert str(e) == "This is a message" - e = RetryAfter(42) - assert repr(e) == "RetryAfter('Flood control exceeded. Retry in 42 seconds')" - assert str(e) == "Flood control exceeded. Retry in 42 seconds" + e = RetryAfter(dtm.timedelta(seconds=42)) + if PTB_TIMEDELTA: + assert repr(e) == "RetryAfter('Flood control exceeded. Retry in 0:00:42')" + assert str(e) == "Flood control exceeded. Retry in 0:00:42" + else: + assert repr(e) == "RetryAfter('Flood control exceeded. Retry in 42 seconds')" + assert str(e) == "Flood control exceeded. Retry in 42 seconds" e = BadRequest("This is a message") assert repr(e) == "BadRequest('This is a message')" diff --git a/tests/test_forcereply.py b/tests/test_forcereply.py index 30d259e43a3..cfbe51cce34 100644 --- a/tests/test_forcereply.py +++ b/tests/test_forcereply.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_forum.py b/tests/test_forum.py index d5c6b1a5ada..73d20c6d786 100644 --- a/tests/test_forum.py +++ b/tests/test_forum.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -40,13 +40,20 @@ async def forum_topic_object(forum_group_id, emoji_id): return ForumTopic( message_thread_id=forum_group_id, - name=TEST_TOPIC_NAME, - icon_color=TEST_TOPIC_ICON_COLOR, + name=ForumTopicTestBase.TEST_TOPIC_NAME, + icon_color=ForumTopicTestBase.TEST_TOPIC_ICON_COLOR, icon_custom_emoji_id=emoji_id, + is_name_implicit=ForumTopicTestBase.is_name_implicit, ) -class TestForumTopicWithoutRequest: +class ForumTopicTestBase: + TEST_TOPIC_NAME = TEST_TOPIC_NAME + TEST_TOPIC_ICON_COLOR = TEST_TOPIC_ICON_COLOR + is_name_implicit = False + + +class TestForumTopicWithoutRequest(ForumTopicTestBase): def test_slot_behaviour(self, forum_topic_object): inst = forum_topic_object for attr in inst.__slots__: @@ -55,35 +62,37 @@ def test_slot_behaviour(self, forum_topic_object): async def test_expected_values(self, emoji_id, forum_group_id, forum_topic_object): assert forum_topic_object.message_thread_id == forum_group_id - assert forum_topic_object.icon_color == TEST_TOPIC_ICON_COLOR - assert forum_topic_object.name == TEST_TOPIC_NAME + assert forum_topic_object.icon_color == self.TEST_TOPIC_ICON_COLOR + assert forum_topic_object.name == self.TEST_TOPIC_NAME assert forum_topic_object.icon_custom_emoji_id == emoji_id + assert forum_topic_object.is_name_implicit == self.is_name_implicit def test_de_json(self, offline_bot, emoji_id, forum_group_id): - assert ForumTopic.de_json(None, bot=offline_bot) is None - json_dict = { "message_thread_id": forum_group_id, - "name": TEST_TOPIC_NAME, - "icon_color": TEST_TOPIC_ICON_COLOR, + "name": self.TEST_TOPIC_NAME, + "icon_color": self.TEST_TOPIC_ICON_COLOR, "icon_custom_emoji_id": emoji_id, + "is_name_implicit": self.is_name_implicit, } topic = ForumTopic.de_json(json_dict, offline_bot) assert topic.api_kwargs == {} assert topic.message_thread_id == forum_group_id - assert topic.icon_color == TEST_TOPIC_ICON_COLOR - assert topic.name == TEST_TOPIC_NAME + assert topic.icon_color == self.TEST_TOPIC_ICON_COLOR + assert topic.name == self.TEST_TOPIC_NAME assert topic.icon_custom_emoji_id == emoji_id + assert topic.is_name_implicit == self.is_name_implicit def test_to_dict(self, emoji_id, forum_group_id, forum_topic_object): topic_dict = forum_topic_object.to_dict() assert isinstance(topic_dict, dict) assert topic_dict["message_thread_id"] == forum_group_id - assert topic_dict["name"] == TEST_TOPIC_NAME - assert topic_dict["icon_color"] == TEST_TOPIC_ICON_COLOR + assert topic_dict["name"] == self.TEST_TOPIC_NAME + assert topic_dict["icon_color"] == self.TEST_TOPIC_ICON_COLOR assert topic_dict["icon_custom_emoji_id"] == emoji_id + assert topic_dict["is_name_implicit"] == self.is_name_implicit def test_equality(self, emoji_id, forum_group_id): a = ForumTopic( @@ -291,37 +300,52 @@ async def test_close_reopen_hide_unhide_general_forum_topic(self, bot, forum_gro @pytest.fixture(scope="module") def topic_created(): - return ForumTopicCreated(name=TEST_TOPIC_NAME, icon_color=TEST_TOPIC_ICON_COLOR) + return ForumTopicCreated( + name=ForumTopicCreatedTestBase.TEST_TOPIC_NAME, + icon_color=ForumTopicCreatedTestBase.TEST_TOPIC_ICON_COLOR, + is_name_implicit=ForumTopicCreatedTestBase.is_name_implicit, + ) -class TestForumTopicCreatedWithoutRequest: +class ForumTopicCreatedTestBase: + TEST_TOPIC_NAME = TEST_TOPIC_NAME + TEST_TOPIC_ICON_COLOR = TEST_TOPIC_ICON_COLOR + is_name_implicit = False + + +class TestForumTopicCreatedWithoutRequest(ForumTopicCreatedTestBase): def test_slot_behaviour(self, topic_created): for attr in topic_created.__slots__: assert getattr(topic_created, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(topic_created)) == len( - set(mro_slots(topic_created)) - ), "duplicate slot" + assert len(mro_slots(topic_created)) == len(set(mro_slots(topic_created))), ( + "duplicate slot" + ) def test_expected_values(self, topic_created): - assert topic_created.icon_color == TEST_TOPIC_ICON_COLOR - assert topic_created.name == TEST_TOPIC_NAME + assert topic_created.icon_color == self.TEST_TOPIC_ICON_COLOR + assert topic_created.name == self.TEST_TOPIC_NAME + assert topic_created.is_name_implicit == self.is_name_implicit def test_de_json(self, offline_bot): - assert ForumTopicCreated.de_json(None, bot=offline_bot) is None - - json_dict = {"icon_color": TEST_TOPIC_ICON_COLOR, "name": TEST_TOPIC_NAME} + json_dict = { + "icon_color": self.TEST_TOPIC_ICON_COLOR, + "name": self.TEST_TOPIC_NAME, + "is_name_implicit": self.is_name_implicit, + } action = ForumTopicCreated.de_json(json_dict, offline_bot) assert action.api_kwargs == {} - assert action.icon_color == TEST_TOPIC_ICON_COLOR - assert action.name == TEST_TOPIC_NAME + assert action.icon_color == self.TEST_TOPIC_ICON_COLOR + assert action.name == self.TEST_TOPIC_NAME + assert action.is_name_implicit == self.is_name_implicit def test_to_dict(self, topic_created): action_dict = topic_created.to_dict() assert isinstance(action_dict, dict) - assert action_dict["name"] == TEST_TOPIC_NAME - assert action_dict["icon_color"] == TEST_TOPIC_ICON_COLOR + assert action_dict["name"] == self.TEST_TOPIC_NAME + assert action_dict["icon_color"] == self.TEST_TOPIC_ICON_COLOR + assert action_dict["is_name_implicit"] == self.is_name_implicit def test_equality(self, emoji_id): a = ForumTopicCreated(name=TEST_TOPIC_NAME, icon_color=TEST_TOPIC_ICON_COLOR) @@ -395,8 +419,6 @@ def test_expected_values(self, topic_edited, emoji_id): assert topic_edited.icon_custom_emoji_id == emoji_id def test_de_json(self, bot, emoji_id): - assert ForumTopicEdited.de_json(None, bot=bot) is None - json_dict = {"name": TEST_TOPIC_NAME, "icon_custom_emoji_id": emoji_id} action = ForumTopicEdited.de_json(json_dict, bot) assert action.api_kwargs == {} diff --git a/tests/test_gifts.py b/tests/test_gifts.py index d294aa8dba9..c09d1b30a6e 100644 --- a/tests/test_gifts.py +++ b/tests/test_gifts.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,12 +20,78 @@ import pytest -from telegram import BotCommand, Gift, Gifts, MessageEntity, Sticker +from telegram import BotCommand, Chat, Gift, GiftInfo, Gifts, MessageEntity, Sticker +from telegram._gifts import AcceptedGiftTypes, GiftBackground from telegram._utils.defaultvalue import DEFAULT_NONE from telegram.request import RequestData from tests.auxil.slots import mro_slots +@pytest.fixture +def gift_background(): + return GiftBackground( + center_color=GiftBackgroundTestBase.center_color, + edge_color=GiftBackgroundTestBase.edge_color, + text_color=GiftBackgroundTestBase.text_color, + ) + + +class GiftBackgroundTestBase: + center_color = 0xFFFFFF + edge_color = 0x000000 + text_color = 0xFF0000 + + +class TestGiftBackgroundWithoutRequest(GiftBackgroundTestBase): + def test_slot_behaviour(self, gift_background): + for attr in gift_background.__slots__: + assert getattr(gift_background, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(gift_background)) == len(set(mro_slots(gift_background))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "center_color": self.center_color, + "edge_color": self.edge_color, + "text_color": self.text_color, + } + gift_background = GiftBackground.de_json(json_dict, offline_bot) + assert gift_background.api_kwargs == {} + assert gift_background.center_color == self.center_color + assert gift_background.edge_color == self.edge_color + assert gift_background.text_color == self.text_color + + def test_to_dict(self, gift_background): + json_dict = gift_background.to_dict() + assert json_dict["center_color"] == self.center_color + assert json_dict["edge_color"] == self.edge_color + assert json_dict["text_color"] == self.text_color + + def test_equality(self, gift_background): + a = gift_background + b = GiftBackground( + self.center_color, + self.edge_color, + self.text_color, + ) + c = GiftBackground( + 0x000000, + self.edge_color, + self.text_color, + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + @pytest.fixture def gift(request): return Gift( @@ -35,6 +101,13 @@ def gift(request): total_count=GiftTestBase.total_count, remaining_count=GiftTestBase.remaining_count, upgrade_star_count=GiftTestBase.upgrade_star_count, + publisher_chat=GiftTestBase.publisher_chat, + personal_total_count=GiftTestBase.personal_total_count, + personal_remaining_count=GiftTestBase.personal_remaining_count, + background=GiftTestBase.background, + is_premium=GiftTestBase.is_premium, + has_colors=GiftTestBase.has_colors, + unique_gift_variant_count=GiftTestBase.unique_gift_variant_count, ) @@ -53,6 +126,13 @@ class GiftTestBase: total_count = 10 remaining_count = 5 upgrade_star_count = 10 + publisher_chat = Chat(1, Chat.PRIVATE) + personal_total_count = 37 + personal_remaining_count = 23 + background = GiftBackground(0xFFFFFF, 0x000000, 0xFF0000) + is_premium = True + has_colors = True + unique_gift_variant_count = 42 class TestGiftWithoutRequest(GiftTestBase): @@ -69,6 +149,13 @@ def test_de_json(self, offline_bot, gift): "total_count": self.total_count, "remaining_count": self.remaining_count, "upgrade_star_count": self.upgrade_star_count, + "publisher_chat": self.publisher_chat.to_dict(), + "personal_total_count": self.personal_total_count, + "personal_remaining_count": self.personal_remaining_count, + "background": self.background.to_dict(), + "is_premium": self.is_premium, + "has_colors": self.has_colors, + "unique_gift_variant_count": self.unique_gift_variant_count, } gift = Gift.de_json(json_dict, offline_bot) assert gift.api_kwargs == {} @@ -79,8 +166,13 @@ def test_de_json(self, offline_bot, gift): assert gift.total_count == self.total_count assert gift.remaining_count == self.remaining_count assert gift.upgrade_star_count == self.upgrade_star_count - - assert Gift.de_json(None, offline_bot) is None + assert gift.publisher_chat == self.publisher_chat + assert gift.personal_total_count == self.personal_total_count + assert gift.personal_remaining_count == self.personal_remaining_count + assert gift.background == self.background + assert gift.is_premium == self.is_premium + assert gift.has_colors == self.has_colors + assert gift.unique_gift_variant_count == self.unique_gift_variant_count def test_to_dict(self, gift): gift_dict = gift.to_dict() @@ -92,6 +184,13 @@ def test_to_dict(self, gift): assert gift_dict["total_count"] == self.total_count assert gift_dict["remaining_count"] == self.remaining_count assert gift_dict["upgrade_star_count"] == self.upgrade_star_count + assert gift_dict["publisher_chat"] == self.publisher_chat.to_dict() + assert gift_dict["personal_total_count"] == self.personal_total_count + assert gift_dict["personal_remaining_count"] == self.personal_remaining_count + assert gift_dict["background"] == self.background.to_dict() + assert gift_dict["is_premium"] == self.is_premium + assert gift_dict["has_colors"] == self.has_colors + assert gift_dict["unique_gift_variant_count"] == self.unique_gift_variant_count def test_equality(self, gift): a = gift @@ -102,6 +201,7 @@ def test_equality(self, gift): self.total_count, self.remaining_count, self.upgrade_star_count, + self.publisher_chat, ) c = Gift( "other_uid", @@ -110,6 +210,7 @@ def test_equality(self, gift): self.total_count, self.remaining_count, self.upgrade_star_count, + self.publisher_chat, ) d = BotCommand("start", "description") @@ -137,7 +238,8 @@ def test_equality(self, gift): ], ids=["string", "Gift"], ) - async def test_send_gift(self, offline_bot, gift, monkeypatch): + @pytest.mark.parametrize("id_name", ["user_id", "chat_id"]) + async def test_send_gift(self, offline_bot, gift, monkeypatch, id_name): # We can't send actual gifts, so we just check that the correct parameters are passed text_entities = [ MessageEntity(MessageEntity.TEXT_LINK, 0, 4, "url"), @@ -145,7 +247,7 @@ async def test_send_gift(self, offline_bot, gift, monkeypatch): ] async def make_assertion(url, request_data: RequestData, *args, **kwargs): - user_id = request_data.parameters["user_id"] == "user_id" + received_id = request_data.parameters[id_name] == id_name gift_id = request_data.parameters["gift_id"] == "gift_id" text = request_data.parameters["text"] == "text" text_parse_mode = request_data.parameters["text_parse_mode"] == "text_parse_mode" @@ -154,16 +256,16 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): ] pay_for_upgrade = request_data.parameters["pay_for_upgrade"] is True - return user_id and gift_id and text and text_parse_mode and tes and pay_for_upgrade + return received_id and gift_id and text and text_parse_mode and tes and pay_for_upgrade monkeypatch.setattr(offline_bot.request, "post", make_assertion) assert await offline_bot.send_gift( - "user_id", gift, "text", text_parse_mode="text_parse_mode", text_entities=text_entities, pay_for_upgrade=True, + **{id_name: id_name}, ) @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) @@ -210,6 +312,7 @@ class GiftsTestBase: total_count=5, remaining_count=5, upgrade_star_count=5, + publisher_chat=Chat(5, Chat.PRIVATE), ), Gift( id="id2", @@ -226,6 +329,7 @@ class GiftsTestBase: total_count=6, remaining_count=6, upgrade_star_count=6, + publisher_chat=Chat(6, Chat.PRIVATE), ), Gift( id="id3", @@ -242,6 +346,7 @@ class GiftsTestBase: total_count=7, remaining_count=7, upgrade_star_count=7, + publisher_chat=Chat(7, Chat.PRIVATE), ), ] @@ -258,15 +363,14 @@ def test_de_json(self, offline_bot, gifts): assert gifts.api_kwargs == {} assert gifts.gifts == tuple(self.gifts) - for de_json_gift, original_gift in zip(gifts.gifts, self.gifts): + for de_json_gift, original_gift in zip(gifts.gifts, self.gifts, strict=False): assert de_json_gift.id == original_gift.id assert de_json_gift.sticker == original_gift.sticker assert de_json_gift.star_count == original_gift.star_count assert de_json_gift.total_count == original_gift.total_count assert de_json_gift.remaining_count == original_gift.remaining_count assert de_json_gift.upgrade_star_count == original_gift.upgrade_star_count - - assert Gifts.de_json(None, offline_bot) is None + assert de_json_gift.publisher_chat == original_gift.publisher_chat def test_to_dict(self, gifts): gifts_dict = gifts.to_dict() @@ -294,3 +398,210 @@ class TestGiftsWithRequest(GiftTestBase): async def test_get_available_gifts(self, bot, chat_id): # We don't control the available gifts, so we can not make any better assertions assert isinstance(await bot.get_available_gifts(), Gifts) + + +@pytest.fixture +def gift_info(): + return GiftInfo( + gift=GiftInfoTestBase.gift, + owned_gift_id=GiftInfoTestBase.owned_gift_id, + convert_star_count=GiftInfoTestBase.convert_star_count, + prepaid_upgrade_star_count=GiftInfoTestBase.prepaid_upgrade_star_count, + can_be_upgraded=GiftInfoTestBase.can_be_upgraded, + text=GiftInfoTestBase.text, + entities=GiftInfoTestBase.entities, + is_private=GiftInfoTestBase.is_private, + is_upgrade_separate=GiftInfoTestBase.is_upgrade_separate, + unique_gift_number=GiftInfoTestBase.unique_gift_number, + ) + + +class GiftInfoTestBase: + gift = Gift( + id="some_id", + sticker=Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular"), + star_count=5, + total_count=10, + remaining_count=15, + upgrade_star_count=20, + ) + owned_gift_id = "some_owned_gift_id" + convert_star_count = 100 + prepaid_upgrade_star_count = 200 + can_be_upgraded = True + text = "test text" + entities = ( + MessageEntity(MessageEntity.BOLD, 0, 4), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ) + is_private = True + is_upgrade_separate = False + unique_gift_number = 42 + + +class TestGiftInfoWithoutRequest(GiftInfoTestBase): + def test_slot_behaviour(self, gift_info): + for attr in gift_info.__slots__: + assert getattr(gift_info, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(gift_info)) == len(set(mro_slots(gift_info))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "gift": self.gift.to_dict(), + "owned_gift_id": self.owned_gift_id, + "convert_star_count": self.convert_star_count, + "prepaid_upgrade_star_count": self.prepaid_upgrade_star_count, + "can_be_upgraded": self.can_be_upgraded, + "text": self.text, + "entities": [e.to_dict() for e in self.entities], + "is_private": self.is_private, + "is_upgrade_separate": self.is_upgrade_separate, + "unique_gift_number": self.unique_gift_number, + } + gift_info = GiftInfo.de_json(json_dict, offline_bot) + assert gift_info.api_kwargs == {} + assert gift_info.gift == self.gift + assert gift_info.owned_gift_id == self.owned_gift_id + assert gift_info.convert_star_count == self.convert_star_count + assert gift_info.prepaid_upgrade_star_count == self.prepaid_upgrade_star_count + assert gift_info.can_be_upgraded == self.can_be_upgraded + assert gift_info.text == self.text + assert gift_info.entities == self.entities + assert gift_info.is_private == self.is_private + assert gift_info.is_upgrade_separate == self.is_upgrade_separate + assert gift_info.unique_gift_number == self.unique_gift_number + + def test_to_dict(self, gift_info): + json_dict = gift_info.to_dict() + assert json_dict["gift"] == self.gift.to_dict() + assert json_dict["owned_gift_id"] == self.owned_gift_id + assert json_dict["convert_star_count"] == self.convert_star_count + assert json_dict["prepaid_upgrade_star_count"] == self.prepaid_upgrade_star_count + assert json_dict["can_be_upgraded"] == self.can_be_upgraded + assert json_dict["text"] == self.text + assert json_dict["entities"] == [e.to_dict() for e in self.entities] + assert json_dict["is_private"] == self.is_private + assert json_dict["is_upgrade_separate"] == self.is_upgrade_separate + assert json_dict["unique_gift_number"] == self.unique_gift_number + + def test_parse_entity(self, gift_info): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + + assert gift_info.parse_entity(entity) == "test" + + with pytest.raises(RuntimeError, match="GiftInfo has no"): + GiftInfo( + gift=self.gift, + ).parse_entity(entity) + + def test_parse_entities(self, gift_info): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + entity_2 = MessageEntity(MessageEntity.ITALIC, 5, 8) + + assert gift_info.parse_entities(MessageEntity.BOLD) == {entity: "test"} + assert gift_info.parse_entities() == {entity: "test", entity_2: "text"} + + with pytest.raises(RuntimeError, match="GiftInfo has no"): + GiftInfo( + gift=self.gift, + ).parse_entities() + + def test_equality(self, gift_info): + a = gift_info + b = GiftInfo(gift=self.gift) + c = GiftInfo( + gift=Gift( + id="some_other_gift_id", + sticker=Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular"), + star_count=5, + ), + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def accepted_gift_types(): + return AcceptedGiftTypes( + unlimited_gifts=AcceptedGiftTypesTestBase.unlimited_gifts, + limited_gifts=AcceptedGiftTypesTestBase.limited_gifts, + unique_gifts=AcceptedGiftTypesTestBase.unique_gifts, + premium_subscription=AcceptedGiftTypesTestBase.premium_subscription, + gifts_from_channels=AcceptedGiftTypesTestBase.gifts_from_channels, + ) + + +class AcceptedGiftTypesTestBase: + unlimited_gifts = False + limited_gifts = True + unique_gifts = True + premium_subscription = True + gifts_from_channels = False + + +class TestAcceptedGiftTypesWithoutRequest(AcceptedGiftTypesTestBase): + def test_slot_behaviour(self, accepted_gift_types): + for attr in accepted_gift_types.__slots__: + assert getattr(accepted_gift_types, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(accepted_gift_types)) == len(set(mro_slots(accepted_gift_types))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "unlimited_gifts": self.unlimited_gifts, + "limited_gifts": self.limited_gifts, + "unique_gifts": self.unique_gifts, + "premium_subscription": self.premium_subscription, + "gifts_from_channels": self.gifts_from_channels, + } + accepted_gift_types = AcceptedGiftTypes.de_json(json_dict, offline_bot) + assert accepted_gift_types.api_kwargs == {} + assert accepted_gift_types.unlimited_gifts == self.unlimited_gifts + assert accepted_gift_types.limited_gifts == self.limited_gifts + assert accepted_gift_types.unique_gifts == self.unique_gifts + assert accepted_gift_types.premium_subscription == self.premium_subscription + assert accepted_gift_types.gifts_from_channels == self.gifts_from_channels + + def test_to_dict(self, accepted_gift_types): + json_dict = accepted_gift_types.to_dict() + assert json_dict["unlimited_gifts"] == self.unlimited_gifts + assert json_dict["limited_gifts"] == self.limited_gifts + assert json_dict["unique_gifts"] == self.unique_gifts + assert json_dict["premium_subscription"] == self.premium_subscription + assert json_dict["gifts_from_channels"] == self.gifts_from_channels + + def test_equality(self, accepted_gift_types): + a = accepted_gift_types + b = AcceptedGiftTypes( + self.unlimited_gifts, + self.limited_gifts, + self.unique_gifts, + self.premium_subscription, + self.gifts_from_channels, + ) + c = AcceptedGiftTypes( + not self.unlimited_gifts, + self.limited_gifts, + self.unique_gifts, + self.premium_subscription, + self.gifts_from_channels, + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_giveaway.py b/tests/test_giveaway.py index 8ec07e59ee9..1c54dd6d8f5 100644 --- a/tests/test_giveaway.py +++ b/tests/test_giveaway.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -94,8 +94,6 @@ def test_de_json(self, offline_bot): assert giveaway.premium_subscription_month_count == self.premium_subscription_month_count assert giveaway.prize_star_count == self.prize_star_count - assert Giveaway.de_json(None, offline_bot) is None - def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): json_dict = { "chats": [chat.to_dict() for chat in self.chats], @@ -183,9 +181,9 @@ class TestGiveawayCreatedWithoutRequest: def test_slot_behaviour(self, giveaway_created): for attr in giveaway_created.__slots__: assert getattr(giveaway_created, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(giveaway_created)) == len( - set(mro_slots(giveaway_created)) - ), "duplicate slot" + assert len(mro_slots(giveaway_created)) == len(set(mro_slots(giveaway_created))), ( + "duplicate slot" + ) def test_de_json(self, bot): json_dict = { @@ -196,8 +194,6 @@ def test_de_json(self, bot): assert gac.api_kwargs == {} assert gac.prize_star_count == self.prize_star_count - assert Giveaway.de_json(None, bot) is None - def test_to_dict(self, giveaway_created): gac_dict = giveaway_created.to_dict() @@ -242,9 +238,9 @@ class TestGiveawayWinnersWithoutRequest: def test_slot_behaviour(self, giveaway_winners): for attr in giveaway_winners.__slots__: assert getattr(giveaway_winners, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(giveaway_winners)) == len( - set(mro_slots(giveaway_winners)) - ), "duplicate slot" + assert len(mro_slots(giveaway_winners)) == len(set(mro_slots(giveaway_winners))), ( + "duplicate slot" + ) def test_de_json(self, offline_bot): json_dict = { @@ -281,8 +277,6 @@ def test_de_json(self, offline_bot): assert giveaway_winners.prize_description == self.prize_description assert giveaway_winners.prize_star_count == self.prize_star_count - assert GiveawayWinners.de_json(None, offline_bot) is None - def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): json_dict = { "chat": self.chat.to_dict(), @@ -391,9 +385,9 @@ class TestGiveawayCompletedWithoutRequest: def test_slot_behaviour(self, giveaway_completed): for attr in giveaway_completed.__slots__: assert getattr(giveaway_completed, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(giveaway_completed)) == len( - set(mro_slots(giveaway_completed)) - ), "duplicate slot" + assert len(mro_slots(giveaway_completed)) == len(set(mro_slots(giveaway_completed))), ( + "duplicate slot" + ) def test_de_json(self, offline_bot): json_dict = { @@ -411,8 +405,6 @@ def test_de_json(self, offline_bot): assert giveaway_completed.giveaway_message == self.giveaway_message assert giveaway_completed.is_star_giveaway == self.is_star_giveaway - assert GiveawayCompleted.de_json(None, offline_bot) is None - def test_to_dict(self, giveaway_completed): giveaway_completed_dict = giveaway_completed.to_dict() diff --git a/tests/test_helpers.py b/tests/test_helpers.py index bb7c0f61233..86a9b52f468 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_inputchecklist.py b/tests/test_inputchecklist.py new file mode 100644 index 00000000000..e2543562d54 --- /dev/null +++ b/tests/test_inputchecklist.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import pytest + +from telegram import Dice, InputChecklist, InputChecklistTask, MessageEntity +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def input_checklist_task(): + return InputChecklistTask( + id=InputChecklistTaskTestBase.id, + text=InputChecklistTaskTestBase.text, + parse_mode=InputChecklistTaskTestBase.parse_mode, + text_entities=InputChecklistTaskTestBase.text_entities, + ) + + +class InputChecklistTaskTestBase: + id = 1 + text = "buy food" + parse_mode = "MarkdownV2" + text_entities = [ + MessageEntity(type="bold", offset=0, length=3), + MessageEntity(type="italic", offset=4, length=4), + ] + + +class TestInputChecklistTaskWithoutRequest(InputChecklistTaskTestBase): + def test_slot_behaviour(self, input_checklist_task): + for attr in input_checklist_task.__slots__: + assert getattr(input_checklist_task, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(input_checklist_task)) == len(set(mro_slots(input_checklist_task))), ( + "duplicate slot" + ) + + def test_expected_values(self, input_checklist_task): + assert input_checklist_task.id == self.id + assert input_checklist_task.text == self.text + assert input_checklist_task.parse_mode == self.parse_mode + assert input_checklist_task.text_entities == tuple(self.text_entities) + + def test_to_dict(self, input_checklist_task): + iclt_dict = input_checklist_task.to_dict() + + assert isinstance(iclt_dict, dict) + assert iclt_dict["id"] == self.id + assert iclt_dict["text"] == self.text + assert iclt_dict["parse_mode"] == self.parse_mode + assert iclt_dict["text_entities"] == [entity.to_dict() for entity in self.text_entities] + + # Test that default-value parameter `parse_mode` is handled correctly + input_checklist_task = InputChecklistTask(id=1, text="text") + iclt_dict = input_checklist_task.to_dict() + assert "parse_mode" not in iclt_dict + + def test_equality(self, input_checklist_task): + a = input_checklist_task + b = InputChecklistTask(id=self.id, text=f"other {self.text}") + c = InputChecklistTask(id=self.id + 1, text=self.text) + d = Dice(value=1, emoji="🎲") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture(scope="module") +def input_checklist(): + return InputChecklist( + title=InputChecklistTestBase.title, + tasks=InputChecklistTestBase.tasks, + parse_mode=InputChecklistTestBase.parse_mode, + title_entities=InputChecklistTestBase.title_entities, + others_can_add_tasks=InputChecklistTestBase.others_can_add_tasks, + others_can_mark_tasks_as_done=InputChecklistTestBase.others_can_mark_tasks_as_done, + ) + + +class InputChecklistTestBase: + title = "test list" + tasks = [ + InputChecklistTask(id=1, text="eat"), + InputChecklistTask(id=2, text="sleep"), + ] + parse_mode = "MarkdownV2" + title_entities = [ + MessageEntity(type="bold", offset=0, length=4), + MessageEntity(type="italic", offset=5, length=4), + ] + others_can_add_tasks = True + others_can_mark_tasks_as_done = False + + +class TestInputChecklistWithoutRequest(InputChecklistTestBase): + def test_slot_behaviour(self, input_checklist): + for attr in input_checklist.__slots__: + assert getattr(input_checklist, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(input_checklist)) == len(set(mro_slots(input_checklist))), ( + "duplicate slot" + ) + + def test_expected_values(self, input_checklist): + assert input_checklist.title == self.title + assert input_checklist.tasks == tuple(self.tasks) + assert input_checklist.parse_mode == self.parse_mode + assert input_checklist.title_entities == tuple(self.title_entities) + assert input_checklist.others_can_add_tasks == self.others_can_add_tasks + assert input_checklist.others_can_mark_tasks_as_done == self.others_can_mark_tasks_as_done + + def test_to_dict(self, input_checklist): + icl_dict = input_checklist.to_dict() + + assert isinstance(icl_dict, dict) + assert icl_dict["title"] == self.title + assert icl_dict["tasks"] == [task.to_dict() for task in self.tasks] + assert icl_dict["parse_mode"] == self.parse_mode + assert icl_dict["title_entities"] == [entity.to_dict() for entity in self.title_entities] + assert icl_dict["others_can_add_tasks"] == self.others_can_add_tasks + assert icl_dict["others_can_mark_tasks_as_done"] == self.others_can_mark_tasks_as_done + + # Test that default-value parameter `parse_mode` is handled correctly + input_checklist = InputChecklist(title=self.title, tasks=self.tasks) + icl_dict = input_checklist.to_dict() + assert "parse_mode" not in icl_dict + + def test_equality(self, input_checklist): + a = input_checklist + b = InputChecklist( + title=f"other {self.title}", + tasks=[InputChecklistTask(id=1, text="eat"), InputChecklistTask(id=2, text="sleep")], + ) + c = InputChecklist( + title=self.title, + tasks=[InputChecklistTask(id=9, text="Other Task")], + ) + d = Dice(value=1, emoji="🎲") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_keyboardbutton.py b/tests/test_keyboardbutton.py index 35db28b3924..f7d14902784 100644 --- a/tests/test_keyboardbutton.py +++ b/tests/test_keyboardbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -108,9 +108,6 @@ def test_de_json(self, request_user): assert keyboard_button.request_chat == self.request_chat assert keyboard_button.request_users == self.request_users - none = KeyboardButton.de_json({}, None) - assert none is None - def test_equality(self): a = KeyboardButton("test", request_contact=True) b = KeyboardButton("test", request_contact=True) diff --git a/tests/test_keyboardbuttonpolltype.py b/tests/test_keyboardbuttonpolltype.py index 5a1f7b26f24..5abcfed0aa7 100644 --- a/tests/test_keyboardbuttonpolltype.py +++ b/tests/test_keyboardbuttonpolltype.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_keyboardbuttonrequest.py b/tests/test_keyboardbuttonrequest.py index f196977d309..8a63bb2dbad 100644 --- a/tests/test_keyboardbuttonrequest.py +++ b/tests/test_keyboardbuttonrequest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -44,9 +44,9 @@ class TestKeyboardButtonRequestUsersWithoutRequest(KeyboardButtonRequestUsersTes def test_slot_behaviour(self, request_users): for attr in request_users.__slots__: assert getattr(request_users, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(request_users)) == len( - set(mro_slots(request_users)) - ), "duplicate slot" + assert len(mro_slots(request_users)) == len(set(mro_slots(request_users))), ( + "duplicate slot" + ) def test_to_dict(self, request_users): request_users_dict = request_users.to_dict() @@ -179,9 +179,6 @@ def test_de_json(self, offline_bot): assert request_chat.bot_administrator_rights == self.bot_administrator_rights assert request_chat.bot_is_member == self.bot_is_member - empty_chat = KeyboardButtonRequestChat.de_json({}, offline_bot) - assert empty_chat is None - def test_equality(self): a = KeyboardButtonRequestChat(self.request_id, True) b = KeyboardButtonRequestChat(self.request_id, True) diff --git a/tests/test_linkpreviewoptions.py b/tests/test_linkpreviewoptions.py index da280528289..946a1f7bf6f 100644 --- a/tests/test_linkpreviewoptions.py +++ b/tests/test_linkpreviewoptions.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_loginurl.py b/tests/test_loginurl.py index ff354f372e5..c98bd974404 100644 --- a/tests/test_loginurl.py +++ b/tests/test_loginurl.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_maybeinaccessiblemessage.py b/tests/test_maybeinaccessiblemessage.py index 4e715ed8a65..d92e8c99591 100644 --- a/tests/test_maybeinaccessiblemessage.py +++ b/tests/test_maybeinaccessiblemessage.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -44,9 +44,9 @@ class MaybeInaccessibleMessageTestBase: class TestMaybeInaccessibleMessageWithoutRequest(MaybeInaccessibleMessageTestBase): def test_slot_behaviour(self, maybe_inaccessible_message): for attr in maybe_inaccessible_message.__slots__: - assert ( - getattr(maybe_inaccessible_message, attr, "err") != "err" - ), f"got extra slot '{attr}'" + assert getattr(maybe_inaccessible_message, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) assert len(mro_slots(maybe_inaccessible_message)) == len( set(mro_slots(maybe_inaccessible_message)) ), "duplicate slot" diff --git a/tests/test_menubutton.py b/tests/test_menubutton.py index ef9afebff16..8253ec33d86 100644 --- a/tests/test_menubutton.py +++ b/tests/test_menubutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,8 +16,6 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -from copy import deepcopy - import pytest from telegram import ( @@ -32,137 +30,102 @@ from tests.auxil.slots import mro_slots -@pytest.fixture( - scope="module", - params=[ - MenuButton.DEFAULT, - MenuButton.WEB_APP, - MenuButton.COMMANDS, - ], -) -def scope_type(request): - return request.param - - -@pytest.fixture( - scope="module", - params=[ - MenuButtonDefault, - MenuButtonCommands, - MenuButtonWebApp, - ], - ids=[ - MenuButton.DEFAULT, - MenuButton.COMMANDS, - MenuButton.WEB_APP, - ], -) -def scope_class(request): - return request.param - - -@pytest.fixture( - scope="module", - params=[ - (MenuButtonDefault, MenuButton.DEFAULT), - (MenuButtonCommands, MenuButton.COMMANDS), - (MenuButtonWebApp, MenuButton.WEB_APP), - ], - ids=[ - MenuButton.DEFAULT, - MenuButton.COMMANDS, - MenuButton.WEB_APP, - ], -) -def scope_class_and_type(request): - return request.param - - -@pytest.fixture(scope="module") -def menu_button(scope_class_and_type): - # We use de_json here so that we don't have to worry about which class gets which arguments - return scope_class_and_type[0].de_json( - { - "type": scope_class_and_type[1], - "text": MenuButtonTestBase.text, - "web_app": MenuButtonTestBase.web_app.to_dict(), - }, - bot=None, - ) +@pytest.fixture +def menu_button(): + return MenuButton(MenuButtonTestBase.type) class MenuButtonTestBase: - text = "button_text" - web_app = WebAppInfo(url="https://python-telegram-bot.org/web_app") + type = MenuButtonType.DEFAULT + text = "this is a test string" + web_app = WebAppInfo(url="https://python-telegram-bot.org") -# All the scope types are very similar, so we test everything via parametrization class TestMenuButtonWithoutRequest(MenuButtonTestBase): def test_slot_behaviour(self, menu_button): - for attr in menu_button.__slots__: - assert getattr(menu_button, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(menu_button)) == len(set(mro_slots(menu_button))), "duplicate slot" - - def test_de_json(self, offline_bot, scope_class_and_type): - cls = scope_class_and_type[0] - type_ = scope_class_and_type[1] - - json_dict = {"type": type_, "text": self.text, "web_app": self.web_app.to_dict()} - menu_button = MenuButton.de_json(json_dict, offline_bot) - assert set(menu_button.api_kwargs.keys()) == {"text", "web_app"} - set(cls.__slots__) - - assert isinstance(menu_button, MenuButton) - assert type(menu_button) is cls - assert menu_button.type == type_ - if "web_app" in cls.__slots__: - assert menu_button.web_app == self.web_app - if "text" in cls.__slots__: - assert menu_button.text == self.text - - assert cls.de_json(None, offline_bot) is None - assert MenuButton.de_json({}, offline_bot) is None - - def test_de_json_invalid_type(self, offline_bot): - json_dict = {"type": "invalid", "text": self.text, "web_app": self.web_app.to_dict()} - menu_button = MenuButton.de_json(json_dict, offline_bot) - assert menu_button.api_kwargs == {"text": self.text, "web_app": self.web_app.to_dict()} - - assert type(menu_button) is MenuButton - assert menu_button.type == "invalid" - - def test_de_json_subclass(self, scope_class, offline_bot): - """This makes sure that e.g. MenuButtonDefault(data) never returns a - MenuButtonChat instance.""" - json_dict = {"type": "invalid", "text": self.text, "web_app": self.web_app.to_dict()} - assert type(scope_class.de_json(json_dict, offline_bot)) is scope_class - - def test_de_json_empty_data(self, scope_class): - if scope_class in (MenuButtonWebApp,): - pytest.skip( - "This test is not relevant for subclasses that have more attributes than just type" - ) - assert isinstance(scope_class.de_json({}, None), scope_class) + inst = menu_button + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_type_enum_conversion(self, menu_button): + assert type(MenuButton("default").type) is MenuButtonType + assert MenuButton("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + transaction_partner = MenuButton.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "unknown" + + @pytest.mark.parametrize( + ("mb_type", "subclass"), + [ + ("commands", MenuButtonCommands), + ("web_app", MenuButtonWebApp), + ("default", MenuButtonDefault), + ], + ) + def test_de_json_subclass(self, offline_bot, mb_type, subclass): + json_dict = { + "type": mb_type, + "web_app": self.web_app.to_dict(), + "text": self.text, + } + mb = MenuButton.de_json(json_dict, offline_bot) + + assert type(mb) is subclass + assert set(mb.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert mb.type == mb_type def test_to_dict(self, menu_button): - menu_button_dict = menu_button.to_dict() + assert menu_button.to_dict() == {"type": menu_button.type} - assert isinstance(menu_button_dict, dict) - assert menu_button_dict["type"] == menu_button.type - if hasattr(menu_button, "web_app"): - assert menu_button_dict["web_app"] == menu_button.web_app.to_dict() - if hasattr(menu_button, "text"): - assert menu_button_dict["text"] == menu_button.text + def test_equality(self, menu_button): + a = menu_button + b = MenuButton(self.type) + c = MenuButton("unknown") + d = Dice(5, "test") - def test_type_enum_conversion(self): - assert type(MenuButton("commands").type) is MenuButtonType - assert MenuButton("unknown").type == "unknown" + assert a == b + assert hash(a) == hash(b) - def test_equality(self, menu_button, offline_bot): - a = MenuButton("base_type") - b = MenuButton("base_type") - c = menu_button - d = deepcopy(menu_button) - e = Dice(4, "emoji") + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def menu_button_commands(): + return MenuButtonCommands() + + +class TestMenuButtonCommandsWithoutRequest(MenuButtonTestBase): + type = MenuButtonType.COMMANDS + + def test_slot_behaviour(self, menu_button_commands): + inst = menu_button_commands + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + transaction_partner = MenuButtonCommands.de_json({}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "commands" + + def test_to_dict(self, menu_button_commands): + assert menu_button_commands.to_dict() == {"type": menu_button_commands.type} + + def test_equality(self, menu_button_commands): + a = menu_button_commands + b = MenuButtonCommands() + c = Dice(5, "test") + d = MenuButtonDefault() assert a == b assert hash(a) == hash(b) @@ -173,27 +136,92 @@ def test_equality(self, menu_button, offline_bot): assert a != d assert hash(a) != hash(d) - assert a != e - assert hash(a) != hash(e) - assert c == d - assert hash(c) == hash(d) +@pytest.fixture +def menu_button_default(): + return MenuButtonDefault() - assert c != e - assert hash(c) != hash(e) - if hasattr(c, "web_app"): - json_dict = c.to_dict() - json_dict["web_app"] = WebAppInfo("https://foo.bar/web_app").to_dict() - f = c.__class__.de_json(json_dict, offline_bot) +class TestMenuButtonDefaultWithoutRequest(MenuButtonTestBase): + type = MenuButtonType.DEFAULT - assert c != f - assert hash(c) != hash(f) + def test_slot_behaviour(self, menu_button_default): + inst = menu_button_default + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - if hasattr(c, "text"): - json_dict = c.to_dict() - json_dict["text"] = "other text" - g = c.__class__.de_json(json_dict, offline_bot) + def test_de_json(self, offline_bot): + transaction_partner = MenuButtonDefault.de_json({}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "default" - assert c != g - assert hash(c) != hash(g) + def test_to_dict(self, menu_button_default): + assert menu_button_default.to_dict() == {"type": menu_button_default.type} + + def test_equality(self, menu_button_default): + a = menu_button_default + b = MenuButtonDefault() + c = Dice(5, "test") + d = MenuButtonCommands() + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def menu_button_web_app(): + return MenuButtonWebApp( + web_app=TestMenuButtonWebAppWithoutRequest.web_app, + text=TestMenuButtonWebAppWithoutRequest.text, + ) + + +class TestMenuButtonWebAppWithoutRequest(MenuButtonTestBase): + type = MenuButtonType.WEB_APP + + def test_slot_behaviour(self, menu_button_web_app): + inst = menu_button_web_app + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = {"web_app": self.web_app.to_dict(), "text": self.text} + transaction_partner = MenuButtonWebApp.de_json(json_dict, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "web_app" + assert transaction_partner.web_app == self.web_app + assert transaction_partner.text == self.text + + def test_to_dict(self, menu_button_web_app): + assert menu_button_web_app.to_dict() == { + "type": menu_button_web_app.type, + "web_app": menu_button_web_app.web_app.to_dict(), + "text": menu_button_web_app.text, + } + + def test_equality(self, menu_button_web_app): + a = menu_button_web_app + b = MenuButtonWebApp(web_app=self.web_app, text=self.text) + c = MenuButtonWebApp(web_app=self.web_app, text="other text") + d = MenuButtonWebApp(web_app=WebAppInfo(url="https://example.org"), text=self.text) + e = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) diff --git a/tests/test_message.py b/tests/test_message.py index 5ef3ba1e50c..218b210f991 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,9 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -import contextlib import datetime as dtm -from copy import copy +from copy import copy, deepcopy import pytest @@ -32,15 +31,24 @@ ChatBackground, ChatBoostAdded, ChatShared, + Checklist, + ChecklistTask, + ChecklistTasksAdded, + ChecklistTasksDone, Contact, Dice, + DirectMessagePriceChanged, Document, ExternalReplyInfo, Game, + Gift, + GiftInfo, Giveaway, GiveawayCompleted, GiveawayCreated, GiveawayWinners, + InputChecklist, + InputChecklistTask, InputPaidMediaPhoto, Invoice, LinkPreviewOptions, @@ -51,6 +59,7 @@ MessageOriginChat, PaidMediaInfo, PaidMediaPreview, + PaidMessagePriceChanged, PassportData, PhotoSize, Poll, @@ -62,7 +71,20 @@ Sticker, Story, SuccessfulPayment, + SuggestedPostApprovalFailed, + SuggestedPostApproved, + SuggestedPostDeclined, + SuggestedPostInfo, + SuggestedPostPaid, + SuggestedPostPrice, + SuggestedPostRefunded, TextQuote, + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftInfo, + UniqueGiftModel, + UniqueGiftSymbol, Update, User, UsersShared, @@ -76,6 +98,7 @@ Voice, WebAppData, ) +from telegram._directmessagestopic import DirectMessagesTopic from telegram._utils.datetime import UTC from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import ODVInput @@ -89,6 +112,7 @@ check_shortcut_signature, ) from tests.auxil.build_messages import make_message +from tests.auxil.dummy_objects import get_dummy_object_json_dict from tests.auxil.pytest_classes import PytestExtBot, PytestMessage from tests.auxil.slots import mro_slots @@ -232,6 +256,43 @@ def message(bot): {"message_thread_id": 123}, {"users_shared": UsersShared(1, users=[SharedUser(2, "user2"), SharedUser(3, "user3")])}, {"chat_shared": ChatShared(3, 4)}, + { + "gift": GiftInfo( + gift=Gift( + "gift_id", + Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular"), + 5, + ) + ) + }, + { + "unique_gift": UniqueGiftInfo( + gift=UniqueGift( + gift_id="gift_id", + base_name="human_readable_name", + name="unique_name", + number=2, + model=UniqueGiftModel( + "model_name", + Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + 10, + ), + symbol=UniqueGiftSymbol( + "symbol_name", + Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + 20, + ), + backdrop=UniqueGiftBackdrop( + "backdrop_name", + UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + 30, + ), + ), + origin=UniqueGiftInfo.UPGRADE, + owned_gift_id="id", + transfer_star_count=10, + ) + }, { "giveaway": Giveaway( chats=[Chat(1, Chat.SUPERGROUP)], @@ -283,6 +344,92 @@ def message(bot): {"show_caption_above_media": True}, {"paid_media": PaidMediaInfo(5, [PaidMediaPreview(10, 10, 10)])}, {"refunded_payment": RefundedPayment("EUR", 243, "payload", "charge_id", "provider_id")}, + {"paid_star_count": 291}, + {"paid_message_price_changed": PaidMessagePriceChanged(291)}, + {"direct_message_price_changed": DirectMessagePriceChanged(True, 100)}, + { + "checklist": Checklist( + "checklist_id", + tasks=[ChecklistTask(id=42, text="task 1"), ChecklistTask(id=43, text="task 2")], + ) + }, + { + "checklist_tasks_done": ChecklistTasksDone( + marked_as_done_task_ids=[1, 2, 3], + marked_as_not_done_task_ids=[4, 5], + ) + }, + { + "checklist_tasks_added": ChecklistTasksAdded( + tasks=[ChecklistTask(id=42, text="task 1"), ChecklistTask(id=43, text="task 2")], + ) + }, + {"is_paid_post": True}, + { + "direct_messages_topic": DirectMessagesTopic( + topic_id=1234, + user=User(id=5678, first_name="TestUser", is_bot=False), + ) + }, + {"reply_to_checklist_task_id": 11}, + { + "suggested_post_declined": SuggestedPostDeclined( + suggested_post_message=Message( + 7, dtm.datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) + ), + comment="comment", + ) + }, + { + "suggested_post_paid": SuggestedPostPaid( + currency="XTR", + suggested_post_message=Message( + 7, dtm.datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) + ), + amount=100, + ) + }, + { + "suggested_post_refunded": SuggestedPostRefunded( + reason="post_deleted", + suggested_post_message=Message( + 7, dtm.datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) + ), + ) + }, + { + "suggested_post_approved": SuggestedPostApproved( + send_date=dtm.datetime.utcnow(), + price=SuggestedPostPrice(currency="XTR", amount=100), + suggested_post_message=Message( + 7, dtm.datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) + ), + ) + }, + { + "suggested_post_approval_failed": SuggestedPostApprovalFailed( + price=SuggestedPostPrice(currency="XTR", amount=100), + suggested_post_message=Message( + 7, dtm.datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) + ), + ) + }, + { + "suggested_post_info": SuggestedPostInfo( + state="pending", + price=SuggestedPostPrice(currency="XTR", amount=100), + send_date=dtm.datetime.utcnow(), + ) + }, + { + "gift_upgrade_sent": GiftInfo( + gift=Gift( + "gift_id", + Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular"), + 5, + ) + ) + }, ], ids=[ "reply", @@ -337,6 +484,8 @@ def message(bot): "message_thread_id", "users_shared", "chat_shared", + "gift", + "unique_gift", "giveaway", "giveaway_created", "giveaway_winners", @@ -356,6 +505,22 @@ def message(bot): "show_caption_above_media", "paid_media", "refunded_payment", + "paid_star_count", + "paid_message_price_changed", + "direct_message_price_changed", + "checklist", + "checklist_tasks_done", + "checklist_tasks_added", + "is_paid_post", + "direct_messages_topic", + "reply_to_checklist_task_id", + "suggested_post_declined", + "suggested_post_paid", + "suggested_post_refunded", + "suggested_post_approved", + "suggested_post_approval_failed", + "suggested_post_info", + "gift_upgrade_sent", ], ) def message_params(bot, request): @@ -443,56 +608,84 @@ class MessageTestBase: class TestMessageWithoutRequest(MessageTestBase): - @staticmethod async def check_quote_parsing( - message: Message, method, bot_method_name: str, args, monkeypatch + self, message: Message, method, bot_method_name: str, args, monkeypatch ): - """Used in testing reply_* below. Makes sure that quote and do_quote are handled - correctly - """ - with contextlib.suppress(TypeError): - # for newer methods that don't have the deprecated argument - with pytest.raises(ValueError, match="`quote` and `do_quote` are mutually exclusive"): - await method(*args, quote=True, do_quote=True) - - # for newer methods that don't have the deprecated argument - with pytest.warns(PTBDeprecationWarning, match="`quote` parameter is deprecated"): - await method(*args, quote=True) - + """Used in testing reply_* below. Makes sure that do_quote is handled correctly""" with pytest.raises( ValueError, - match="`reply_to_message_id` and `reply_parameters` are mutually exclusive.", + match="`reply_to_message_id` and `reply_parameters` are mutually exclusive\\.", ): await method(*args, reply_to_message_id=42, reply_parameters=42) + with pytest.raises( + ValueError, + match="`allow_sending_without_reply` and `reply_parameters` are mutually exclusive\\.", + ): + await method(*args, allow_sending_without_reply=True, reply_parameters=42) + async def make_assertion(*args, **kwargs): return kwargs.get("chat_id"), kwargs.get("reply_parameters") monkeypatch.setattr(message.get_bot(), bot_method_name, make_assertion) - for param in ("quote", "do_quote"): - with contextlib.suppress(TypeError): - # for newer methods that don't have the deprecated argument - chat_id, reply_parameters = await method(*args, **{param: True}) - if chat_id != message.chat.id: - pytest.fail(f"chat_id is {chat_id} but should be {message.chat.id}") - if reply_parameters is None or reply_parameters.message_id != message.message_id: - pytest.fail( - f"reply_parameters is {reply_parameters} " - "but should be {message.message_id}" - ) + for aswr in (DEFAULT_NONE, True): + await self._check_quote_parsing( + message=message, + method=method, + bot_method_name=bot_method_name, + args=args, + monkeypatch=monkeypatch, + aswr=aswr, + ) + + @staticmethod + async def _check_quote_parsing( + message: Message, method, bot_method_name: str, args, monkeypatch, aswr + ): + # test that boolean input for do_quote is parse correctly + for value in (True, False): + chat_id, reply_parameters = await method( + *args, do_quote=value, allow_sending_without_reply=aswr + ) + if chat_id != message.chat.id: + pytest.fail(f"chat_id is {chat_id} but should be {message.chat.id}") + expected = ( + ReplyParameters(message.message_id, allow_sending_without_reply=aswr) + if value + else None + ) + if reply_parameters != expected: + pytest.fail(f"reply_parameters is {reply_parameters} but should be {expected}") + # test that dict input for do_quote is parsed correctly input_chat_id = object() input_reply_parameters = ReplyParameters(message_id=1, chat_id=42) - chat_id, reply_parameters = await method( - *args, do_quote={"chat_id": input_chat_id, "reply_parameters": input_reply_parameters} - ) - if chat_id is not input_chat_id: - pytest.fail(f"chat_id is {chat_id} but should be {chat_id}") - if reply_parameters is not input_reply_parameters: - pytest.fail(f"reply_parameters is {reply_parameters} but should be {reply_parameters}") + coro = method( + *args, + do_quote={"chat_id": input_chat_id, "reply_parameters": input_reply_parameters}, + allow_sending_without_reply=aswr, + ) + if aswr is True: + with pytest.raises( + ValueError, + match="`allow_sending_without_reply` and `dict`-value input", + ): + await coro + else: + chat_id, reply_parameters = await coro + if chat_id is not input_chat_id: + pytest.fail(f"chat_id is {chat_id} but should be {input_chat_id}") + if reply_parameters is not input_reply_parameters: + pytest.fail( + f"reply_parameters is {reply_parameters} " + f"but should be {input_reply_parameters}" + ) - input_parameters_2 = ReplyParameters(message_id=2, chat_id=43) + # test that do_quote input is overridden by reply_parameters + input_parameters_2 = ReplyParameters( + message_id=message.message_id + 1, chat_id=message.chat_id + 1 + ) chat_id, reply_parameters = await method( *args, reply_parameters=input_parameters_2, @@ -506,16 +699,23 @@ async def make_assertion(*args, **kwargs): f"reply_parameters is {reply_parameters} but should be {input_parameters_2}" ) + # test that do_quote input is overridden by reply_to_message_id chat_id, reply_parameters = await method( *args, reply_to_message_id=42, # passing these here to make sure that `reply_to_message_id` has higher priority do_quote={"chat_id": input_chat_id, "reply_parameters": input_reply_parameters}, + allow_sending_without_reply=aswr, ) if chat_id != message.chat.id: pytest.fail(f"chat_id is {chat_id} but should be {message.chat.id}") if reply_parameters is None or reply_parameters.message_id != 42: pytest.fail(f"reply_parameters is {reply_parameters} but should be 42") + if reply_parameters is None or reply_parameters.allow_sending_without_reply != aswr: + pytest.fail( + f"reply_parameters.allow_sending_without_reply is " + f"{reply_parameters.allow_sending_without_reply} it should be {aswr}" + ) @staticmethod async def check_thread_id_parsing( @@ -546,7 +746,8 @@ async def extract_message_thread_id(*args, **kwargs): message_thread_id = await method(*args, message_thread_id=None) assert message_thread_id is None - if bot_method_name == "send_chat_action": + # These methods do not accept `do_quote` as passed below + if bot_method_name in ["send_chat_action", "send_message_draft"]: return message_thread_id = await method( @@ -591,9 +792,9 @@ def test_all_possibilities_de_json_and_to_dict(self, offline_bot, message_params def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): json_dict = { "message_id": 12, - "from_user": None, + "from_user": get_dummy_object_json_dict("User"), "date": int(dtm.datetime.now().timestamp()), - "chat": None, + "chat": get_dummy_object_json_dict("Chat"), "edit_date": int(dtm.datetime.now().timestamp()), } @@ -1274,8 +1475,7 @@ def test_compute_quote_position_and_entities_false_index(self, message): message.text = "AA" with pytest.raises( ValueError, - match="You requested the 5-th occurrence of 'A', " - "but this text appears only 2 times.", + match="You requested the 5-th occurrence of 'A', but this text appears only 2 times", ): message.compute_quote_position_and_entities("A", 5) @@ -1284,7 +1484,7 @@ def test_compute_quote_position_and_entities_no_text_or_caption(self, message): message.caption = None with pytest.raises( RuntimeError, - match="This message has neither text nor caption.", + match="This message has neither text nor caption\\.", ): message.compute_quote_position_and_entities("A", 5) @@ -1449,8 +1649,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_text, Bot.send_message, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1458,7 +1663,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_message", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_text, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1491,8 +1696,14 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_markdown, Bot.send_message, - ["chat_id", "parse_mode", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "parse_mode", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1500,7 +1711,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_message", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_text, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1540,8 +1751,14 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_markdown_v2, Bot.send_message, - ["chat_id", "parse_mode", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "parse_mode", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1549,7 +1766,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_message", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_text, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1592,8 +1809,14 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_html, Bot.send_message, - ["chat_id", "parse_mode", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "parse_mode", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1601,7 +1824,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_message", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_text, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1620,6 +1843,37 @@ async def make_assertion(*_, **kwargs): message, message.reply_html, "send_message", ["test"], monkeypatch ) + async def test_reply_text_draft(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + id_ = kwargs["chat_id"] == message.chat_id + text = kwargs["text"] == "test" + return id_ and text + + assert check_shortcut_signature( + Message.reply_text_draft, + Bot.send_message_draft, + ["chat_id"], + [], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_text_draft, + message.get_bot(), + "send_message_draft", + skip_params=[""], + shortcut_kwargs=["chat_id"], + ) + assert await check_defaults_handling( + message.reply_text_draft, message.get_bot(), no_default_kwargs={"message_thread_id"} + ) + + monkeypatch.setattr(message.get_bot(), "send_message_draft", make_assertion) + assert await message.reply_text_draft(draft_id=1, text="test") + + await self.check_thread_id_parsing( + message, message.reply_text_draft, "send_message_draft", [1, "test"], monkeypatch + ) + async def test_reply_media_group(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id @@ -1629,8 +1883,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_media_group, Bot.send_media_group, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1638,7 +1897,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_media_group", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_media_group, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1671,8 +1930,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_photo, Bot.send_photo, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1680,7 +1944,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_photo", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_photo, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1705,8 +1969,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_audio, Bot.send_audio, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1714,7 +1983,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_audio", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_audio, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1739,8 +2008,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_document, Bot.send_document, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1748,7 +2022,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_document", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_document, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1773,8 +2047,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_animation, Bot.send_animation, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1782,7 +2061,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_animation", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_animation, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1807,8 +2086,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_sticker, Bot.send_sticker, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1816,7 +2100,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_sticker", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_sticker, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1841,8 +2125,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_video, Bot.send_video, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1850,7 +2139,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_video", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_video, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1875,8 +2164,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_video_note, Bot.send_video_note, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1884,7 +2178,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_video_note", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_video_note, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1909,8 +2203,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_voice, Bot.send_voice, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1918,7 +2217,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_voice", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_voice, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1943,8 +2242,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_location, Bot.send_location, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1952,7 +2256,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_location", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_location, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -1977,8 +2281,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_venue, Bot.send_venue, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1986,7 +2295,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_venue", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_venue, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -2011,8 +2320,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_contact, Bot.send_contact, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -2020,7 +2334,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_contact", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_contact, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -2047,7 +2361,7 @@ async def make_assertion(*_, **kwargs): Message.reply_poll, Bot.send_poll, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -2055,7 +2369,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_poll", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_poll, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -2080,8 +2394,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_dice, Bot.send_dice, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -2089,7 +2408,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_dice", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_dice, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -2109,6 +2428,42 @@ async def make_assertion(*_, **kwargs): message, message.reply_dice, "send_dice", [], monkeypatch ) + async def test_reply_checklist(self, monkeypatch, message): + checklist = InputChecklist(title="My Checklist", tasks=[InputChecklistTask(1, "Task 1")]) + + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == message.chat_id + and kwargs["business_connection_id"] == message.business_connection_id + and kwargs["checklist"] == checklist + and kwargs["disable_notification"] is True + ) + + assert check_shortcut_signature( + Message.reply_checklist, + Bot.send_checklist, + ["chat_id", "business_connection_id", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_checklist, + message.get_bot(), + "send_checklist", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["chat_id", "business_connection_id"], + ) + assert await check_defaults_handling(message.reply_checklist, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "send_checklist", make_assertion) + assert await message.reply_checklist(checklist, disable_notification=True) + await self.check_quote_parsing( + message, + message.reply_checklist, + "send_checklist", + [checklist, True], + monkeypatch, + ) + async def test_reply_action(self, monkeypatch, message: Message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id @@ -2153,7 +2508,7 @@ async def make_assertion(*_, **kwargs): Message.reply_game, Bot.send_game, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -2195,8 +2550,13 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_invoice, Bot.send_invoice, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -2204,7 +2564,7 @@ async def make_assertion(*_, **kwargs): message.get_bot(), "send_invoice", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_invoice, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -2215,9 +2575,9 @@ async def make_assertion(*_, **kwargs): "title", "description", "payload", - "provider_token", "currency", "prices", + "provider_token", ) await self.check_quote_parsing( message, @@ -2246,9 +2606,17 @@ async def make_assertion(*_, **kwargs): return chat_id and from_chat and message_id and notification and protected_cont assert check_shortcut_signature( - Message.forward, Bot.forward_message, ["from_chat_id", "message_id"], [] + Message.forward, + Bot.forward_message, + ["from_chat_id", "message_id", "direct_messages_topic_id"], + [], + ) + assert await check_shortcut_call( + message.forward, + message.get_bot(), + "forward_message", + shortcut_kwargs=["direct_messages_topic_id"], ) - assert await check_shortcut_call(message.forward, message.get_bot(), "forward_message") assert await check_defaults_handling(message.forward, message.get_bot()) monkeypatch.setattr(message.get_bot(), "forward_message", make_assertion) @@ -2281,9 +2649,17 @@ async def make_assertion(*_, **kwargs): ) assert check_shortcut_signature( - Message.copy, Bot.copy_message, ["from_chat_id", "message_id"], [] + Message.copy, + Bot.copy_message, + ["from_chat_id", "message_id", "direct_messages_topic_id"], + [], + ) + assert await check_shortcut_call( + message.copy, + message.get_bot(), + "copy_message", + shortcut_kwargs=["direct_messages_topic_id"], ) - assert await check_shortcut_call(message.copy, message.get_bot(), "copy_message") assert await check_defaults_handling(message.copy, message.get_bot()) monkeypatch.setattr(message.get_bot(), "copy_message", make_assertion) @@ -2324,11 +2700,21 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_copy, Bot.copy_message, - ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) - assert await check_shortcut_call(message.copy, message.get_bot(), "copy_message") + assert await check_shortcut_call( + message.copy, + message.get_bot(), + "copy_message", + shortcut_kwargs=["direct_messages_topic_id"], + ) assert await check_defaults_handling(message.copy, message.get_bot()) monkeypatch.setattr(message.get_bot(), "copy_message", make_assertion) @@ -2368,15 +2754,21 @@ async def make_assertion(*_, **kwargs): assert check_shortcut_signature( Message.reply_paid_media, Bot.send_paid_media, - ["chat_id", "reply_to_message_id", "business_connection_id"], + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( message.reply_paid_media, message.get_bot(), "send_paid_media", skip_params=["reply_to_message_id"], - shortcut_kwargs=["business_connection_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], ) assert await check_defaults_handling( message.reply_paid_media, message.get_bot(), no_default_kwargs={"message_thread_id"} @@ -2444,6 +2836,34 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(message.get_bot(), "edit_message_caption", make_assertion) assert await message.edit_caption(caption="new caption") + async def test_edit_checklist(self, monkeypatch, message): + checklist = InputChecklist(title="My Checklist", tasks=[InputChecklistTask(1, "Task 1")]) + + async def make_assertion(*_, **kwargs): + return ( + kwargs["business_connection_id"] == message.business_connection_id + and kwargs["chat_id"] == message.chat_id + and kwargs["message_id"] == message.message_id + and kwargs["checklist"] == checklist + ) + + assert check_shortcut_signature( + Message.edit_checklist, + Bot.edit_message_checklist, + ["chat_id", "message_id", "business_connection_id"], + [], + ) + assert await check_shortcut_call( + message.edit_checklist, + message.get_bot(), + "edit_message_checklist", + shortcut_kwargs=["chat_id", "message_id", "business_connection_id"], + ) + assert await check_defaults_handling(message.edit_checklist, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "edit_message_checklist", make_assertion) + assert await message.edit_checklist(checklist=checklist) + async def test_edit_media(self, monkeypatch, message): async def make_assertion(*_, **kwargs): chat_id = kwargs["chat_id"] == message.chat_id @@ -2596,20 +3016,49 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(message.get_bot(), "get_game_high_scores", make_assertion) assert await message.get_game_high_scores(user_id=1) - async def test_delete(self, monkeypatch, message): + @pytest.mark.parametrize("business_connection_id", [None, "123456789"]) + async def test_delete(self, monkeypatch, message, business_connection_id): + message = deepcopy(message) + message.business_connection_id = business_connection_id + async def make_assertion(*_, **kwargs): - chat_id = kwargs["chat_id"] == message.chat_id - message_id = kwargs["message_id"] == message.message_id - return chat_id and message_id + url: str = kwargs.get("url") + data = kwargs.get("request_data").parameters + + if not message.business_connection_id: + endpoint = url.endswith("deleteMessage") + chat_id = data.get("chat_id") == message.chat_id + message_id = data.get("message_id") == message.message_id + return endpoint and chat_id and message_id + + endpoint = url.endswith("deleteBusinessMessages") + business_connection_id = ( + data.get("business_connection_id") == message.business_connection_id + ) + message_ids = data.get("message_ids") == [message.message_id] + return business_connection_id and message_ids and endpoint - assert check_shortcut_signature( - Message.delete, Bot.delete_message, ["chat_id", "message_id"], [] - ) - assert await check_shortcut_call(message.delete, message.get_bot(), "delete_message") - assert await check_defaults_handling(message.delete, message.get_bot()) + monkeypatch.setattr(message.get_bot().request, "post", make_assertion) - monkeypatch.setattr(message.get_bot(), "delete_message", make_assertion) - assert await message.delete() + if not message.business_connection_id: + assert check_shortcut_signature( + Message.delete, Bot.delete_message, ["chat_id", "message_id"], [] + ) + assert await check_shortcut_call(message.delete, message.get_bot(), "delete_message") + assert await check_defaults_handling(message.delete, message.get_bot()) + assert await message.delete() + else: + assert check_shortcut_signature( + Message.delete, + Bot.delete_business_messages, + ["business_connection_id", "message_ids"], + [], + ) + assert await check_shortcut_call( + message.delete, message.get_bot(), "delete_business_messages" + ) + assert await check_defaults_handling(message.delete, message.get_bot()) + assert await message.delete() async def test_stop_poll(self, monkeypatch, message): async def make_assertion(*_, **kwargs): @@ -2835,6 +3284,31 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(message.get_bot(), "unpin_all_forum_topic_messages", make_assertion) assert await message.unpin_all_forum_topic_messages() + async def test_read_business_message(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == message.chat_id + and kwargs["business_connection_id"] == message.business_connection_id + and kwargs["message_id"] == message.message_id, + ) + + assert check_shortcut_signature( + Message.read_business_message, + Bot.read_business_message, + ["chat_id", "message_id", "business_connection_id"], + [], + ) + assert await check_shortcut_call( + message.read_business_message, + message.get_bot(), + "read_business_message", + shortcut_kwargs=["chat_id", "message_id", "business_connection_id"], + ) + assert await check_defaults_handling(message.read_business_message, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "read_business_message", make_assertion) + assert await message.read_business_message() + def test_attachement_successful_payment_deprecated(self, message, recwarn): message.successful_payment = "something" # kinda unnecessary to assert but one needs to call the function ofc so. Here we are. @@ -2846,3 +3320,53 @@ def test_attachement_successful_payment_deprecated(self, message, recwarn): ) assert recwarn[0].category is PTBDeprecationWarning assert recwarn[0].filename == __file__ + + async def test_approve_suggested_post(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == message.chat_id + and kwargs["message_id"] == message.message_id + and kwargs["send_date"] == 1234567890 + ) + + assert check_shortcut_signature( + Message.approve_suggested_post, + Bot.approve_suggested_post, + ["chat_id", "message_id"], + [], + ) + assert await check_shortcut_call( + message.approve_suggested_post, + message.get_bot(), + "approve_suggested_post", + shortcut_kwargs=["chat_id", "message_id"], + ) + assert await check_defaults_handling(message.approve_suggested_post, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "approve_suggested_post", make_assertion) + assert await message.approve_suggested_post(send_date=1234567890) + + async def test_decline_suggested_post(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == message.chat_id + and kwargs["message_id"] == message.message_id + and kwargs["comment"] == "some comment" + ) + + assert check_shortcut_signature( + Message.decline_suggested_post, + Bot.decline_suggested_post, + ["chat_id", "message_id"], + [], + ) + assert await check_shortcut_call( + message.decline_suggested_post, + message.get_bot(), + "decline_suggested_post", + shortcut_kwargs=["chat_id", "message_id"], + ) + assert await check_defaults_handling(message.decline_suggested_post, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "decline_suggested_post", make_assertion) + assert await message.decline_suggested_post(comment="some comment") diff --git a/tests/test_messageautodeletetimerchanged.py b/tests/test_messageautodeletetimerchanged.py index 19133e9aaa9..fc92af4fc7c 100644 --- a/tests/test_messageautodeletetimerchanged.py +++ b/tests/test_messageautodeletetimerchanged.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,12 +16,15 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + from telegram import MessageAutoDeleteTimerChanged, VideoChatEnded +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots class TestMessageAutoDeleteTimerChangedWithoutRequest: - message_auto_delete_time = 100 + message_auto_delete_time = dtm.timedelta(seconds=100) def test_slot_behaviour(self): action = MessageAutoDeleteTimerChanged(self.message_auto_delete_time) @@ -30,18 +33,47 @@ def test_slot_behaviour(self): assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" def test_de_json(self): - json_dict = {"message_auto_delete_time": self.message_auto_delete_time} + json_dict = { + "message_auto_delete_time": int(self.message_auto_delete_time.total_seconds()) + } madtc = MessageAutoDeleteTimerChanged.de_json(json_dict, None) assert madtc.api_kwargs == {} - assert madtc.message_auto_delete_time == self.message_auto_delete_time + assert madtc._message_auto_delete_time == self.message_auto_delete_time def test_to_dict(self): madtc = MessageAutoDeleteTimerChanged(self.message_auto_delete_time) madtc_dict = madtc.to_dict() assert isinstance(madtc_dict, dict) - assert madtc_dict["message_auto_delete_time"] == self.message_auto_delete_time + assert madtc_dict["message_auto_delete_time"] == int( + self.message_auto_delete_time.total_seconds() + ) + assert isinstance(madtc_dict["message_auto_delete_time"], int) + + def test_time_period_properties(self, PTB_TIMEDELTA): + message_auto_delete_time = MessageAutoDeleteTimerChanged( + self.message_auto_delete_time + ).message_auto_delete_time + + if PTB_TIMEDELTA: + assert message_auto_delete_time == self.message_auto_delete_time + assert isinstance(message_auto_delete_time, dtm.timedelta) + else: + assert message_auto_delete_time == int(self.message_auto_delete_time.total_seconds()) + assert isinstance(message_auto_delete_time, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA): + MessageAutoDeleteTimerChanged(self.message_auto_delete_time).message_auto_delete_time + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`message_auto_delete_time` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): a = MessageAutoDeleteTimerChanged(100) diff --git a/tests/test_messageentity.py b/tests/test_messageentity.py index dc5cf84d53b..63350790048 100644 --- a/tests/test_messageentity.py +++ b/tests/test_messageentity.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -96,7 +96,7 @@ def test_fix_utf16(self): for _input, _ in inputs_outputs ] utf_16_entities = MessageEntity.adjust_message_entities_to_utf_16(text, unicode_entities) - for out_entity, input_output in zip(utf_16_entities, inputs_outputs): + for out_entity, input_output in zip(utf_16_entities, inputs_outputs, strict=False): _, output = input_output offset, length = output assert out_entity.offset == offset @@ -144,7 +144,9 @@ def test_concatenate(self): assert new_text == "prefix 𝛙𝌢𑁍 | text 𝛙𝌢𑁍 | suffix 𝛙𝌢𑁍" assert [entity.offset for entity in new_entities] == [0, 16, 30] - for old, new in zip([first_entity, second_entity, third_entity], new_entities): + for old, new in zip( + [first_entity, second_entity, third_entity], new_entities, strict=False + ): assert new is not old assert new.type == old.type for key, value in kwargs.items(): diff --git a/tests/test_messageid.py b/tests/test_messageid.py index 717a330cef4..85053d82ba9 100644 --- a/tests/test_messageid.py +++ b/tests/test_messageid.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_messageorigin.py b/tests/test_messageorigin.py index 14eec28ebd9..0666c85bf2b 100644 --- a/tests/test_messageorigin.py +++ b/tests/test_messageorigin.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -138,7 +138,6 @@ def test_slot_behaviour(self, message_origin_type): def test_de_json_required_args(self, offline_bot, message_origin_type): cls = message_origin_type.__class__ - assert cls.de_json({}, offline_bot) is None json_dict = make_json_dict(message_origin_type) const_message_origin = MessageOrigin.de_json(json_dict, offline_bot) diff --git a/tests/test_meta.py b/tests/test_meta.py index 13242ff9160..6e8d59dccfe 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_modules.py b/tests/test_modules.py index 086e7fe5a8f..3666c2b5c8b 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,13 +19,16 @@ """This tests whether our submodules have __all__ or not. Additionally also tests if all public submodules are included in __all__ for __init__'s. """ + import importlib import os from pathlib import Path +from tests.auxil.files import SOURCE_ROOT_PATH + def test_public_submodules_dunder_all(): - modules_to_search = list(Path("telegram").rglob("*.py")) + modules_to_search = list(SOURCE_ROOT_PATH.rglob("*.py")) if not modules_to_search: raise AssertionError("No modules found to search through, please modify this test.") @@ -52,6 +55,7 @@ def test_public_submodules_dunder_all(): def load_module(path: Path): + path = path.relative_to(SOURCE_ROOT_PATH.parent) if path.name == "__init__.py": mod_name = str(path.parent).replace(os.sep, ".") # telegram(.ext) format else: diff --git a/tests/test_official/arg_type_checker.py b/tests/test_official/arg_type_checker.py index d686d3ff835..98d6d8ca564 100644 --- a/tests/test_official/arg_type_checker.py +++ b/tests/test_official/arg_type_checker.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -68,7 +68,7 @@ """, re.VERBOSE, ) -TIMEDELTA_REGEX = re.compile(r"\w+_period$") # Parameter names ending with "_period" +TIMEDELTA_REGEX = re.compile(r"((in|number of) seconds)|(\w+_period$)") log = logging.debug @@ -126,9 +126,9 @@ def check_param_type( assert len(mapped) <= 1, f"More than one match found for {tg_param_type}" # it may be a list of objects, so let's extract them using _extract_words: - mapped_type = _unionizer(_extract_words(tg_param_type)) if not mapped else mapped.pop() + org_mapped_type = _unionizer(_extract_words(tg_param_type)) if not mapped else mapped.pop() # If the parameter is not required by TG, `None` should be added to `mapped_type` - mapped_type = wrap_with_none(tg_parameter, mapped_type, obj) + mapped_type = wrap_with_none(tg_parameter, org_mapped_type, obj) log( "At the end of PRE-PROCESSING, the values of variables are:\n" @@ -190,21 +190,15 @@ def check_param_type( or "Unix time" in tg_parameter.param_description ): log("Checking that `%s` is a datetime!\n", ptb_param.name) - if ptb_param.name in PTCE.DATETIME_EXCEPTIONS: - return True, mapped_type # If it's a class, we only accept datetime as the parameter mapped_type = dtm.datetime if is_class else mapped_type | dtm.datetime # 4) HANDLING TIMEDELTA: - elif re.search(TIMEDELTA_REGEX, ptb_param.name) and obj.__name__ in ( - "TransactionPartnerUser", - "create_invoice_link", + elif re.search(TIMEDELTA_REGEX, tg_parameter.param_description) or re.search( + TIMEDELTA_REGEX, ptb_param.name ): - # Currently we only support timedelta for `subscription_period` in `TransactionPartnerUser` - # and `create_invoice_link`. - # See https://github.com/python-telegram-bot/python-telegram-bot/issues/4575 log("Checking that `%s` is a timedelta!\n", ptb_param.name) - mapped_type = dtm.timedelta if is_class else mapped_type | dtm.timedelta + mapped_type = mapped_type | dtm.timedelta # 5) COMPLEX TYPES: # Some types are too complicated, so we replace our annotation with a simpler type: @@ -217,7 +211,8 @@ def check_param_type( # Classes whose parameters are all ODVInput should be converted and checked. elif obj.__name__ in PTCE.IGNORED_DEFAULTS_CLASSES: log("Checking that `%s`'s param is ODVInput:\n", obj.__name__) - mapped_type = ODVInput[mapped_type] + # We have to use org_mapped_type here, because ODVInput will not take a None value as well + mapped_type = ODVInput[org_mapped_type] elif not ( # Defaults checking should not be done for: # 1. Parameters that have name conflict with `Defaults.name` @@ -229,7 +224,8 @@ def check_param_type( for name, _ in ALL_DEFAULTS: if name == ptb_param.name or "parse_mode" in ptb_param.name: log("Checking that `%s` is a Defaults parameter!\n", ptb_param.name) - mapped_type = ODVInput[mapped_type] + # We have to use org_mapped_type here, because ODVInput will not take a None value + mapped_type = ODVInput[org_mapped_type] break # RESULTS:- diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index e86056a8733..1f7a86f0ebc 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -65,36 +65,48 @@ class ParamTypeCheckingExceptions: ("keyboard", True): "KeyboardButton", # + sequence[sequence[str]] ("reaction", False): "ReactionType", # + str ("options", False): "InputPollOption", # + str - # TODO: Deprecated and will be corrected (and removed) in next major PTB version: - ("file_hashes", True): "list[str]", } # Special cases for other parameters that accept more types than the official API, and are # too complex to compare/predict with official API # structure: class/method_name: {param_name: reduced form of annotation} - COMPLEX_TYPES = ( - { # (param_name, is_class (i.e appears in a class?)): reduced form of annotation - "send_poll": {"correct_option_id": int}, # actual: Literal - "get_file": { - "file_id": str, # actual: Union[str, objs_with_file_id_attr] - }, - r"\w+invite_link": { - "invite_link": str, # actual: Union[str, ChatInviteLink] - }, - "send_invoice|create_invoice_link": { - "provider_data": str, # actual: Union[str, obj] - }, - "InlineKeyboardButton": { - "callback_data": str, # actual: Union[str, obj] - }, - "Input(Paid)?Media.*": { - "media": str, # actual: Union[str, InputMedia*, FileInput] - }, - "EncryptedPassportElement": { - "data": str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress] - }, - } - ) + COMPLEX_TYPES = { + "send_poll": {"correct_option_id": int}, # actual: Literal + "get_file": { + "file_id": str, # actual: Union[str, objs_with_file_id_attr] + }, + r"\w+invite_link": { + "invite_link": str, # actual: Union[str, ChatInviteLink] + }, + "send_invoice|create_invoice_link": { + "provider_data": str, # actual: Union[str, obj] + }, + "InlineKeyboardButton": { + "callback_data": str, # actual: Union[str, obj] + }, + "Input(Paid)?Media.*": { + "media": str, # actual: Union[str, InputMedia*, FileInput] + # see also https://github.com/tdlib/telegram-bot-api/issues/707 + "thumbnail": str, # actual: Union[str, FileInput] + "cover": str, # actual: Union[str, FileInput] + }, + "InputProfilePhotoStatic": { + "photo": str, # actual: Union[str, FileInput] + }, + "InputProfilePhotoAnimated": { + "animation": str, # actual: Union[str, FileInput] + }, + "InputSticker": { + "sticker": str, # actual: Union[str, FileInput] + }, + "InputStoryContent.*": { + "photo": str, # actual: Union[str, FileInput] + "video": str, # actual: Union[str, FileInput] + }, + "EncryptedPassportElement": { + "data": str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress] + }, + } # param names ignored in the param type checking in classes for the `tg.Defaults` case. IGNORED_DEFAULTS_PARAM_NAMES = { @@ -105,11 +117,6 @@ class ParamTypeCheckingExceptions: # These classes' params are all ODVInput, so we ignore them in the defaults type checking. IGNORED_DEFAULTS_CLASSES = {"LinkPreviewOptions"} - # TODO: Remove this in v22 when it becomes a datetime (also remove from arg_type_checker.py) - DATETIME_EXCEPTIONS = { - "file_date", - } - # Arguments *added* to the official API PTB_EXTRA_PARAMS = { @@ -143,11 +150,15 @@ class ParamTypeCheckingExceptions: "ReactionType": {"type"}, # attributes common to all subclasses "BackgroundType": {"type"}, # attributes common to all subclasses "BackgroundFill": {"type"}, # attributes common to all subclasses + "OwnedGift": {"type"}, # attributes common to all subclasses "InputTextMessageContent": {"disable_web_page_preview"}, # convenience arg, here for bw compat "RevenueWithdrawalState": {"type"}, # attributes common to all subclasses "TransactionPartner": {"type"}, # attributes common to all subclasses "PaidMedia": {"type"}, # attributes common to all subclasses "InputPaidMedia": {"type", "media"}, # attributes common to all subclasses + "InputStoryContent": {"type"}, # attributes common to all subclasses + "StoryAreaType": {"type"}, # attributes common to all subclasses + "InputProfilePhoto": {"type"}, # attributes common to all subclasses } @@ -176,6 +187,10 @@ def ptb_extra_params(object_name: str) -> set[str]: r"TransactionPartner\w+": {"type"}, r"PaidMedia\w+": {"type"}, r"InputPaidMedia\w+": {"type"}, + r"InputProfilePhoto\w+": {"type"}, + r"OwnedGift\w+": {"type"}, + r"InputStoryContent\w+": {"type"}, + r"StoryAreaType\w+": {"type"}, } @@ -199,10 +214,7 @@ def ignored_param_requirements(object_name: str) -> set[str]: # Arguments that are optional arguments for now for backwards compatibility -BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = { - "send_invoice|create_invoice_link|InputInvoiceMessageContent": {"provider_token"}, - "InlineQueryResultArticle": {"hide_url"}, -} +BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = {} def backwards_compat_kwargs(object_name: str) -> set[str]: diff --git a/tests/test_official/helpers.py b/tests/test_official/helpers.py index f2fcf890344..2257adb942a 100644 --- a/tests/test_official/helpers.py +++ b/tests/test_official/helpers.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -21,7 +21,7 @@ import functools import re from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, Optional, TypeVar, _eval_type, get_type_hints +from typing import TYPE_CHECKING, Any, TypeVar, _eval_type, get_type_hints from bs4 import PageElement, Tag @@ -93,7 +93,7 @@ def is_parameter_required_by_tg(field: str) -> bool: def wrap_with_none(tg_parameter: "TelegramParameter", mapped_type: Any, obj: object) -> type: """Adds `None` to type annotation if the parameter isn't required. Respects ignored params.""" # have to import here to avoid circular imports - from tests.test_official.exceptions import ignored_param_requirements + from tests.test_official.exceptions import ignored_param_requirements # noqa: PLC0415 if tg_parameter.param_name in ignored_param_requirements(obj.__name__): return mapped_type | type(None) @@ -117,7 +117,7 @@ def resolve_forward_refs_in_type(obj: type) -> type: def extract_mappings( exceptions: dict[str, dict[str, T]], obj: object, param_name: str -) -> Optional[list[T]]: +) -> list[T] | None: mappings = ( mapping for pattern, mapping in exceptions.items() if (re.match(pattern, obj.__name__)) ) diff --git a/tests/test_official/scraper.py b/tests/test_official/scraper.py index aba61d93bbe..f32e5070243 100644 --- a/tests/test_official/scraper.py +++ b/tests/test_official/scraper.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_official/test_official.py b/tests/test_official/test_official.py index b699a2b2eea..2c3fd03d6b1 100644 --- a/tests/test_official/test_official.py +++ b/tests/test_official/test_official.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -73,19 +73,19 @@ def test_check_method(tg_method: TelegramMethod) -> None: for tg_parameter in tg_method.method_parameters: # Check if parameter is present in our method ptb_param = sig.parameters.get(tg_parameter.param_name) - assert ( - ptb_param is not None - ), f"Parameter {tg_parameter.param_name} not found in {ptb_method.__name__}" + assert ptb_param is not None, ( + f"Parameter {tg_parameter.param_name} not found in {ptb_method.__name__}" + ) # Now check if the parameter is required or not - assert check_required_param( - tg_parameter, ptb_param, ptb_method.__name__ - ), f"Param {ptb_param.name!r} of {ptb_method.__name__!r} requirement mismatch" + assert check_required_param(tg_parameter, ptb_param, ptb_method.__name__), ( + f"Param {ptb_param.name!r} of {ptb_method.__name__!r} requirement mismatch" + ) # Check if type annotation is present - assert ( - ptb_param.annotation is not inspect.Parameter.empty - ), f"Param {ptb_param.name!r} of {ptb_method.__name__!r} should have a type annotation!" + assert ptb_param.annotation is not inspect.Parameter.empty, ( + f"Param {ptb_param.name!r} of {ptb_method.__name__!r} should have a type annotation!" + ) # Check if type annotation is correct correct_type_hint, expected_type_hint = check_param_type( ptb_param, @@ -100,9 +100,9 @@ def test_check_method(tg_method: TelegramMethod) -> None: # Now we will check that we don't pass default values if the parameter is not required. if ptb_param.default is not inspect.Parameter.empty: # If there is a default argument... default_arg_none = check_defaults_type(ptb_param) # check if it's None - assert ( - default_arg_none - ), f"Param {ptb_param.name!r} of {ptb_method.__name__!r} should be `None`" + assert default_arg_none, ( + f"Param {ptb_param.name!r} of {ptb_method.__name__!r} should be `None`" + ) checked.append(tg_parameter.param_name) expected_additional_args = GLOBALLY_IGNORED_PARAMETERS.copy() @@ -110,9 +110,9 @@ def test_check_method(tg_method: TelegramMethod) -> None: expected_additional_args |= backwards_compat_kwargs(tg_method.method_name) unexpected_args = (sig.parameters.keys() ^ checked) - expected_additional_args - assert ( - unexpected_args == set() - ), f"In {ptb_method.__qualname__}, unexpected args were found: {unexpected_args}." + assert unexpected_args == set(), ( + f"In {ptb_method.__qualname__}, unexpected args were found: {unexpected_args}." + ) kw_or_positional_args = [ p.name for p in sig.parameters.values() if p.kind != inspect.Parameter.KEYWORD_ONLY @@ -158,14 +158,14 @@ def test_check_object(tg_class: TelegramClass) -> None: assert ptb_param is not None, f"Attribute {field} not found in {obj.__name__}" # Now check if the parameter is required or not - assert check_required_param( - tg_parameter, ptb_param, obj.__name__ - ), f"Param {ptb_param.name!r} of {obj.__name__!r} requirement mismatch" + assert check_required_param(tg_parameter, ptb_param, obj.__name__), ( + f"Param {ptb_param.name!r} of {obj.__name__!r} requirement mismatch" + ) # Check if type annotation is present - assert ( - ptb_param.annotation is not inspect.Parameter.empty - ), f"Param {ptb_param.name!r} of {obj.__name__!r} should have a type annotation" + assert ptb_param.annotation is not inspect.Parameter.empty, ( + f"Param {ptb_param.name!r} of {obj.__name__!r} should have a type annotation" + ) # Check if type annotation is correct correct_type_hint, expected_type_hint = check_param_type(ptb_param, tg_parameter, obj) @@ -177,9 +177,9 @@ def test_check_object(tg_class: TelegramClass) -> None: # Now we will check that we don't pass default values if the parameter is not required. if ptb_param.default is not inspect.Parameter.empty: # If there is a default argument... default_arg_none = check_defaults_type(ptb_param) # check if its None - assert ( - default_arg_none - ), f"Param {ptb_param.name!r} of {obj.__name__!r} should be `None`" + assert default_arg_none, ( + f"Param {ptb_param.name!r} of {obj.__name__!r} should be `None`" + ) checked.add(field) @@ -188,6 +188,6 @@ def test_check_object(tg_class: TelegramClass) -> None: expected_additional_args |= backwards_compat_kwargs(tg_class.class_name) unexpected_args = (sig.parameters.keys() ^ checked) - expected_additional_args - assert ( - unexpected_args == set() - ), f"In {tg_class.class_name}, unexpected args were found: {unexpected_args}." + assert unexpected_args == set(), ( + f"In {tg_class.class_name}, unexpected args were found: {unexpected_args}." + ) diff --git a/tests/test_ownedgift.py b/tests/test_ownedgift.py new file mode 100644 index 00000000000..ed6c6bbd9da --- /dev/null +++ b/tests/test_ownedgift.py @@ -0,0 +1,478 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import datetime as dtm +from copy import deepcopy + +import pytest + +from telegram import Dice, User +from telegram._files.sticker import Sticker +from telegram._gifts import Gift +from telegram._messageentity import MessageEntity +from telegram._ownedgift import OwnedGift, OwnedGiftRegular, OwnedGifts, OwnedGiftUnique +from telegram._uniquegift import ( + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftModel, + UniqueGiftSymbol, +) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import OwnedGiftType +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def owned_gift(): + return OwnedGift(type=OwnedGiftTestBase.type) + + +class OwnedGiftTestBase: + type = OwnedGiftType.REGULAR + gift = Gift( + id="some_id", + sticker=Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=512, + height=512, + is_animated=False, + is_video=False, + type="regular", + ), + star_count=5, + ) + unique_gift = UniqueGift( + gift_id="gift_id", + base_name="human_readable", + name="unique_name", + number=10, + model=UniqueGiftModel( + name="model_name", + sticker=Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + rarity_per_mille=10, + ), + symbol=UniqueGiftSymbol( + name="symbol_name", + sticker=Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + rarity_per_mille=20, + ), + backdrop=UniqueGiftBackdrop( + name="backdrop_name", + colors=UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + rarity_per_mille=30, + ), + ) + send_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + owned_gift_id = "not_real_id" + sender_user = User(1, "test user", False) + text = "test text" + entities = ( + MessageEntity(MessageEntity.BOLD, 0, 4), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ) + is_private = True + is_saved = True + can_be_upgraded = True + was_refunded = False + convert_star_count = 100 + prepaid_upgrade_star_count = 200 + can_be_transferred = True + transfer_star_count = 300 + next_transfer_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + is_upgrade_separate = False + unique_gift_number = 37 + + +class TestOwnedGiftWithoutRequest(OwnedGiftTestBase): + def test_slot_behaviour(self, owned_gift): + inst = owned_gift + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_type_enum_conversion(self, owned_gift): + assert type(OwnedGift("regular").type) is OwnedGiftType + assert OwnedGift("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + paid_media = OwnedGift.de_json(data, offline_bot) + assert paid_media.api_kwargs == {} + assert paid_media.type == "unknown" + + @pytest.mark.parametrize( + ("og_type", "subclass", "gift"), + [ + ("regular", OwnedGiftRegular, OwnedGiftTestBase.gift), + ("unique", OwnedGiftUnique, OwnedGiftTestBase.unique_gift), + ], + ) + def test_de_json_subclass(self, offline_bot, og_type, subclass, gift): + json_dict = { + "type": og_type, + "gift": gift.to_dict(), + "send_date": to_timestamp(self.send_date), + "owned_gift_id": self.owned_gift_id, + "sender_user": self.sender_user.to_dict(), + "text": self.text, + "entities": [e.to_dict() for e in self.entities], + "is_private": self.is_private, + "is_saved": self.is_saved, + "can_be_upgraded": self.can_be_upgraded, + "was_refunded": self.was_refunded, + "convert_star_count": self.convert_star_count, + "prepaid_upgrade_star_count": self.prepaid_upgrade_star_count, + "can_be_transferred": self.can_be_transferred, + "transfer_star_count": self.transfer_star_count, + "next_transfer_date": to_timestamp(self.next_transfer_date), + } + og = OwnedGift.de_json(json_dict, offline_bot) + + assert type(og) is subclass + assert set(og.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert og.type == og_type + + def test_to_dict(self, owned_gift): + assert owned_gift.to_dict() == {"type": owned_gift.type} + + def test_equality(self, owned_gift): + a = owned_gift + b = OwnedGift(self.type) + c = OwnedGift("unknown") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def owned_gift_regular(): + return OwnedGiftRegular( + gift=TestOwnedGiftRegularWithoutRequest.gift, + send_date=TestOwnedGiftRegularWithoutRequest.send_date, + owned_gift_id=TestOwnedGiftRegularWithoutRequest.owned_gift_id, + sender_user=TestOwnedGiftRegularWithoutRequest.sender_user, + text=TestOwnedGiftRegularWithoutRequest.text, + entities=TestOwnedGiftRegularWithoutRequest.entities, + is_private=TestOwnedGiftRegularWithoutRequest.is_private, + is_saved=TestOwnedGiftRegularWithoutRequest.is_saved, + can_be_upgraded=TestOwnedGiftRegularWithoutRequest.can_be_upgraded, + was_refunded=TestOwnedGiftRegularWithoutRequest.was_refunded, + convert_star_count=TestOwnedGiftRegularWithoutRequest.convert_star_count, + prepaid_upgrade_star_count=TestOwnedGiftRegularWithoutRequest.prepaid_upgrade_star_count, + is_upgrade_separate=TestOwnedGiftRegularWithoutRequest.is_upgrade_separate, + unique_gift_number=TestOwnedGiftRegularWithoutRequest.unique_gift_number, + ) + + +class TestOwnedGiftRegularWithoutRequest(OwnedGiftTestBase): + type = OwnedGiftType.REGULAR + + def test_slot_behaviour(self, owned_gift_regular): + inst = owned_gift_regular + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "gift": self.gift.to_dict(), + "send_date": to_timestamp(self.send_date), + "owned_gift_id": self.owned_gift_id, + "sender_user": self.sender_user.to_dict(), + "text": self.text, + "entities": [e.to_dict() for e in self.entities], + "is_private": self.is_private, + "is_saved": self.is_saved, + "can_be_upgraded": self.can_be_upgraded, + "was_refunded": self.was_refunded, + "convert_star_count": self.convert_star_count, + "prepaid_upgrade_star_count": self.prepaid_upgrade_star_count, + "is_upgrade_separate": self.is_upgrade_separate, + "unique_gift_number": self.unique_gift_number, + } + ogr = OwnedGiftRegular.de_json(json_dict, offline_bot) + assert ogr.gift == self.gift + assert ogr.send_date == self.send_date + assert ogr.owned_gift_id == self.owned_gift_id + assert ogr.sender_user == self.sender_user + assert ogr.text == self.text + assert ogr.entities == self.entities + assert ogr.is_private == self.is_private + assert ogr.is_saved == self.is_saved + assert ogr.can_be_upgraded == self.can_be_upgraded + assert ogr.was_refunded == self.was_refunded + assert ogr.convert_star_count == self.convert_star_count + assert ogr.prepaid_upgrade_star_count == self.prepaid_upgrade_star_count + assert ogr.is_upgrade_separate == self.is_upgrade_separate + assert ogr.unique_gift_number == self.unique_gift_number + assert ogr.api_kwargs == {} + + def test_to_dict(self, owned_gift_regular): + json_dict = owned_gift_regular.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["gift"] == self.gift.to_dict() + assert json_dict["send_date"] == to_timestamp(self.send_date) + assert json_dict["owned_gift_id"] == self.owned_gift_id + assert json_dict["sender_user"] == self.sender_user.to_dict() + assert json_dict["text"] == self.text + assert json_dict["entities"] == [e.to_dict() for e in self.entities] + assert json_dict["is_private"] == self.is_private + assert json_dict["is_saved"] == self.is_saved + assert json_dict["can_be_upgraded"] == self.can_be_upgraded + assert json_dict["was_refunded"] == self.was_refunded + assert json_dict["convert_star_count"] == self.convert_star_count + assert json_dict["prepaid_upgrade_star_count"] == self.prepaid_upgrade_star_count + assert json_dict["is_upgrade_separate"] == self.is_upgrade_separate + assert json_dict["unique_gift_number"] == self.unique_gift_number + + def test_parse_entity(self, owned_gift_regular): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + + assert owned_gift_regular.parse_entity(entity) == "test" + + with pytest.raises(RuntimeError, match="OwnedGiftRegular has no"): + OwnedGiftRegular( + gift=self.gift, + send_date=self.send_date, + ).parse_entity(entity) + + def test_parse_entities(self, owned_gift_regular): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + entity_2 = MessageEntity(MessageEntity.ITALIC, 5, 8) + + assert owned_gift_regular.parse_entities(MessageEntity.BOLD) == {entity: "test"} + assert owned_gift_regular.parse_entities() == {entity: "test", entity_2: "text"} + + with pytest.raises(RuntimeError, match="OwnedGiftRegular has no"): + OwnedGiftRegular( + gift=self.gift, + send_date=self.send_date, + ).parse_entities() + + def test_equality(self, owned_gift_regular): + a = owned_gift_regular + b = OwnedGiftRegular(deepcopy(self.gift), deepcopy(self.send_date)) + c = OwnedGiftRegular(self.gift, self.send_date + dtm.timedelta(seconds=1)) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def owned_gift_unique(): + return OwnedGiftUnique( + gift=TestOwnedGiftUniqueWithoutRequest.unique_gift, + send_date=TestOwnedGiftUniqueWithoutRequest.send_date, + owned_gift_id=TestOwnedGiftUniqueWithoutRequest.owned_gift_id, + sender_user=TestOwnedGiftUniqueWithoutRequest.sender_user, + is_saved=TestOwnedGiftUniqueWithoutRequest.is_saved, + can_be_transferred=TestOwnedGiftUniqueWithoutRequest.can_be_transferred, + transfer_star_count=TestOwnedGiftUniqueWithoutRequest.transfer_star_count, + next_transfer_date=TestOwnedGiftUniqueWithoutRequest.next_transfer_date, + ) + + +class TestOwnedGiftUniqueWithoutRequest(OwnedGiftTestBase): + type = OwnedGiftType.UNIQUE + + def test_slot_behaviour(self, owned_gift_unique): + inst = owned_gift_unique + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "gift": self.unique_gift.to_dict(), + "send_date": to_timestamp(self.send_date), + "owned_gift_id": self.owned_gift_id, + "sender_user": self.sender_user.to_dict(), + "is_saved": self.is_saved, + "can_be_transferred": self.can_be_transferred, + "transfer_star_count": self.transfer_star_count, + "next_transfer_date": to_timestamp(self.next_transfer_date), + } + ogu = OwnedGiftUnique.de_json(json_dict, offline_bot) + assert ogu.gift == self.unique_gift + assert ogu.send_date == self.send_date + assert ogu.owned_gift_id == self.owned_gift_id + assert ogu.sender_user == self.sender_user + assert ogu.is_saved == self.is_saved + assert ogu.can_be_transferred == self.can_be_transferred + assert ogu.transfer_star_count == self.transfer_star_count + assert ogu.next_transfer_date == self.next_transfer_date + assert ogu.api_kwargs == {} + + def test_to_dict(self, owned_gift_unique): + json_dict = owned_gift_unique.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["gift"] == self.unique_gift.to_dict() + assert json_dict["send_date"] == to_timestamp(self.send_date) + assert json_dict["owned_gift_id"] == self.owned_gift_id + assert json_dict["sender_user"] == self.sender_user.to_dict() + assert json_dict["is_saved"] == self.is_saved + assert json_dict["can_be_transferred"] == self.can_be_transferred + assert json_dict["transfer_star_count"] == self.transfer_star_count + assert json_dict["next_transfer_date"] == to_timestamp(self.next_transfer_date) + + def test_equality(self, owned_gift_unique): + a = owned_gift_unique + b = OwnedGiftUnique(deepcopy(self.unique_gift), deepcopy(self.send_date)) + c = OwnedGiftUnique(self.unique_gift, self.send_date + dtm.timedelta(seconds=1)) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def owned_gifts(request): + return OwnedGifts( + total_count=OwnedGiftsTestBase.total_count, + gifts=OwnedGiftsTestBase.gifts, + next_offset=OwnedGiftsTestBase.next_offset, + ) + + +class OwnedGiftsTestBase: + total_count = 2 + next_offset = "next_offset_str" + gifts: list[OwnedGift] = [ + OwnedGiftRegular( + gift=Gift( + id="id1", + sticker=Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=512, + height=512, + is_animated=False, + is_video=False, + type="regular", + ), + star_count=5, + total_count=5, + remaining_count=5, + upgrade_star_count=5, + ), + send_date=dtm.datetime.now(tz=UTC).replace(microsecond=0), + owned_gift_id="some_id_1", + ), + OwnedGiftUnique( + gift=UniqueGift( + gift_id="gift_id", + base_name="human_readable", + name="unique_name", + number=10, + model=UniqueGiftModel( + name="model_name", + sticker=Sticker( + "file_id1", "file_unique_id1", 512, 512, False, False, "regular" + ), + rarity_per_mille=10, + ), + symbol=UniqueGiftSymbol( + name="symbol_name", + sticker=Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + rarity_per_mille=20, + ), + backdrop=UniqueGiftBackdrop( + name="backdrop_name", + colors=UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + rarity_per_mille=30, + ), + ), + send_date=dtm.datetime.now(tz=UTC).replace(microsecond=0), + owned_gift_id="some_id_2", + ), + ] + + +class TestOwnedGiftsWithoutRequest(OwnedGiftsTestBase): + def test_slot_behaviour(self, owned_gifts): + for attr in owned_gifts.__slots__: + assert getattr(owned_gifts, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(owned_gifts)) == len(set(mro_slots(owned_gifts))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "total_count": self.total_count, + "gifts": [gift.to_dict() for gift in self.gifts], + "next_offset": self.next_offset, + } + owned_gifts = OwnedGifts.de_json(json_dict, offline_bot) + assert owned_gifts.api_kwargs == {} + + assert owned_gifts.total_count == self.total_count + assert owned_gifts.gifts == tuple(self.gifts) + assert type(owned_gifts.gifts[0]) is OwnedGiftRegular + assert type(owned_gifts.gifts[1]) is OwnedGiftUnique + assert owned_gifts.next_offset == self.next_offset + + def test_to_dict(self, owned_gifts): + gifts_dict = owned_gifts.to_dict() + + assert isinstance(gifts_dict, dict) + assert gifts_dict["total_count"] == self.total_count + assert gifts_dict["gifts"] == [gift.to_dict() for gift in self.gifts] + assert gifts_dict["next_offset"] == self.next_offset + + def test_equality(self, owned_gifts): + a = owned_gifts + b = OwnedGifts(self.total_count, self.gifts) + c = OwnedGifts(self.total_count - 1, self.gifts[:1]) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_paidmedia.py b/tests/test_paidmedia.py index e99c0d0e903..536fcc1f2ad 100644 --- a/tests/test_paidmedia.py +++ b/tests/test_paidmedia.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm from copy import deepcopy import pytest @@ -34,114 +35,26 @@ Video, ) from telegram.constants import PaidMediaType +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots -@pytest.fixture( - scope="module", - params=[ - PaidMedia.PREVIEW, - PaidMedia.PHOTO, - PaidMedia.VIDEO, - ], -) -def pm_scope_type(request): - return request.param - - -@pytest.fixture( - scope="module", - params=[ - PaidMediaPreview, - PaidMediaPhoto, - PaidMediaVideo, - ], - ids=[ - PaidMedia.PREVIEW, - PaidMedia.PHOTO, - PaidMedia.VIDEO, - ], -) -def pm_scope_class(request): - return request.param - - -@pytest.fixture( - scope="module", - params=[ - ( - PaidMediaPreview, - PaidMedia.PREVIEW, - ), - ( - PaidMediaPhoto, - PaidMedia.PHOTO, - ), - ( - PaidMediaVideo, - PaidMedia.VIDEO, - ), - ], - ids=[ - PaidMedia.PREVIEW, - PaidMedia.PHOTO, - PaidMedia.VIDEO, - ], -) -def pm_scope_class_and_type(request): - return request.param - - -@pytest.fixture(scope="module") -def paid_media(pm_scope_class_and_type): - # We use de_json here so that we don't have to worry about which class gets which arguments - return pm_scope_class_and_type[0].de_json( - { - "type": pm_scope_class_and_type[1], - "width": PaidMediaTestBase.width, - "height": PaidMediaTestBase.height, - "duration": PaidMediaTestBase.duration, - "video": PaidMediaTestBase.video.to_dict(), - "photo": [p.to_dict() for p in PaidMediaTestBase.photo], - }, - bot=None, - ) - - -def paid_media_video(): - return PaidMediaVideo(video=PaidMediaTestBase.video) - - -def paid_media_photo(): - return PaidMediaPhoto(photo=PaidMediaTestBase.photo) - - -@pytest.fixture(scope="module") -def paid_media_info(): - return PaidMediaInfo( - star_count=PaidMediaInfoTestBase.star_count, - paid_media=[paid_media_video(), paid_media_photo()], - ) - - -@pytest.fixture(scope="module") -def paid_media_purchased(): - return PaidMediaPurchased( - from_user=PaidMediaPurchasedTestBase.from_user, - paid_media_payload=PaidMediaPurchasedTestBase.paid_media_payload, - ) +@pytest.fixture +def paid_media(): + return PaidMedia(type=PaidMediaType.PHOTO) class PaidMediaTestBase: + type = PaidMediaType.PHOTO width = 640 height = 480 - duration = 60 + duration = dtm.timedelta(60) video = Video( file_id="video_file_id", width=640, height=480, file_unique_id="file_unique_id", - duration=60, + duration=dtm.timedelta(seconds=60), ) photo = ( PhotoSize( @@ -160,103 +73,222 @@ def test_slot_behaviour(self, paid_media): assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - def test_de_json(self, offline_bot, pm_scope_class_and_type): - cls = pm_scope_class_and_type[0] - type_ = pm_scope_class_and_type[1] + def test_type_enum_conversion(self, paid_media): + assert type(PaidMedia("photo").type) is PaidMediaType + assert PaidMedia("unknown").type == "unknown" + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + paid_media = PaidMedia.de_json(data, offline_bot) + assert paid_media.api_kwargs == {} + assert paid_media.type == "unknown" + + @pytest.mark.parametrize( + ("pm_type", "subclass"), + [ + ("photo", PaidMediaPhoto), + ("video", PaidMediaVideo), + ("preview", PaidMediaPreview), + ], + ) + def test_de_json_subclass(self, offline_bot, pm_type, subclass): json_dict = { - "type": type_, - "width": self.width, - "height": self.height, - "duration": self.duration, + "type": pm_type, "video": self.video.to_dict(), "photo": [p.to_dict() for p in self.photo], + "width": self.width, + "height": self.height, + "duration": int(self.duration.total_seconds()), } pm = PaidMedia.de_json(json_dict, offline_bot) - assert set(pm.api_kwargs.keys()) == { - "width", - "height", - "duration", - "video", - "photo", - } - set(cls.__slots__) - - assert isinstance(pm, PaidMedia) - assert type(pm) is cls - assert pm.type == type_ - if "width" in cls.__slots__: - assert pm.width == self.width - assert pm.height == self.height - assert pm.duration == self.duration - if "video" in cls.__slots__: - assert pm.video == self.video - if "photo" in cls.__slots__: - assert pm.photo == self.photo - - assert cls.de_json(None, offline_bot) is None - assert PaidMedia.de_json({}, offline_bot) is None - - def test_de_json_invalid_type(self, offline_bot): + + # TODO: Should be removed when the timedelta migartion is complete + extra_slots = {"duration"} if subclass is PaidMediaPreview else set() + + assert type(pm) is subclass + assert set(pm.api_kwargs.keys()) == set(json_dict.keys()) - ( + set(subclass.__slots__) | extra_slots + ) - {"type"} + assert pm.type == pm_type + + def test_to_dict(self, paid_media): + assert paid_media.to_dict() == {"type": paid_media.type} + + def test_equality(self, paid_media): + a = paid_media + b = PaidMedia(self.type) + c = PaidMedia("unknown") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def paid_media_photo(): + return PaidMediaPhoto( + photo=TestPaidMediaPhotoWithoutRequest.photo, + ) + + +class TestPaidMediaPhotoWithoutRequest(PaidMediaTestBase): + type = PaidMediaType.PHOTO + + def test_slot_behaviour(self, paid_media_photo): + inst = paid_media_photo + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): json_dict = { - "type": "invalid", - "width": self.width, - "height": self.height, - "duration": self.duration, - "video": self.video.to_dict(), "photo": [p.to_dict() for p in self.photo], } - pm = PaidMedia.de_json(json_dict, offline_bot) - assert pm.api_kwargs == { - "width": self.width, - "height": self.height, - "duration": self.duration, - "video": self.video.to_dict(), + pmp = PaidMediaPhoto.de_json(json_dict, offline_bot) + assert pmp.photo == tuple(self.photo) + assert pmp.api_kwargs == {} + + def test_to_dict(self, paid_media_photo): + assert paid_media_photo.to_dict() == { + "type": paid_media_photo.type, "photo": [p.to_dict() for p in self.photo], } - assert type(pm) is PaidMedia - assert pm.type == "invalid" + def test_equality(self, paid_media_photo): + a = paid_media_photo + b = PaidMediaPhoto(deepcopy(self.photo)) + c = PaidMediaPhoto([PhotoSize("file_id", 640, 480, "file_unique_id")]) + d = Dice(5, "test") - def test_de_json_subclass(self, pm_scope_class, offline_bot): - """This makes sure that e.g. PaidMediaPreivew(data) never returns a - TransactionPartnerPhoto instance.""" + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def paid_media_video(): + return PaidMediaVideo( + video=TestPaidMediaVideoWithoutRequest.video, + ) + + +class TestPaidMediaVideoWithoutRequest(PaidMediaTestBase): + type = PaidMediaType.VIDEO + + def test_slot_behaviour(self, paid_media_video): + inst = paid_media_video + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): json_dict = { - "type": "invalid", - "width": self.width, - "height": self.height, - "duration": self.duration, "video": self.video.to_dict(), - "photo": [p.to_dict() for p in self.photo], } - assert type(pm_scope_class.de_json(json_dict, offline_bot)) is pm_scope_class + pmv = PaidMediaVideo.de_json(json_dict, offline_bot) + assert pmv.video == self.video + assert pmv.api_kwargs == {} + + def test_to_dict(self, paid_media_video): + assert paid_media_video.to_dict() == { + "type": self.type, + "video": paid_media_video.video.to_dict(), + } - def test_to_dict(self, paid_media): - pm_dict = paid_media.to_dict() - - assert isinstance(pm_dict, dict) - assert pm_dict["type"] == paid_media.type - if hasattr(paid_media_info, "width"): - assert pm_dict["width"] == paid_media.width - assert pm_dict["height"] == paid_media.height - assert pm_dict["duration"] == paid_media.duration - if hasattr(paid_media_info, "video"): - assert pm_dict["video"] == paid_media.video.to_dict() - if hasattr(paid_media_info, "photo"): - assert pm_dict["photo"] == [p.to_dict() for p in paid_media.photo] - - def test_type_enum_conversion(self): - assert type(PaidMedia("video").type) is PaidMediaType - assert PaidMedia("unknown").type == "unknown" + def test_equality(self, paid_media_video): + a = paid_media_video + b = PaidMediaVideo( + video=deepcopy(self.video), + ) + c = PaidMediaVideo( + video=Video("test", "test_unique", 640, 480, 60), + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) - def test_equality(self, paid_media, offline_bot): - a = PaidMedia("base_type") - b = PaidMedia("base_type") - c = paid_media - d = deepcopy(paid_media) - e = Dice(4, "emoji") + +@pytest.fixture +def paid_media_preview(): + return PaidMediaPreview( + width=TestPaidMediaPreviewWithoutRequest.width, + height=TestPaidMediaPreviewWithoutRequest.height, + duration=TestPaidMediaPreviewWithoutRequest.duration, + ) + + +class TestPaidMediaPreviewWithoutRequest(PaidMediaTestBase): + type = PaidMediaType.PREVIEW + + def test_slot_behaviour(self, paid_media_preview): + inst = paid_media_preview + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "width": self.width, + "height": self.height, + "duration": int(self.duration.total_seconds()), + } + pmp = PaidMediaPreview.de_json(json_dict, offline_bot) + assert pmp.width == self.width + assert pmp.height == self.height + assert pmp._duration == self.duration + assert pmp.api_kwargs == {} + + def test_to_dict(self, paid_media_preview): + paid_media_preview_dict = paid_media_preview.to_dict() + + assert isinstance(paid_media_preview_dict, dict) + assert paid_media_preview_dict["type"] == paid_media_preview.type + assert paid_media_preview_dict["width"] == paid_media_preview.width + assert paid_media_preview_dict["height"] == paid_media_preview.height + assert paid_media_preview_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(paid_media_preview_dict["duration"], int) + + def test_equality(self, paid_media_preview): + a = paid_media_preview + b = PaidMediaPreview( + width=self.width, + height=self.height, + duration=self.duration, + ) + x = PaidMediaPreview( + width=self.width, + height=self.height, + duration=int(self.duration.total_seconds()), + ) + c = PaidMediaPreview( + width=100, + height=100, + duration=100, + ) + d = Dice(5, "test") assert a == b + assert b == x assert hash(a) == hash(b) + assert hash(b) == hash(x) assert a != c assert hash(a) != hash(c) @@ -264,35 +296,78 @@ def test_equality(self, paid_media, offline_bot): assert a != d assert hash(a) != hash(d) - assert a != e - assert hash(a) != hash(e) + def test_time_period_properties(self, PTB_TIMEDELTA, paid_media_preview): + duration = paid_media_preview.duration - assert c == d - assert hash(c) == hash(d) + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) - assert c != e - assert hash(c) != hash(e) + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, paid_media_preview): + paid_media_preview.duration - if hasattr(c, "video"): - json_dict = c.to_dict() - json_dict["video"] = Video("different", "d2", 1, 1, 1).to_dict() - f = c.__class__.de_json(json_dict, offline_bot) + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning - assert c != f - assert hash(c) != hash(f) - if hasattr(c, "photo"): - json_dict = c.to_dict() - json_dict["photo"] = [PhotoSize("different", "d2", 1, 1, 1).to_dict()] - f = c.__class__.de_json(json_dict, offline_bot) +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== - assert c != f - assert hash(c) != hash(f) + +@pytest.fixture(scope="module") +def paid_media_info(): + return PaidMediaInfo( + star_count=PaidMediaInfoTestBase.star_count, + paid_media=PaidMediaInfoTestBase.paid_media, + ) + + +@pytest.fixture(scope="module") +def paid_media_purchased(): + return PaidMediaPurchased( + from_user=PaidMediaPurchasedTestBase.from_user, + paid_media_payload=PaidMediaPurchasedTestBase.paid_media_payload, + ) class PaidMediaInfoTestBase: star_count = 200 - paid_media = [paid_media_video(), paid_media_photo()] + paid_media = [ + PaidMediaVideo( + video=Video( + file_id="video_file_id", + width=640, + height=480, + file_unique_id="file_unique_id", + duration=60, + ) + ), + PaidMediaPhoto( + photo=[ + PhotoSize( + file_id="photo_file_id", + width=640, + height=480, + file_unique_id="file_unique_id", + ) + ] + ), + ] class TestPaidMediaInfoWithoutRequest(PaidMediaInfoTestBase): @@ -308,10 +383,8 @@ def test_de_json(self, offline_bot): "paid_media": [t.to_dict() for t in self.paid_media], } pmi = PaidMediaInfo.de_json(json_dict, offline_bot) - pmi_none = PaidMediaInfo.de_json(None, offline_bot) assert pmi.paid_media == tuple(self.paid_media) assert pmi.star_count == self.star_count - assert pmi_none is None def test_to_dict(self, paid_media_info): assert paid_media_info.to_dict() == { @@ -320,13 +393,9 @@ def test_to_dict(self, paid_media_info): } def test_equality(self): - pmi1 = PaidMediaInfo( - star_count=self.star_count, paid_media=[paid_media_video(), paid_media_photo()] - ) - pmi2 = PaidMediaInfo( - star_count=self.star_count, paid_media=[paid_media_video(), paid_media_photo()] - ) - pmi3 = PaidMediaInfo(star_count=100, paid_media=[paid_media_photo()]) + pmi1 = PaidMediaInfo(star_count=self.star_count, paid_media=self.paid_media) + pmi2 = PaidMediaInfo(star_count=self.star_count, paid_media=self.paid_media) + pmi3 = PaidMediaInfo(star_count=100, paid_media=[self.paid_media[0]]) assert pmi1 == pmi2 assert hash(pmi1) == hash(pmi2) @@ -353,11 +422,9 @@ def test_de_json(self, bot): "paid_media_payload": self.paid_media_payload, } pmp = PaidMediaPurchased.de_json(json_dict, bot) - pmp_none = PaidMediaPurchased.de_json(None, bot) assert pmp.from_user == self.from_user assert pmp.paid_media_payload == self.paid_media_payload assert pmp.api_kwargs == {} - assert pmp_none is None def test_to_dict(self, paid_media_purchased): assert paid_media_purchased.to_dict() == { diff --git a/tests/test_paidmessagepricechanged.py b/tests/test_paidmessagepricechanged.py new file mode 100644 index 00000000000..4ee0e39fb3e --- /dev/null +++ b/tests/test_paidmessagepricechanged.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import pytest + +from telegram import Dice, PaidMessagePriceChanged +from tests.auxil.slots import mro_slots + + +class PaidMessagePriceChangedTestBase: + paid_message_star_count = 291 + + +@pytest.fixture(scope="module") +def paid_message_price_changed(): + return PaidMessagePriceChanged(PaidMessagePriceChangedTestBase.paid_message_star_count) + + +class TestPaidMessagePriceChangedWithoutRequest(PaidMessagePriceChangedTestBase): + def test_slot_behaviour(self, paid_message_price_changed): + for attr in paid_message_price_changed.__slots__: + assert getattr(paid_message_price_changed, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(paid_message_price_changed)) == len( + set(mro_slots(paid_message_price_changed)) + ), "duplicate slot" + + def test_to_dict(self, paid_message_price_changed): + pmpc_dict = paid_message_price_changed.to_dict() + assert isinstance(pmpc_dict, dict) + assert pmpc_dict["paid_message_star_count"] == self.paid_message_star_count + + def test_de_json(self, offline_bot): + json_dict = {"paid_message_star_count": self.paid_message_star_count} + pmpc = PaidMessagePriceChanged.de_json(json_dict, offline_bot) + assert isinstance(pmpc, PaidMessagePriceChanged) + assert pmpc.paid_message_star_count == self.paid_message_star_count + assert pmpc.api_kwargs == {} + + def test_equality(self): + pmpc1 = PaidMessagePriceChanged(self.paid_message_star_count) + pmpc2 = PaidMessagePriceChanged(self.paid_message_star_count) + pmpc3 = PaidMessagePriceChanged(3) + dice = Dice(5, "emoji") + + assert pmpc1 == pmpc2 + assert hash(pmpc1) == hash(pmpc2) + + assert pmpc1 != pmpc3 + assert hash(pmpc1) != hash(pmpc3) + + assert pmpc1 != dice + assert hash(pmpc1) != hash(dice) diff --git a/tests/test_poll.py b/tests/test_poll.py index 42c44c6fb58..2edc93d3d9f 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -22,6 +22,7 @@ from telegram import Chat, InputPollOption, MessageEntity, Poll, PollAnswer, PollOption, User from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import PollType +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -49,13 +50,11 @@ class TestInputPollOptionWithoutRequest(InputPollOptionTestBase): def test_slot_behaviour(self, input_poll_option): for attr in input_poll_option.__slots__: assert getattr(input_poll_option, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(input_poll_option)) == len( - set(mro_slots(input_poll_option)) - ), "duplicate slot" + assert len(mro_slots(input_poll_option)) == len(set(mro_slots(input_poll_option))), ( + "duplicate slot" + ) def test_de_json(self): - assert InputPollOption.de_json({}, None) is None - json_dict = { "text": self.text, "text_parse_mode": self.text_parse_mode, @@ -144,7 +143,7 @@ def test_de_json_all(self): "text_entities": [e.to_dict() for e in self.text_entities], } poll_option = PollOption.de_json(json_dict, None) - assert PollOption.de_json(None, None) is None + assert poll_option.api_kwargs == {} assert poll_option.text == self.text @@ -296,7 +295,7 @@ class PollTestBase: b"\\u200d\\U0001f467\\U0001f431http://google.com" ).decode("unicode-escape") explanation_entities = [MessageEntity(13, 17, MessageEntity.URL)] - open_period = 42 + open_period = dtm.timedelta(seconds=42) close_date = dtm.datetime.now(dtm.timezone.utc) question_entities = [ MessageEntity(MessageEntity.BOLD, 0, 4), @@ -317,7 +316,7 @@ def test_de_json(self, offline_bot): "allows_multiple_answers": self.allows_multiple_answers, "explanation": self.explanation, "explanation_entities": [self.explanation_entities[0].to_dict()], - "open_period": self.open_period, + "open_period": int(self.open_period.total_seconds()), "close_date": to_timestamp(self.close_date), "question_entities": [e.to_dict() for e in self.question_entities], } @@ -338,7 +337,7 @@ def test_de_json(self, offline_bot): assert poll.allows_multiple_answers == self.allows_multiple_answers assert poll.explanation == self.explanation assert poll.explanation_entities == tuple(self.explanation_entities) - assert poll.open_period == self.open_period + assert poll._open_period == self.open_period assert abs(poll.close_date - self.close_date) < dtm.timedelta(seconds=1) assert to_timestamp(poll.close_date) == to_timestamp(self.close_date) assert poll.question_entities == tuple(self.question_entities) @@ -355,7 +354,7 @@ def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): "allows_multiple_answers": self.allows_multiple_answers, "explanation": self.explanation, "explanation_entities": [self.explanation_entities[0].to_dict()], - "open_period": self.open_period, + "open_period": int(self.open_period.total_seconds()), "close_date": to_timestamp(self.close_date), "question_entities": [e.to_dict() for e in self.question_entities], } @@ -388,10 +387,28 @@ def test_to_dict(self, poll): assert poll_dict["allows_multiple_answers"] == poll.allows_multiple_answers assert poll_dict["explanation"] == poll.explanation assert poll_dict["explanation_entities"] == [poll.explanation_entities[0].to_dict()] - assert poll_dict["open_period"] == poll.open_period + assert poll_dict["open_period"] == int(self.open_period.total_seconds()) assert poll_dict["close_date"] == to_timestamp(poll.close_date) assert poll_dict["question_entities"] == [e.to_dict() for e in poll.question_entities] + def test_time_period_properties(self, PTB_TIMEDELTA, poll): + if PTB_TIMEDELTA: + assert poll.open_period == self.open_period + assert isinstance(poll.open_period, dtm.timedelta) + else: + assert poll.open_period == int(self.open_period.total_seconds()) + assert isinstance(poll.open_period, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, poll): + poll.open_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`open_period` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) b = Poll(123, "question", ["o1", "o2"], 1, True, False, Poll.REGULAR, True) diff --git a/tests/test_proximityalerttriggered.py b/tests/test_proximityalerttriggered.py index 45006e94a4f..c8464b284c3 100644 --- a/tests/test_proximityalerttriggered.py +++ b/tests/test_proximityalerttriggered.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_reaction.py b/tests/test_reaction.py index 84a37af94a7..be64ae17a83 100644 --- a/tests/test_reaction.py +++ b/tests/test_reaction.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,8 +16,6 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -import inspect -from copy import deepcopy import pytest @@ -29,163 +27,148 @@ ReactionTypeCustomEmoji, ReactionTypeEmoji, ReactionTypePaid, + constants, ) from telegram.constants import ReactionEmoji from tests.auxil.slots import mro_slots -ignored = ["self", "api_kwargs"] - - -class RTDefaults: - custom_emoji = "123custom" - normal_emoji = ReactionEmoji.THUMBS_UP - - -def reaction_type_custom_emoji(): - return ReactionTypeCustomEmoji(RTDefaults.custom_emoji) +@pytest.fixture(scope="module") +def reaction_type(): + return ReactionType(type=TestReactionTypeWithoutRequest.type) -def reaction_type_emoji(): - return ReactionTypeEmoji(RTDefaults.normal_emoji) +class ReactionTypeTestBase: + type = "emoji" + emoji = "some_emoji" + custom_emoji_id = "some_custom_emoji_id" -def reaction_type_paid(): - return ReactionTypePaid() - -def make_json_dict(instance: ReactionType, include_optional_args: bool = False) -> dict: - """Used to make the json dict which we use for testing de_json. Similar to iter_args()""" - json_dict = {"type": instance.type} - sig = inspect.signature(instance.__class__.__init__) - - for param in sig.parameters.values(): - if param.name in ignored: # ignore irrelevant params - continue - - val = getattr(instance, param.name) - # Compulsory args- - if param.default is inspect.Parameter.empty: - if hasattr(val, "to_dict"): # convert the user object or any future ones to dict. - val = val.to_dict() - json_dict[param.name] = val - - # If we want to test all args (for de_json)- - # currently not needed, keeping for completeness - elif param.default is not inspect.Parameter.empty and include_optional_args: - json_dict[param.name] = val - return json_dict - - -def iter_args(instance: ReactionType, de_json_inst: ReactionType, include_optional: bool = False): - """ - We accept both the regular instance and de_json created instance and iterate over them for - easy one line testing later one. - """ - yield instance.type, de_json_inst.type # yield this here cause it's not available in sig. - - sig = inspect.signature(instance.__class__.__init__) - for param in sig.parameters.values(): - if param.name in ignored: - continue - inst_at, json_at = getattr(instance, param.name), getattr(de_json_inst, param.name) - if ( - param.default is not inspect.Parameter.empty and include_optional - ) or param.default is inspect.Parameter.empty: - yield inst_at, json_at - - -@pytest.fixture -def reaction_type(request): - return request.param() - - -@pytest.mark.parametrize( - "reaction_type", - [ - reaction_type_custom_emoji, - reaction_type_emoji, - reaction_type_paid, - ], - indirect=True, -) -class TestReactionTypesWithoutRequest: +class TestReactionTypeWithoutRequest(ReactionTypeTestBase): def test_slot_behaviour(self, reaction_type): inst = reaction_type for attr in inst.__slots__: assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - def test_de_json_required_args(self, offline_bot, reaction_type): - cls = reaction_type.__class__ - assert cls.de_json(None, offline_bot) is None - assert ReactionType.de_json({}, offline_bot) is None - - json_dict = make_json_dict(reaction_type) - const_reaction_type = ReactionType.de_json(json_dict, offline_bot) - assert const_reaction_type.api_kwargs == {} - - assert isinstance(const_reaction_type, ReactionType) - assert isinstance(const_reaction_type, cls) - for reaction_type_at, const_reaction_type_at in iter_args( - reaction_type, const_reaction_type - ): - assert reaction_type_at == const_reaction_type_at - - def test_de_json_all_args(self, offline_bot, reaction_type): - json_dict = make_json_dict(reaction_type, include_optional_args=True) - const_reaction_type = ReactionType.de_json(json_dict, offline_bot) - assert const_reaction_type.api_kwargs == {} - - assert isinstance(const_reaction_type, ReactionType) - assert isinstance(const_reaction_type, reaction_type.__class__) - for c_mem_type_at, const_c_mem_at in iter_args(reaction_type, const_reaction_type, True): - assert c_mem_type_at == const_c_mem_at - - def test_de_json_invalid_type(self, offline_bot, reaction_type): - json_dict = {"type": "invalid"} - reaction_type = ReactionType.de_json(json_dict, offline_bot) + def test_type_enum_conversion(self): + assert type(ReactionType("emoji").type) is constants.ReactionType + assert ReactionType("unknown").type == "unknown" - assert type(reaction_type) is ReactionType - assert reaction_type.type == "invalid" + def test_de_json(self, offline_bot): + json_dict = {"type": "unknown"} + reaction_type = ReactionType.de_json(json_dict, offline_bot) + assert reaction_type.api_kwargs == {} + assert reaction_type.type == "unknown" + + @pytest.mark.parametrize( + ("rt_type", "subclass"), + [ + ("emoji", ReactionTypeEmoji), + ("custom_emoji", ReactionTypeCustomEmoji), + ("paid", ReactionTypePaid), + ], + ) + def test_de_json_subclass(self, offline_bot, rt_type, subclass): + json_dict = { + "type": rt_type, + "emoji": self.emoji, + "custom_emoji_id": self.custom_emoji_id, + } + rt = ReactionType.de_json(json_dict, offline_bot) - def test_de_json_subclass(self, reaction_type, offline_bot, chat_id): - """This makes sure that e.g. ReactionTypeEmoji(data, offline_bot) never returns a - ReactionTypeCustomEmoji instance.""" - cls = reaction_type.__class__ - json_dict = make_json_dict(reaction_type, True) - assert type(cls.de_json(json_dict, offline_bot)) is cls + assert type(rt) is subclass + assert set(rt.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert rt.type == rt_type def test_to_dict(self, reaction_type): reaction_type_dict = reaction_type.to_dict() - assert isinstance(reaction_type_dict, dict) assert reaction_type_dict["type"] == reaction_type.type - if reaction_type.type == ReactionType.EMOJI: - assert reaction_type_dict["emoji"] == reaction_type.emoji - elif reaction_type.type == ReactionType.CUSTOM_EMOJI: - assert reaction_type_dict["custom_emoji_id"] == reaction_type.custom_emoji_id - - for slot in reaction_type.__slots__: # additional verification for the optional args - assert getattr(reaction_type, slot) == reaction_type_dict[slot] - - def test_reaction_type_api_kwargs(self, reaction_type): - json_dict = make_json_dict(reaction_type_custom_emoji()) - json_dict["custom_arg"] = "wuhu" - reaction_type_custom_emoji_instance = ReactionType.de_json(json_dict, None) - assert reaction_type_custom_emoji_instance.api_kwargs == { - "custom_arg": "wuhu", - } - def test_equality(self, reaction_type): - a = ReactionTypeEmoji(emoji=RTDefaults.normal_emoji) - b = ReactionTypeEmoji(emoji=RTDefaults.normal_emoji) - c = ReactionTypeCustomEmoji(custom_emoji_id=RTDefaults.custom_emoji) - d = ReactionTypeCustomEmoji(custom_emoji_id=RTDefaults.custom_emoji) - e = ReactionTypeEmoji(emoji=ReactionEmoji.RED_HEART) - f = ReactionTypeCustomEmoji(custom_emoji_id="1234custom") - g = deepcopy(a) - h = deepcopy(c) - i = Dice(4, "emoji") + +@pytest.fixture(scope="module") +def reaction_type_emoji(): + return ReactionTypeEmoji(emoji=TestReactionTypeEmojiWithoutRequest.emoji) + + +class TestReactionTypeEmojiWithoutRequest(ReactionTypeTestBase): + type = constants.ReactionType.EMOJI + + def test_slot_behaviour(self, reaction_type_emoji): + inst = reaction_type_emoji + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = {"emoji": self.emoji} + reaction_type_emoji = ReactionTypeEmoji.de_json(json_dict, offline_bot) + assert reaction_type_emoji.api_kwargs == {} + assert reaction_type_emoji.type == self.type + assert reaction_type_emoji.emoji == self.emoji + + def test_to_dict(self, reaction_type_emoji): + reaction_type_emoji_dict = reaction_type_emoji.to_dict() + assert isinstance(reaction_type_emoji_dict, dict) + assert reaction_type_emoji_dict["type"] == reaction_type_emoji.type + assert reaction_type_emoji_dict["emoji"] == reaction_type_emoji.emoji + + def test_equality(self, reaction_type_emoji): + a = reaction_type_emoji + b = ReactionTypeEmoji(emoji=self.emoji) + c = ReactionTypeEmoji(emoji="other_emoji") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture(scope="module") +def reaction_type_custom_emoji(): + return ReactionTypeCustomEmoji( + custom_emoji_id=TestReactionTypeCustomEmojiWithoutRequest.custom_emoji_id + ) + + +class TestReactionTypeCustomEmojiWithoutRequest(ReactionTypeTestBase): + type = constants.ReactionType.CUSTOM_EMOJI + + def test_slot_behaviour(self, reaction_type_custom_emoji): + inst = reaction_type_custom_emoji + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = {"custom_emoji_id": self.custom_emoji_id} + reaction_type_custom_emoji = ReactionTypeCustomEmoji.de_json(json_dict, offline_bot) + assert reaction_type_custom_emoji.api_kwargs == {} + assert reaction_type_custom_emoji.type == self.type + assert reaction_type_custom_emoji.custom_emoji_id == self.custom_emoji_id + + def test_to_dict(self, reaction_type_custom_emoji): + reaction_type_custom_emoji_dict = reaction_type_custom_emoji.to_dict() + assert isinstance(reaction_type_custom_emoji_dict, dict) + assert reaction_type_custom_emoji_dict["type"] == reaction_type_custom_emoji.type + assert ( + reaction_type_custom_emoji_dict["custom_emoji_id"] + == reaction_type_custom_emoji.custom_emoji_id + ) + + def test_equality(self, reaction_type_custom_emoji): + a = reaction_type_custom_emoji + b = ReactionTypeCustomEmoji(custom_emoji_id=self.custom_emoji_id) + c = ReactionTypeCustomEmoji(custom_emoji_id="other_custom_emoji_id") + d = Dice(5, "test") assert a == b assert hash(a) == hash(b) @@ -193,29 +176,34 @@ def test_equality(self, reaction_type): assert a != c assert hash(a) != hash(c) - assert a != e - assert hash(a) != hash(e) + assert a != d + assert hash(a) != hash(d) - assert a == g - assert hash(a) == hash(g) - assert a != i - assert hash(a) != hash(i) +@pytest.fixture(scope="module") +def reaction_type_paid(): + return ReactionTypePaid() - assert c == d - assert hash(c) == hash(d) - assert c != e - assert hash(c) != hash(e) +class TestReactionTypePaidWithoutRequest(ReactionTypeTestBase): + type = constants.ReactionType.PAID - assert c != f - assert hash(c) != hash(f) + def test_slot_behaviour(self, reaction_type_paid): + inst = reaction_type_paid + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" - assert c == h - assert hash(c) == hash(h) + def test_de_json(self, offline_bot): + json_dict = {} + reaction_type_paid = ReactionTypePaid.de_json(json_dict, offline_bot) + assert reaction_type_paid.api_kwargs == {} + assert reaction_type_paid.type == self.type - assert c != i - assert hash(c) != hash(i) + def test_to_dict(self, reaction_type_paid): + reaction_type_paid_dict = reaction_type_paid.to_dict() + assert isinstance(reaction_type_paid_dict, dict) + assert reaction_type_paid_dict["type"] == reaction_type_paid.type @pytest.fixture(scope="module") @@ -233,9 +221,9 @@ class TestReactionCountWithoutRequest: def test_slot_behaviour(self, reaction_count): for attr in reaction_count.__slots__: assert getattr(reaction_count, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(reaction_count)) == len( - set(mro_slots(reaction_count)) - ), "duplicate slot" + assert len(mro_slots(reaction_count)) == len(set(mro_slots(reaction_count))), ( + "duplicate slot" + ) def test_de_json(self, offline_bot): json_dict = { @@ -252,8 +240,6 @@ def test_de_json(self, offline_bot): assert reaction_count.type.emoji == self.type.emoji assert reaction_count.total_count == self.total_count - assert ReactionCount.de_json(None, offline_bot) is None - def test_to_dict(self, reaction_count): reaction_count_dict = reaction_count.to_dict() diff --git a/tests/test_reply.py b/tests/test_reply.py index 7cf83c8b1e4..16e9da49957 100644 --- a/tests/test_reply.py +++ b/tests/test_reply.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -24,6 +24,8 @@ from telegram import ( BotCommand, Chat, + Checklist, + ChecklistTask, ExternalReplyInfo, Giveaway, LinkPreviewOptions, @@ -47,6 +49,7 @@ def external_reply_info(): link_preview_options=ExternalReplyInfoTestBase.link_preview_options, giveaway=ExternalReplyInfoTestBase.giveaway, paid_media=ExternalReplyInfoTestBase.paid_media, + checklist=ExternalReplyInfoTestBase.checklist, ) @@ -63,15 +66,22 @@ class ExternalReplyInfoTestBase: 1, ) paid_media = PaidMediaInfo(5, [PaidMediaPreview(10, 10, 10)]) + checklist = Checklist( + title="Checklist Title", + tasks=[ + ChecklistTask(text="Item 1", id=1), + ChecklistTask(text="Item 2", id=2), + ], + ) class TestExternalReplyInfoWithoutRequest(ExternalReplyInfoTestBase): def test_slot_behaviour(self, external_reply_info): for attr in external_reply_info.__slots__: assert getattr(external_reply_info, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(external_reply_info)) == len( - set(mro_slots(external_reply_info)) - ), "duplicate slot" + assert len(mro_slots(external_reply_info)) == len(set(mro_slots(external_reply_info))), ( + "duplicate slot" + ) def test_de_json(self, offline_bot): json_dict = { @@ -81,6 +91,7 @@ def test_de_json(self, offline_bot): "link_preview_options": self.link_preview_options.to_dict(), "giveaway": self.giveaway.to_dict(), "paid_media": self.paid_media.to_dict(), + "checklist": self.checklist.to_dict(), } external_reply_info = ExternalReplyInfo.de_json(json_dict, offline_bot) @@ -92,8 +103,7 @@ def test_de_json(self, offline_bot): assert external_reply_info.link_preview_options == self.link_preview_options assert external_reply_info.giveaway == self.giveaway assert external_reply_info.paid_media == self.paid_media - - assert ExternalReplyInfo.de_json(None, offline_bot) is None + assert external_reply_info.checklist == self.checklist def test_to_dict(self, external_reply_info): ext_reply_info_dict = external_reply_info.to_dict() @@ -105,6 +115,7 @@ def test_to_dict(self, external_reply_info): assert ext_reply_info_dict["link_preview_options"] == self.link_preview_options.to_dict() assert ext_reply_info_dict["giveaway"] == self.giveaway.to_dict() assert ext_reply_info_dict["paid_media"] == self.paid_media.to_dict() + assert ext_reply_info_dict["checklist"] == self.checklist.to_dict() def test_equality(self, external_reply_info): a = external_reply_info @@ -167,8 +178,6 @@ def test_de_json(self, offline_bot): assert text_quote.entities == tuple(self.entities) assert text_quote.is_manual == self.is_manual - assert TextQuote.de_json(None, offline_bot) is None - def test_to_dict(self, text_quote): text_quote_dict = text_quote.to_dict() @@ -209,6 +218,7 @@ def reply_parameters(): quote_parse_mode=ReplyParametersTestBase.quote_parse_mode, quote_entities=ReplyParametersTestBase.quote_entities, quote_position=ReplyParametersTestBase.quote_position, + checklist_task_id=ReplyParametersTestBase.checklist_task_id, ) @@ -223,15 +233,16 @@ class ReplyParametersTestBase: MessageEntity(MessageEntity.EMAIL, 3, 4), ] quote_position = 5 + checklist_task_id = 9 class TestReplyParametersWithoutRequest(ReplyParametersTestBase): def test_slot_behaviour(self, reply_parameters): for attr in reply_parameters.__slots__: assert getattr(reply_parameters, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(reply_parameters)) == len( - set(mro_slots(reply_parameters)) - ), "duplicate slot" + assert len(mro_slots(reply_parameters)) == len(set(mro_slots(reply_parameters))), ( + "duplicate slot" + ) def test_de_json(self, offline_bot): json_dict = { @@ -242,6 +253,7 @@ def test_de_json(self, offline_bot): "quote_parse_mode": self.quote_parse_mode, "quote_entities": [entity.to_dict() for entity in self.quote_entities], "quote_position": self.quote_position, + "checklist_task_id": self.checklist_task_id, } reply_parameters = ReplyParameters.de_json(json_dict, offline_bot) @@ -254,8 +266,7 @@ def test_de_json(self, offline_bot): assert reply_parameters.quote_parse_mode == self.quote_parse_mode assert reply_parameters.quote_entities == tuple(self.quote_entities) assert reply_parameters.quote_position == self.quote_position - - assert ReplyParameters.de_json(None, offline_bot) is None + assert reply_parameters.checklist_task_id == self.checklist_task_id def test_to_dict(self, reply_parameters): reply_parameters_dict = reply_parameters.to_dict() @@ -273,6 +284,7 @@ def test_to_dict(self, reply_parameters): entity.to_dict() for entity in self.quote_entities ] assert reply_parameters_dict["quote_position"] == self.quote_position + assert reply_parameters_dict["checklist_task_id"] == self.checklist_task_id def test_equality(self, reply_parameters): a = reply_parameters diff --git a/tests/test_replykeyboardmarkup.py b/tests/test_replykeyboardmarkup.py index ad7a1cbd6ff..19750cc41bd 100644 --- a/tests/test_replykeyboardmarkup.py +++ b/tests/test_replykeyboardmarkup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_replykeyboardremove.py b/tests/test_replykeyboardremove.py index 2024211213f..4c5ada7736c 100644 --- a/tests/test_replykeyboardremove.py +++ b/tests/test_replykeyboardremove.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_sentwebappmessage.py b/tests/test_sentwebappmessage.py index 78fbf88502d..b820ae72892 100644 --- a/tests/test_sentwebappmessage.py +++ b/tests/test_sentwebappmessage.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_shared.py b/tests/test_shared.py index 1e11e8f56f3..a35e5f4ee6c 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -59,8 +59,6 @@ def test_de_json(self, offline_bot): assert users_shared.request_id == self.request_id assert users_shared.users == self.users - assert UsersShared.de_json({}, offline_bot) is None - def test_equality(self): a = UsersShared(self.request_id, users=self.users) b = UsersShared(self.request_id, users=self.users) @@ -119,6 +117,12 @@ def test_de_json(self, offline_bot): assert chat_shared.request_id == self.request_id assert chat_shared.chat_id == self.chat_id + def test_link(self): + chat_shared = ChatShared(1, 123, username="username") + assert chat_shared.link == f"https://t.me/{chat_shared.username}" + chat_shared = ChatShared(1, 123) + assert chat_shared.link is None + def test_equality(self, users_shared): a = ChatShared(self.request_id, self.chat_id) b = ChatShared(self.request_id, self.chat_id) @@ -209,7 +213,31 @@ def test_de_json_all(self, offline_bot): assert shared_user.username == self.username assert shared_user.photo == self.photo - assert SharedUser.de_json({}, offline_bot) is None + def test_name(self): + shared_user = SharedUser(123, "first_name", "last_name", "username") + assert shared_user.name == f"@{shared_user.username}" + shared_user = SharedUser(123, "first_name", "last_name") + assert shared_user.name == f"{shared_user.first_name} {shared_user.last_name}" + shared_user = SharedUser(123, "first_name") + assert shared_user.name == f"{shared_user.first_name}" + shared_user = SharedUser(123, "first_name", username="username") + assert shared_user.name == f"@{shared_user.username}" + shared_user = SharedUser(123) + assert shared_user.name is None + + def test_full_name(self): + shared_user = SharedUser(123, "first_name", "last_name") + assert shared_user.full_name == f"{shared_user.first_name} {shared_user.last_name}" + shared_user = SharedUser(123, "first_name") + assert shared_user.full_name == f"{shared_user.first_name}" + shared_user = SharedUser(123) + assert shared_user.full_name is None + + def test_link(self): + shared_user = SharedUser(123, username="username") + assert shared_user.link == f"https://t.me/{shared_user.username}" + shared_user = SharedUser(123, "first_name", "last_name") + assert shared_user.link is None def test_equality(self, chat_shared): a = SharedUser( diff --git a/tests/test_slots.py b/tests/test_slots.py index 1ae1158697e..dd23c660471 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_story.py b/tests/test_story.py index 69c60289e79..a1472a60aef 100644 --- a/tests/test_story.py +++ b/tests/test_story.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,13 +18,20 @@ import pytest -from telegram import Chat, Story +from telegram import Bot, Chat, Story +from tests.auxil.bot_method_checks import ( + check_defaults_handling, + check_shortcut_call, + check_shortcut_signature, +) from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") -def story(): - return Story(StoryTestBase.chat, StoryTestBase.id) +def story(bot): + story = Story(StoryTestBase.chat, StoryTestBase.id) + story.set_bot(bot) + return story class StoryTestBase: @@ -45,7 +52,6 @@ def test_de_json(self, offline_bot): assert story.chat == self.chat assert story.id == self.id assert isinstance(story, Story) - assert Story.de_json(None, offline_bot) is None def test_to_dict(self, story): story_dict = story.to_dict() @@ -70,3 +76,32 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) + + async def test_instance_method_repost(self, monkeypatch, story): + async def make_assertion(*_, **kwargs): + chat_id = kwargs["from_chat_id"] == story.chat.id + story_id = kwargs["from_story_id"] == story.id + return chat_id and story_id + + assert check_shortcut_signature( + Story.repost, + Bot.repost_story, + [ + "from_chat_id", + "from_story_id", + ], + additional_kwargs=[], + ) + assert await check_shortcut_call( + story.repost, + story.get_bot(), + "repost_story", + shortcut_kwargs=["from_chat_id", "from_story_id"], + ) + assert await check_defaults_handling(story.repost, story.get_bot()) + + monkeypatch.setattr(story.get_bot(), "repost_story", make_assertion) + assert await story.repost( + business_connection_id="bcid", + active_period=3600, + ) diff --git a/tests/test_storyarea.py b/tests/test_storyarea.py new file mode 100644 index 00000000000..fd78c14c318 --- /dev/null +++ b/tests/test_storyarea.py @@ -0,0 +1,508 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + + +import pytest + +from telegram._dice import Dice +from telegram._reaction import ReactionTypeEmoji +from telegram._storyarea import ( + LocationAddress, + StoryArea, + StoryAreaPosition, + StoryAreaType, + StoryAreaTypeLink, + StoryAreaTypeLocation, + StoryAreaTypeSuggestedReaction, + StoryAreaTypeUniqueGift, + StoryAreaTypeWeather, +) +from telegram.constants import StoryAreaTypeType +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def story_area_position(): + return StoryAreaPosition( + x_percentage=StoryAreaPositionTestBase.x_percentage, + y_percentage=StoryAreaPositionTestBase.y_percentage, + width_percentage=StoryAreaPositionTestBase.width_percentage, + height_percentage=StoryAreaPositionTestBase.height_percentage, + rotation_angle=StoryAreaPositionTestBase.rotation_angle, + corner_radius_percentage=StoryAreaPositionTestBase.corner_radius_percentage, + ) + + +class StoryAreaPositionTestBase: + x_percentage = 50.0 + y_percentage = 10.0 + width_percentage = 15 + height_percentage = 15 + rotation_angle = 0.0 + corner_radius_percentage = 8.0 + + +class TestStoryAreaPositionWithoutRequest(StoryAreaPositionTestBase): + def test_slot_behaviour(self, story_area_position): + inst = story_area_position + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, story_area_position): + assert story_area_position.x_percentage == self.x_percentage + assert story_area_position.y_percentage == self.y_percentage + assert story_area_position.width_percentage == self.width_percentage + assert story_area_position.height_percentage == self.height_percentage + assert story_area_position.rotation_angle == self.rotation_angle + assert story_area_position.corner_radius_percentage == self.corner_radius_percentage + + def test_to_dict(self, story_area_position): + json_dict = story_area_position.to_dict() + assert json_dict["x_percentage"] == self.x_percentage + assert json_dict["y_percentage"] == self.y_percentage + assert json_dict["width_percentage"] == self.width_percentage + assert json_dict["height_percentage"] == self.height_percentage + assert json_dict["rotation_angle"] == self.rotation_angle + assert json_dict["corner_radius_percentage"] == self.corner_radius_percentage + + def test_equality(self, story_area_position): + a = story_area_position + b = StoryAreaPosition( + self.x_percentage, + self.y_percentage, + self.width_percentage, + self.height_percentage, + self.rotation_angle, + self.corner_radius_percentage, + ) + c = StoryAreaPosition( + self.x_percentage + 10.0, + self.y_percentage, + self.width_percentage, + self.height_percentage, + self.rotation_angle, + self.corner_radius_percentage, + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def location_address(): + return LocationAddress( + country_code=LocationAddressTestBase.country_code, + state=LocationAddressTestBase.state, + city=LocationAddressTestBase.city, + street=LocationAddressTestBase.street, + ) + + +class LocationAddressTestBase: + country_code = "CC" + state = "State" + city = "City" + street = "12 downtown" + + +class TestLocationAddressWithoutRequest(LocationAddressTestBase): + def test_slot_behaviour(self, location_address): + inst = location_address + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, location_address): + assert location_address.country_code == self.country_code + assert location_address.state == self.state + assert location_address.city == self.city + assert location_address.street == self.street + + def test_to_dict(self, location_address): + json_dict = location_address.to_dict() + assert json_dict["country_code"] == self.country_code + assert json_dict["state"] == self.state + assert json_dict["city"] == self.city + assert json_dict["street"] == self.street + + def test_equality(self, location_address): + a = location_address + b = LocationAddress(self.country_code, self.state, self.city, self.street) + c = LocationAddress("some_other_code", self.state, self.city, self.street) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def story_area(): + return StoryArea( + position=StoryAreaTestBase.position, + type=StoryAreaTestBase.type, + ) + + +class StoryAreaTestBase: + position = StoryAreaPosition( + x_percentage=50.0, + y_percentage=10.0, + width_percentage=15, + height_percentage=15, + rotation_angle=0.0, + corner_radius_percentage=8.0, + ) + type = StoryAreaTypeLink(url="some_url") + + +class TestStoryAreaWithoutRequest(StoryAreaTestBase): + def test_slot_behaviour(self, story_area): + inst = story_area + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, story_area): + assert story_area.position == self.position + assert story_area.type == self.type + + def test_to_dict(self, story_area): + json_dict = story_area.to_dict() + assert json_dict["position"] == self.position.to_dict() + assert json_dict["type"] == self.type.to_dict() + + def test_equality(self, story_area): + a = story_area + b = StoryArea(self.position, self.type) + c = StoryArea(self.position, StoryAreaTypeLink(url="some_other_url")) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def story_area_type(): + return StoryAreaType(type=StoryAreaTypeTestBase.type) + + +class StoryAreaTypeTestBase: + type = StoryAreaTypeType.LOCATION + latitude = 100.5 + longitude = 200.5 + address = LocationAddress( + country_code="cc", + state="State", + city="City", + street="12 downtown", + ) + reaction_type = ReactionTypeEmoji(emoji="emoji") + is_dark = False + is_flipped = False + url = "http_url" + temperature = 35.0 + emoji = "emoji" + background_color = 0xFF66CCFF + name = "unique_gift_name" + + +class TestStoryAreaTypeWithoutRequest(StoryAreaTypeTestBase): + def test_slot_behaviour(self, story_area_type): + inst = story_area_type + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, story_area_type): + assert story_area_type.type == self.type + + def test_type_enum_conversion(self, story_area_type): + assert type(StoryAreaType("location").type) is StoryAreaTypeType + assert StoryAreaType("unknown").type == "unknown" + + def test_to_dict(self, story_area_type): + assert story_area_type.to_dict() == {"type": self.type} + + def test_equality(self, story_area_type): + a = story_area_type + b = StoryAreaType(self.type) + c = StoryAreaType("unknown") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def story_area_type_location(): + return StoryAreaTypeLocation( + latitude=TestStoryAreaTypeLocationWithoutRequest.latitude, + longitude=TestStoryAreaTypeLocationWithoutRequest.longitude, + address=TestStoryAreaTypeLocationWithoutRequest.address, + ) + + +class TestStoryAreaTypeLocationWithoutRequest(StoryAreaTypeTestBase): + type = StoryAreaTypeType.LOCATION + + def test_slot_behaviour(self, story_area_type_location): + inst = story_area_type_location + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, story_area_type_location): + assert story_area_type_location.type == self.type + assert story_area_type_location.latitude == self.latitude + assert story_area_type_location.longitude == self.longitude + assert story_area_type_location.address == self.address + + def test_to_dict(self, story_area_type_location): + json_dict = story_area_type_location.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["latitude"] == self.latitude + assert json_dict["longitude"] == self.longitude + assert json_dict["address"] == self.address.to_dict() + + def test_equality(self, story_area_type_location): + a = story_area_type_location + b = StoryAreaTypeLocation(self.latitude, self.longitude, self.address) + c = StoryAreaTypeLocation(self.latitude + 0.5, self.longitude, self.address) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def story_area_type_suggested_reaction(): + return StoryAreaTypeSuggestedReaction( + reaction_type=TestStoryAreaTypeSuggestedReactionWithoutRequest.reaction_type, + is_dark=TestStoryAreaTypeSuggestedReactionWithoutRequest.is_dark, + is_flipped=TestStoryAreaTypeSuggestedReactionWithoutRequest.is_flipped, + ) + + +class TestStoryAreaTypeSuggestedReactionWithoutRequest(StoryAreaTypeTestBase): + type = StoryAreaTypeType.SUGGESTED_REACTION + + def test_slot_behaviour(self, story_area_type_suggested_reaction): + inst = story_area_type_suggested_reaction + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, story_area_type_suggested_reaction): + assert story_area_type_suggested_reaction.type == self.type + assert story_area_type_suggested_reaction.reaction_type == self.reaction_type + assert story_area_type_suggested_reaction.is_dark is self.is_dark + assert story_area_type_suggested_reaction.is_flipped is self.is_flipped + + def test_to_dict(self, story_area_type_suggested_reaction): + json_dict = story_area_type_suggested_reaction.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["reaction_type"] == self.reaction_type.to_dict() + assert json_dict["is_dark"] is self.is_dark + assert json_dict["is_flipped"] is self.is_flipped + + def test_equality(self, story_area_type_suggested_reaction): + a = story_area_type_suggested_reaction + b = StoryAreaTypeSuggestedReaction(self.reaction_type, self.is_dark, self.is_flipped) + c = StoryAreaTypeSuggestedReaction(self.reaction_type, not self.is_dark, self.is_flipped) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def story_area_type_link(): + return StoryAreaTypeLink( + url=TestStoryAreaTypeLinkWithoutRequest.url, + ) + + +class TestStoryAreaTypeLinkWithoutRequest(StoryAreaTypeTestBase): + type = StoryAreaTypeType.LINK + + def test_slot_behaviour(self, story_area_type_link): + inst = story_area_type_link + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, story_area_type_link): + assert story_area_type_link.type == self.type + assert story_area_type_link.url == self.url + + def test_to_dict(self, story_area_type_link): + json_dict = story_area_type_link.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["url"] == self.url + + def test_equality(self, story_area_type_link): + a = story_area_type_link + b = StoryAreaTypeLink(self.url) + c = StoryAreaTypeLink("other_http_url") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def story_area_type_weather(): + return StoryAreaTypeWeather( + temperature=TestStoryAreaTypeWeatherWithoutRequest.temperature, + emoji=TestStoryAreaTypeWeatherWithoutRequest.emoji, + background_color=TestStoryAreaTypeWeatherWithoutRequest.background_color, + ) + + +class TestStoryAreaTypeWeatherWithoutRequest(StoryAreaTypeTestBase): + type = StoryAreaTypeType.WEATHER + + def test_slot_behaviour(self, story_area_type_weather): + inst = story_area_type_weather + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, story_area_type_weather): + assert story_area_type_weather.type == self.type + assert story_area_type_weather.temperature == self.temperature + assert story_area_type_weather.emoji == self.emoji + assert story_area_type_weather.background_color == self.background_color + + def test_to_dict(self, story_area_type_weather): + json_dict = story_area_type_weather.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["temperature"] == self.temperature + assert json_dict["emoji"] == self.emoji + assert json_dict["background_color"] == self.background_color + + def test_equality(self, story_area_type_weather): + a = story_area_type_weather + b = StoryAreaTypeWeather(self.temperature, self.emoji, self.background_color) + c = StoryAreaTypeWeather(self.temperature - 5.0, self.emoji, self.background_color) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def story_area_type_unique_gift(): + return StoryAreaTypeUniqueGift( + name=TestStoryAreaTypeUniqueGiftWithoutRequest.name, + ) + + +class TestStoryAreaTypeUniqueGiftWithoutRequest(StoryAreaTypeTestBase): + type = StoryAreaTypeType.UNIQUE_GIFT + + def test_slot_behaviour(self, story_area_type_unique_gift): + inst = story_area_type_unique_gift + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, story_area_type_unique_gift): + assert story_area_type_unique_gift.type == self.type + assert story_area_type_unique_gift.name == self.name + + def test_to_dict(self, story_area_type_unique_gift): + json_dict = story_area_type_unique_gift.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["name"] == self.name + + def test_equality(self, story_area_type_unique_gift): + a = story_area_type_unique_gift + b = StoryAreaTypeUniqueGift(self.name) + c = StoryAreaTypeUniqueGift("other_name") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_suggestedpost.py b/tests/test_suggestedpost.py new file mode 100644 index 00000000000..b3bc89568ca --- /dev/null +++ b/tests/test_suggestedpost.py @@ -0,0 +1,600 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import datetime as dtm + +import pytest + +from telegram import Dice +from telegram._chat import Chat +from telegram._message import Message +from telegram._payment.stars.staramount import StarAmount +from telegram._suggestedpost import ( + SuggestedPostApprovalFailed, + SuggestedPostApproved, + SuggestedPostDeclined, + SuggestedPostInfo, + SuggestedPostPaid, + SuggestedPostParameters, + SuggestedPostPrice, + SuggestedPostRefunded, +) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import SuggestedPostInfoState +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def suggested_post_parameters(): + return SuggestedPostParameters( + price=SuggestedPostParametersTestBase.price, + send_date=SuggestedPostParametersTestBase.send_date, + ) + + +class SuggestedPostParametersTestBase: + price = SuggestedPostPrice(currency="XTR", amount=100) + send_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + + +class TestSuggestedPostParametersWithoutRequest(SuggestedPostParametersTestBase): + def test_slot_behaviour(self, suggested_post_parameters): + for attr in suggested_post_parameters.__slots__: + assert getattr(suggested_post_parameters, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(suggested_post_parameters)) == len( + set(mro_slots(suggested_post_parameters)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "price": self.price.to_dict(), + "send_date": to_timestamp(self.send_date), + } + spp = SuggestedPostParameters.de_json(json_dict, offline_bot) + assert spp.price == self.price + assert spp.send_date == self.send_date + assert spp.api_kwargs == {} + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "price": self.price.to_dict(), + "send_date": to_timestamp(self.send_date), + } + + spp_bot = SuggestedPostParameters.de_json(json_dict, offline_bot) + spp_bot_raw = SuggestedPostParameters.de_json(json_dict, raw_bot) + spp_bot_tz = SuggestedPostParameters.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing tzinfo objects is not reliable + send_date_offset = spp_bot_tz.send_date.utcoffset() + send_date_offset_tz = tz_bot.defaults.tzinfo.utcoffset( + spp_bot_tz.send_date.replace(tzinfo=None) + ) + + assert spp_bot.send_date.tzinfo == UTC + assert spp_bot_raw.send_date.tzinfo == UTC + assert send_date_offset_tz == send_date_offset + + def test_to_dict(self, suggested_post_parameters): + spp_dict = suggested_post_parameters.to_dict() + + assert isinstance(spp_dict, dict) + assert spp_dict["price"] == self.price.to_dict() + assert spp_dict["send_date"] == to_timestamp(self.send_date) + + def test_equality(self, suggested_post_parameters): + a = suggested_post_parameters + b = SuggestedPostParameters(price=self.price, send_date=self.send_date) + c = SuggestedPostParameters( + price=self.price, send_date=self.send_date + dtm.timedelta(seconds=1) + ) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_info(): + return SuggestedPostInfo( + state=SuggestedPostInfoTestBase.state, + price=SuggestedPostInfoTestBase.price, + send_date=SuggestedPostInfoTestBase.send_date, + ) + + +class SuggestedPostInfoTestBase: + state = SuggestedPostInfoState.PENDING + price = SuggestedPostPrice(currency="XTR", amount=100) + send_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + + +class TestSuggestedPostInfoWithoutRequest(SuggestedPostInfoTestBase): + def test_slot_behaviour(self, suggested_post_info): + for attr in suggested_post_info.__slots__: + assert getattr(suggested_post_info, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(suggested_post_info)) == len(set(mro_slots(suggested_post_info))), ( + "duplicate slot" + ) + + def test_type_enum_conversion(self): + assert type(SuggestedPostInfo("pending").state) is SuggestedPostInfoState + assert SuggestedPostInfo("unknown").state == "unknown" + + def test_de_json(self, offline_bot): + json_dict = { + "state": self.state, + "price": self.price.to_dict(), + "send_date": to_timestamp(self.send_date), + } + spi = SuggestedPostInfo.de_json(json_dict, offline_bot) + assert spi.state == self.state + assert spi.price == self.price + assert spi.send_date == self.send_date + assert spi.api_kwargs == {} + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "state": self.state, + "price": self.price.to_dict(), + "send_date": to_timestamp(self.send_date), + } + + spi_bot = SuggestedPostInfo.de_json(json_dict, offline_bot) + spi_bot_raw = SuggestedPostInfo.de_json(json_dict, raw_bot) + spi_bot_tz = SuggestedPostInfo.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing tzinfo objects is not reliable + send_date_offset = spi_bot_tz.send_date.utcoffset() + send_date_offset_tz = tz_bot.defaults.tzinfo.utcoffset( + spi_bot_tz.send_date.replace(tzinfo=None) + ) + + assert spi_bot.send_date.tzinfo == UTC + assert spi_bot_raw.send_date.tzinfo == UTC + assert send_date_offset_tz == send_date_offset + + def test_to_dict(self, suggested_post_info): + spi_dict = suggested_post_info.to_dict() + + assert isinstance(spi_dict, dict) + assert spi_dict["state"] == self.state + assert spi_dict["price"] == self.price.to_dict() + assert spi_dict["send_date"] == to_timestamp(self.send_date) + + def test_equality(self, suggested_post_info): + a = suggested_post_info + b = SuggestedPostInfo(state=self.state, price=self.price) + c = SuggestedPostInfo(state=SuggestedPostInfoState.DECLINED, price=self.price) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_price(): + return SuggestedPostPrice( + currency=SuggestedPostPriceTestBase.currency, + amount=SuggestedPostPriceTestBase.amount, + ) + + +class SuggestedPostPriceTestBase: + currency = "XTR" + amount = 100 + + +class TestSuggestedPostPriceWithoutRequest(SuggestedPostPriceTestBase): + def test_slot_behaviour(self, suggested_post_price): + for attr in suggested_post_price.__slots__: + assert getattr(suggested_post_price, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(suggested_post_price)) == len(set(mro_slots(suggested_post_price))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "currency": self.currency, + "amount": self.amount, + } + spp = SuggestedPostPrice.de_json(json_dict, offline_bot) + assert spp.currency == self.currency + assert spp.amount == self.amount + assert spp.api_kwargs == {} + + def test_to_dict(self, suggested_post_price): + spp_dict = suggested_post_price.to_dict() + + assert isinstance(spp_dict, dict) + assert spp_dict["currency"] == self.currency + assert spp_dict["amount"] == self.amount + + def test_equality(self, suggested_post_price): + a = suggested_post_price + b = SuggestedPostPrice(currency=self.currency, amount=self.amount) + c = SuggestedPostPrice(currency="TON", amount=self.amount) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_declined(): + return SuggestedPostDeclined( + suggested_post_message=SuggestedPostDeclinedTestBase.suggested_post_message, + comment=SuggestedPostDeclinedTestBase.comment, + ) + + +class SuggestedPostDeclinedTestBase: + suggested_post_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="post this pls.") + comment = "another time" + + +class TestSuggestedPostDeclinedWithoutRequest(SuggestedPostDeclinedTestBase): + def test_slot_behaviour(self, suggested_post_declined): + for attr in suggested_post_declined.__slots__: + assert getattr(suggested_post_declined, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(suggested_post_declined)) == len( + set(mro_slots(suggested_post_declined)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "suggested_post_message": self.suggested_post_message.to_dict(), + "comment": self.comment, + } + spd = SuggestedPostDeclined.de_json(json_dict, offline_bot) + assert spd.suggested_post_message == self.suggested_post_message + assert spd.comment == self.comment + assert spd.api_kwargs == {} + + def test_to_dict(self, suggested_post_declined): + spd_dict = suggested_post_declined.to_dict() + + assert isinstance(spd_dict, dict) + assert spd_dict["suggested_post_message"] == self.suggested_post_message.to_dict() + assert spd_dict["comment"] == self.comment + + def test_equality(self, suggested_post_declined): + a = suggested_post_declined + b = SuggestedPostDeclined( + suggested_post_message=self.suggested_post_message, comment=self.comment + ) + c = SuggestedPostDeclined(suggested_post_message=self.suggested_post_message, comment="no") + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_paid(): + return SuggestedPostPaid( + currency=SuggestedPostPaidTestBase.currency, + suggested_post_message=SuggestedPostPaidTestBase.suggested_post_message, + amount=SuggestedPostPaidTestBase.amount, + star_amount=SuggestedPostPaidTestBase.star_amount, + ) + + +class SuggestedPostPaidTestBase: + suggested_post_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="post this pls.") + currency = "XTR" + amount = 100 + star_amount = StarAmount(100) + + +class TestSuggestedPostPaidWithoutRequest(SuggestedPostPaidTestBase): + def test_slot_behaviour(self, suggested_post_paid): + for attr in suggested_post_paid.__slots__: + assert getattr(suggested_post_paid, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(suggested_post_paid)) == len(set(mro_slots(suggested_post_paid))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "suggested_post_message": self.suggested_post_message.to_dict(), + "currency": self.currency, + "amount": self.amount, + "star_amount": self.star_amount.to_dict(), + } + spp = SuggestedPostPaid.de_json(json_dict, offline_bot) + assert spp.suggested_post_message == self.suggested_post_message + assert spp.currency == self.currency + assert spp.amount == self.amount + assert spp.star_amount == self.star_amount + assert spp.api_kwargs == {} + + def test_to_dict(self, suggested_post_paid): + spp_dict = suggested_post_paid.to_dict() + + assert isinstance(spp_dict, dict) + assert spp_dict["suggested_post_message"] == self.suggested_post_message.to_dict() + assert spp_dict["currency"] == self.currency + assert spp_dict["amount"] == self.amount + assert spp_dict["star_amount"] == self.star_amount.to_dict() + + def test_equality(self, suggested_post_paid): + a = suggested_post_paid + b = SuggestedPostPaid( + suggested_post_message=self.suggested_post_message, + currency=self.currency, + amount=self.amount, + star_amount=self.star_amount, + ) + c = SuggestedPostPaid( + suggested_post_message=self.suggested_post_message, + currency=self.currency, + amount=self.amount - 1, + star_amount=StarAmount(self.amount - 1), + ) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_refunded(): + return SuggestedPostRefunded( + reason=SuggestedPostRefundedTestBase.reason, + suggested_post_message=SuggestedPostRefundedTestBase.suggested_post_message, + ) + + +class SuggestedPostRefundedTestBase: + reason = "post_deleted" + suggested_post_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="post this pls.") + + +class TestSuggestedPostRefundedWithoutRequest(SuggestedPostRefundedTestBase): + def test_slot_behaviour(self, suggested_post_refunded): + for attr in suggested_post_refunded.__slots__: + assert getattr(suggested_post_refunded, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(suggested_post_refunded)) == len( + set(mro_slots(suggested_post_refunded)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "suggested_post_message": self.suggested_post_message.to_dict(), + "reason": self.reason, + } + spr = SuggestedPostRefunded.de_json(json_dict, offline_bot) + assert spr.suggested_post_message == self.suggested_post_message + assert spr.reason == self.reason + assert spr.api_kwargs == {} + + def test_to_dict(self, suggested_post_refunded): + spr_dict = suggested_post_refunded.to_dict() + + assert isinstance(spr_dict, dict) + assert spr_dict["suggested_post_message"] == self.suggested_post_message.to_dict() + assert spr_dict["reason"] == self.reason + + def test_equality(self, suggested_post_refunded): + a = suggested_post_refunded + b = SuggestedPostRefunded( + suggested_post_message=self.suggested_post_message, reason=self.reason + ) + c = SuggestedPostRefunded( + suggested_post_message=self.suggested_post_message, reason="payment_refunded" + ) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_approved(): + return SuggestedPostApproved( + send_date=SuggestedPostApprovedTestBase.send_date, + suggested_post_message=SuggestedPostApprovedTestBase.suggested_post_message, + price=SuggestedPostApprovedTestBase.price, + ) + + +class SuggestedPostApprovedTestBase: + send_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + suggested_post_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="post this pls.") + price = SuggestedPostPrice(currency="XTR", amount=100) + + +class TestSuggestedPostApprovedWithoutRequest(SuggestedPostApprovedTestBase): + def test_slot_behaviour(self, suggested_post_approved): + for attr in suggested_post_approved.__slots__: + assert getattr(suggested_post_approved, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(suggested_post_approved)) == len( + set(mro_slots(suggested_post_approved)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "send_date": to_timestamp(self.send_date), + "suggested_post_message": self.suggested_post_message.to_dict(), + "price": self.price.to_dict(), + } + spa = SuggestedPostApproved.de_json(json_dict, offline_bot) + assert spa.send_date == self.send_date + assert spa.suggested_post_message == self.suggested_post_message + assert spa.price == self.price + assert spa.api_kwargs == {} + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "send_date": to_timestamp(self.send_date), + "suggested_post_message": self.suggested_post_message.to_dict(), + "price": self.price.to_dict(), + } + + spa_bot = SuggestedPostApproved.de_json(json_dict, offline_bot) + spa_bot_raw = SuggestedPostApproved.de_json(json_dict, raw_bot) + spi_bot_tz = SuggestedPostApproved.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing tzinfo objects is not reliable + send_date_offset = spi_bot_tz.send_date.utcoffset() + send_date_offset_tz = tz_bot.defaults.tzinfo.utcoffset( + spi_bot_tz.send_date.replace(tzinfo=None) + ) + + assert spa_bot.send_date.tzinfo == UTC + assert spa_bot_raw.send_date.tzinfo == UTC + assert send_date_offset_tz == send_date_offset + + def test_to_dict(self, suggested_post_approved): + spa_dict = suggested_post_approved.to_dict() + + assert isinstance(spa_dict, dict) + assert spa_dict["send_date"] == to_timestamp(self.send_date) + assert spa_dict["suggested_post_message"] == self.suggested_post_message.to_dict() + assert spa_dict["price"] == self.price.to_dict() + + def test_equality(self, suggested_post_approved): + a = suggested_post_approved + b = SuggestedPostApproved( + send_date=self.send_date, + suggested_post_message=self.suggested_post_message, + price=self.price, + ) + c = SuggestedPostApproved( + send_date=self.send_date, + suggested_post_message=self.suggested_post_message, + price=SuggestedPostPrice(currency="XTR", amount=self.price.amount - 1), + ) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_approval_failed(): + return SuggestedPostApprovalFailed( + price=SuggestedPostApprovalFailedTestBase.price, + suggested_post_message=SuggestedPostApprovalFailedTestBase.suggested_post_message, + ) + + +class SuggestedPostApprovalFailedTestBase: + price = SuggestedPostPrice(currency="XTR", amount=100) + suggested_post_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="post this pls.") + + +class TestSuggestedPostApprovalFailedWithoutRequest(SuggestedPostApprovalFailedTestBase): + def test_slot_behaviour(self, suggested_post_approval_failed): + for attr in suggested_post_approval_failed.__slots__: + assert getattr(suggested_post_approval_failed, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(suggested_post_approval_failed)) == len( + set(mro_slots(suggested_post_approval_failed)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "price": self.price.to_dict(), + "suggested_post_message": self.suggested_post_message.to_dict(), + } + spaf = SuggestedPostApprovalFailed.de_json(json_dict, offline_bot) + assert spaf.price == self.price + assert spaf.suggested_post_message == self.suggested_post_message + assert spaf.api_kwargs == {} + + def test_to_dict(self, suggested_post_approval_failed): + spaf_dict = suggested_post_approval_failed.to_dict() + + assert isinstance(spaf_dict, dict) + assert spaf_dict["price"] == self.price.to_dict() + assert spaf_dict["suggested_post_message"] == self.suggested_post_message.to_dict() + + def test_equality(self, suggested_post_approval_failed): + a = suggested_post_approval_failed + b = SuggestedPostApprovalFailed( + price=self.price, + suggested_post_message=self.suggested_post_message, + ) + c = SuggestedPostApprovalFailed( + price=SuggestedPostPrice(currency="XTR", amount=self.price.amount - 1), + suggested_post_message=self.suggested_post_message, + ) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) diff --git a/tests/test_switchinlinequerychosenchat.py b/tests/test_switchinlinequerychosenchat.py index a1b453d5e55..10c5de3e33b 100644 --- a/tests/test_switchinlinequerychosenchat.py +++ b/tests/test_switchinlinequerychosenchat.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_telegramobject.py b/tests/test_telegramobject.py index 8496a9f1ca0..953bf392cec 100644 --- a/tests/test_telegramobject.py +++ b/tests/test_telegramobject.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -103,7 +103,7 @@ def __init__(self, arg: int, **kwargs): self._id_attrs = (self.arg,) - assert SubClass.de_list([{"arg": 1}, None, {"arg": 2}, None], bot) == ( + assert SubClass.de_list([{"arg": 1}, {"arg": 2}], bot) == ( SubClass(1), SubClass(2), ) @@ -146,9 +146,9 @@ def test_subclasses_have_api_kwargs(self, cls): if cls is TelegramObject: # TelegramObject doesn't have a super class return - assert "api_kwargs=api_kwargs" in inspect.getsource( - cls.__init__ - ), f"{cls.__name__} doesn't seem to pass `api_kwargs` to `super().__init__`" + assert "api_kwargs=api_kwargs" in inspect.getsource(cls.__init__), ( + f"{cls.__name__} doesn't seem to pass `api_kwargs` to `super().__init__`" + ) def test_de_json_arbitrary_exceptions(self, bot): class SubClass(TelegramObject): diff --git a/tests/test_uniquegift.py b/tests/test_uniquegift.py new file mode 100644 index 00000000000..0ea8049435f --- /dev/null +++ b/tests/test_uniquegift.py @@ -0,0 +1,631 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import datetime as dtm + +import pytest + +from telegram import ( + BotCommand, + Chat, + Sticker, + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftColors, + UniqueGiftInfo, + UniqueGiftModel, + UniqueGiftSymbol, +) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import UniqueGiftInfoOrigin +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def unique_gift_colors(): + return UniqueGiftColors( + model_custom_emoji_id=UniqueGiftColorsTestBase.model_custom_emoji_id, + symbol_custom_emoji_id=UniqueGiftColorsTestBase.symbol_custom_emoji_id, + light_theme_main_color=UniqueGiftColorsTestBase.light_theme_main_color, + light_theme_other_colors=UniqueGiftColorsTestBase.light_theme_other_colors, + dark_theme_main_color=UniqueGiftColorsTestBase.dark_theme_main_color, + dark_theme_other_colors=UniqueGiftColorsTestBase.dark_theme_other_colors, + ) + + +class UniqueGiftColorsTestBase: + model_custom_emoji_id = "model_emoji_id" + symbol_custom_emoji_id = "symbol_emoji_id" + light_theme_main_color = 0xFFFFFF + light_theme_other_colors = [0xAAAAAA, 0xBBBBBB] + dark_theme_main_color = 0x000000 + dark_theme_other_colors = [0x111111, 0x222222] + + +class TestUniqueGiftColorsWithoutRequest(UniqueGiftColorsTestBase): + def test_slot_behaviour(self, unique_gift_colors): + for attr in unique_gift_colors.__slots__: + assert getattr(unique_gift_colors, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_colors)) == len(set(mro_slots(unique_gift_colors))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "model_custom_emoji_id": self.model_custom_emoji_id, + "symbol_custom_emoji_id": self.symbol_custom_emoji_id, + "light_theme_main_color": self.light_theme_main_color, + "light_theme_other_colors": self.light_theme_other_colors, + "dark_theme_main_color": self.dark_theme_main_color, + "dark_theme_other_colors": self.dark_theme_other_colors, + } + unique_gift_colors = UniqueGiftColors.de_json(json_dict, offline_bot) + assert unique_gift_colors.api_kwargs == {} + assert unique_gift_colors.model_custom_emoji_id == self.model_custom_emoji_id + assert unique_gift_colors.symbol_custom_emoji_id == self.symbol_custom_emoji_id + assert unique_gift_colors.light_theme_main_color == self.light_theme_main_color + assert unique_gift_colors.light_theme_other_colors == tuple(self.light_theme_other_colors) + assert unique_gift_colors.dark_theme_main_color == self.dark_theme_main_color + assert unique_gift_colors.dark_theme_other_colors == tuple(self.dark_theme_other_colors) + + def test_to_dict(self, unique_gift_colors): + json_dict = unique_gift_colors.to_dict() + assert json_dict["model_custom_emoji_id"] == self.model_custom_emoji_id + assert json_dict["symbol_custom_emoji_id"] == self.symbol_custom_emoji_id + assert json_dict["light_theme_main_color"] == self.light_theme_main_color + assert json_dict["light_theme_other_colors"] == self.light_theme_other_colors + assert json_dict["dark_theme_main_color"] == self.dark_theme_main_color + assert json_dict["dark_theme_other_colors"] == self.dark_theme_other_colors + + def test_equality(self, unique_gift_colors): + a = unique_gift_colors + b = UniqueGiftColors( + self.model_custom_emoji_id, + self.symbol_custom_emoji_id, + self.light_theme_main_color, + self.light_theme_other_colors, + self.dark_theme_main_color, + self.dark_theme_other_colors, + ) + c = UniqueGiftColors( + "other_model_emoji_id", + self.symbol_custom_emoji_id, + self.light_theme_main_color, + self.light_theme_other_colors, + self.dark_theme_main_color, + self.dark_theme_other_colors, + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def unique_gift(): + return UniqueGift( + gift_id=UniqueGiftTestBase.gift_id, + base_name=UniqueGiftTestBase.base_name, + name=UniqueGiftTestBase.name, + number=UniqueGiftTestBase.number, + model=UniqueGiftTestBase.model, + symbol=UniqueGiftTestBase.symbol, + backdrop=UniqueGiftTestBase.backdrop, + publisher_chat=UniqueGiftTestBase.publisher_chat, + is_premium=UniqueGiftTestBase.is_premium, + is_from_blockchain=UniqueGiftTestBase.is_from_blockchain, + colors=UniqueGiftTestBase.colors, + ) + + +class UniqueGiftTestBase: + gift_id = "gift_id" + base_name = "human_readable" + name = "unique_name" + number = 10 + model = UniqueGiftModel( + name="model_name", + sticker=Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + rarity_per_mille=10, + ) + symbol = UniqueGiftSymbol( + name="symbol_name", + sticker=Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + rarity_per_mille=20, + ) + backdrop = UniqueGiftBackdrop( + name="backdrop_name", + colors=UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + rarity_per_mille=30, + ) + publisher_chat = Chat(1, Chat.PRIVATE) + is_premium = False + is_from_blockchain = True + colors = UniqueGiftColors( + model_custom_emoji_id="M", + symbol_custom_emoji_id="S", + light_theme_main_color=0xFFFFFF, + light_theme_other_colors=[0xAAAAAA], + dark_theme_main_color=0x000000, + dark_theme_other_colors=[0x111111], + ) + + +class TestUniqueGiftWithoutRequest(UniqueGiftTestBase): + def test_slot_behaviour(self, unique_gift): + for attr in unique_gift.__slots__: + assert getattr(unique_gift, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift)) == len(set(mro_slots(unique_gift))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "gift_id": self.gift_id, + "base_name": self.base_name, + "name": self.name, + "number": self.number, + "model": self.model.to_dict(), + "symbol": self.symbol.to_dict(), + "backdrop": self.backdrop.to_dict(), + "publisher_chat": self.publisher_chat.to_dict(), + "is_premium": self.is_premium, + "is_from_blockchain": self.is_from_blockchain, + "colors": self.colors.to_dict(), + } + unique_gift = UniqueGift.de_json(json_dict, offline_bot) + assert unique_gift.api_kwargs == {} + + assert unique_gift.base_name == self.base_name + assert unique_gift.name == self.name + assert unique_gift.number == self.number + assert unique_gift.model == self.model + assert unique_gift.symbol == self.symbol + assert unique_gift.backdrop == self.backdrop + assert unique_gift.publisher_chat == self.publisher_chat + assert unique_gift.is_premium == self.is_premium + assert unique_gift.is_from_blockchain == self.is_from_blockchain + assert unique_gift.colors == self.colors + + def test_to_dict(self, unique_gift): + gift_dict = unique_gift.to_dict() + + assert isinstance(gift_dict, dict) + assert gift_dict["gift_id"] == self.gift_id + assert gift_dict["base_name"] == self.base_name + assert gift_dict["name"] == self.name + assert gift_dict["number"] == self.number + assert gift_dict["model"] == self.model.to_dict() + assert gift_dict["symbol"] == self.symbol.to_dict() + assert gift_dict["backdrop"] == self.backdrop.to_dict() + assert gift_dict["publisher_chat"] == self.publisher_chat.to_dict() + assert gift_dict["is_premium"] == self.is_premium + assert gift_dict["is_from_blockchain"] == self.is_from_blockchain + assert gift_dict["colors"] == self.colors.to_dict() + + def test_equality(self, unique_gift): + a = unique_gift + b = UniqueGift( + gift_id=self.gift_id, + base_name=self.base_name, + name=self.name, + number=self.number, + model=self.model, + symbol=self.symbol, + backdrop=self.backdrop, + publisher_chat=self.publisher_chat, + ) + c = UniqueGift( + gift_id=self.gift_id, + base_name="other_base_name", + name=self.name, + number=self.number, + model=self.model, + symbol=self.symbol, + backdrop=self.backdrop, + publisher_chat=self.publisher_chat, + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def unique_gift_model(): + return UniqueGiftModel( + name=UniqueGiftModelTestBase.name, + sticker=UniqueGiftModelTestBase.sticker, + rarity_per_mille=UniqueGiftModelTestBase.rarity_per_mille, + ) + + +class UniqueGiftModelTestBase: + name = "model_name" + sticker = Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular") + rarity_per_mille = 10 + + +class TestUniqueGiftModelWithoutRequest(UniqueGiftModelTestBase): + def test_slot_behaviour(self, unique_gift_model): + for attr in unique_gift_model.__slots__: + assert getattr(unique_gift_model, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_model)) == len(set(mro_slots(unique_gift_model))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "name": self.name, + "sticker": self.sticker.to_dict(), + "rarity_per_mille": self.rarity_per_mille, + } + unique_gift_model = UniqueGiftModel.de_json(json_dict, offline_bot) + assert unique_gift_model.api_kwargs == {} + assert unique_gift_model.name == self.name + assert unique_gift_model.sticker == self.sticker + assert unique_gift_model.rarity_per_mille == self.rarity_per_mille + + def test_to_dict(self, unique_gift_model): + json_dict = unique_gift_model.to_dict() + assert json_dict["name"] == self.name + assert json_dict["sticker"] == self.sticker.to_dict() + assert json_dict["rarity_per_mille"] == self.rarity_per_mille + + def test_equality(self, unique_gift_model): + a = unique_gift_model + b = UniqueGiftModel(self.name, self.sticker, self.rarity_per_mille) + c = UniqueGiftModel("other_name", self.sticker, self.rarity_per_mille) + d = UniqueGiftSymbol(self.name, self.sticker, self.rarity_per_mille) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def unique_gift_symbol(): + return UniqueGiftSymbol( + name=UniqueGiftSymbolTestBase.name, + sticker=UniqueGiftSymbolTestBase.sticker, + rarity_per_mille=UniqueGiftSymbolTestBase.rarity_per_mille, + ) + + +class UniqueGiftSymbolTestBase: + name = "symbol_name" + sticker = Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular") + rarity_per_mille = 20 + + +class TestUniqueGiftSymbolWithoutRequest(UniqueGiftSymbolTestBase): + def test_slot_behaviour(self, unique_gift_symbol): + for attr in unique_gift_symbol.__slots__: + assert getattr(unique_gift_symbol, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_symbol)) == len(set(mro_slots(unique_gift_symbol))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "name": self.name, + "sticker": self.sticker.to_dict(), + "rarity_per_mille": self.rarity_per_mille, + } + unique_gift_symbol = UniqueGiftSymbol.de_json(json_dict, offline_bot) + assert unique_gift_symbol.api_kwargs == {} + assert unique_gift_symbol.name == self.name + assert unique_gift_symbol.sticker == self.sticker + assert unique_gift_symbol.rarity_per_mille == self.rarity_per_mille + + def test_to_dict(self, unique_gift_symbol): + json_dict = unique_gift_symbol.to_dict() + assert json_dict["name"] == self.name + assert json_dict["sticker"] == self.sticker.to_dict() + assert json_dict["rarity_per_mille"] == self.rarity_per_mille + + def test_equality(self, unique_gift_symbol): + a = unique_gift_symbol + b = UniqueGiftSymbol(self.name, self.sticker, self.rarity_per_mille) + c = UniqueGiftSymbol("other_name", self.sticker, self.rarity_per_mille) + d = UniqueGiftModel(self.name, self.sticker, self.rarity_per_mille) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def unique_gift_backdrop(): + return UniqueGiftBackdrop( + name=UniqueGiftBackdropTestBase.name, + colors=UniqueGiftBackdropTestBase.colors, + rarity_per_mille=UniqueGiftBackdropTestBase.rarity_per_mille, + ) + + +class UniqueGiftBackdropTestBase: + name = "backdrop_name" + colors = UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F) + rarity_per_mille = 30 + + +class TestUniqueGiftBackdropWithoutRequest(UniqueGiftBackdropTestBase): + def test_slot_behaviour(self, unique_gift_backdrop): + for attr in unique_gift_backdrop.__slots__: + assert getattr(unique_gift_backdrop, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_backdrop)) == len(set(mro_slots(unique_gift_backdrop))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "name": self.name, + "colors": self.colors.to_dict(), + "rarity_per_mille": self.rarity_per_mille, + } + unique_gift_backdrop = UniqueGiftBackdrop.de_json(json_dict, offline_bot) + assert unique_gift_backdrop.api_kwargs == {} + assert unique_gift_backdrop.name == self.name + assert unique_gift_backdrop.colors == self.colors + assert unique_gift_backdrop.rarity_per_mille == self.rarity_per_mille + + def test_to_dict(self, unique_gift_backdrop): + json_dict = unique_gift_backdrop.to_dict() + assert json_dict["name"] == self.name + assert json_dict["colors"] == self.colors.to_dict() + assert json_dict["rarity_per_mille"] == self.rarity_per_mille + + def test_equality(self, unique_gift_backdrop): + a = unique_gift_backdrop + b = UniqueGiftBackdrop(self.name, self.colors, self.rarity_per_mille) + c = UniqueGiftBackdrop("other_name", self.colors, self.rarity_per_mille) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def unique_gift_backdrop_colors(): + return UniqueGiftBackdropColors( + center_color=UniqueGiftBackdropColorsTestBase.center_color, + edge_color=UniqueGiftBackdropColorsTestBase.edge_color, + symbol_color=UniqueGiftBackdropColorsTestBase.symbol_color, + text_color=UniqueGiftBackdropColorsTestBase.text_color, + ) + + +class UniqueGiftBackdropColorsTestBase: + center_color = 0x00FF00 + edge_color = 0xEE00FF + symbol_color = 0xAA22BB + text_color = 0x20FE8F + + +class TestUniqueGiftBackdropColorsWithoutRequest(UniqueGiftBackdropColorsTestBase): + def test_slot_behaviour(self, unique_gift_backdrop_colors): + for attr in unique_gift_backdrop_colors.__slots__: + assert getattr(unique_gift_backdrop_colors, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(unique_gift_backdrop_colors)) == len( + set(mro_slots(unique_gift_backdrop_colors)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "center_color": self.center_color, + "edge_color": self.edge_color, + "symbol_color": self.symbol_color, + "text_color": self.text_color, + } + unique_gift_backdrop_colors = UniqueGiftBackdropColors.de_json(json_dict, offline_bot) + assert unique_gift_backdrop_colors.api_kwargs == {} + assert unique_gift_backdrop_colors.center_color == self.center_color + assert unique_gift_backdrop_colors.edge_color == self.edge_color + assert unique_gift_backdrop_colors.symbol_color == self.symbol_color + assert unique_gift_backdrop_colors.text_color == self.text_color + + def test_to_dict(self, unique_gift_backdrop_colors): + json_dict = unique_gift_backdrop_colors.to_dict() + assert json_dict["center_color"] == self.center_color + assert json_dict["edge_color"] == self.edge_color + assert json_dict["symbol_color"] == self.symbol_color + assert json_dict["text_color"] == self.text_color + + def test_equality(self, unique_gift_backdrop_colors): + a = unique_gift_backdrop_colors + b = UniqueGiftBackdropColors( + center_color=self.center_color, + edge_color=self.edge_color, + symbol_color=self.symbol_color, + text_color=self.text_color, + ) + c = UniqueGiftBackdropColors( + center_color=0x000000, + edge_color=self.edge_color, + symbol_color=self.symbol_color, + text_color=self.text_color, + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def unique_gift_info(): + return UniqueGiftInfo( + gift=UniqueGiftInfoTestBase.gift, + origin=UniqueGiftInfoTestBase.origin, + owned_gift_id=UniqueGiftInfoTestBase.owned_gift_id, + transfer_star_count=UniqueGiftInfoTestBase.transfer_star_count, + last_resale_currency=UniqueGiftInfoTestBase.last_resale_currency, + last_resale_amount=UniqueGiftInfoTestBase.last_resale_amount, + next_transfer_date=UniqueGiftInfoTestBase.next_transfer_date, + ) + + +class UniqueGiftInfoTestBase: + gift = UniqueGift( + gift_id="gift_id", + base_name="human_readable_name", + name="unique_name", + number=10, + model=UniqueGiftModel( + name="model_name", + sticker=Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + rarity_per_mille=10, + ), + symbol=UniqueGiftSymbol( + name="symbol_name", + sticker=Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + rarity_per_mille=20, + ), + backdrop=UniqueGiftBackdrop( + name="backdrop_name", + colors=UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + rarity_per_mille=2, + ), + ) + origin = UniqueGiftInfo.UPGRADE + owned_gift_id = "some_id" + transfer_star_count = 10 + last_resale_currency = "XTR" + last_resale_amount = 1234 + next_transfer_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + + +class TestUniqueGiftInfoWithoutRequest(UniqueGiftInfoTestBase): + def test_slot_behaviour(self, unique_gift_info): + for attr in unique_gift_info.__slots__: + assert getattr(unique_gift_info, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_info)) == len(set(mro_slots(unique_gift_info))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "gift": self.gift.to_dict(), + "origin": self.origin, + "owned_gift_id": self.owned_gift_id, + "transfer_star_count": self.transfer_star_count, + "last_resale_currency": self.last_resale_currency, + "last_resale_amount": self.last_resale_amount, + "next_transfer_date": to_timestamp(self.next_transfer_date), + } + unique_gift_info = UniqueGiftInfo.de_json(json_dict, offline_bot) + assert unique_gift_info.api_kwargs == {} + assert unique_gift_info.gift == self.gift + assert unique_gift_info.origin == self.origin + assert unique_gift_info.owned_gift_id == self.owned_gift_id + assert unique_gift_info.transfer_star_count == self.transfer_star_count + assert unique_gift_info.last_resale_currency == self.last_resale_currency + assert unique_gift_info.last_resale_amount == self.last_resale_amount + assert unique_gift_info.next_transfer_date == self.next_transfer_date + + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): + json_dict = { + "gift": self.gift.to_dict(), + "origin": self.origin, + "owned_gift_id": self.owned_gift_id, + "transfer_star_count": self.transfer_star_count, + "last_resale_currency": self.last_resale_currency, + "last_resale_amount": self.last_resale_amount, + "next_transfer_date": to_timestamp(self.next_transfer_date), + } + + unique_gift_info_raw = UniqueGiftInfo.de_json(json_dict, raw_bot) + unique_gift_info_offline = UniqueGiftInfo.de_json(json_dict, offline_bot) + unique_gift_info_tz = UniqueGiftInfo.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + unique_gift_info_tz_offset = unique_gift_info_tz.next_transfer_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + unique_gift_info_tz.next_transfer_date.replace(tzinfo=None) + ) + + assert unique_gift_info_raw.next_transfer_date.tzinfo == UTC + assert unique_gift_info_offline.next_transfer_date.tzinfo == UTC + assert unique_gift_info_tz_offset == tz_bot_offset + + def test_to_dict(self, unique_gift_info): + json_dict = unique_gift_info.to_dict() + assert json_dict["gift"] == self.gift.to_dict() + assert json_dict["origin"] == self.origin + assert json_dict["owned_gift_id"] == self.owned_gift_id + assert json_dict["transfer_star_count"] == self.transfer_star_count + assert json_dict["last_resale_currency"] == self.last_resale_currency + assert json_dict["last_resale_amount"] == self.last_resale_amount + assert json_dict["next_transfer_date"] == to_timestamp(self.next_transfer_date) + + def test_enum_type_conversion(self, unique_gift_info): + assert type(unique_gift_info.origin) is UniqueGiftInfoOrigin + assert unique_gift_info.origin == UniqueGiftInfoOrigin.UPGRADE + + def test_equality(self, unique_gift_info): + a = unique_gift_info + b = UniqueGiftInfo(self.gift, self.origin, self.owned_gift_id, self.transfer_star_count) + c = UniqueGiftInfo( + self.gift, UniqueGiftInfo.TRANSFER, self.owned_gift_id, self.transfer_star_count + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_update.py b/tests/test_update.py index d3018e8b6fe..b74234b9435 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,6 +23,7 @@ import pytest from telegram import ( + BusinessBotRights, BusinessConnection, BusinessMessagesDeleted, CallbackQuery, @@ -47,6 +48,7 @@ PreCheckoutQuery, ReactionCount, ReactionTypeEmoji, + ShippingAddress, ShippingQuery, Update, User, @@ -128,7 +130,7 @@ 1, from_timestamp(int(time.time())), True, - True, + rights=BusinessBotRights(can_reply=True), ) deleted_business_messages = BusinessMessagesDeleted( @@ -158,7 +160,11 @@ {"edited_channel_post": channel_post}, {"inline_query": InlineQuery(1, User(1, "", False), "", "")}, {"chosen_inline_result": ChosenInlineResult("id", User(1, "", False), "")}, - {"shipping_query": ShippingQuery("id", User(1, "", False), "", None)}, + { + "shipping_query": ShippingQuery( + "id", User(1, "", False), "", ShippingAddress("", "", "", "", "", "") + ) + }, {"pre_checkout_query": PreCheckoutQuery("id", User(1, "", False), "", 0, "")}, {"poll": Poll("id", "?", [PollOption(".", 1)], False, False, False, Poll.REGULAR, True)}, { @@ -252,11 +258,6 @@ def test_de_json(self, offline_bot, paramdict): assert getattr(update, _type) == paramdict[_type] assert i == 1 - def test_update_de_json_empty(self, offline_bot): - update = Update.de_json(None, offline_bot) - - assert update is None - def test_to_dict(self, update): update_dict = update.to_dict() diff --git a/tests/test_user.py b/tests/test_user.py index 54657c59047..4d85d616a65 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -44,6 +44,7 @@ def json_dict(): "added_to_attachment_menu": UserTestBase.added_to_attachment_menu, "can_connect_to_business": UserTestBase.can_connect_to_business, "has_main_web_app": UserTestBase.has_main_web_app, + "has_topics_enabled": UserTestBase.has_topics_enabled, } @@ -63,6 +64,7 @@ def user(bot): added_to_attachment_menu=UserTestBase.added_to_attachment_menu, can_connect_to_business=UserTestBase.can_connect_to_business, has_main_web_app=UserTestBase.has_main_web_app, + has_topics_enabled=UserTestBase.has_topics_enabled, ) user.set_bot(bot) user._unfreeze() @@ -83,6 +85,7 @@ class UserTestBase: added_to_attachment_menu = False can_connect_to_business = True has_main_web_app = False + has_topics_enabled = False class TestUserWithoutRequest(UserTestBase): @@ -108,6 +111,7 @@ def test_de_json(self, json_dict, offline_bot): assert user.added_to_attachment_menu == self.added_to_attachment_menu assert user.can_connect_to_business == self.can_connect_to_business assert user.has_main_web_app == self.has_main_web_app + assert user.has_topics_enabled == self.has_topics_enabled def test_to_dict(self, user): user_dict = user.to_dict() @@ -126,6 +130,7 @@ def test_to_dict(self, user): assert user_dict["added_to_attachment_menu"] == user.added_to_attachment_menu assert user_dict["can_connect_to_business"] == user.can_connect_to_business assert user_dict["has_main_web_app"] == user.has_main_web_app + assert user_dict["has_topics_enabled"] == user.has_topics_enabled def test_equality(self): a = User(self.id_, self.first_name, self.is_bot, self.last_name) @@ -231,6 +236,25 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(user.get_bot(), "send_message", make_assertion) assert await user.send_message("test") + async def test_instance_method_send_message_draft(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == user.id + and kwargs["draft_id"] == 123 + and kwargs["text"] == "test" + ) + + assert check_shortcut_signature( + User.send_message_draft, Bot.send_message_draft, ["chat_id"], [] + ) + assert await check_shortcut_call( + user.send_message_draft, user.get_bot(), "send_message_draft" + ) + assert await check_defaults_handling(user.send_message_draft, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "send_message_draft", make_assertion) + assert await user.send_message_draft(123, "test") + async def test_instance_method_send_photo(self, monkeypatch, user): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == user.id and kwargs["photo"] == "test_photo" @@ -343,9 +367,9 @@ async def make_assertion(*_, **kwargs): "title", "description", "payload", - "provider_token", "currency", "prices", + "provider_token", ) async def test_instance_method_send_location(self, monkeypatch, user): @@ -731,8 +755,10 @@ async def make_assertion(*_, **kwargs): and kwargs["text_entities"] == "text_entities" ) - assert check_shortcut_signature(user.send_gift, Bot.send_gift, ["user_id"], []) - assert await check_shortcut_call(user.send_gift, user.get_bot(), "send_gift") + assert check_shortcut_signature(user.send_gift, Bot.send_gift, ["user_id", "chat_id"], []) + assert await check_shortcut_call( + user.send_gift, user.get_bot(), "send_gift", ["chat_id", "user_id"] + ) assert await check_defaults_handling(user.send_gift, user.get_bot()) monkeypatch.setattr(user.get_bot(), "send_gift", make_assertion) @@ -743,6 +769,36 @@ async def make_assertion(*_, **kwargs): text_entities="text_entities", ) + async def test_instance_method_gift_premium_subscription(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["user_id"] == user.id + and kwargs["month_count"] == 3 + and kwargs["star_count"] == 1000 + and kwargs["text"] == "text" + and kwargs["text_parse_mode"] == "text_parse_mode" + and kwargs["text_entities"] == "text_entities" + ) + + assert check_shortcut_signature( + user.gift_premium_subscription, Bot.gift_premium_subscription, ["user_id"], [] + ) + assert await check_shortcut_call( + user.gift_premium_subscription, + user.get_bot(), + "gift_premium_subscription", + ) + assert await check_defaults_handling(user.gift_premium_subscription, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "gift_premium_subscription", make_assertion) + assert await user.gift_premium_subscription( + month_count=3, + star_count=1000, + text="text", + text_parse_mode="text_parse_mode", + text_entities="text_entities", + ) + async def test_instance_method_verify_user(self, monkeypatch, user): async def make_assertion(*_, **kwargs): return ( @@ -773,3 +829,41 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(user.get_bot(), "remove_user_verification", make_assertion) assert await user.remove_verification() + + async def test_instance_method_repost_story(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["from_chat_id"] == user.id + + assert check_shortcut_signature( + User.repost_story, + Bot.repost_story, + [ + "from_chat_id", + ], + additional_kwargs=[], + ) + assert await check_shortcut_call( + user.repost_story, + user.get_bot(), + "repost_story", + shortcut_kwargs=["from_chat_id"], + ) + assert await check_defaults_handling(user.repost_story, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "repost_story", make_assertion) + assert await user.repost_story( + business_connection_id="bcid", + from_story_id=123, + active_period=3600, + ) + + async def test_instance_method_get_gifts(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["user_id"] == user.id + + assert check_shortcut_signature(user.get_gifts, Bot.get_user_gifts, ["user_id"], []) + assert await check_shortcut_call(user.get_gifts, user.get_bot(), "get_user_gifts") + assert await check_defaults_handling(user.get_gifts, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "get_user_gifts", make_assertion) + assert await user.get_gifts() diff --git a/tests/test_userprofilephotos.py b/tests/test_userprofilephotos.py index 3c1c7bf33e0..973d5158841 100644 --- a/tests/test_userprofilephotos.py +++ b/tests/test_userprofilephotos.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_userrating.py b/tests/test_userrating.py new file mode 100644 index 00000000000..effcdebc68b --- /dev/null +++ b/tests/test_userrating.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import pytest + +from telegram import BotCommand, UserRating +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def user_rating(): + return UserRating( + level=UserRatingTestBase.level, + rating=UserRatingTestBase.rating, + current_level_rating=UserRatingTestBase.current_level_rating, + next_level_rating=UserRatingTestBase.next_level_rating, + ) + + +class UserRatingTestBase: + level = 2 + rating = 120 + current_level_rating = 100 + next_level_rating = 180 + + +class TestUserRatingWithoutRequest(UserRatingTestBase): + def test_slot_behaviour(self, user_rating): + for attr in user_rating.__slots__: + assert getattr(user_rating, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(user_rating)) == len(set(mro_slots(user_rating))), "duplicate slot" + + def test_de_json_with_next(self, offline_bot): + json_dict = { + "level": self.level, + "rating": self.rating, + "current_level_rating": self.current_level_rating, + "next_level_rating": self.next_level_rating, + } + ur = UserRating.de_json(json_dict, offline_bot) + assert ur.api_kwargs == {} + + assert ur.level == self.level + assert ur.rating == self.rating + assert ur.current_level_rating == self.current_level_rating + assert ur.next_level_rating == self.next_level_rating + + def test_de_json_no_optional(self, offline_bot): + json_dict = { + "level": self.level, + "rating": self.rating, + "current_level_rating": self.current_level_rating, + } + ur = UserRating.de_json(json_dict, offline_bot) + assert ur.api_kwargs == {} + + assert ur.level == self.level + assert ur.rating == self.rating + assert ur.current_level_rating == self.current_level_rating + assert ur.next_level_rating is None + + def test_to_dict(self, user_rating): + ur_dict = user_rating.to_dict() + + assert isinstance(ur_dict, dict) + assert ur_dict["level"] == user_rating.level + assert ur_dict["rating"] == user_rating.rating + assert ur_dict["current_level_rating"] == user_rating.current_level_rating + assert ur_dict["next_level_rating"] == user_rating.next_level_rating + + def test_equality(self): + a = UserRating(3, 200, 150, 300) + b = UserRating(3, 200, 100, None) + c = UserRating(3, 201, 150, 300) + d = UserRating(4, 200, 150, 300) + e = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) diff --git a/tests/test_version.py b/tests/test_version.py index 983b6b80eba..ab57f9325e8 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_videochat.py b/tests/test_videochat.py index af268c0863f..325c8ac8e5e 100644 --- a/tests/test_videochat.py +++ b/tests/test_videochat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -28,6 +28,7 @@ VideoChatStarted, ) from telegram._utils.datetime import UTC, to_timestamp +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -60,7 +61,7 @@ def test_to_dict(self): class TestVideoChatEndedWithoutRequest: - duration = 100 + duration = dtm.timedelta(seconds=100) def test_slot_behaviour(self): action = VideoChatEnded(8) @@ -69,27 +70,50 @@ def test_slot_behaviour(self): assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" def test_de_json(self): - json_dict = {"duration": self.duration} + json_dict = {"duration": int(self.duration.total_seconds())} video_chat_ended = VideoChatEnded.de_json(json_dict, None) assert video_chat_ended.api_kwargs == {} - assert video_chat_ended.duration == self.duration + assert video_chat_ended._duration == self.duration def test_to_dict(self): video_chat_ended = VideoChatEnded(self.duration) video_chat_dict = video_chat_ended.to_dict() assert isinstance(video_chat_dict, dict) - assert video_chat_dict["duration"] == self.duration + assert video_chat_dict["duration"] == int(self.duration.total_seconds()) + + def test_time_period_properties(self, PTB_TIMEDELTA): + duration = VideoChatEnded(duration=self.duration).duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA): + VideoChatEnded(self.duration).duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): a = VideoChatEnded(100) b = VideoChatEnded(100) + x = VideoChatEnded(dtm.timedelta(seconds=100)) c = VideoChatEnded(50) d = VideoChatStarted() assert a == b assert hash(a) == hash(b) + assert b == x + assert hash(b) == hash(x) assert a != c assert hash(a) != hash(c) @@ -162,8 +186,6 @@ def test_expected_values(self): assert VideoChatScheduled(self.start_date).start_date == self.start_date def test_de_json(self, offline_bot): - assert VideoChatScheduled.de_json({}, bot=offline_bot) is None - json_dict = {"start_date": to_timestamp(self.start_date)} video_chat_scheduled = VideoChatScheduled.de_json(json_dict, offline_bot) assert video_chat_scheduled.api_kwargs == {} diff --git a/tests/test_warnings.py b/tests/test_warnings.py index 18be85689ed..211e3d511e1 100644 --- a/tests/test_warnings.py +++ b/tests/test_warnings.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,7 +23,7 @@ from telegram._utils.warnings import warn from telegram.warnings import PTBDeprecationWarning, PTBRuntimeWarning, PTBUserWarning -from tests.auxil.files import PROJECT_ROOT_PATH +from tests.auxil.files import SOURCE_ROOT_PATH from tests.auxil.slots import mro_slots @@ -66,7 +66,7 @@ def make_assertion(cls): make_assertion(PTBUserWarning) def test_warn(self, recwarn): - expected_file = PROJECT_ROOT_PATH / "telegram" / "_utils" / "warnings.py" + expected_file = SOURCE_ROOT_PATH / "_utils" / "warnings.py" warn("test message") assert len(recwarn) == 1 diff --git a/tests/test_webappdata.py b/tests/test_webappdata.py index 83cb22617e7..a52e49275a9 100644 --- a/tests/test_webappdata.py +++ b/tests/test_webappdata.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_webappinfo.py b/tests/test_webappinfo.py index 4450035e87f..d831945696e 100644 --- a/tests/test_webappinfo.py +++ b/tests/test_webappinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_webhookinfo.py b/tests/test_webhookinfo.py index 92c1f3be445..857fc942704 100644 --- a/tests/test_webhookinfo.py +++ b/tests/test_webhookinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -99,9 +99,6 @@ def test_de_json(self, offline_bot): self.last_synchronization_error_date ) - none = WebhookInfo.de_json(None, offline_bot) - assert none is None - def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): json_dict = { "url": self.url, diff --git a/tests/test_writeaccessallowed.py b/tests/test_writeaccessallowed.py index 48e61e3279e..598db3ca83e 100644 --- a/tests/test_writeaccessallowed.py +++ b/tests/test_writeaccessallowed.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2025 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000000..b352011dadc --- /dev/null +++ b/uv.lock @@ -0,0 +1,1969 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + +[[package]] +name = "aiolimiter" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/23/b52debf471f7a1e42e362d959a3982bdcb4fe13a5d46e63d28868807a79c/aiolimiter-1.2.1.tar.gz", hash = "sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9", size = 7185, upload-time = "2024-12-08T15:31:51.496Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/ba/df6e8e1045aebc4778d19b8a3a9bc1808adb1619ba94ca354d9ba17d86c3/aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7", size = 6711, upload-time = "2024-12-08T15:31:49.874Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "apscheduler" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/81/192db4f8471de5bc1f0d098783decffb1e6e69c4f8b4bc6711094691950b/apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221", size = 108044, upload-time = "2025-10-31T18:55:42.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" }, +] + +[[package]] +name = "astroid" +version = "4.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/22/97df040e15d964e592d3a180598ace67e91b7c559d8298bdb3c949dc6e42/astroid-4.0.2.tar.gz", hash = "sha256:ac8fb7ca1c08eb9afec91ccc23edbd8ac73bb22cbdd7da1d488d9fb8d6579070", size = 405714, upload-time = "2025-11-09T21:21:18.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/ac/a85b4bfb4cf53221513e27f33cc37ad158fce02ac291d18bee6b49ab477d/astroid-4.0.2-py3-none-any.whl", hash = "sha256:d7546c00a12efc32650b19a2bb66a153883185d3179ab0d4868086f807338b9b", size = 276354, upload-time = "2025-11-09T21:21:16.54Z" }, +] + +[[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 = "beautifulsoup4" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, +] + +[[package]] +name = "build" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/1c/23e33405a7c9eac261dff640926b8b5adaed6a6eb3e1767d441ed611d0c0/build-1.3.0.tar.gz", hash = "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", size = 48544, upload-time = "2025-08-01T21:27:09.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/8c/2b30c12155ad8de0cf641d76a8b396a16d2c36bc6d50b621a62b7c4567c1/build-1.3.0-py3-none-any.whl", hash = "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4", size = 23382, upload-time = "2025-08-01T21:27:07.844Z" }, +] + +[[package]] +name = "cachetools" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/af/df70e9b65bc77a1cbe0768c0aa4617147f30f8306ded98c1744bcdc0ae1e/cachetools-7.0.0.tar.gz", hash = "sha256:a9abf18ff3b86c7d05b27ead412e235e16ae045925e531fae38d5fada5ed5b08", size = 35796, upload-time = "2026-02-01T18:59:47.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/df/2dd32cce20cbcf6f2ec456b58d44368161ad28320729f64e5e1d5d7bd0ae/cachetools-7.0.0-py3-none-any.whl", hash = "sha256:d52fef60e6e964a1969cfb61ccf6242a801b432790fe520d78720d757c81cbd2", size = 13487, upload-time = "2026-02-01T18:59:45.981Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "chango" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic-settings", marker = "python_full_version >= '3.12'" }, + { name = "shortuuid", marker = "python_full_version >= '3.12'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "tomlkit", marker = "python_full_version >= '3.12'" }, + { name = "typer", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/d2/bc136bce9f3cd8db1610cd7bd6b7039a723c8ffc550ee095e7b33a777d34/chango-0.6.0.tar.gz", hash = "sha256:004e279f7fe6683de25e0f3fff4e3ae25042a4a2c9a1980315cb65a7c7a7b55f", size = 342670, upload-time = "2025-10-15T18:46:21.842Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/dc/f7b359fc2af5873a42d92c00ecc8768fbd4b81e8888a26d0c49d0ac8694b/chango-0.6.0-py3-none-any.whl", hash = "sha256:f735ffaf77f32ca2d3098b199f60020aea41769640b79a0039f96aef1b8b193a", size = 56881, upload-time = "2025-10-15T18:46:20.261Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/95/c49df0aceb5507a80b9fe5172d3d39bf23f05be40c23c8d77d556df96cec/coverage-7.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31", size = 215800, upload-time = "2025-10-15T15:12:19.824Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c6/7bb46ce01ed634fff1d7bb53a54049f539971862cc388b304ff3c51b4f66/coverage-7.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075", size = 216198, upload-time = "2025-10-15T15:12:22.549Z" }, + { url = "https://files.pythonhosted.org/packages/94/b2/75d9d8fbf2900268aca5de29cd0a0fe671b0f69ef88be16767cc3c828b85/coverage-7.11.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab", size = 242953, upload-time = "2025-10-15T15:12:24.139Z" }, + { url = "https://files.pythonhosted.org/packages/65/ac/acaa984c18f440170525a8743eb4b6c960ace2dbad80dc22056a437fc3c6/coverage-7.11.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0", size = 244766, upload-time = "2025-10-15T15:12:25.974Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/938d0bff76dfa4a6b228c3fc4b3e1c0e2ad4aa6200c141fcda2bd1170227/coverage-7.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785", size = 246625, upload-time = "2025-10-15T15:12:27.387Z" }, + { url = "https://files.pythonhosted.org/packages/38/54/8f5f5e84bfa268df98f46b2cb396b1009734cfb1e5d6adb663d284893b32/coverage-7.11.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591", size = 243568, upload-time = "2025-10-15T15:12:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/8ba337c2877fe3f2e1af0ed7ff4be0c0c4aca44d6f4007040f3ca2255e99/coverage-7.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088", size = 244665, upload-time = "2025-10-15T15:12:30.297Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fb/c6f1d6d9a665536b7dde2333346f0cc41dc6a60bd1ffc10cd5c33e7eb000/coverage-7.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f", size = 242681, upload-time = "2025-10-15T15:12:32.326Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/1b532319af5f991fa153c20373291dc65c2bf532af7dbcffdeef745c8f79/coverage-7.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866", size = 242912, upload-time = "2025-10-15T15:12:34.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/3d/f39331c60ef6050d2a861dc1b514fa78f85f792820b68e8c04196ad733d6/coverage-7.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841", size = 243559, upload-time = "2025-10-15T15:12:35.809Z" }, + { url = "https://files.pythonhosted.org/packages/4b/55/cb7c9df9d0495036ce582a8a2958d50c23cd73f84a23284bc23bd4711a6f/coverage-7.11.0-cp310-cp310-win32.whl", hash = "sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf", size = 218266, upload-time = "2025-10-15T15:12:37.429Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/b79cb275fa7bd0208767f89d57a1b5f6ba830813875738599741b97c2e04/coverage-7.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969", size = 219169, upload-time = "2025-10-15T15:12:39.25Z" }, + { url = "https://files.pythonhosted.org/packages/49/3a/ee1074c15c408ddddddb1db7dd904f6b81bc524e01f5a1c5920e13dbde23/coverage-7.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847", size = 215912, upload-time = "2025-10-15T15:12:40.665Z" }, + { url = "https://files.pythonhosted.org/packages/70/c4/9f44bebe5cb15f31608597b037d78799cc5f450044465bcd1ae8cb222fe1/coverage-7.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc", size = 216310, upload-time = "2025-10-15T15:12:42.461Z" }, + { url = "https://files.pythonhosted.org/packages/42/01/5e06077cfef92d8af926bdd86b84fb28bf9bc6ad27343d68be9b501d89f2/coverage-7.11.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0", size = 246706, upload-time = "2025-10-15T15:12:44.001Z" }, + { url = "https://files.pythonhosted.org/packages/40/b8/7a3f1f33b35cc4a6c37e759137533119560d06c0cc14753d1a803be0cd4a/coverage-7.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7", size = 248634, upload-time = "2025-10-15T15:12:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/7a/41/7f987eb33de386bc4c665ab0bf98d15fcf203369d6aacae74f5dd8ec489a/coverage-7.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623", size = 250741, upload-time = "2025-10-15T15:12:47.222Z" }, + { url = "https://files.pythonhosted.org/packages/23/c1/a4e0ca6a4e83069fb8216b49b30a7352061ca0cb38654bd2dc96b7b3b7da/coverage-7.11.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287", size = 246837, upload-time = "2025-10-15T15:12:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/5d/03/ced062a17f7c38b4728ff76c3acb40d8465634b20b4833cdb3cc3a74e115/coverage-7.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552", size = 248429, upload-time = "2025-10-15T15:12:50.73Z" }, + { url = "https://files.pythonhosted.org/packages/97/af/a7c6f194bb8c5a2705ae019036b8fe7f49ea818d638eedb15fdb7bed227c/coverage-7.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de", size = 246490, upload-time = "2025-10-15T15:12:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c3/aab4df02b04a8fde79068c3c41ad7a622b0ef2b12e1ed154da986a727c3f/coverage-7.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601", size = 246208, upload-time = "2025-10-15T15:12:54.586Z" }, + { url = "https://files.pythonhosted.org/packages/30/d8/e282ec19cd658238d60ed404f99ef2e45eed52e81b866ab1518c0d4163cf/coverage-7.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e", size = 247126, upload-time = "2025-10-15T15:12:56.485Z" }, + { url = "https://files.pythonhosted.org/packages/d1/17/a635fa07fac23adb1a5451ec756216768c2767efaed2e4331710342a3399/coverage-7.11.0-cp311-cp311-win32.whl", hash = "sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c", size = 218314, upload-time = "2025-10-15T15:12:58.365Z" }, + { url = "https://files.pythonhosted.org/packages/2a/29/2ac1dfcdd4ab9a70026edc8d715ece9b4be9a1653075c658ee6f271f394d/coverage-7.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9", size = 219203, upload-time = "2025-10-15T15:12:59.902Z" }, + { url = "https://files.pythonhosted.org/packages/03/21/5ce8b3a0133179115af4c041abf2ee652395837cb896614beb8ce8ddcfd9/coverage-7.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745", size = 217879, upload-time = "2025-10-15T15:13:01.35Z" }, + { url = "https://files.pythonhosted.org/packages/c4/db/86f6906a7c7edc1a52b2c6682d6dd9be775d73c0dfe2b84f8923dfea5784/coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1", size = 216098, upload-time = "2025-10-15T15:13:02.916Z" }, + { url = "https://files.pythonhosted.org/packages/21/54/e7b26157048c7ba555596aad8569ff903d6cd67867d41b75287323678ede/coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007", size = 216331, upload-time = "2025-10-15T15:13:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/1ce6bf444f858b83a733171306134a0544eaddf1ca8851ede6540a55b2ad/coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46", size = 247825, upload-time = "2025-10-15T15:13:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893", size = 250573, upload-time = "2025-10-15T15:13:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/b0ff3641a320abb047258d36ed1c21d16be33beed4152628331a1baf3365/coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115", size = 251706, upload-time = "2025-10-15T15:13:09.4Z" }, + { url = "https://files.pythonhosted.org/packages/59/c8/5a586fe8c7b0458053d9c687f5cff515a74b66c85931f7fe17a1c958b4ac/coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415", size = 248221, upload-time = "2025-10-15T15:13:10.964Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ff/3a25e3132804ba44cfa9a778cdf2b73dbbe63ef4b0945e39602fc896ba52/coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186", size = 249624, upload-time = "2025-10-15T15:13:12.5Z" }, + { url = "https://files.pythonhosted.org/packages/c5/12/ff10c8ce3895e1b17a73485ea79ebc1896a9e466a9d0f4aef63e0d17b718/coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d", size = 247744, upload-time = "2025-10-15T15:13:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/d500b91f5471b2975947e0629b8980e5e90786fe316b6d7299852c1d793d/coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d", size = 247325, upload-time = "2025-10-15T15:13:16.438Z" }, + { url = "https://files.pythonhosted.org/packages/77/11/dee0284fbbd9cd64cfce806b827452c6df3f100d9e66188e82dfe771d4af/coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2", size = 249180, upload-time = "2025-10-15T15:13:17.959Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/cdf1def928f0a150a057cab03286774e73e29c2395f0d30ce3d9e9f8e697/coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5", size = 218479, upload-time = "2025-10-15T15:13:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/ff/55/e5884d55e031da9c15b94b90a23beccc9d6beee65e9835cd6da0a79e4f3a/coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0", size = 219290, upload-time = "2025-10-15T15:13:21.593Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/faa930cfc71c1d16bc78f9a19bb73700464f9c331d9e547bfbc1dbd3a108/coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad", size = 217924, upload-time = "2025-10-15T15:13:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129, upload-time = "2025-10-15T15:13:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380, upload-time = "2025-10-15T15:13:26.976Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375, upload-time = "2025-10-15T15:13:28.923Z" }, + { url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978, upload-time = "2025-10-15T15:13:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253, upload-time = "2025-10-15T15:13:32.174Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591, upload-time = "2025-10-15T15:13:34.147Z" }, + { url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411, upload-time = "2025-10-15T15:13:38.425Z" }, + { url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303, upload-time = "2025-10-15T15:13:40.464Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157, upload-time = "2025-10-15T15:13:42.087Z" }, + { url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921, upload-time = "2025-10-15T15:13:43.715Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526, upload-time = "2025-10-15T15:13:45.336Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317, upload-time = "2025-10-15T15:13:47.401Z" }, + { url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948, upload-time = "2025-10-15T15:13:49.096Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837, upload-time = "2025-10-15T15:13:51.09Z" }, + { url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061, upload-time = "2025-10-15T15:13:52.747Z" }, + { url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398, upload-time = "2025-10-15T15:13:54.45Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574, upload-time = "2025-10-15T15:13:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797, upload-time = "2025-10-15T15:13:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361, upload-time = "2025-10-15T15:14:00.409Z" }, + { url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349, upload-time = "2025-10-15T15:14:02.188Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114, upload-time = "2025-10-15T15:14:03.907Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723, upload-time = "2025-10-15T15:14:06.324Z" }, + { url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238, upload-time = "2025-10-15T15:14:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" }, + { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, + { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, + { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, + { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, + { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "dill" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "flaky" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/c5/ef69119a01427204ff2db5fc8f98001087bcce719bbb94749dcd7b191365/flaky-3.8.1.tar.gz", hash = "sha256:47204a81ec905f3d5acfbd61daeabcada8f9d4031616d9bcb0618461729699f5", size = 25248, upload-time = "2024-03-12T22:17:59.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/b8/b830fc43663246c3f3dd1ae7dca4847b96ed992537e85311e27fa41ac40e/flaky-3.8.1-py2.py3-none-any.whl", hash = "sha256:194ccf4f0d3a22b2de7130f4b62e45e977ac1b5ccad74d4d48f3005dcc38815e", size = 19139, upload-time = "2024-03-12T22:17:51.59Z" }, +] + +[[package]] +name = "furo" +version = "2025.9.25" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-basic-ng" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/29/ff3b83a1ffce74676043ab3e7540d398e0b1ce7660917a00d7c4958b93da/furo-2025.9.25.tar.gz", hash = "sha256:3eac05582768fdbbc2bdfa1cdbcdd5d33cfc8b4bd2051729ff4e026a1d7e0a98", size = 1662007, upload-time = "2025-09-25T21:37:19.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/69/964b55f389c289e16ba2a5dfe587c3c462aac09e24123f09ddf703889584/furo-2025.9.25-py3-none-any.whl", hash = "sha256:2937f68e823b8e37b410c972c371bc2b1d88026709534927158e0cb3fac95afe", size = 340409, upload-time = "2025-09-25T21:37:17.244Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +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 = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] +socks = [ + { name = "socksio" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +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" } +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" }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, + { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +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 = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types", marker = "python_full_version >= '3.12'" }, + { name = "pydantic-core", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/3d/9b8ca77b0f76fcdbf8bc6b72474e264283f461284ca84ac3fde570c6c49a/pydantic_core-2.41.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e", size = 2111197, upload-time = "2025-10-14T10:19:43.303Z" }, + { url = "https://files.pythonhosted.org/packages/59/92/b7b0fe6ed4781642232755cb7e56a86e2041e1292f16d9ae410a0ccee5ac/pydantic_core-2.41.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b", size = 1917909, upload-time = "2025-10-14T10:19:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/52/8c/3eb872009274ffa4fb6a9585114e161aa1a0915af2896e2d441642929fe4/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd", size = 1969905, upload-time = "2025-10-14T10:19:46.567Z" }, + { url = "https://files.pythonhosted.org/packages/f4/21/35adf4a753bcfaea22d925214a0c5b880792e3244731b3f3e6fec0d124f7/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945", size = 2051938, upload-time = "2025-10-14T10:19:48.237Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d0/cdf7d126825e36d6e3f1eccf257da8954452934ede275a8f390eac775e89/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706", size = 2250710, upload-time = "2025-10-14T10:19:49.619Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1c/af1e6fd5ea596327308f9c8d1654e1285cc3d8de0d584a3c9d7705bf8a7c/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba", size = 2367445, upload-time = "2025-10-14T10:19:51.269Z" }, + { url = "https://files.pythonhosted.org/packages/d3/81/8cece29a6ef1b3a92f956ea6da6250d5b2d2e7e4d513dd3b4f0c7a83dfea/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b", size = 2072875, upload-time = "2025-10-14T10:19:52.671Z" }, + { url = "https://files.pythonhosted.org/packages/e3/37/a6a579f5fc2cd4d5521284a0ab6a426cc6463a7b3897aeb95b12f1ba607b/pydantic_core-2.41.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d", size = 2191329, upload-time = "2025-10-14T10:19:54.214Z" }, + { url = "https://files.pythonhosted.org/packages/ae/03/505020dc5c54ec75ecba9f41119fd1e48f9e41e4629942494c4a8734ded1/pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700", size = 2151658, upload-time = "2025-10-14T10:19:55.843Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5d/2c0d09fb53aa03bbd2a214d89ebfa6304be7df9ed86ee3dc7770257f41ee/pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6", size = 2316777, upload-time = "2025-10-14T10:19:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/ea/4b/c2c9c8f5e1f9c864b57d08539d9d3db160e00491c9f5ee90e1bfd905e644/pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9", size = 2320705, upload-time = "2025-10-14T10:19:59.016Z" }, + { url = "https://files.pythonhosted.org/packages/28/c3/a74c1c37f49c0a02c89c7340fafc0ba816b29bd495d1a31ce1bdeacc6085/pydantic_core-2.41.4-cp310-cp310-win32.whl", hash = "sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57", size = 1975464, upload-time = "2025-10-14T10:20:00.581Z" }, + { url = "https://files.pythonhosted.org/packages/d6/23/5dd5c1324ba80303368f7569e2e2e1a721c7d9eb16acb7eb7b7f85cb1be2/pydantic_core-2.41.4-cp310-cp310-win_amd64.whl", hash = "sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc", size = 2024497, upload-time = "2025-10-14T10:20:03.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/4c/f6cbfa1e8efacd00b846764e8484fe173d25b8dab881e277a619177f3384/pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80", size = 2109062, upload-time = "2025-10-14T10:20:04.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/f8/40b72d3868896bfcd410e1bd7e516e762d326201c48e5b4a06446f6cf9e8/pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae", size = 1916301, upload-time = "2025-10-14T10:20:06.857Z" }, + { url = "https://files.pythonhosted.org/packages/94/4d/d203dce8bee7faeca791671c88519969d98d3b4e8f225da5b96dad226fc8/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827", size = 1968728, upload-time = "2025-10-14T10:20:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/6a66187775df87c24d526985b3a5d78d861580ca466fbd9d4d0e792fcf6c/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f", size = 2050238, upload-time = "2025-10-14T10:20:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/78336345de97298cf53236b2f271912ce11f32c1e59de25a374ce12f9cce/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def", size = 2249424, upload-time = "2025-10-14T10:20:11.732Z" }, + { url = "https://files.pythonhosted.org/packages/99/bb/a4584888b70ee594c3d374a71af5075a68654d6c780369df269118af7402/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2", size = 2366047, upload-time = "2025-10-14T10:20:13.647Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8d/17fc5de9d6418e4d2ae8c675f905cdafdc59d3bf3bf9c946b7ab796a992a/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8", size = 2071163, upload-time = "2025-10-14T10:20:15.307Z" }, + { url = "https://files.pythonhosted.org/packages/54/e7/03d2c5c0b8ed37a4617430db68ec5e7dbba66358b629cd69e11b4d564367/pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265", size = 2190585, upload-time = "2025-10-14T10:20:17.3Z" }, + { url = "https://files.pythonhosted.org/packages/be/fc/15d1c9fe5ad9266a5897d9b932b7f53d7e5cfc800573917a2c5d6eea56ec/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c", size = 2150109, upload-time = "2025-10-14T10:20:19.143Z" }, + { url = "https://files.pythonhosted.org/packages/26/ef/e735dd008808226c83ba56972566138665b71477ad580fa5a21f0851df48/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a", size = 2315078, upload-time = "2025-10-14T10:20:20.742Z" }, + { url = "https://files.pythonhosted.org/packages/90/00/806efdcf35ff2ac0f938362350cd9827b8afb116cc814b6b75cf23738c7c/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e", size = 2318737, upload-time = "2025-10-14T10:20:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/41/7e/6ac90673fe6cb36621a2283552897838c020db343fa86e513d3f563b196f/pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03", size = 1974160, upload-time = "2025-10-14T10:20:23.817Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9d/7c5e24ee585c1f8b6356e1d11d40ab807ffde44d2db3b7dfd6d20b09720e/pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e", size = 2021883, upload-time = "2025-10-14T10:20:25.48Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/5c172357460fc28b2871eb4a0fb3843b136b429c6fa827e4b588877bf115/pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db", size = 1968026, upload-time = "2025-10-14T10:20:27.039Z" }, + { url = "https://files.pythonhosted.org/packages/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043, upload-time = "2025-10-14T10:20:28.561Z" }, + { url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699, upload-time = "2025-10-14T10:20:30.217Z" }, + { url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121, upload-time = "2025-10-14T10:20:32.246Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590, upload-time = "2025-10-14T10:20:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869, upload-time = "2025-10-14T10:20:35.965Z" }, + { url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169, upload-time = "2025-10-14T10:20:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165, upload-time = "2025-10-14T10:20:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067, upload-time = "2025-10-14T10:20:41.015Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997, upload-time = "2025-10-14T10:20:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187, upload-time = "2025-10-14T10:20:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204, upload-time = "2025-10-14T10:20:46.781Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536, upload-time = "2025-10-14T10:20:48.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132, upload-time = "2025-10-14T10:20:50.421Z" }, + { url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483, upload-time = "2025-10-14T10:20:52.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" }, + { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" }, + { url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" }, + { url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" }, + { url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" }, + { url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" }, + { url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" }, + { url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, + { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, + { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, + { url = "https://files.pythonhosted.org/packages/b0/12/5ba58daa7f453454464f92b3ca7b9d7c657d8641c48e370c3ebc9a82dd78/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b", size = 2122139, upload-time = "2025-10-14T10:22:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/21/fb/6860126a77725c3108baecd10fd3d75fec25191d6381b6eb2ac660228eac/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42", size = 1936674, upload-time = "2025-10-14T10:22:49.555Z" }, + { url = "https://files.pythonhosted.org/packages/de/be/57dcaa3ed595d81f8757e2b44a38240ac5d37628bce25fb20d02c7018776/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee", size = 1956398, upload-time = "2025-10-14T10:22:52.19Z" }, + { url = "https://files.pythonhosted.org/packages/2f/1d/679a344fadb9695f1a6a294d739fbd21d71fa023286daeea8c0ed49e7c2b/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c", size = 2138674, upload-time = "2025-10-14T10:22:54.499Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087, upload-time = "2025-10-14T10:22:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387, upload-time = "2025-10-14T10:22:59.342Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495, upload-time = "2025-10-14T10:23:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/912e976a2dd0b49f31c98a060ca90b353f3b73ee3ea2fd0030412f6ac5ec/pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00", size = 2106739, upload-time = "2025-10-14T10:23:06.934Z" }, + { url = "https://files.pythonhosted.org/packages/71/f0/66ec5a626c81eba326072d6ee2b127f8c139543f1bf609b4842978d37833/pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9", size = 1932549, upload-time = "2025-10-14T10:23:09.24Z" }, + { url = "https://files.pythonhosted.org/packages/c4/af/625626278ca801ea0a658c2dcf290dc9f21bb383098e99e7c6a029fccfc0/pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2", size = 2135093, upload-time = "2025-10-14T10:23:11.626Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/2fba049f54e0f4975fef66be654c597a1d005320fa141863699180c7697d/pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258", size = 2187971, upload-time = "2025-10-14T10:23:14.437Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/65ab839a2dfcd3b949202f9d920c34f9de5a537c3646662bdf2f7d999680/pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347", size = 2147939, upload-time = "2025-10-14T10:23:16.831Z" }, + { url = "https://files.pythonhosted.org/packages/44/58/627565d3d182ce6dfda18b8e1c841eede3629d59c9d7cbc1e12a03aeb328/pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa", size = 2311400, upload-time = "2025-10-14T10:23:19.234Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/8a84711162ad5a5f19a88cead37cca81b4b1f294f46260ef7334ae4f24d3/pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a", size = 2316840, upload-time = "2025-10-14T10:23:21.738Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8b/b7bb512a4682a2f7fbfae152a755d37351743900226d29bd953aaf870eaa/pydantic_core-2.41.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d", size = 2149135, upload-time = "2025-10-14T10:23:24.379Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7d/138e902ed6399b866f7cfe4435d22445e16fff888a1c00560d9dc79a780f/pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5", size = 2104721, upload-time = "2025-10-14T10:23:26.906Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/0525623cf94627f7b53b4c2034c81edc8491cbfc7c28d5447fa318791479/pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2", size = 1931608, upload-time = "2025-10-14T10:23:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f9/744bc98137d6ef0a233f808bfc9b18cf94624bf30836a18d3b05d08bf418/pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd", size = 2132986, upload-time = "2025-10-14T10:23:32.057Z" }, + { url = "https://files.pythonhosted.org/packages/17/c8/629e88920171173f6049386cc71f893dff03209a9ef32b4d2f7e7c264bcf/pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c", size = 2187516, upload-time = "2025-10-14T10:23:34.871Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/4f2734688d98488782218ca61bcc118329bf5de05bb7fe3adc7dd79b0b86/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405", size = 2146146, upload-time = "2025-10-14T10:23:37.342Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f2/ab385dbd94a052c62224b99cf99002eee99dbec40e10006c78575aead256/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8", size = 2311296, upload-time = "2025-10-14T10:23:40.145Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8e/e4f12afe1beeb9823bba5375f8f258df0cc61b056b0195fb1cf9f62a1a58/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308", size = 2315386, upload-time = "2025-10-14T10:23:42.624Z" }, + { url = "https://files.pythonhosted.org/packages/48/f7/925f65d930802e3ea2eb4d5afa4cb8730c8dc0d2cb89a59dc4ed2fcb2d74/pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f", size = 2147775, upload-time = "2025-10-14T10:23:45.406Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", marker = "python_full_version >= '3.12'" }, + { name = "python-dotenv", marker = "python_full_version >= '3.12'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pylint" +version = "4.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/53/57663d99acaac2fcdafdc697e52a9b1b7d6fcf36616281ff9768a44e7ff3/pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45", size = 30656, upload-time = "2024-04-29T13:23:24.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/ce/1e4b53c213dce25d6e8b163697fbce2d43799d76fa08eea6ad270451c370/pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b", size = 13368, upload-time = "2024-04-29T13:23:23.126Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +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" } +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" }, +] + +[[package]] +name = "python-telegram-bot" +source = { editable = "." } +dependencies = [ + { name = "httpcore", marker = "python_full_version >= '3.14'" }, + { name = "httpx" }, +] + +[package.optional-dependencies] +all = [ + { name = "aiolimiter" }, + { name = "apscheduler" }, + { name = "cachetools" }, + { name = "cffi", marker = "python_full_version >= '3.13'" }, + { name = "cryptography" }, + { name = "httpx", extra = ["http2", "socks"] }, + { name = "tornado" }, +] +callback-data = [ + { name = "cachetools" }, +] +ext = [ + { name = "aiolimiter" }, + { name = "apscheduler" }, + { name = "cachetools" }, + { name = "tornado" }, +] +http2 = [ + { name = "httpx", extra = ["http2"] }, +] +job-queue = [ + { name = "apscheduler" }, +] +passport = [ + { name = "cffi", marker = "python_full_version >= '3.13'" }, + { name = "cryptography" }, +] +rate-limiter = [ + { name = "aiolimiter" }, +] +socks = [ + { name = "httpx", extra = ["socks"] }, +] +webhooks = [ + { name = "tornado" }, +] + +[package.dev-dependencies] +all = [ + { name = "beautifulsoup4" }, + { name = "build" }, + { name = "chango", marker = "python_full_version >= '3.12'" }, + { name = "flaky" }, + { name = "furo" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pydantic", marker = "python_full_version >= '3.14'" }, + { name = "pylint" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, + { name = "pytz" }, + { name = "ruff" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-build-compatibility" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-inline-tabs" }, + { name = "sphinx-paramlinks" }, + { name = "sphinxcontrib-mermaid" }, + { name = "tzdata" }, +] +docs = [ + { name = "chango", marker = "python_full_version >= '3.12'" }, + { name = "furo" }, + { name = "pydantic", marker = "python_full_version >= '3.14'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-build-compatibility" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-inline-tabs" }, + { name = "sphinx-paramlinks" }, + { name = "sphinxcontrib-mermaid" }, +] +linting = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pylint" }, + { name = "ruff" }, +] +tests = [ + { name = "beautifulsoup4" }, + { name = "build" }, + { name = "flaky" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, + { name = "pytz" }, + { name = "tzdata" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiolimiter", marker = "extra == 'all'", specifier = ">=1.1,<1.3" }, + { name = "aiolimiter", marker = "extra == 'ext'", specifier = ">=1.1,<1.3" }, + { name = "aiolimiter", marker = "extra == 'rate-limiter'", specifier = ">=1.1,<1.3" }, + { name = "apscheduler", marker = "extra == 'all'", specifier = ">=3.10.4,<3.12.0" }, + { name = "apscheduler", marker = "extra == 'ext'", specifier = ">=3.10.4,<3.12.0" }, + { name = "apscheduler", marker = "extra == 'job-queue'", specifier = ">=3.10.4,<3.12.0" }, + { name = "cachetools", marker = "extra == 'all'", specifier = ">=7.0.0,<8.0.0" }, + { name = "cachetools", marker = "extra == 'callback-data'", specifier = ">=7.0.0,<8.0.0" }, + { name = "cachetools", marker = "extra == 'ext'", specifier = ">=7.0.0,<8.0.0" }, + { name = "cffi", marker = "python_full_version >= '3.13' and extra == 'all'", specifier = ">=1.17.0rc1" }, + { name = "cffi", marker = "python_full_version >= '3.13' and extra == 'passport'", specifier = ">=1.17.0rc1" }, + { name = "cryptography", marker = "extra == 'all'", specifier = "!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=39.0.1" }, + { name = "cryptography", marker = "extra == 'passport'", specifier = "!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=39.0.1" }, + { name = "httpcore", marker = "python_full_version >= '3.14'", specifier = ">=1.0.9" }, + { name = "httpx", specifier = ">=0.27,<0.29" }, + { name = "httpx", extras = ["http2"], marker = "extra == 'all'" }, + { name = "httpx", extras = ["http2"], marker = "extra == 'http2'" }, + { name = "httpx", extras = ["socks"], marker = "extra == 'all'" }, + { name = "httpx", extras = ["socks"], marker = "extra == 'socks'" }, + { name = "tornado", marker = "extra == 'all'", specifier = "~=6.5" }, + { name = "tornado", marker = "extra == 'ext'", specifier = "~=6.5" }, + { name = "tornado", marker = "extra == 'webhooks'", specifier = "~=6.5" }, +] +provides-extras = ["all", "callback-data", "ext", "http2", "job-queue", "passport", "rate-limiter", "socks", "webhooks"] + +[package.metadata.requires-dev] +all = [ + { name = "beautifulsoup4" }, + { name = "build" }, + { name = "chango", marker = "python_full_version >= '3.12'", specifier = "~=0.6.0" }, + { name = "flaky", specifier = ">=3.8.1" }, + { name = "furo", specifier = "==2025.9.25" }, + { name = "mypy", specifier = "==1.18.2" }, + { name = "pre-commit" }, + { name = "pydantic", marker = "python_full_version >= '3.14'", specifier = ">=2.12.0a1" }, + { name = "pylint", specifier = "==4.0.5" }, + { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest-asyncio", specifier = "==0.21.2" }, + { name = "pytest-cov" }, + { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "pytz" }, + { name = "ruff", specifier = "==0.15.2" }, + { name = "sphinx", marker = "python_full_version >= '3.11'", specifier = "==8.2.3" }, + { name = "sphinx-build-compatibility", git = "https://github.com/readthedocs/sphinx-build-compatibility.git?rev=58aabc5f207c6c2421f23d3578adc0b14af57047" }, + { name = "sphinx-copybutton", specifier = "==0.5.2" }, + { name = "sphinx-inline-tabs", specifier = "==2023.4.21" }, + { name = "sphinx-paramlinks", specifier = "==0.6.0" }, + { name = "sphinxcontrib-mermaid", specifier = "==1.0.0" }, + { name = "tzdata" }, +] +docs = [ + { name = "chango", marker = "python_full_version >= '3.12'", specifier = "~=0.6.0" }, + { name = "furo", specifier = "==2025.9.25" }, + { name = "pydantic", marker = "python_full_version >= '3.14'", specifier = ">=2.12.0a1" }, + { name = "sphinx", marker = "python_full_version >= '3.11'", specifier = "==8.2.3" }, + { name = "sphinx-build-compatibility", git = "https://github.com/readthedocs/sphinx-build-compatibility.git?rev=58aabc5f207c6c2421f23d3578adc0b14af57047" }, + { name = "sphinx-copybutton", specifier = "==0.5.2" }, + { name = "sphinx-inline-tabs", specifier = "==2023.4.21" }, + { name = "sphinx-paramlinks", specifier = "==0.6.0" }, + { name = "sphinxcontrib-mermaid", specifier = "==1.0.0" }, +] +linting = [ + { name = "mypy", specifier = "==1.18.2" }, + { name = "pre-commit" }, + { name = "pylint", specifier = "==4.0.5" }, + { name = "ruff", specifier = "==0.15.2" }, +] +tests = [ + { name = "beautifulsoup4" }, + { name = "build" }, + { name = "flaky", specifier = ">=3.8.1" }, + { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest-asyncio", specifier = "==0.21.2" }, + { name = "pytest-cov" }, + { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "pytz" }, + { name = "tzdata" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "roman-numerals-py" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "shortuuid" +version = "1.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/e2/bcf761f3bff95856203f9559baf3741c416071dd200c0fc19fad7f078f86/shortuuid-1.0.13.tar.gz", hash = "sha256:3bb9cf07f606260584b1df46399c0b87dd84773e7b25912b7e391e30797c5e72", size = 9662, upload-time = "2024-03-11T20:11:06.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/44/21d6bf170bf40b41396480d8d49ad640bca3f2b02139cd52aa1e272830a5/shortuuid-1.0.13-py3-none-any.whl", hash = "sha256:a482a497300b49b4953e15108a7913244e1bb0d41f9d332f5e9925dba33a3c5a", size = 10529, upload-time = "2024-03-11T20:11:04.807Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "8.2.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.11'" }, + { name = "babel", marker = "python_full_version >= '3.11'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.11'" }, + { name = "imagesize", marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, +] + +[[package]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, +] + +[[package]] +name = "sphinx-build-compatibility" +version = "0.0.1" +source = { git = "https://github.com/readthedocs/sphinx-build-compatibility.git?rev=58aabc5f207c6c2421f23d3578adc0b14af57047#58aabc5f207c6c2421f23d3578adc0b14af57047" } +dependencies = [ + { name = "requests" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, +] + +[[package]] +name = "sphinx-inline-tabs" +version = "2023.4.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/f5/f8a2be63ed7be9f91a4c2bea0e25bcb56aa4c5cc37ec4d8ead8065f926b1/sphinx_inline_tabs-2023.4.21.tar.gz", hash = "sha256:5df2f13f602c158f3f5f6c509e008aeada199a8c76d97ba3aa2822206683bebc", size = 42664, upload-time = "2023-04-21T20:25:30.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/60/1e4c9017d722b9c7731abc11f39ac8b083b479fbcefe12015b57e457a296/sphinx_inline_tabs-2023.4.21-py3-none-any.whl", hash = "sha256:06809ac613f7c48ddd6e2fa588413e3fe92cff2397b56e2ccf0b0218f9ef6a78", size = 6850, upload-time = "2023-04-21T20:25:28.778Z" }, +] + +[[package]] +name = "sphinx-paramlinks" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/21/62d3a58ff7bd02bbb9245a63d1f0d2e0455522a11a78951d16088569fca8/sphinx-paramlinks-0.6.0.tar.gz", hash = "sha256:746a0816860aa3fff5d8d746efcbec4deead421f152687411db1d613d29f915e", size = 12363, upload-time = "2023-08-11T16:09:28.604Z" } + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-mermaid" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/69/bf039237ad260073e8c02f820b3e00dc34f3a2de20aff7861e6b19d2f8c5/sphinxcontrib_mermaid-1.0.0.tar.gz", hash = "sha256:2e8ab67d3e1e2816663f9347d026a8dee4a858acdd4ad32dd1c808893db88146", size = 15153, upload-time = "2024-10-12T16:33:03.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/c8/784b9ac6ea08aa594c1a4becbd0dbe77186785362e31fd633b8c6ae0197a/sphinxcontrib_mermaid-1.0.0-py3-none-any.whl", hash = "sha256:60b72710ea02087f212028feb09711225fbc2e343a10d34822fe787510e1caa3", size = 9597, upload-time = "2024-10-12T16:33:02.303Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[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" }, +] + +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, + { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, + { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, + { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, +] + +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", marker = "python_full_version >= '3.12'" }, + { name = "rich", marker = "python_full_version >= '3.12'" }, + { name = "shellingham", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]