diff --git a/.github/labeler.yml b/.github/labeler.yml index 9adf0b94d4b..aee55107411 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -239,6 +239,7 @@ - redbot/core/_settings_caches.py - redbot/core/_sharedlibdeprecation.py - redbot/core/utils/_internal_utils.py + - redbot/ext_cogs/__init__.py # Tests - redbot/pytest/__init__.py - redbot/pytest/cog_manager.py diff --git a/.github/workflows/auto_labeler_issues.yml b/.github/workflows/auto_labeler_issues.yml index 2dc30274d7f..011ed06414a 100644 --- a/.github/workflows/auto_labeler_issues.yml +++ b/.github/workflows/auto_labeler_issues.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Apply Triage Label - uses: actions/github-script@v6 + uses: actions/github-script@v9 with: github-token: ${{secrets.GITHUB_TOKEN}} script: | diff --git a/.github/workflows/auto_labeler_pr.yml b/.github/workflows/auto_labeler_pr.yml index 8939e91f386..7a6abcf4199 100644 --- a/.github/workflows/auto_labeler_pr.yml +++ b/.github/workflows/auto_labeler_pr.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Apply Type Label - uses: actions/labeler@v4 + uses: actions/labeler@v4 # TODO: bump with: repo-token: "${{ secrets.GITHUB_TOKEN }}" sync-labels: true diff --git a/.github/workflows/check_label_pattern_exhaustiveness.yaml b/.github/workflows/check_label_pattern_exhaustiveness.yaml index 0a78c11e17c..d0ff3d6f019 100644 --- a/.github/workflows/check_label_pattern_exhaustiveness.yaml +++ b/.github/workflows/check_label_pattern_exhaustiveness.yaml @@ -9,11 +9,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: "3.8" + python-version: "3.11" - name: Install script's pre-requirements run: | python -m pip install -U pip diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f5ced093f85..2007135b0a0 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -17,12 +17,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: "3.8" + python-version: "3.11" - name: Install dependencies run: | @@ -34,7 +34,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: 'python' # Override the default behavior so that the action doesn't attempt @@ -54,4 +54,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/crowdin_upload_strings.yml b/.github/workflows/crowdin_upload_strings.yml index 6b37a1d8b59..42ef01da1af 100644 --- a/.github/workflows/crowdin_upload_strings.yml +++ b/.github/workflows/crowdin_upload_strings.yml @@ -9,11 +9,11 @@ jobs: if: github.repository == 'Cog-Creators/Red-DiscordBot' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: '3.8' + python-version: '3.11' - name: Install dependencies run: | curl https://artifacts.crowdin.com/repo/GPG-KEY-crowdin | sudo apt-key add - diff --git a/.github/workflows/lint_python.yaml b/.github/workflows/lint_python.yaml index 8ca6a4a6394..b3b4eeae71b 100644 --- a/.github/workflows/lint_python.yaml +++ b/.github/workflows/lint_python.yaml @@ -14,12 +14,12 @@ jobs: name: Lint Python runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ env.ref }} - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v6 with: - python-version: "3.8" + python-version: "3.11" - run: > python -m pip install 'pyflakes @ https://github.com/pycqa/pyflakes/tarball/1911c20' diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml index 2986c87c79e..b056e9970e8 100644 --- a/.github/workflows/prepare_release.yml +++ b/.github/workflows/prepare_release.yml @@ -13,7 +13,7 @@ jobs: needs: pr_stable_bump runs-on: ubuntu-latest steps: - - uses: actions/create-github-app-token@v2 + - uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ secrets.RED_RELEASER_CLIENT_ID }} @@ -26,7 +26,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.8' + python-version: '3.11' - name: Install dependencies run: | curl https://artifacts.crowdin.com/repo/GPG-KEY-crowdin | sudo apt-key add - @@ -47,7 +47,8 @@ jobs: - name: Create Pull Request id: cpr_crowdin - uses: peter-evans/create-pull-request@v4 + # TODO: Switch to `gh` + uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 # v4.2.4 with: token: ${{ steps.app-token.outputs.token }} commit-message: Automated Crowdin downstream @@ -71,7 +72,7 @@ jobs: outputs: milestone_number: ${{ steps.get_milestone_number.outputs.result }} steps: - - uses: actions/create-github-app-token@v2 + - uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ secrets.RED_RELEASER_CLIENT_ID }} @@ -84,7 +85,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.8' + python-version: '3.11' # Create PR for stable version bump - name: Update Red version number from input @@ -98,7 +99,7 @@ jobs: # Get milestone number of the milestone for the new stable version - name: Get milestone number id: get_milestone_number - uses: actions/github-script@v6 + uses: actions/github-script@v9 env: MILESTONE_TITLE: ${{ steps.bump_version_stable.outputs.new_version }} with: @@ -110,7 +111,8 @@ jobs: - name: Create Pull Request id: cpr_bump_stable - uses: peter-evans/create-pull-request@v4 + # TODO: Switch to `gh` + uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 # v4.2.4 with: token: ${{ steps.app-token.outputs.token }} commit-message: Version bump to ${{ steps.bump_version_stable.outputs.new_version }} diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml index 767db2ea588..89b8fde1bcc 100644 --- a/.github/workflows/publish_release.yml +++ b/.github/workflows/publish_release.yml @@ -11,11 +11,11 @@ jobs: runs-on: ubuntu-latest steps: # Checkout repository and install Python - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: '3.8' + python-version: '3.11' # Get version to release - name: Get version to release @@ -57,12 +57,12 @@ jobs: name: Build package runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: '3.8' + python-version: '3.11' - name: Install dependencies run: | @@ -75,7 +75,7 @@ jobs: run: python -m twine check dist/* - name: Upload packaged distributions - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: build-output path: ./dist @@ -84,12 +84,12 @@ jobs: name: Generate default application.yml runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: '3.8' + python-version: '3.11' - name: Install script's dependencies run: python -m pip install PyYAML @@ -102,7 +102,7 @@ jobs: python .github/workflows/scripts/get_default_ll_server_config.py "release_assets/$APP_YML_FILE" - name: Upload default application.yml - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ll-default-server-config path: ./release_assets @@ -120,13 +120,13 @@ jobs: id-token: write steps: - name: Download packaged distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: build-output path: dist/ - name: Download default application.yml - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ll-default-server-config path: release_assets/ @@ -158,7 +158,7 @@ jobs: run: | echo "BASE_BRANCH=${TAG_BASE_BRANCH#'refs/heads/'}" >> $GITHUB_ENV - - uses: actions/create-github-app-token@v2 + - uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ secrets.RED_RELEASER_CLIENT_ID }} @@ -171,7 +171,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.8' + python-version: '3.11' # Version bump to development version - name: Update Red version number to dev @@ -185,7 +185,7 @@ jobs: # Get milestone number of the milestone for the old version - name: Get milestone number id: get_milestone_number - uses: actions/github-script@v6 + uses: actions/github-script@v9 env: MILESTONE_TITLE: ${{ steps.bump_version_dev.outputs.old_version }} with: @@ -197,7 +197,8 @@ jobs: - name: Create Pull Request id: cpr_bump_dev - uses: peter-evans/create-pull-request@v4 + # TODO: Switch to `gh` + uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 # v4.2.4 with: token: ${{ steps.app-token.outputs.token }} commit-message: Version bump to ${{ steps.bump_version_dev.outputs.new_version }} diff --git a/.github/workflows/run_pip_compile.yaml b/.github/workflows/run_pip_compile.yaml index 1b0298366cd..305e6510d89 100644 --- a/.github/workflows/run_pip_compile.yaml +++ b/.github/workflows/run_pip_compile.yaml @@ -15,32 +15,32 @@ jobs: - macos-latest steps: - name: Checkout the repository. - uses: actions/checkout@v4 + uses: actions/checkout@v6 - - name: Set up Python 3.8. - uses: actions/setup-python@v4 + - name: Set up Python + uses: actions/setup-python@v6 with: python-version: | + 3.14 + 3.13 + 3.12 3.11 - 3.10 - 3.9 - 3.8 - name: Install dependencies on Linux/macOS if: matrix.os != 'windows-latest' run: | + python3.14 -m pip install -U pip pip-tools + python3.13 -m pip install -U pip pip-tools + python3.12 -m pip install -U pip pip-tools python3.11 -m pip install -U pip pip-tools - python3.10 -m pip install -U pip pip-tools - python3.9 -m pip install -U pip pip-tools - python3.8 -m pip install -U pip pip-tools - name: Install dependencies on Windows if: matrix.os == 'windows-latest' run: | + py -3.14 -m pip install -U pip pip-tools + py -3.13 -m pip install -U pip pip-tools + py -3.12 -m pip install -U pip pip-tools py -3.11 -m pip install -U pip pip-tools - py -3.10 -m pip install -U pip pip-tools - py -3.9 -m pip install -U pip pip-tools - py -3.8 -m pip install -U pip pip-tools - name: Generate requirements files. id: compile_requirements @@ -48,7 +48,7 @@ jobs: python .github/workflows/scripts/compile_requirements.py - name: Upload requirements files. - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ steps.compile_requirements.outputs.sys_platform }} path: requirements/${{ steps.compile_requirements.outputs.sys_platform }}-*.txt @@ -59,29 +59,29 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository. - uses: actions/checkout@v4 + uses: actions/checkout@v6 - - name: Set up Python 3.8. - uses: actions/setup-python@v4 + - name: Set up Python 3.11. + uses: actions/setup-python@v6 with: - python-version: '3.8' + python-version: '3.11' - name: Install dependencies run: | python -m pip install -U "packaging>=22.0" - name: Download Windows requirements. - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: win32 path: requirements - name: Download Linux requirements. - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: linux path: requirements - name: Download macOS requirements. - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: darwin path: requirements @@ -91,7 +91,7 @@ jobs: python .github/workflows/scripts/merge_requirements.py - name: Upload merged requirements files. - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: merged path: | diff --git a/.github/workflows/scripts/compile_requirements.py b/.github/workflows/scripts/compile_requirements.py index 77d3361598c..00721e7e99f 100644 --- a/.github/workflows/scripts/compile_requirements.py +++ b/.github/workflows/scripts/compile_requirements.py @@ -6,7 +6,7 @@ from pathlib import Path -EXCLUDE_STEM_RE = re.compile(r".*-3\.(?!8-)(\d+)-extra-(doc|style)") +EXCLUDE_STEM_RE = re.compile(r".*-3\.(?!11-)(\d+)-extra-(doc|style)") GITHUB_OUTPUT = os.environ["GITHUB_OUTPUT"] REQUIREMENTS_FOLDER = Path(__file__).parents[3].absolute() / "requirements" os.chdir(REQUIREMENTS_FOLDER) @@ -19,7 +19,7 @@ def pip_compile(version: str, name: str) -> None: constraint_flags = [ arg - for file in REQUIREMENTS_FOLDER.glob(f"{sys.platform}-3.8-*.txt") + for file in REQUIREMENTS_FOLDER.glob(f"{sys.platform}-3.11-*.txt") for arg in ("-c", file.name) ] @@ -41,7 +41,7 @@ def pip_compile(version: str, name: str) -> None: ) -for minor in range(8, 11 + 1): +for minor in range(11, 14 + 1): version = f"3.{minor}" pip_compile(version, "base") shutil.copyfile(f"{sys.platform}-{version}-base.txt", "base.txt") diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 45cecf5544c..7c73aa27aea 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,23 +15,23 @@ jobs: strategy: matrix: python_version: - - "3.8" + - "3.11" tox_env: - style - docs include: - - tox_env: py38 - python_version: "3.8" - friendly_name: Python 3.8 - Tests - - tox_env: py39 - python_version: "3.9" - friendly_name: Python 3.9 - Tests - - tox_env: py310 - python_version: "3.10" - friendly_name: Python 3.10 - Tests - tox_env: py311 python_version: "3.11" friendly_name: Python 3.11 - Tests + - tox_env: py312 + python_version: "3.12" + friendly_name: Python 3.12 - Tests + - tox_env: py313 + python_version: "3.13" + friendly_name: Python 3.13 - Tests + - tox_env: py314 + python_version: "3.14" + friendly_name: Python 3.14 - Tests - tox_env: style friendly_name: Style - tox_env: docs @@ -39,11 +39,11 @@ jobs: fail-fast: false name: Tox - ${{ matrix.friendly_name }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ env.ref }} - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python_version }} - name: Install tox @@ -60,10 +60,10 @@ jobs: strategy: matrix: python_version: - - "3.8" - - "3.9" - - "3.10" - "3.11" + - "3.12" + - "3.13" + - "3.14" fail-fast: false name: Tox - Postgres services: @@ -76,11 +76,11 @@ jobs: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ env.ref }} - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python_version }} - name: Install tox diff --git a/.readthedocs.yml b/.readthedocs.yml index de7f4dcf675..84a3d04c201 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,7 +3,7 @@ version: 2 build: os: "ubuntu-22.04" tools: - python: "3.8" + python: "3.11" jobs: install: - pip install .[doc] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0682cafd90..db50418de6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ We love receiving contributions from our community. Any assistance you can provi # 2. Ground Rules 1. Ensure cross compatibility for Windows, Mac OS and Linux. -2. Ensure all Python features used in contributions exist and work in Python 3.8.1 and above. +2. Ensure all Python features used in contributions exist and work in Python 3.11 and above. 3. Create new tests for code you add or bugs you fix. It helps us help you by making sure we don't accidentally break anything :grinning: 4. Create any issues for new features you'd like to implement and explain why this feature is useful to everyone and not just you personally. 5. Don't add new cogs unless specifically given approval in an issue discussing said cog idea. @@ -52,7 +52,7 @@ Red's repository is configured to follow a particular development workflow, usin ### 4.1 Setting up your development environment The following requirements must be installed prior to setting up: - - Python 3.8.1 or greater + - Python 3.11 or greater - git - pip @@ -81,7 +81,7 @@ If you're not on Windows, you should also have GNU make installed, and you can o We're using [tox](https://github.com/tox-dev/tox) to run all of our tests. It's extremely simple to use, and if you followed the previous section correctly, it is already installed to your virtual environment. Currently, tox does the following, creating its own virtual environments for each stage: -- Runs all of our unit tests with [pytest](https://github.com/pytest-dev/pytest) on python 3.8 (test environment `py38`) +- Runs all of our unit tests with [pytest](https://github.com/pytest-dev/pytest) on python 3.11 (test environment `py311`) - Ensures documentation builds without warnings, and all hyperlinks have a valid destination (test environment `docs`) - Ensures that the code meets our style guide with [black](https://github.com/psf/black) (test environment `style`) @@ -105,7 +105,7 @@ You may have noticed we have a `Makefile` and a `make.bat` in the top-level dire The other make recipes are most likely for project maintainers rather than contributors. -You can specify the Python executable used in the make recipes with the `PYTHON` environment variable, e.g. `make PYTHON=/usr/bin/python3.8 newenv`. +You can specify the Python executable used in the make recipes with the `PYTHON` environment variable, e.g. `make PYTHON=/usr/bin/python3.11 newenv`. ### 4.5 Keeping your dependencies up to date Whenever you pull from upstream (V3/develop on the main repository) and you notice either of the files `setup.cfg` or `tools/dev-requirements.txt` have been changed, it can often mean some package dependencies have been updated, added or removed. To make sure you're testing and formatting with the most up-to-date versions of our dependencies, run `make syncenv`. You could also simply do `make newenv` to install them to a clean new virtual environment. diff --git a/Makefile b/Makefile index 0f92df923e4..93fb6845386 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := help -PYTHON ?= python3.8 +PYTHON ?= python3.11 ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) diff --git a/docs/autostart_mac.rst b/docs/autostart_mac.rst index ea908293f6f..031dff34416 100644 --- a/docs/autostart_mac.rst +++ b/docs/autostart_mac.rst @@ -36,6 +36,7 @@ Paste the following and replace the following: ProgramArguments path + -P -O -m redbot diff --git a/docs/autostart_systemd.rst b/docs/autostart_systemd.rst index 51ef7e4d50c..ed1430bdd01 100644 --- a/docs/autostart_systemd.rst +++ b/docs/autostart_systemd.rst @@ -44,7 +44,7 @@ Paste the following in the file, and replace all instances of :code:`username` w Wants=network-online.target [Service] - ExecStart=path -O -m redbot %I --no-prompt + ExecStart=path -P -O -m redbot %I --no-prompt User=username Group=username Type=idle diff --git a/docs/autostart_windows.rst b/docs/autostart_windows.rst index 6a41ca73ddf..5a5487a87c7 100644 --- a/docs/autostart_windows.rst +++ b/docs/autostart_windows.rst @@ -19,7 +19,7 @@ Open that document in Notepad, and paste the following text in it: @ECHO OFF :RED CALL "%userprofile%\redenv\Scripts\activate.bat" - python -O -m redbot + python -P -O -m redbot IF %ERRORLEVEL% == 1 GOTO RESTART_RED IF %ERRORLEVEL% == 26 GOTO RESTART_RED diff --git a/docs/conf.py b/docs/conf.py index a0d9b6a7d50..c73e2935eaf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -279,7 +279,7 @@ class IgnoreCoroSubstitution(SphinxTransform): default_priority = 210 def apply(self, **kwargs) -> None: - for ref in self.document.traverse(nodes.substitution_reference): + for ref in self.document.findall(nodes.substitution_reference): if ref["refname"] == "coro": ref.replace_self(nodes.Text("")) diff --git a/docs/guide_cog_creation.rst b/docs/guide_cog_creation.rst index 2f125c17b0a..c8f834b3c9c 100644 --- a/docs/guide_cog_creation.rst +++ b/docs/guide_cog_creation.rst @@ -17,7 +17,7 @@ you in the process. Getting started --------------- -To start off, be sure that you have installed Python 3.8. +To start off, be sure that you have installed Python 3.11. Next, you need to decide if you want to develop against the Stable or Develop version of Red. Depending on what your goal is should help determine which version you need. @@ -26,7 +26,7 @@ Depending on what your goal is should help determine which version you need. If your goal is to support both versions, make sure you build compatibility layers or use separate branches to keep compatibility until the next Red release Open a terminal or command prompt and type one of the following - Stable Version: :code:`python3.8 -m pip install -U Red-DiscordBot` + Stable Version: :code:`python3.11 -m pip install -U Red-DiscordBot` .. note:: @@ -43,7 +43,7 @@ Open a terminal or command prompt and type one of the following Red-DiscordBot @ https://github.com/Cog-Creators/Red-DiscordBot/tarball/V3/develop -(Windows users may need to use :code:`py -3.8` or :code:`python` instead of :code:`python3.8`) +(Windows users may need to use :code:`py -3.11` or :code:`python` instead of :code:`python3.11`) -------------------- Setting up a package diff --git a/docs/install_guides/_includes/create-env-with-venv3.10.rst b/docs/install_guides/_includes/create-env-with-venv3.12.rst similarity index 80% rename from docs/install_guides/_includes/create-env-with-venv3.10.rst rename to docs/install_guides/_includes/create-env-with-venv3.12.rst index af858034ce0..4719206bfa5 100644 --- a/docs/install_guides/_includes/create-env-with-venv3.10.rst +++ b/docs/install_guides/_includes/create-env-with-venv3.12.rst @@ -2,6 +2,6 @@ .. prompt:: bash - python3.10 -m venv ~/redenv + python3.12 -m venv ~/redenv .. include:: _includes/_create-env-with-venv-outro.rst diff --git a/docs/install_guides/_includes/create-env-with-venv3.9.rst b/docs/install_guides/_includes/create-env-with-venv3.13.rst similarity index 80% rename from docs/install_guides/_includes/create-env-with-venv3.9.rst rename to docs/install_guides/_includes/create-env-with-venv3.13.rst index b2aed286986..d13ee9fa473 100644 --- a/docs/install_guides/_includes/create-env-with-venv3.9.rst +++ b/docs/install_guides/_includes/create-env-with-venv3.13.rst @@ -2,6 +2,6 @@ .. prompt:: bash - python3.9 -m venv ~/redenv + python3.13 -m venv ~/redenv .. include:: _includes/_create-env-with-venv-outro.rst diff --git a/docs/install_guides/_includes/create-env-with-venv3.14.rst b/docs/install_guides/_includes/create-env-with-venv3.14.rst new file mode 100644 index 00000000000..be30ce48fdf --- /dev/null +++ b/docs/install_guides/_includes/create-env-with-venv3.14.rst @@ -0,0 +1,7 @@ +.. include:: _includes/_create-env-with-venv-intro.rst + +.. prompt:: bash + + python3.14 -m venv ~/redenv + +.. include:: _includes/_create-env-with-venv-outro.rst diff --git a/docs/install_guides/_includes/install-guide-rhel8-derivatives.rst b/docs/install_guides/_includes/install-guide-rhel8-derivatives.rst index 27b9ef7c822..73078757b4b 100644 --- a/docs/install_guides/_includes/install-guide-rhel8-derivatives.rst +++ b/docs/install_guides/_includes/install-guide-rhel8-derivatives.rst @@ -13,7 +13,7 @@ Install them with dnf: sudo dnf -y update sudo dnf -y group install development - sudo dnf -y install python3.11 python3.11-devel java-17-openjdk-headless nano git + sudo dnf -y install python3.12 python3.12-devel java-17-openjdk-headless nano git Set ``java`` executable to point to Java 17: @@ -23,6 +23,6 @@ Set ``java`` executable to point to Java 17: .. Include common instructions: -.. include:: _includes/create-env-with-venv3.11.rst +.. include:: _includes/create-env-with-venv3.12.rst .. include:: _includes/install-and-setup-red-unix.rst diff --git a/docs/install_guides/_includes/install-guide-rhel9-derivatives.rst b/docs/install_guides/_includes/install-guide-rhel9-derivatives.rst index 0e80f222257..cbd3769405f 100644 --- a/docs/install_guides/_includes/install-guide-rhel9-derivatives.rst +++ b/docs/install_guides/_includes/install-guide-rhel9-derivatives.rst @@ -11,10 +11,10 @@ Install them with dnf: .. prompt:: bash - sudo dnf -y install python3.11 python3.11-devel git java-17-openjdk-headless @development nano + sudo dnf -y install python3.12 python3.12-devel git java-17-openjdk-headless @development nano .. Include common instructions: -.. include:: _includes/create-env-with-venv3.11.rst +.. include:: _includes/create-env-with-venv3.12.rst .. include:: _includes/install-and-setup-red-unix.rst diff --git a/docs/install_guides/amazon-linux-2023.rst b/docs/install_guides/amazon-linux-2023.rst index c2f6f9309ce..c92ba0f29bb 100644 --- a/docs/install_guides/amazon-linux-2023.rst +++ b/docs/install_guides/amazon-linux-2023.rst @@ -17,10 +17,10 @@ them with dnf: .. prompt:: bash - sudo dnf -y install python3.11 python3.11-devel git java-17-amazon-corretto-headless @development nano + sudo dnf -y install python3.14 python3.14-devel git java-17-amazon-corretto-headless @development nano .. Include common instructions: -.. include:: _includes/create-env-with-venv3.11.rst +.. include:: _includes/create-env-with-venv3.14.rst .. include:: _includes/install-and-setup-red-unix.rst diff --git a/docs/install_guides/arch.rst b/docs/install_guides/arch.rst index b72c5e2ad27..d3bb3bd93ed 100644 --- a/docs/install_guides/arch.rst +++ b/docs/install_guides/arch.rst @@ -18,20 +18,20 @@ Install the pre-requirements with pacman: sudo pacman -Syu git jre17-openjdk-headless base-devel nano -On Arch Linux, Python 3.11 can be installed from the Arch User Repository (AUR) from the ``python311`` package. +On Arch Linux, Python 3.13 can be installed from the Arch User Repository (AUR) from the ``python313`` package. -The manual build process is the Arch-supported install method for AUR packages. You can install ``python311`` package with the following commands: +The manual build process is the Arch-supported install method for AUR packages. You can install ``python313`` package with the following commands: .. prompt:: bash - git clone https://aur.archlinux.org/python311.git /tmp/python311 - cd /tmp/python311 + git clone https://aur.archlinux.org/python313.git /tmp/python313 + cd /tmp/python313 makepkg -sicL cd - - rm -rf /tmp/python311 + rm -rf /tmp/python313 .. Include common instructions: -.. include:: _includes/create-env-with-venv3.11.rst +.. include:: _includes/create-env-with-venv3.13.rst .. include:: _includes/install-and-setup-red-unix.rst diff --git a/docs/install_guides/fedora.rst b/docs/install_guides/fedora.rst index 93f5020b511..b2aa571faa8 100644 --- a/docs/install_guides/fedora.rst +++ b/docs/install_guides/fedora.rst @@ -17,12 +17,12 @@ them with dnf: .. prompt:: bash - sudo dnf -y install python3.11 python3.11-devel git adoptium-temurin-java-repository @development-tools nano + sudo dnf -y install python3.14 python3.14-devel git adoptium-temurin-java-repository @development-tools nano sudo dnf config-manager setopt adoptium-temurin-java-repository.enabled=1 sudo dnf -y install temurin-17-jre .. Include common instructions: -.. include:: _includes/create-env-with-venv3.11.rst +.. include:: _includes/create-env-with-venv3.14.rst .. include:: _includes/install-and-setup-red-unix.rst diff --git a/docs/install_guides/mac.rst b/docs/install_guides/mac.rst index d6de8e35489..ce684bf9e17 100644 --- a/docs/install_guides/mac.rst +++ b/docs/install_guides/mac.rst @@ -26,7 +26,7 @@ one-by-one: .. prompt:: bash - brew install python@3.11 + brew install python@3.14 brew install git brew install temurin@17 @@ -35,11 +35,11 @@ To fix this, you should run these commands: .. prompt:: bash - echo 'export PATH="$(brew --prefix)/opt/python@3.11/bin:$PATH"' >> "$([ -n "$ZSH_VERSION" ] && echo ~/.zprofile || ([ -f ~/.bash_profile ] && echo ~/.bash_profile || echo ~/.profile))" - export PATH="$(brew --prefix)/opt/python@3.11/bin:$PATH" + echo 'export PATH="$(brew --prefix)/opt/python@3.14/bin:$PATH"' >> "$([ -n "$ZSH_VERSION" ] && echo ~/.zprofile || ([ -f ~/.bash_profile ] && echo ~/.bash_profile || echo ~/.profile))" + export PATH="$(brew --prefix)/opt/python@3.14/bin:$PATH" .. Include common instructions: -.. include:: _includes/create-env-with-venv3.11.rst +.. include:: _includes/create-env-with-venv3.14.rst .. include:: _includes/install-and-setup-red-unix.rst diff --git a/docs/install_guides/opensuse-leap-15.rst b/docs/install_guides/opensuse-leap-15.rst index 44bd2867f85..662be6a0bb6 100644 --- a/docs/install_guides/opensuse-leap-15.rst +++ b/docs/install_guides/opensuse-leap-15.rst @@ -17,11 +17,11 @@ with zypper: .. prompt:: bash - sudo zypper -n install python311 python311-devel git-core java-17-openjdk-headless nano + sudo zypper -n install python312 python312-devel git-core java-17-openjdk-headless nano sudo zypper -n install -t pattern devel_basis .. Include common instructions: -.. include:: _includes/create-env-with-venv3.11.rst +.. include:: _includes/create-env-with-venv3.12.rst .. include:: _includes/install-and-setup-red-unix.rst diff --git a/docs/install_guides/opensuse-tumbleweed.rst b/docs/install_guides/opensuse-tumbleweed.rst index 022cb7794b2..8c6bb72cb21 100644 --- a/docs/install_guides/opensuse-tumbleweed.rst +++ b/docs/install_guides/opensuse-tumbleweed.rst @@ -17,11 +17,11 @@ with zypper: .. prompt:: bash - sudo zypper -n install python311 python311-devel git-core java-17-openjdk-headless nano + sudo zypper -n install python314 python314-devel git-core java-17-openjdk-headless nano sudo zypper -n install -t pattern devel_basis .. Include common instructions: -.. include:: _includes/create-env-with-venv3.11.rst +.. include:: _includes/create-env-with-venv3.14.rst .. include:: _includes/install-and-setup-red-unix.rst diff --git a/docs/install_guides/ubuntu-2204.rst b/docs/install_guides/ubuntu-2204.rst index f96cb87fac4..8590327583d 100644 --- a/docs/install_guides/ubuntu-2204.rst +++ b/docs/install_guides/ubuntu-2204.rst @@ -12,16 +12,22 @@ Installing Red on Ubuntu 22.04 LTS Installing the pre-requirements ------------------------------- -Ubuntu 22.04 LTS has all required packages available in official repositories. Install them -with apt: +We recommend adding the ``deadsnakes`` ppa to install Python 3.11: .. prompt:: bash sudo apt update - sudo apt -y install python3.10 python3.10-dev python3.10-venv git openjdk-17-jre-headless build-essential nano + sudo apt -y install software-properties-common + sudo add-apt-repository -y ppa:deadsnakes/ppa + +Now install the pre-requirements with apt: + +.. prompt:: bash + + sudo apt -y install python3.11 python3.11-dev python3.11-venv git openjdk-17-jre-headless build-essential nano .. Include common instructions: -.. include:: _includes/create-env-with-venv3.10.rst +.. include:: _includes/create-env-with-venv3.11.rst .. include:: _includes/install-and-setup-red-unix.rst diff --git a/docs/install_guides/ubuntu-2404.rst b/docs/install_guides/ubuntu-2404.rst index 087e9871d37..78d1e38cff6 100644 --- a/docs/install_guides/ubuntu-2404.rst +++ b/docs/install_guides/ubuntu-2404.rst @@ -12,22 +12,15 @@ Installing Red on Ubuntu 24.04 LTS Installing the pre-requirements ------------------------------- -We recommend adding the ``deadsnakes`` ppa to install Python 3.11: +Ubuntu 24.04 LTS has all required packages available in official repositories. Install them +with apt: .. prompt:: bash - sudo apt update - sudo apt -y install software-properties-common - sudo add-apt-repository -y ppa:deadsnakes/ppa - -Now install the pre-requirements with apt: - -.. prompt:: bash - - sudo apt -y install python3.11 python3.11-dev python3.11-venv git openjdk-17-jre-headless build-essential nano + sudo apt -y install python3.12 python3.12-dev python3.12-venv git openjdk-17-jre-headless build-essential nano .. Include common instructions: -.. include:: _includes/create-env-with-venv3.11.rst +.. include:: _includes/create-env-with-venv3.13.rst .. include:: _includes/install-and-setup-red-unix.rst diff --git a/docs/install_guides/ubuntu-non-lts.rst b/docs/install_guides/ubuntu-non-lts.rst index 73d41cd8ae5..52b0c0d5505 100644 --- a/docs/install_guides/ubuntu-non-lts.rst +++ b/docs/install_guides/ubuntu-non-lts.rst @@ -4,10 +4,23 @@ Installing Red on Ubuntu non-LTS versions ========================================= -Latest Ubuntu non-LTS version (24.10 at the time of writing) is not supported at current time -due to lack of availability of Python 3.11 or older in its repositories. +.. include:: _includes/supported-arch-x64+aarch64.rst -The support should come back once we get back on track with supporting current Python versions. +.. include:: _includes/linux-preamble.rst -We recommend usage of latest Ubuntu **LTS** versions instead, you can find -`an install guide for Ubuntu 24.04 ` in our docs. +------------------------------- +Installing the pre-requirements +------------------------------- + +Now install the pre-requirements with apt: + +.. prompt:: bash + + sudo apt update + sudo apt -y install python3.14 python3.14-dev python3.14-venv git openjdk-17-jre-headless build-essential nano + +.. Include common instructions: + +.. include:: _includes/create-env-with-venv3.14.rst + +.. include:: _includes/install-and-setup-red-unix.rst diff --git a/docs/install_guides/windows.rst b/docs/install_guides/windows.rst index ab03a4753ce..eeb65ef0351 100644 --- a/docs/install_guides/windows.rst +++ b/docs/install_guides/windows.rst @@ -33,7 +33,7 @@ Then run each of the following commands: iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) choco upgrade git --params "/GitOnlyOnPath /WindowsTerminal" -y choco upgrade visualstudio2022-workload-vctools -y - choco upgrade python311 -y + choco upgrade python314 -y For Audio support, you should also run the following command before exiting: @@ -57,7 +57,7 @@ Manually installing dependencies * `MSVC Build tools `_ -* `Python 3.8.1 - 3.11.x `_ +* `Python 3.11.x-3.14.x `_ .. attention:: Please make sure that the box to add Python to PATH is CHECKED, otherwise you may run into issues when trying to run Red. diff --git a/make.bat b/make.bat index 7f3d046b516..f87a18fdfaa 100644 --- a/make.bat +++ b/make.bat @@ -23,7 +23,7 @@ goto:eof goto:eof :newenv -py -3.8 -m venv --clear .venv +py -3.11 -m venv --clear .venv "%~dp0.venv\Scripts\python" -m pip install -U pip wheel goto syncenv diff --git a/make.ps1 b/make.ps1 index 370a21837f1..1bd70e540a8 100644 --- a/make.ps1 +++ b/make.ps1 @@ -55,7 +55,7 @@ function stylediff() { } function newenv() { - py -3.8 -m venv --clear .venv + py -3.11 -m venv --clear .venv & $PSScriptRoot\.venv\Scripts\python.exe -m pip install -U pip wheel syncenv } diff --git a/pyproject.toml b/pyproject.toml index 512bdbc7d23..ce4a7d3a262 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=64", "wheel"] +requires = ["setuptools>=77", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -7,21 +7,22 @@ name = "Red-DiscordBot" description = "A highly customisable Discord bot" readme = "README.md" authors = [{ name = "Cog Creators" }] +license = "GPL-3.0-only" +license-files = ["LICENSE", "redbot/**/*.LICENSE"] classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Natural Language :: English", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "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", "Topic :: Communications :: Chat", ] dynamic = ["version", "requires-python", "dependencies", "optional-dependencies"] @@ -39,13 +40,16 @@ dynamic = ["version", "requires-python", "dependencies", "optional-dependencies" redbot = "redbot.__main__:main" redbot-setup = "redbot.setup:run_cli" +[tool.setuptools.packages.find] +include = ["redbot", "redbot.*"] + [project.entry-points.pytest11] red-discordbot = "redbot.pytest" [tool.black] line-length = 99 required-version = '23' -target-version = ['py38'] +target-version = ['py311'] include = '\.py$' force-exclude = ''' /( @@ -55,3 +59,5 @@ force-exclude = ''' [tool.pytest.ini_options] asyncio_mode = 'auto' +asyncio_default_fixture_loop_scope = 'session' +asyncio_default_test_loop_scope = 'session' diff --git a/redbot/__init__.py b/redbot/__init__.py index 671c475b061..1186f004a88 100644 --- a/redbot/__init__.py +++ b/redbot/__init__.py @@ -19,7 +19,7 @@ "VersionInfo", ) -MIN_PYTHON_VERSION = (3, 8, 1) +MIN_PYTHON_VERSION = (3, 11, 0) if _sys.version_info < MIN_PYTHON_VERSION: print( f"Python {'.'.join(map(str, MIN_PYTHON_VERSION))} is required to run Red, but you have " @@ -348,19 +348,6 @@ def _early_init(): # TODO: Rearrange cli flags here and use the value instead of this monkeypatch if not any(_re.match("^-(-debug|d+|-verbose|v+)$", i) for i in _sys.argv): - # DEP-WARN - # Individual warnings - tracked in https://github.com/Cog-Creators/Red-DiscordBot/issues/3529 - # DeprecationWarning: an integer is required (got type float). Implicit conversion to integers using __int__ is deprecated, and may be removed in a future version of Python. - _warnings.filterwarnings("ignore", category=DeprecationWarning, module="importlib", lineno=219) - # DeprecationWarning: The loop argument is deprecated since Python 3.8, and scheduled for removal in Python 3.10 - # stdin, stdout, stderr = await tasks.gather(stdin, stdout, stderr, - # this is a bug in CPython - _warnings.filterwarnings( - "ignore", - category=DeprecationWarning, - module="asyncio", - message="The loop argument is deprecated since Python 3.8", - ) # DEP-WARN - d.py currently uses audioop module, Danny is aware of the deprecation # # DeprecationWarning: 'audioop' is deprecated and slated for removal in Python 3.13 @@ -371,12 +358,3 @@ def _early_init(): module="discord", message="'audioop' is deprecated and slated for removal", ) - # DEP-WARN - will need a fix before Python 3.12 support - # - # DeprecationWarning: the load_module() method is deprecated and slated for removal in Python 3.12; use exec_module() instead - _warnings.filterwarnings( - "ignore", - category=DeprecationWarning, - module="importlib", - message=r"the load_module\(\) method is deprecated and slated for removal", - ) diff --git a/redbot/__main__.py b/redbot/__main__.py index f741af28c80..c6168e64190 100644 --- a/redbot/__main__.py +++ b/redbot/__main__.py @@ -257,11 +257,7 @@ def _copy_data(data): if Path(data["DATA_PATH"]).exists(): if any(os.scandir(data["DATA_PATH"])): return False - else: - # this is needed because copytree doesn't work when destination folder exists - # Python 3.8 has `dirs_exist_ok` option for that - os.rmdir(data["DATA_PATH"]) - shutil.copytree(data_manager.basic_config["DATA_PATH"], data["DATA_PATH"]) + shutil.copytree(data_manager.basic_config["DATA_PATH"], data["DATA_PATH"], dirs_exist_ok=True) return True diff --git a/redbot/cogs/customcom/customcom.py b/redbot/cogs/customcom/customcom.py index 7fefb36b713..126d316f749 100644 --- a/redbot/cogs/customcom/customcom.py +++ b/redbot/cogs/customcom/customcom.py @@ -1,7 +1,7 @@ import asyncio import re import random -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Iterable, List, Mapping, Tuple, Dict, Set, Literal, Union from urllib.parse import quote_plus @@ -118,7 +118,7 @@ async def get_responses(self, ctx): def get_now() -> str: # Get current time as a string, for 'created_at' and 'edited_at' fields # in the ccinfo dict - return "{:%d/%m/%Y %H:%M:%S}".format(datetime.utcnow()) + return "{:%d/%m/%Y %H:%M:%S}".format(datetime.now(timezone.utc)) async def get(self, message: discord.Message, command: str) -> Tuple[str, Dict]: if not command: @@ -781,7 +781,7 @@ def prepare_args(raw_response) -> Mapping[str, Parameter]: return dict((p.name, p) for p in fin) def test_cooldowns(self, ctx, command, cooldowns): - now = datetime.utcnow() + now = datetime.now(timezone.utc) new_cooldowns = {} for per, rate in cooldowns.items(): if per == "guild": diff --git a/redbot/cogs/downloader/downloader.py b/redbot/cogs/downloader/downloader.py index 451177dbf53..46ffa7082d1 100644 --- a/redbot/cogs/downloader/downloader.py +++ b/redbot/cogs/downloader/downloader.py @@ -1112,25 +1112,6 @@ async def _ask_for_cog_reload( await ctx.invoke(ctx.bot.get_cog("Core").reload, *updated_cognames) - def cog_name_from_instance(self, instance: object) -> str: - """Determines the cog name that Downloader knows from the cog instance. - - Probably. - - Parameters - ---------- - instance : object - The cog instance. - - Returns - ------- - str - The name of the cog according to Downloader.. - - """ - splitted = instance.__module__.split(".") - return splitted[0] - @commands.command() async def findcog(self, ctx: commands.Context, command_name: str) -> None: """Find which cog a command comes from. @@ -1152,8 +1133,21 @@ async def findcog(self, ctx: commands.Context, command_name: str) -> None: # Check if in installed cogs cog = command.cog - if cog: - cog_pkg_name = self.cog_name_from_instance(cog) + if not cog: + await ctx.send(_("This command is not provided by a cog.")) + return + + try: + top_level_package, subpackage, cog_pkg_name, *__ = cog.__module__.split(".", 3) + if top_level_package != "redbot": + raise ValueError + except ValueError: + await ctx.send(_("The cog package for the given command could not be determined.")) + return + + cog_name = cog.__class__.__name__ + repo_branch = None + if subpackage == "ext_cogs": installed, cog_installable = await _downloader.is_installed(cog_pkg_name) if installed: made_by = ( @@ -1171,25 +1165,18 @@ async def findcog(self, ctx: commands.Context, command_name: str) -> None: if cog_installable.repo is None else cog_installable.repo.name ) + repo_branch = cog_installable.repo and cog_installable.repo.branch cog_pkg_name = cog_installable.name - elif cog.__module__.startswith("redbot."): # core commands or core cog - made_by = "Cog Creators" - repo_url = "https://github.com/Cog-Creators/Red-DiscordBot" - module_fragments = cog.__module__.split(".") - if module_fragments[1] == "core": - cog_pkg_name = "N/A - Built-in commands" - else: - cog_pkg_name = module_fragments[2] - repo_name = "Red-DiscordBot" else: # assume not installed via downloader made_by = _("Unknown") repo_url = _("None - this cog wasn't installed via downloader") repo_name = _("Unknown") - cog_name = cog.__class__.__name__ - else: - msg = _("This command is not provided by a cog.") - await ctx.send(msg) - return + else: # core commands or core cog + made_by = "Cog Creators" + repo_url = "https://github.com/Cog-Creators/Red-DiscordBot" + if subpackage == "core": + cog_pkg_name = "N/A - Built-in commands" + repo_name = "Red-DiscordBot" if await ctx.embed_requested(): embed = discord.Embed(color=(await ctx.embed_colour())) @@ -1199,10 +1186,8 @@ async def findcog(self, ctx: commands.Context, command_name: str) -> None: embed.add_field(name=_("Made by:"), value=made_by, inline=False) embed.add_field(name=_("Repo name:"), value=repo_name, inline=False) embed.add_field(name=_("Repo URL:"), value=repo_url, inline=False) - if installed and cog_installable.repo is not None and cog_installable.repo.branch: - embed.add_field( - name=_("Repo branch:"), value=cog_installable.repo.branch, inline=False - ) + if repo_branch: + embed.add_field(name=_("Repo branch:"), value=repo_branch, inline=False) await ctx.send(embed=embed) else: @@ -1221,10 +1206,8 @@ async def findcog(self, ctx: commands.Context, command_name: str) -> None: repo_url=repo_url, repo_name=repo_name, ) - if installed and cog_installable.repo is not None and cog_installable.repo.branch: - msg += _("Repo branch: {branch_name}\n").format( - branch_name=cog_installable.repo.branch - ) + if repo_branch: + msg += _("Repo branch: {branch_name}\n").format(branch_name=repo_branch) await ctx.send(box(msg)) @staticmethod diff --git a/redbot/core/_cli.py b/redbot/core/_cli.py index ffbe6482aa6..849a63dc900 100644 --- a/redbot/core/_cli.py +++ b/redbot/core/_cli.py @@ -331,19 +331,13 @@ def parse_cli_flags(args): default=None, help="Forcefully disables the Rich logging handlers.", ) - # DEP-WARN: use argparse.BooleanOptionalAction when we drop support for Python 3.8 parser.add_argument( "--rich-tracebacks", - action="store_true", + action=argparse.BooleanOptionalAction, default=False, help="Format the Python exception tracebacks using Rich (with syntax highlighting)." " *May* be useful to increase traceback readability during development.", ) - parser.add_argument( - "--no-rich-tracebacks", - action="store_false", - dest="rich_tracebacks", - ) parser.add_argument( "--rich-traceback-extra-lines", type=non_negative_int, diff --git a/redbot/core/_cog_manager.py b/redbot/core/_cog_manager.py index f07a07e67b1..214f979ec42 100644 --- a/redbot/core/_cog_manager.py +++ b/redbot/core/_cog_manager.py @@ -1,20 +1,49 @@ +""" +Cog path manager for Red. + +This module provides both the internal API and the external UI for +adding, removing or modifying extra paths for Red to be able to +discover cogs. + +By default, cogs can be imported from the install path, where +Downloader will place installed cogs; and the core cogs path. Other +arbitrary paths can be added by the user - these user-defined paths are +particularly useful for cog development. + +Internally, this modifies use of the `__path__` attribute of the +``redbot.ext_cogs`` package. When extra paths are added to a package's +`__path__` attribute, they are used to locate sub-packages. + +The precedence of paths goes: +1. Install path +2. Non-persistent (temporary) paths defined by the user (i.e. through the `--cog-path` flag) +3. Persistent paths defined by the user (i.e. through the `[p]addpath` command) +4. Core path (redbot.cogs) + +This is so users who wish to modify core cogs can do so by copying or +installing cogs into a user-defined/core path, and this modified one +will be loaded instead. +""" + import contextlib import keyword import pkgutil import sys import textwrap -from importlib import import_module, invalidate_caches -from importlib.machinery import ModuleSpec +import importlib +import itertools from pathlib import Path -from typing import Union, List, Optional +from types import ModuleType +from typing import Union, List, Optional, Set import redbot.cogs +import redbot.ext_cogs from redbot.core.commands import positive_int from redbot.core.utils import deduplicate_iterables from redbot.core.utils.views import ConfirmView import discord -from . import commands +from . import commands, errors from .config import Config from .i18n import Translator, cog_i18n from .data_manager import cog_data_path, data_path @@ -23,32 +52,29 @@ __all__ = ("CogManager", "CogManagerUI") -_TEMP_PATHS: List[Path] = [] - - -class NoSuchCog(ImportError): - """Thrown when a cog is missing. - - Different from ImportError because some ImportErrors can happen inside cogs. - """ - class CogManager: """Directory manager for Red's cogs. This module allows you to load cogs from multiple directories and even from - outside the bot directory. You may also set a directory for downloader to - install new cogs to, the default being the :code:`cogs/` folder in the root - bot directory. + outside the bot directory. You may also set a directory for Downloader to + install new cogs to, the default being the ``cogs/`` folder in + `CogManager`'s data path. """ - CORE_PATH = Path(redbot.cogs.__path__[0]).resolve() + CORE_PATH = Path(redbot.cogs.__file__).parent def __init__(self): self.config = Config.get_conf(self, 2938473984732, True) - tmp_cog_install_path = cog_data_path(self) / "cogs" - tmp_cog_install_path.mkdir(parents=True, exist_ok=True) - self.config.register_global(paths=[], install_path=str(tmp_cog_install_path)) + default_cog_install_path = cog_data_path(self) / "cogs" + default_cog_install_path.mkdir(parents=True, exist_ok=True) + self.config.register_global(paths=[], install_path=str(default_cog_install_path)) + + self._temp_paths: List[Path] = [] + + async def initialize(self): + # we want to include all paths except for the core path here, hence last entry is excluded + redbot.ext_cogs.__path__ = [str(p) for p in (await self.paths())[:-1]] async def paths(self) -> List[Path]: """Get all currently valid path directories, in order of priority @@ -64,7 +90,7 @@ async def paths(self) -> List[Path]: """ return deduplicate_iterables( [await self.install_path()], - _TEMP_PATHS, + self._temp_paths, await self.user_defined_paths(), [self.CORE_PATH], ) @@ -80,6 +106,19 @@ async def install_path(self) -> Path: """ return Path(await self.config.install_path()).resolve() + def temp_paths(self) -> List[Path]: + """Get a list of non-persistent (temporary) paths defined by the user. + + All paths will be absolute and unique, in order of priority. + + Returns + ------- + List[pathlib.Path] + A list of non-persistent (temporary) paths defined by the user. + + """ + return list(self._temp_paths) + async def user_defined_paths(self) -> List[Path]: """Get a list of user-defined cog paths. @@ -120,7 +159,10 @@ async def set_install_path(self, path: Path) -> Path: if not path.is_dir(): raise ValueError("The install path must be an existing directory.") resolved = path.resolve() - await self.config.install_path.set(str(resolved)) + to_add = str(resolved) + await self.config.install_path.set(to_add) + # install path is always first + redbot.ext_cogs.__path__[0] = to_add return resolved @staticmethod @@ -174,10 +216,11 @@ async def add_path(self, path: Union[Path, str], *, persist: bool = True) -> Non current_paths = await self.user_defined_paths() if path not in current_paths: current_paths.append(path) - await self.set_paths(current_paths) + await self._set_user_defined_paths(current_paths) + redbot.ext_cogs.__path__.append(str(path)) else: - if path not in _TEMP_PATHS: - _TEMP_PATHS.append(path) + if path not in self._temp_paths: + self._temp_paths.append(path) async def remove_path(self, path: Union[Path, str]) -> None: """Remove a path from the current paths list. @@ -192,143 +235,149 @@ async def remove_path(self, path: Union[Path, str]) -> None: paths = await self.user_defined_paths() paths.remove(path) - await self.set_paths(paths) + await self._set_user_defined_paths(paths) + redbot.ext_cogs.__path__.remove(str(path)) - async def set_paths(self, paths_: List[Path]): - """Set the current paths list. + async def reorder_path(self, path: Union[Path, str], new_index: int) -> None: + """Reorder a path in the user-defined paths list. + + The ``path`` will be removed from the paths list and + re-inserted at ``new_index``. Parameters ---------- - paths_ : `list` of `pathlib.Path` - List of paths to set. + path : `pathlib.Path` or `str` + Path to move. + new_index : `int` + The index to re-insert the path at. """ - str_paths = list(map(str, paths_)) - await self.config.paths.set(str_paths) + path = self._ensure_path_obj(path).resolve() + paths = await self.user_defined_paths() + + paths.remove(path) + paths.insert(new_index, path) + await self._set_user_defined_paths(paths) - async def _find_ext_cog(self, name: str) -> ModuleSpec: + redbot.ext_cogs.__path__[1:] = list(map(str, paths)) + + async def _set_user_defined_paths(self, paths_: List[Path]): """ - Attempts to find a spec for a third party installed cog. + Store the new list of user-defined paths. + + This doesn't update `redbot.ext_cogs.__path__`. Parameters ---------- - name : str - Name of the cog package to look for. - - Returns - ------- - importlib.machinery.ModuleSpec - Module spec to be used for cog loading. - - Raises - ------ - NoSuchCog - When no cog with the requested name was found. + paths_ : `list` of `pathlib.Path` + List of paths to set. """ - if not name.isidentifier() or keyword.iskeyword(name): - # reject package names that can't be valid python identifiers - raise NoSuchCog( - f"No 3rd party module by the name of '{name}' was found in any available path.", - name=name, - ) - - real_paths = list( - map(str, [await self.install_path()] + _TEMP_PATHS + await self.user_defined_paths()) - ) - - for finder, module_name, _ in pkgutil.iter_modules(real_paths): - if name == module_name: - spec = finder.find_spec(name) - if spec: - return spec + str_paths = list(map(str, paths_)) + await self.config.paths.set(str_paths) - raise NoSuchCog( - f"No 3rd party module by the name of '{name}' was found in any available path.", - name=name, + @staticmethod + def is_valid_module_name(name: str) -> bool: + # reject package names that: + return ( + # - can't be valid python identifiers + (name.isidentifier() and not keyword.iskeyword(name)) + # - would return a clearly invalid namespace package + and name != "__pycache__" ) - @staticmethod - async def _find_core_cog(name: str) -> ModuleSpec: + @classmethod + def load_cog_module(cls, name: str) -> ModuleType: """ - Attempts to find a spec for a core cog. + Load a cog module or package. Parameters ---------- name : str - - Returns - ------- - importlib.machinery.ModuleSpec - - Raises - ------ - RuntimeError - When no matching spec can be found. + Name of the cog package to look for. """ - real_name = ".{}".format(name) - package = "redbot.cogs" - try: - mod = import_module(real_name, package=package) - if mod.__spec__.name == "redbot.cogs.locales": - raise NoSuchCog( - "No core cog by the name of '{}' could be found.".format(name), - path=mod.__spec__.origin, - name=name, - ) - except ImportError as e: - if e.name == package + real_name: - raise NoSuchCog( - "No core cog by the name of '{}' could be found.".format(name), - path=e.path, - name=e.name, - ) from e - - raise + module = cls._load_cog_module(name) + if module is None: + raise errors.NoSuchCog( + f"No core or 3rd-party cog module by the name of '{name}' could be found.", + name=name, + ) - return mod.__spec__ + return module - # noinspection PyUnreachableCode - async def find_cog(self, name: str) -> Optional[ModuleSpec]: - """Find a cog in the list of available paths. + @classmethod + def _load_cog_module(cls, name: str) -> Optional[ModuleType]: + """ + Load a cog module or package. Parameters ---------- name : str - Name of the cog to find. - - Returns - ------- - Optional[importlib.machinery.ModuleSpec] - A module spec to be used for specialized cog loading, if found. - + Name of the cog package to look for. """ - with contextlib.suppress(NoSuchCog): - return await self._find_ext_cog(name) - with contextlib.suppress(NoSuchCog): - return await self._find_core_cog(name) + if not cls.is_valid_module_name(name): + # reject package names that can't be valid python identifiers + return None - async def available_modules(self) -> List[str]: - """Finds the names of all available modules to load.""" - paths = list(map(str, await self.paths())) + for parent_package in ("redbot.ext_cogs", "redbot.cogs"): + module_name = ".".join((parent_package, name)) + if module_name == "redbot.cogs.locales": + # we don't want a clearly invalid namespace package + return None - ret = [] - for finder, module_name, _ in pkgutil.iter_modules(paths): - # reject package names that can't be valid python identifiers - if module_name.isidentifier() and not keyword.iskeyword(module_name): - ret.append(module_name) - return ret + try: + module = importlib.import_module(f".{name}", package=parent_package) + except ModuleNotFoundError as e: + if e.name == module_name: + pass + else: + raise + else: + return module + + # If we get here, we failed to find the module + return None @staticmethod - def invalidate_caches(): - """Re-evaluate modules in the py cache. + def reload(module: ModuleType) -> ModuleType: + """Internally reloads modules so that changes are detected.""" + module_name = module.__name__ + a, b, *splitted = module_name.split(".") + splitted[0] = f"{a}.{b}.{splitted[0]}" - This is an alias for an importlib internal and should be called - any time that a new module has been installed to a cog directory. - """ - invalidate_caches() + def maybe_reload(new_name: str) -> None: + try: + lib = sys.modules[new_name] + except KeyError: + pass + else: + importlib.reload(lib) + + modules = itertools.accumulate(splitted, "{}.{}".format) + for m in modules: + maybe_reload(m) + + children = { + name: lib + for name, lib in sys.modules.items() + if name == module_name or name.startswith(f"{module_name}.") + } + for child_name, lib in children.items(): + importlib.reload(lib) + + return sys.modules[module.__name__] + + @classmethod + def find_available_modules(cls) -> Set[str]: + """Find the names of all available modules to load.""" + ret = set() + for package in (redbot.ext_cogs, redbot.cogs): + for finder, module_name, _ in pkgutil.iter_modules(package.__path__): + if cls.is_valid_module_name(module_name): + ret.add(module_name) + return ret _ = Translator("CogManagerUI", __file__) @@ -351,24 +400,26 @@ async def paths(self, ctx: commands.Context): cog_mgr = ctx.bot._cog_mgr install_path = await cog_mgr.install_path() core_path = cog_mgr.CORE_PATH + temp_paths = cog_mgr.temp_paths() cog_paths = await cog_mgr.user_defined_paths() - temporary_paths = [str(path) for path in _TEMP_PATHS] - - paths = [] - for index, path in enumerate(cog_paths, start=1): - paths.append(f"{index}. {path}") + formatted_temp_paths = [ + f"{index}. {path}" for index, path in enumerate(temp_paths, start=1) + ] + formatted_cog_paths = [f"{index}. {path}" for index, path in enumerate(cog_paths, start=1)] msg = _( - ( - "Install Path: {install_path}\nCore Path: {core_path}\n\n" - "Temporary Paths:{temporary_paths}\n\nCog Paths:{cog_paths}" - ) + "Install Path: {install_path}\nCore Path: {core_path}\n\n" + "Temporary Paths:{temp_paths}\n\nCog Paths:{cog_paths}" ).format( install_path=install_path, core_path=core_path, - temporary_paths=("\n" + "\n".join(temporary_paths)) if temporary_paths else _(" None"), - cog_paths=("\n" + "\n".join(paths)) if paths else _(" None"), + temp_paths=( + ("\n" + "\n".join(formatted_temp_paths)) if formatted_temp_paths else _(" None") + ), + cog_paths=( + ("\n" + "\n".join(formatted_cog_paths)) if formatted_cog_paths else _(" None") + ), ) await ctx.send(box(msg)) @@ -385,9 +436,8 @@ async def addpath(self, ctx: commands.Context, *, path: Path): path = path.resolve() - # Path.is_relative_to() is 3.9+ bot_data_path = data_path() - if path == bot_data_path or bot_data_path in path.parents: + if path.is_relative_to(bot_data_path): await ctx.send( _("A cog path cannot be part of bot's data path ({bot_data_path}).").format( bot_data_path=inline(str(bot_data_path)) @@ -395,9 +445,8 @@ async def addpath(self, ctx: commands.Context, *, path: Path): ) return - # Path.is_relative_to() is 3.9+ core_path = ctx.bot._cog_mgr.CORE_PATH - if path == core_path or core_path in path.parents: + if path.is_relative_to(core_path): await ctx.send( _("A cog path cannot be part of bot's core path ({core_path}).").format( core_path=inline(str(core_path)) @@ -520,13 +569,7 @@ async def reorderpath(self, ctx: commands.Context, from_: positive_int, to: posi await ctx.send(_("Invalid 'from' index.")) return - try: - all_paths.insert(to, to_move) - except IndexError: - await ctx.send(_("Invalid 'to' index.")) - return - - await ctx.bot._cog_mgr.set_paths(all_paths) + await ctx.bot._cog_mgr.reorder_path(to_move, to) await ctx.send(_("Paths reordered.")) @commands.command() @@ -562,7 +605,7 @@ async def cogs(self, ctx: commands.Context): """ loaded = set(ctx.bot.extensions.keys()) - all_cogs = set(await ctx.bot._cog_mgr.available_modules()) + all_cogs = ctx.bot._cog_mgr.find_available_modules() unloaded = all_cogs - loaded diff --git a/redbot/core/_debuginfo.py b/redbot/core/_debuginfo.py index 589d406d1ed..f157978843c 100644 --- a/redbot/core/_debuginfo.py +++ b/redbot/core/_debuginfo.py @@ -143,11 +143,7 @@ async def _get_red_vars_section(self) -> DebugInfoSection: parts = [f"Instance name: {instance_name}"] if self.bot is not None: - # sys.original_argv is available since 3.10 and shows the actual command line arguments - # rather than a Python-transformed version (i.e. with '-c' or path to `__main__.py` - # as first element). We could just not show the first argument for consistency - # but it can be useful. - cli_args = getattr(sys, "orig_argv", sys.argv).copy() + cli_args = sys.orig_argv.copy() # best effort attempt to expunge a token argument for idx, arg in enumerate(cli_args): if not arg.startswith("--to"): diff --git a/redbot/core/_events.py b/redbot/core/_events.py index 2ee86e9bd46..29b19e66d62 100644 --- a/redbot/core/_events.py +++ b/redbot/core/_events.py @@ -151,7 +151,7 @@ async def _on_ready(): if bot._uptime is not None: return - bot._uptime = datetime.utcnow() + bot._uptime = datetime.now(timezone.utc) guilds = len(bot.guilds) users = len(set([m for m in bot.get_all_members()])) diff --git a/redbot/core/_rpc.py b/redbot/core/_rpc.py index 5512dbbbd13..cebca51135b 100644 --- a/redbot/core/_rpc.py +++ b/redbot/core/_rpc.py @@ -9,6 +9,7 @@ import logging from redbot.core._cli import ExitCodes +from redbot.core.utils._internal_utils import iscoroutinefunction log = logging.getLogger("red.rpc") @@ -29,7 +30,7 @@ def __init__(self, *args, **kwargs): self.add_methods(("", self.get_method_info)) def _add_method(self, method, name="", prefix=""): - if not asyncio.iscoroutinefunction(method): + if not iscoroutinefunction(method): return name = name or get_name(method, prefix) @@ -121,13 +122,13 @@ def add_method(self, method, prefix: str = None): if prefix is None: prefix = method.__self__.__class__.__name__.lower() - if not asyncio.iscoroutinefunction(method): + if not iscoroutinefunction(method): raise TypeError("RPC methods must be coroutines.") self._rpc.add_methods((prefix, method)) def add_multi_method(self, *methods, prefix: str = None): - if not all(asyncio.iscoroutinefunction(m) for m in methods): + if not all(iscoroutinefunction(m) for m in methods): raise TypeError("RPC methods must be coroutines.") for method in methods: diff --git a/redbot/core/bank.py b/redbot/core/bank.py index 9af9dd8abba..a3f01b596ec 100644 --- a/redbot/core/bank.py +++ b/redbot/core/bank.py @@ -9,6 +9,7 @@ import discord from redbot.core.utils import AsyncIter +from redbot.core.utils._internal_utils import iscoroutinefunction from redbot.core.utils.chat_formatting import humanize_number from . import Config, errors, commands from .i18n import Translator @@ -244,7 +245,7 @@ def _decode_time(time: int) -> datetime: The datetime object from the timestamp. """ - return datetime.utcfromtimestamp(time) + return datetime.fromtimestamp(time, timezone.utc) async def get_balance(member: discord.Member) -> int: @@ -1042,7 +1043,7 @@ def cost(amount: int): def deco(coro_or_command): is_command = isinstance(coro_or_command, commands.Command) - if not is_command and not asyncio.iscoroutinefunction(coro_or_command): + if not is_command and not iscoroutinefunction(coro_or_command): raise TypeError("@bank.cost() can only be used on commands or `async def` functions") coro = coro_or_command.callback if is_command else coro_or_command diff --git a/redbot/core/bot.py b/redbot/core/bot.py index bbcf4595d66..e3605012ed4 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -10,6 +10,7 @@ import contextlib import weakref import functools +import re from collections import namedtuple, OrderedDict from datetime import datetime from importlib.machinery import ModuleSpec @@ -32,7 +33,7 @@ overload, TYPE_CHECKING, ) -from types import MappingProxyType +from types import MappingProxyType, ModuleType import discord from discord.ext import commands as dpy_commands @@ -69,7 +70,7 @@ from .tree import RedTree from .utils import can_user_send_messages_in, common_filters, AsyncIter from .utils.chat_formatting import box, text_to_file -from .utils._internal_utils import send_to_owners_with_prefix_replaced +from .utils._internal_utils import iscoroutinefunction, send_to_owners_with_prefix_replaced if TYPE_CHECKING: from discord.ext.commands.hybrid import CommandCallback, ContextT, P @@ -510,7 +511,7 @@ def before_invoke(self, coro: T_BIC, /) -> T_BIC: TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not iscoroutinefunction(coro): raise TypeError("The pre-invoke hook must be a coroutine.") self._red_before_invoke_objs.add(coro) @@ -528,7 +529,11 @@ def cog_mgr(self) -> NoReturn: @property def uptime(self) -> datetime: - """Allow access to the value, but we don't want cog creators setting it""" + """ + The time when the bot connected to Discord at startup and became ready. + + This is a aware `datetime.datetime` object in UTC timezone. + """ return self._uptime @uptime.setter @@ -1170,6 +1175,7 @@ async def _pre_login(self) -> None: await super()._pre_login() await self._maybe_update_config() + await self._cog_mgr.initialize() self.description = await self._config.description() self._color = discord.Colour(await self._config.color()) @@ -1318,16 +1324,14 @@ async def _pre_connect(self) -> None: log.info("Loading packages...") for package in packages: try: - spec = await self._cog_mgr.find_cog(package) - if spec is None: - log.error( - "Failed to load package %s (package was not found in any cog path)", - package, - ) - await self.remove_loaded_package(package) - to_remove.append(package) - continue - await asyncio.wait_for(self.load_extension(spec), 30) + await asyncio.wait_for(self.load_extension(package), 30) + except errors.NoSuchCog: + log.error( + "Failed to load package %s (package was not found in any cog path)", + package, + ) + await self.remove_loaded_package(package) + to_remove.append(package) except asyncio.TimeoutError: log.exception("Failed to load package %s (timeout)", package) to_remove.append(package) @@ -1766,26 +1770,41 @@ async def remove_loaded_package(self, pkg_name: str): while pkg_name in curr_pkgs: curr_pkgs.remove(pkg_name) - async def load_extension(self, spec: ModuleSpec): - # NB: this completely bypasses `discord.ext.commands.Bot._load_from_module_spec` - name = spec.name.split(".")[-1] + # Pattern to match parent package name of cog module (redbot.cogs. or redbot.ext_cogs.) + _COG_PACKAGE_RE = re.compile(r"^redbot\.(?:ext_)?cogs\.(.+)") + + async def load_extension(self, module: Union[str, ModuleType], /): + # This implementation completely bypasses `discord.ext.commands.Bot._load_from_module_spec` + # with our own cog manager implementation. + + if isinstance(module, str): + module: ModuleType = self._cog_mgr.load_cog_module(module) + + name_match = self._COG_PACKAGE_RE.match(module.__name__) + if name_match is None: + raise errors.NoSuchCog( + f"The passed cog module ({module.__name__}) is not part of" + " redbot.cogs or redbot.ext_cogs package.", + name=module.__name__, + ) + name = name_match.group(1) if name in self.extensions: - raise errors.PackageAlreadyLoaded(spec) + raise errors.PackageAlreadyLoaded(name) - lib = spec.loader.load_module() - if not hasattr(lib, "setup"): - del lib - raise discord.ClientException(f"extension {name} does not have a setup function") + try: + setup = getattr(module, "setup") + except AttributeError: + raise commands.NoEntryPointError(name) try: - await lib.setup(self) + await setup(self) await self.tree.red_check_enabled() except Exception as e: - await self._remove_module_references(lib.__name__) - await self._call_module_finalizers(lib, name) + await self._remove_module_references(module.__name__) + await self._call_module_finalizers(module, name) raise else: - self._BotBase__extensions[name] = lib + self._BotBase__extensions[name] = module async def remove_cog( self, diff --git a/redbot/core/commands/requires.py b/redbot/core/commands/requires.py index 9a94bd03d02..3c4c24faf74 100644 --- a/redbot/core/commands/requires.py +++ b/redbot/core/commands/requires.py @@ -31,6 +31,7 @@ from .errors import BotMissingPermissions from redbot.core import utils +from redbot.core.utils._internal_utils import iscoroutinefunction if TYPE_CHECKING: from .commands import Command @@ -352,7 +353,7 @@ def get_decorator( user_perms = None def decorator(func: "_CommandOrCoro") -> "_CommandOrCoro": - if inspect.iscoroutinefunction(func): + if iscoroutinefunction(func): func.__requires_privilege_level__ = privilege_level if user_perms is None: func.__requires_user_perms__ = None @@ -685,7 +686,7 @@ def bot_has_permissions(**perms: bool): """ def decorator(func: "_CommandOrCoro") -> "_CommandOrCoro": - if asyncio.iscoroutinefunction(func): + if iscoroutinefunction(func): if not hasattr(func, "__requires_bot_perms__"): func.__requires_bot_perms__ = discord.Permissions.none() _validate_perms_dict(perms) diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index 007ca853c35..a23b7996719 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -164,30 +164,28 @@ async def _load(self, pkg_names: Iterable[str]) -> Dict[str, Union[List[str], Di bot = self.bot - pkg_specs = [] - - for name in pkg_names: - if not name.isidentifier() or keyword.iskeyword(name): + async for name in AsyncIter(pkg_names, steps=10): + if not bot._cog_mgr.is_valid_module_name(name): invalid_pkg_names.append(name) continue + try: - spec = await bot._cog_mgr.find_cog(name) - if spec: - pkg_specs.append((spec, name)) - else: - notfound_packages.append(name) + module = bot._cog_mgr.load_cog_module(name) + except errors.NoSuchCog: + notfound_packages.append(name) + continue except Exception as e: log.exception("Package import failed", exc_info=e) - exception_log = "Exception during import of package\n" + exception_log = "Exception during import of cog package\n" exception_log += "".join(traceback.format_exception(type(e), e, e.__traceback__)) bot._last_exception = exception_log failed_packages.append(name) + continue - async for spec, name in AsyncIter(pkg_specs, steps=10): try: - self._cleanup_and_refresh_modules(spec.name) - await bot.load_extension(spec) + bot._cog_mgr.reload(module) + await bot.load_extension(module) except errors.PackageAlreadyLoaded: alreadyloaded_packages.append(name) except errors.CogLoadError as e: @@ -237,32 +235,6 @@ async def _load(self, pkg_names: Iterable[str]) -> Dict[str, Union[List[str], Di "repos_with_shared_libs": list(repos_with_shared_libs), } - @staticmethod - def _cleanup_and_refresh_modules(module_name: str) -> None: - """Internally reloads modules so that changes are detected.""" - splitted = module_name.split(".") - - def maybe_reload(new_name): - try: - lib = sys.modules[new_name] - except KeyError: - pass - else: - importlib._bootstrap._exec(lib.__spec__, lib) - - # noinspection PyTypeChecker - modules = itertools.accumulate(splitted, "{}.{}".format) - for m in modules: - maybe_reload(m) - - children = { - name: lib - for name, lib in sys.modules.items() - if name == module_name or name.startswith(f"{module_name}.") - } - for child_name, lib in children.items(): - importlib._bootstrap._exec(lib.__spec__, lib) - async def _unload(self, pkg_names: Iterable[str]) -> Dict[str, List[str]]: """ Unloads packages with the given names. @@ -411,8 +383,8 @@ async def info(self, ctx: commands.Context): support_server_url = "https://discord.gg/red" dpy_repo = "https://github.com/Rapptz/discord.py" python_url = "https://www.python.org/" - since = datetime.datetime(2016, 1, 2, 0, 0) - days_since = (datetime.datetime.utcnow() - since).days + since = datetime.datetime(2016, 1, 2, tzinfo=datetime.timezone.utc) + days_since = (datetime.datetime.now(datetime.timezone.utc) - since).days app_info = await self.bot.application_info() if app_info.team: @@ -552,12 +524,11 @@ async def info(self, ctx: commands.Context): @commands.command() async def uptime(self, ctx: commands.Context): """Shows [botname]'s uptime.""" - delta = datetime.datetime.utcnow() - self.bot.uptime - uptime = self.bot.uptime.replace(tzinfo=datetime.timezone.utc) + delta = datetime.datetime.now(datetime.timezone.utc) - self.bot.uptime uptime_str = humanize_timedelta(timedelta=delta) or _("Less than one second.") await ctx.send( _("I have been up for: **{time_quantity}** (since {timestamp})").format( - time_quantity=uptime_str, timestamp=discord.utils.format_dt(uptime, "f") + time_quantity=uptime_str, timestamp=discord.utils.format_dt(self.bot.uptime, "f") ) ) @@ -5764,13 +5735,13 @@ async def autoimmune_checkimmune( async def rpc_load(self, request): cog_name = request.params[0] - spec = await self.bot._cog_mgr.find_cog(cog_name) - if spec is None: + try: + module = self.bot._cog_mgr.load_cog_module(cog_name) + except errors.NoSuchCog: raise LookupError("No such cog found.") - self._cleanup_and_refresh_modules(spec.name) - - await self.bot.load_extension(spec) + module = self.bot.cog_mgr.reload(module) + await self.bot.load_extension(module) async def rpc_unload(self, request): cog_name = request.params[0] diff --git a/redbot/core/dev_commands.py b/redbot/core/dev_commands.py index 57940321824..5ccc8176bc5 100644 --- a/redbot/core/dev_commands.py +++ b/redbot/core/dev_commands.py @@ -349,7 +349,6 @@ def format_exception(self, exc: Exception, *, skip_frames: int = 1) -> str: exc.end_lineno -= line_offset top_traceback_exc = traceback.TracebackException(exc_type, exc, tb) - py311_or_above = sys.version_info >= (3, 11) queue = [ # actually a stack but 'stack' is easy to confuse with actual traceback stack top_traceback_exc, ] @@ -357,10 +356,8 @@ def format_exception(self, exc: Exception, *, skip_frames: int = 1) -> str: while queue: traceback_exc = queue.pop() - # handle exception groups; this uses getattr() to support `exceptiongroup` backport lib - exceptions: List[traceback.TracebackException] = ( - getattr(traceback_exc, "exceptions", None) or [] - ) + # handle exception groups + exceptions = traceback_exc.exceptions or [] # handle exception chaining if traceback_exc.__cause__ is not None: exceptions.append(traceback_exc.__cause__) @@ -389,23 +386,18 @@ def format_exception(self, exc: Exception, *, skip_frames: int = 1) -> str: continue lineno -= line_offset # support for enhanced error locations in tracebacks - if py311_or_above: - end_lineno = frame_summary.end_lineno - if end_lineno is not None: - end_lineno -= line_offset - frame_summary = traceback.FrameSummary( - frame_summary.filename, - lineno, - frame_summary.name, - line=line, - end_lineno=end_lineno, - colno=frame_summary.colno, - end_colno=frame_summary.end_colno, - ) - else: - frame_summary = traceback.FrameSummary( - frame_summary.filename, lineno, frame_summary.name, line=line - ) + end_lineno = frame_summary.end_lineno + if end_lineno is not None: + end_lineno -= line_offset + frame_summary = traceback.FrameSummary( + frame_summary.filename, + lineno, + frame_summary.name, + line=line, + end_lineno=end_lineno, + colno=frame_summary.colno, + end_colno=frame_summary.end_colno, + ) stack_summary[idx] = frame_summary return "".join(top_traceback_exc.format()) diff --git a/redbot/core/errors.py b/redbot/core/errors.py index 8f7a7eb8ef6..9910cf1f8b2 100644 --- a/redbot/core/errors.py +++ b/redbot/core/errors.py @@ -27,19 +27,29 @@ class RedError(Exception): class PackageAlreadyLoaded(RedError): """Raised when trying to load an already-loaded package.""" - def __init__(self, spec: importlib.machinery.ModuleSpec, *args, **kwargs): - super().__init__(*args, **kwargs) - self.spec: importlib.machinery.ModuleSpec = spec + def __init__(self, name: str, /): + self.name = name def __str__(self) -> str: - return f"There is already a package named {self.spec.name.split('.')[-1]} loaded" + return f"There is already a package named {self.name} loaded" class CogLoadError(RedError): """Raised by a cog when it cannot load itself. - The message will be sent to the user.""" - pass + The message will be sent to the user. + """ + + +class NoSuchCog(RedError, ModuleNotFoundError): + """Thrown when a cog is missing. + + Different from ImportError because some ImportErrors can happen inside cogs. + """ + + def __init__(self, message: str, /, *, name: str) -> None: + super().__init__(message) + self.name = name class BankError(RedError): diff --git a/redbot/core/utils/__init__.py b/redbot/core/utils/__init__.py index dc3687f6d82..2ebfd4eaac9 100644 --- a/redbot/core/utils/__init__.py +++ b/redbot/core/utils/__init__.py @@ -32,6 +32,7 @@ from discord.utils import maybe_coroutine from redbot.core import commands +from redbot.core.utils._internal_utils import iscoroutinefunction if TYPE_CHECKING: GuildMessageable = Union[ @@ -90,11 +91,11 @@ def __init__( # We assign the generator strategy based on the arguments' types if isinstance(iterable, AsyncIterable): - if asyncio.iscoroutinefunction(func): + if iscoroutinefunction(func): self.__generator_instance = self.__async_generator_async_pred() else: self.__generator_instance = self.__async_generator_sync_pred() - elif asyncio.iscoroutinefunction(func): + elif iscoroutinefunction(func): self.__generator_instance = self.__sync_generator_async_pred() else: raise TypeError("Must be either an async predicate, an async iterable, or both.") diff --git a/redbot/core/utils/_internal_utils.py b/redbot/core/utils/_internal_utils.py index cd70a760ab8..323a4100aad 100644 --- a/redbot/core/utils/_internal_utils.py +++ b/redbot/core/utils/_internal_utils.py @@ -3,6 +3,7 @@ import asyncio import collections.abc import contextlib +import inspect import json import logging import os @@ -10,7 +11,8 @@ import shutil import tarfile import warnings -from datetime import datetime +import datetime +import sys from pathlib import Path from typing import ( AsyncIterable, @@ -59,6 +61,7 @@ "deprecated_removed", "RichIndefiniteBarColumn", "cli_level_to_log_level", + "iscoroutinefunction", ) _T = TypeVar("_T") @@ -222,7 +225,7 @@ async def create_backup(dest: Path = Path.home()) -> Optional[Path]: return None dest.mkdir(parents=True, exist_ok=True) - timestr = datetime.utcnow().strftime("%Y-%m-%dT%H-%M-%S") + timestr = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H-%M-%S") backup_fpath = dest / f"redv3_{data_manager.instance_name()}_{timestr}.tar.gz" to_backup = [] @@ -388,3 +391,12 @@ def cli_level_to_log_level(level: int) -> int: else: log_level = TRACE return log_level + + +# `inspect.iscoroutinefunction()` only became equivalent +# to (now deprecated) `inspect.iscoroutinefunction()` in Python 3.12 +# https://github.com/python/cpython/issues/122858#issuecomment-2466239748 +if sys.version_info >= (3, 12): + iscoroutinefunction = inspect.iscoroutinefunction +else: + iscoroutinefunction = asyncio.iscoroutinefunction diff --git a/redbot/core/utils/antispam.py b/redbot/core/utils/antispam.py index 70d46d9a007..4f91fe7fcc4 100644 --- a/redbot/core/utils/antispam.py +++ b/redbot/core/utils/antispam.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +import datetime from typing import Tuple, List from collections import namedtuple @@ -89,13 +89,13 @@ async def report(self, ctx, content): # for manual stamping on successful command completion default_intervals = [ - (timedelta(seconds=5), 3), - (timedelta(minutes=1), 5), - (timedelta(hours=1), 10), - (timedelta(days=1), 24), + (datetime.timedelta(seconds=5), 3), + (datetime.timedelta(minutes=1), 5), + (datetime.timedelta(hours=1), 10), + (datetime.timedelta(days=1), 24), ] - def __init__(self, intervals: List[Tuple[timedelta, int]]): + def __init__(self, intervals: List[Tuple[datetime.timedelta, int]]): self.__event_timestamps = [] _itvs = intervals or self.default_intervals self.__intervals = [_AntiSpamInterval(*x) for x in _itvs] @@ -103,7 +103,13 @@ def __init__(self, intervals: List[Tuple[timedelta, int]]): def __interval_check(self, interval: _AntiSpamInterval): return ( - len([t for t in self.__event_timestamps if (t + interval.period) > datetime.utcnow()]) + len( + [ + t + for t in self.__event_timestamps + if (t + interval.period) > datetime.datetime.now() + ] + ) >= interval.frequency ) @@ -123,7 +129,9 @@ def stamp(self): The stamp will last until the corresponding interval duration has expired (set when this AntiSpam object was initiated). """ - self.__event_timestamps.append(datetime.utcnow()) + self.__event_timestamps.append(datetime.datetime.now()) self.__event_timestamps = [ - t for t in self.__event_timestamps if t + self.__discard_after > datetime.utcnow() + t + for t in self.__event_timestamps + if t + self.__discard_after > datetime.datetime.now() ] diff --git a/redbot/core/utils/menus.py b/redbot/core/utils/menus.py index 7176188d7cd..8b1039ff70c 100644 --- a/redbot/core/utils/menus.py +++ b/redbot/core/utils/menus.py @@ -11,6 +11,7 @@ import discord from .. import commands +from ._internal_utils import iscoroutinefunction from .predicates import ReactionPredicate from .views import SimpleMenu, _SimplePageSource @@ -203,7 +204,7 @@ async def control_no(*args, **kwargs): maybe_coro = value if isinstance(value, functools.partial): maybe_coro = value.func - if not asyncio.iscoroutinefunction(maybe_coro): + if not iscoroutinefunction(maybe_coro): raise RuntimeError("Function must be a coroutine") if await ctx.bot.use_buttons() and message is None: diff --git a/redbot/core/utils/tunnel.py b/redbot/core/utils/tunnel.py index 15e41ade6e0..a1d0c287979 100644 --- a/redbot/core/utils/tunnel.py +++ b/redbot/core/utils/tunnel.py @@ -1,6 +1,6 @@ import asyncio import discord -from datetime import datetime +import datetime from redbot.core.utils.chat_formatting import pagify import io import weakref @@ -77,7 +77,7 @@ def __init__( self.sender = sender self.origin = origin self.recipient = recipient - self.last_interaction = datetime.utcnow() + self.last_interaction = datetime.datetime.now(datetime.timezone.utc) async def react_close(self, *, uid: int, message: str = ""): send_to = self.recipient if uid == self.sender.id else self.origin @@ -90,7 +90,9 @@ def members(self): @property def minutes_since(self): - return int((self.last_interaction - datetime.utcnow()).seconds / 60) + return int( + (self.last_interaction - datetime.datetime.now(datetime.timezone.utc)).seconds / 60 + ) @staticmethod async def message_forwarder( @@ -252,6 +254,6 @@ async def communicate( await message.add_reaction("\N{WHITE HEAVY CHECK MARK}") await message.add_reaction("\N{NEGATIVE SQUARED CROSS MARK}") - self.last_interaction = datetime.utcnow() + self.last_interaction = datetime.datetime.now(datetime.timezone.utc) await rets[-1].add_reaction("\N{NEGATIVE SQUARED CROSS MARK}") return [rets[-1].id, message.id] diff --git a/redbot/ext_cogs/__init__.py b/redbot/ext_cogs/__init__.py new file mode 100644 index 00000000000..f5bc173167c --- /dev/null +++ b/redbot/ext_cogs/__init__.py @@ -0,0 +1,8 @@ +""" +Package for magically importing 3rd party cogs. + +In terms of this package as a directory, this `__init__.py` file should +always be the only file it contains. It exists so the cog manager can +modify its `__path__` attribute to allow importing cogs as child +packages of this package. +""" diff --git a/redbot/pytest/downloader.py b/redbot/pytest/downloader.py index 4457cc3e9e2..64dfc5a00d8 100644 --- a/redbot/pytest/downloader.py +++ b/redbot/pytest/downloader.py @@ -71,7 +71,7 @@ def repo(tmp_path): @pytest.fixture -def bot_repo(event_loop): +async def bot_repo(): cwd = Path.cwd() return Repo( name="Red-DiscordBot", @@ -163,7 +163,7 @@ def _init_test_repo(destination: Path): @pytest.fixture(scope="session") -async def _session_git_repo(tmp_path_factory, event_loop): +async def _session_git_repo(tmp_path_factory): # we will import repo only once once per session and duplicate the repo folder repo_path = tmp_path_factory.mktemp("session_git_repo") repo = Repo(name="redbot-testrepo", url="", branch="master", commit="", folder_path=repo_path) @@ -179,7 +179,7 @@ async def _session_git_repo(tmp_path_factory, event_loop): @pytest.fixture -async def git_repo(_session_git_repo, tmp_path, event_loop): +async def git_repo(_session_git_repo, tmp_path): # fixture only copies repo that was imported in _session_git_repo repo_path = tmp_path / "redbot-testrepo" shutil.copytree(_session_git_repo.folder_path, repo_path) @@ -194,7 +194,7 @@ async def git_repo(_session_git_repo, tmp_path, event_loop): @pytest.fixture -async def cloned_git_repo(_session_git_repo, tmp_path, event_loop): +async def cloned_git_repo(_session_git_repo, tmp_path): # don't use this if you want to edit origin repo repo_path = tmp_path / "redbot-cloned_testrepo" repo = Repo( @@ -209,7 +209,7 @@ async def cloned_git_repo(_session_git_repo, tmp_path, event_loop): @pytest.fixture -async def git_repo_with_remote(git_repo, tmp_path, event_loop): +async def git_repo_with_remote(git_repo, tmp_path): # this can safely be used when you want to do changes to origin repo repo_path = tmp_path / "redbot-testrepo_with_remote" repo = Repo( diff --git a/redbot/vendored/discord/ext/menus/__init__.py b/redbot/vendored/discord/ext/menus/__init__.py index 661433ed9e0..cee6c1ad8e3 100644 --- a/redbot/vendored/discord/ext/menus/__init__.py +++ b/redbot/vendored/discord/ext/menus/__init__.py @@ -606,26 +606,23 @@ async def _internal_loop(self): self.__timed_out = False # Can't do any requests if the bot is closed - if self.bot.is_closed(): - return - - # Wrap it in another block anyway just to ensure - # nothing leaks out during clean-up - try: - if self.delete_message_after: - return await self.message.delete() - - if self.clear_reactions_after: - if self._can_remove_reactions: - return await self.message.clear_reactions() - - for button_emoji in self.buttons: - try: - await self.message.remove_reaction(button_emoji, self.__me) - except discord.HTTPException: - continue - except Exception: - pass + if not self.bot.is_closed(): + # Wrap it in another block anyway just to ensure + # nothing leaks out during clean-up + try: + if self.delete_message_after: + await self.message.delete() + elif self.clear_reactions_after: + if self._can_remove_reactions: + await self.message.clear_reactions() + else: + for button_emoji in self.buttons: + try: + await self.message.remove_reaction(button_emoji, self.__me) + except discord.HTTPException: + continue + except Exception: + pass async def update(self, payload): """|coro| diff --git a/requirements/base.in b/requirements/base.in index fea323badcd..6ac6b8bb1fe 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -1,4 +1,4 @@ -aiohttp<3.10 +aiohttp aiohttp-json-rpc apsw babel diff --git a/requirements/base.txt b/requirements/base.txt index 59b0f243295..860aa5b3ee3 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,6 @@ -aiohttp==3.9.5 +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.13.5 # via # -r base.in # aiohttp-json-rpc @@ -6,55 +8,57 @@ aiohttp==3.9.5 # red-lavalink aiohttp-json-rpc==0.13.3 # via -r base.in -aiosignal==1.3.1 +aiosignal==1.4.0 # via aiohttp -apsw==3.46.1.0 +apsw==3.53.0.0 # via -r base.in -attrs==25.3.0 +attrs==26.1.0 # via aiohttp babel==2.18.0 # via -r base.in brotli==1.2.0 # via -r base.in -click==8.1.8 +click==8.3.2 # via -r base.in discord-py==2.7.1 # via # -r base.in # red-lavalink -frozenlist==1.5.0 +frozenlist==1.8.0 # via # aiohttp # aiosignal idna==3.11 # via yarl -markdown==3.7 +markdown==3.10.2 # via -r base.in -markdown-it-py==3.0.0 +markdown-it-py==4.0.0 # via rich mdurl==0.1.2 # via markdown-it-py -multidict==6.1.0 +multidict==6.7.1 # via # aiohttp # yarl -orjson==3.10.15 +orjson==3.11.8 # via -r base.in packaging==26.0 # via -r base.in -platformdirs==4.3.6 +platformdirs==4.9.6 # via -r base.in -propcache==0.2.0 - # via yarl +propcache==0.4.1 + # via + # aiohttp + # yarl psutil==7.2.2 # via -r base.in -pygments==2.19.2 +pygments==2.20.0 # via rich python-dateutil==2.9.0.post0 # via -r base.in pyyaml==6.0.3 # via -r base.in -rapidfuzz==3.9.7 +rapidfuzz==3.14.5 # via -r base.in red-commons==1.0.0 # via @@ -62,33 +66,25 @@ red-commons==1.0.0 # red-lavalink red-lavalink==0.11.1 # via -r base.in -rich==14.3.3 +rich==15.0.0 # via -r base.in schema==0.7.8 # via -r base.in six==1.17.0 # via python-dateutil -typing-extensions==4.13.2 +typing-extensions==4.15.0 # via # -r base.in - # multidict -yarl==1.15.2 + # aiosignal +yarl==1.23.0 # via # -r base.in # aiohttp -zstandard==0.23.0 +zstandard==0.25.0 # via -r base.in -async-timeout==4.0.3; python_version != "3.11" - # via aiohttp colorama==0.4.6; sys_platform == "win32" # via click distro==1.9.0; sys_platform == "linux" and sys_platform == "linux" # via -r base.in -importlib-metadata==8.5.0; python_version != "3.10" and python_version != "3.11" - # via markdown -pytz==2026.1.post1; python_version == "3.8" - # via babel uvloop==0.21.0; (sys_platform != "win32" and platform_python_implementation == "CPython") and sys_platform != "win32" # via -r base.in -zipp==3.20.2; python_version != "3.10" and python_version != "3.11" - # via importlib-metadata diff --git a/requirements/extra-doc.txt b/requirements/extra-doc.txt index 462e066a983..e156b5b57f0 100644 --- a/requirements/extra-doc.txt +++ b/requirements/extra-doc.txt @@ -1,62 +1,60 @@ -alabaster==0.7.13 +alabaster==1.0.0 # via sphinx certifi==2026.2.25 + # via + # requests + # sphinx-prompt +charset-normalizer==3.4.7 # via requests -charset-normalizer==3.4.4 - # via requests -docutils==0.20.1 +docutils==0.22.4 # via # sphinx # sphinx-prompt # sphinx-rtd-theme -imagesize==1.5.0 +imagesize==2.0.0 # via sphinx -importlib-metadata==8.5.0 +jinja2==3.1.6 # via - # -c base.txt # sphinx -jinja2==3.1.6 - # via sphinx -markupsafe==2.1.5 + # sphinx-prompt +markupsafe==3.0.3 # via jinja2 -pytz==2026.1.post1 +requests==2.33.1 # via - # -c base.txt - # babel -requests==2.32.4 + # sphinx + # sphinx-prompt +roman-numerals==4.1.0 # via sphinx snowballstemmer==3.0.1 # via sphinx -sphinx==7.1.2 +sphinx==9.0.4 # via # -r extra-doc.in # sphinx-prompt # sphinx-rtd-theme # sphinxcontrib-jquery # sphinxcontrib-trio -sphinx-prompt==1.7.0 +sphinx-prompt==1.10.2 # via -r extra-doc.in sphinx-rtd-theme==3.1.0 # via -r extra-doc.in -sphinxcontrib-applehelp==1.0.4 +sphinxcontrib-applehelp==2.0.0 # via sphinx -sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-devhelp==2.0.0 # via sphinx -sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jquery==4.1 # via sphinx-rtd-theme sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-qthelp==2.0.0 # via sphinx -sphinxcontrib-serializinghtml==1.1.5 +sphinxcontrib-serializinghtml==2.0.0 # via sphinx sphinxcontrib-trio==1.2.0 # via -r extra-doc.in -urllib3==2.2.3 - # via requests -zipp==3.20.2 +urllib3==2.6.3 # via - # -c base.txt - # importlib-metadata + # requests + # sphinx-prompt diff --git a/requirements/extra-postgres.txt b/requirements/extra-postgres.txt index ae8987f19eb..6a4dc6dc024 100644 --- a/requirements/extra-postgres.txt +++ b/requirements/extra-postgres.txt @@ -1,2 +1,2 @@ -asyncpg==0.30.0 +asyncpg==0.31.0 # via -r extra-postgres.in diff --git a/requirements/extra-style.txt b/requirements/extra-style.txt index ba655a2035c..11ee217eda6 100644 --- a/requirements/extra-style.txt +++ b/requirements/extra-style.txt @@ -2,7 +2,5 @@ black==23.12.1 # via -r extra-style.in mypy-extensions==1.1.0 # via black -pathspec==0.12.1 - # via black -tomli==2.4.0 +pathspec==1.0.4 # via black diff --git a/requirements/extra-test.in b/requirements/extra-test.in index 226c71b33d4..b726962fd8e 100644 --- a/requirements/extra-test.in +++ b/requirements/extra-test.in @@ -1,6 +1,6 @@ -c base.txt pylint -pytest<8 -pytest-asyncio<0.22 +pytest +pytest-asyncio pytest-mock diff --git a/requirements/extra-test.txt b/requirements/extra-test.txt index 33f8b5bb2ff..c862ab9bd61 100644 --- a/requirements/extra-test.txt +++ b/requirements/extra-test.txt @@ -1,31 +1,25 @@ -astroid==3.2.4 +astroid==4.0.4 # via pylint -dill==0.4.0 +dill==0.4.1 # via pylint -iniconfig==2.1.0 +iniconfig==2.3.0 # via pytest -isort==5.13.2 +isort==8.0.1 # via pylint mccabe==0.7.0 # via pylint -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -pylint==3.2.7 +pylint==4.0.5 # via -r extra-test.in -pytest==7.4.4 +pytest==9.0.3 # via # -r extra-test.in # pytest-asyncio # pytest-mock -pytest-asyncio==0.21.2 +pytest-asyncio==1.3.0 # via -r extra-test.in -pytest-mock==3.14.1 +pytest-mock==3.15.1 # via -r extra-test.in -tomlkit==0.13.3 +tomlkit==0.14.0 # via pylint -exceptiongroup==1.3.1; python_version != "3.11" - # via pytest -tomli==2.4.0; python_version != "3.11" - # via - # pylint - # pytest diff --git a/setup.py b/setup.py index 3ecbf527d9e..e01c0f3da3a 100644 --- a/setup.py +++ b/setup.py @@ -46,9 +46,9 @@ def extras_combined(*extra_names): extras_require["all"] = extras_combined("postgres") -python_requires = ">=3.8.1" -if not os.getenv("TOX_RED", False) or sys.version_info < (3, 12): - python_requires += ",<3.12" +python_requires = ">=3.11" +if not os.getenv("TOX_RED", False) or sys.version_info < (3, 15): + python_requires += ",<3.15" # Metadata and options defined in pyproject.toml setup( @@ -57,8 +57,4 @@ def extras_combined(*extra_names): # TODO: use [tool.setuptools.dynamic] table once this feature gets out of beta install_requires=install_requires, extras_require=extras_require, - # TODO: use [project] table once PEP 639 gets accepted - license_files=["LICENSE", "redbot/**/*.LICENSE"], - # TODO: use [tool.setuptools.packages] table once this feature gets out of beta - packages=find_namespace_packages(include=["redbot", "redbot.*"]), ) diff --git a/tests/conftest.py b/tests/conftest.py index cb5cb7c66ee..82fa211e089 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,8 @@ @pytest.fixture(scope="session") def event_loop(request): """Create an instance of the default event loop for entire session.""" + # DEP-WARN: switch to event loop factory when pytest-asyncio 1.4.0 releases: + # https://github.com/pytest-dev/pytest-asyncio/pull/1373 loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) yield loop diff --git a/tests/core/test_dev_commands.py b/tests/core/test_dev_commands.py index b4a6b863e9d..8f2fea62950 100644 --- a/tests/core/test_dev_commands.py +++ b/tests/core/test_dev_commands.py @@ -187,20 +187,12 @@ async def _run_dev_output( assert not output.ctx.mock_calls -EXPRESSION_TESTS = { - # invalid syntax - "12x\n": ( - ( - lambda v: v < (3, 10), - """\ - File "", line 1 - 12x - ^ - SyntaxError: invalid syntax - """, - ), +@pytest.mark.parametrize( + "source,result", + ( + # invalid syntax ( - lambda v: v >= (3, 10), + "12x\n", """\ File "", line 1 12x @@ -208,19 +200,8 @@ async def _run_dev_output( SyntaxError: invalid decimal literal """, ), - ), - "foo(x, z for z in range(10), t, w)": ( ( - lambda v: v < (3, 10), - """\ - File "", line 1 - foo(x, z for z in range(10), t, w) - ^ - SyntaxError: Generator expression must be parenthesized - """, - ), - ( - lambda v: v >= (3, 10), + "foo(x, z for z in range(10), t, w)", """\ File "", line 1 foo(x, z for z in range(10), t, w) @@ -228,20 +209,9 @@ async def _run_dev_output( SyntaxError: Generator expression must be parenthesized """, ), - ), - # exception raised - "abs(1 / 0)": ( - ( - lambda v: v < (3, 11), - """\ - Traceback (most recent call last): - File "", line 1, in - abs(1 / 0) - ZeroDivisionError: division by zero - """, - ), + # exception raised ( - lambda v: v >= (3, 11), + "abs(1 / 0)", """\ Traceback (most recent call last): File "", line 1, in @@ -251,24 +221,22 @@ async def _run_dev_output( """, ), ), -} -STATEMENT_TESTS = { - # invalid syntax - """\ - def x(): - 12x - """: ( +) +async def test_format_exception_expressions( + monkeypatch: pytest.MonkeyPatch, source: str, result: str +) -> None: + await _run_dev_output(monkeypatch, source, result, debug=True, repl=True) + + +@pytest.mark.parametrize( + "source,result", + ( + # invalid syntax ( - lambda v: v < (3, 10), """\ - File "", line 2 + def x(): 12x - ^ - SyntaxError: invalid syntax """, - ), - ( - lambda v: v >= (3, 10), """\ File "", line 2 12x @@ -276,22 +244,11 @@ def x(): SyntaxError: invalid decimal literal """, ), - ), - """\ - def x(): - foo(x, z for z in range(10), t, w) - """: ( ( - lambda v: v < (3, 10), """\ - File "", line 2 + def x(): foo(x, z for z in range(10), t, w) - ^ - SyntaxError: Generator expression must be parenthesized """, - ), - ( - lambda v: v >= (3, 10), """\ File "", line 2 foo(x, z for z in range(10), t, w) @@ -299,27 +256,15 @@ def x(): SyntaxError: Generator expression must be parenthesized """, ), - ), - # exception raised - """\ - print(123) - try: - abs(1 / 0) - except ValueError: - pass - """: ( + # exception raised ( - lambda v: v < (3, 11), """\ - 123 - Traceback (most recent call last): - File "", line 3, in + print(123) + try: abs(1 / 0) - ZeroDivisionError: division by zero + except ValueError: + pass """, - ), - ( - lambda v: v >= (3, 11), """\ 123 Traceback (most recent call last): @@ -329,42 +274,17 @@ def x(): ZeroDivisionError: division by zero """, ), - ), - # exception chaining - """\ - try: - 1 / 0 - except ZeroDivisionError as exc: - try: - raise RuntimeError("direct cause") from exc - except RuntimeError: - raise ValueError("indirect cause") - """: ( + # exception chaining ( - lambda v: v < (3, 11), """\ - Traceback (most recent call last): - File "", line 2, in + try: 1 / 0 - ZeroDivisionError: division by zero - - The above exception was the direct cause of the following exception: - - Traceback (most recent call last): - File "", line 5, in - raise RuntimeError("direct cause") from exc - RuntimeError: direct cause - - During handling of the above exception, another exception occurred: - - Traceback (most recent call last): - File "", line 7, in - raise ValueError("indirect cause") - ValueError: indirect cause + except ZeroDivisionError as exc: + try: + raise RuntimeError("direct cause") from exc + except RuntimeError: + raise ValueError("indirect cause") """, - ), - ( - lambda v: v >= (3, 11), """\ Traceback (most recent call last): File "", line 2, in @@ -387,28 +307,26 @@ def x(): ValueError: indirect cause """, ), - ), - # exception groups - """\ - def f(v): - try: - 1 / 0 - except ZeroDivisionError: - try: - raise ValueError(v) - except ValueError as e: - return e - try: - raise ExceptionGroup("one", [f(1)]) - except ExceptionGroup as e: - eg = e - try: - raise ExceptionGroup("two", [f(2), eg]) - except ExceptionGroup as e: - raise RuntimeError("wrapping") from e - """: ( + # exception groups ( - lambda v: v >= (3, 11), + """\ + def f(v): + try: + 1 / 0 + except ZeroDivisionError: + try: + raise ValueError(v) + except ValueError as e: + return e + try: + raise ExceptionGroup("one", [f(1)]) + except ExceptionGroup as e: + eg = e + try: + raise ExceptionGroup("two", [f(2), eg]) + except ExceptionGroup as e: + raise RuntimeError("wrapping") from e + """, """\ + Exception Group Traceback (most recent call last): | File "", line 14, in @@ -456,32 +374,6 @@ def f(v): """, ), ), -} - - -@pytest.mark.parametrize( - "source,result", - [ - (source, result) - for source, results in EXPRESSION_TESTS.items() - for condition, result in results - if condition(sys.version_info) - ], -) -async def test_format_exception_expressions( - monkeypatch: pytest.MonkeyPatch, source: str, result: str -) -> None: - await _run_dev_output(monkeypatch, source, result, debug=True, repl=True) - - -@pytest.mark.parametrize( - "source,result", - [ - (source, result) - for source, results in STATEMENT_TESTS.items() - for condition, result in results - if condition(sys.version_info) - ], ) async def test_format_exception_statements( monkeypatch: pytest.MonkeyPatch, source: str, result: str @@ -526,7 +418,8 @@ async def test_successful_run_repl_exec(monkeypatch: pytest.MonkeyPatch) -> None await _run_dev_output(monkeypatch, source, result, repl=True) -async def test_regression_format_exception_from_previous_snippet( +# https://github.com/Cog-Creators/Red-DiscordBot/pull/6135 +async def test_regression_gh_6135_format_exception_from_previous_snippet( monkeypatch: pytest.MonkeyPatch, ) -> None: snippet_0 = textwrap.dedent( @@ -538,16 +431,30 @@ def repro(): """ ) snippet_1 = "_()" - result = textwrap.dedent( - """\ - Traceback (most recent call last): - File "", line 1, in func - _() - File "", line 2, in repro - raise Exception("this is an error!") - Exception: this is an error! - """ - ) + if sys.version_info >= (3, 13): + # Python 3.13 now points out the specific function call that raised the exception + result = textwrap.dedent( + """\ + Traceback (most recent call last): + File "", line 1, in func + _() + ~^^ + File "", line 2, in repro + raise Exception("this is an error!") + Exception: this is an error! + """ + ) + else: + result = textwrap.dedent( + """\ + Traceback (most recent call last): + File "", line 1, in func + _() + File "", line 2, in repro + raise Exception("this is an error!") + Exception: this is an error! + """ + ) monkeypatch.setattr("redbot.core.dev_commands.sanitize_output", lambda ctx, s: s) source_cache = SourceCache() diff --git a/tests/core/test_rpc.py b/tests/core/test_rpc.py index 12e3c6b5ee4..fba1cdef153 100644 --- a/tests/core/test_rpc.py +++ b/tests/core/test_rpc.py @@ -79,7 +79,7 @@ def test_remove_multi_method(rpc, existing_multi_func): def test_rpcmixin_register(rpcmixin, cog): rpcmixin.register_rpc_handler(cog.cofunc) - assert rpcmixin.rpc.add_method.called_once_with(cog.cofunc) + rpcmixin.rpc.add_method.assert_called_once_with(cog.cofunc) name = get_name(cog.cofunc) cogname = name.split("__")[0] @@ -91,7 +91,7 @@ def test_rpcmixin_unregister(rpcmixin, cog): rpcmixin.register_rpc_handler(cog.cofunc) rpcmixin.unregister_rpc_handler(cog.cofunc) - assert rpcmixin.rpc.remove_method.called_once_with(cog.cofunc) + rpcmixin.rpc.remove_method.assert_called_once_with(cog.cofunc) name = get_name(cog.cofunc) cogname = name.split("__")[0] diff --git a/tests/core/test_version.py b/tests/core/test_version.py index fc38967005e..c529217eea2 100644 --- a/tests/core/test_version.py +++ b/tests/core/test_version.py @@ -73,7 +73,7 @@ def test_python_version_has_lower_bound(): @pytest.mark.skipif( - os.getenv("TOX_RED", False) and sys.version_info >= (3, 12), + os.getenv("TOX_RED", False) and sys.version_info >= (3, 15), reason="Testing on yet to be supported Python version.", ) def test_python_version_has_upper_bound(): diff --git a/tools/edit_testrepo.py b/tools/edit_testrepo.py index 93a4b29db7c..c6fcfb18f07 100644 --- a/tools/edit_testrepo.py +++ b/tools/edit_testrepo.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3.8 +#!/usr/bin/env python3.11 """Script to edit test repo used by Downloader git integration tests. This script aims to help update the human-readable version of repo diff --git a/tools/release_helper.py b/tools/release_helper.py index 83a86836298..86ef18a87d9 100755 --- a/tools/release_helper.py +++ b/tools/release_helper.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3.8 +#!/usr/bin/env python3.11 """Script helping with making releases. This script mostly aims to help with the changelog-related tasks but it does also guide you diff --git a/tox.ini b/tox.ini index de84c457add..c17b71752a9 100644 --- a/tox.ini +++ b/tox.ini @@ -5,10 +5,10 @@ [tox] envlist = - py38 - py39 - py310 py311 + py312 + py313 + py314 docs style skip_missing_interpreters = True @@ -53,8 +53,8 @@ allowlist_externals = setenv = # This is just for Windows # Prioritise make.bat over any make.exe which might be on PATH - PATHEXT=.BAT;.EXE -basepython = python3.8 + PATHEXT=.BAT\;.EXE +basepython = python3.11 extras = doc commands = sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out/html" -W --keep-going -bhtml @@ -67,8 +67,8 @@ allowlist_externals = setenv = # This is just for Windows # Prioritise make.bat over any make.exe which might be on PATH - PATHEXT=.BAT;.EXE -basepython = python3.8 + PATHEXT=.BAT\;.EXE +basepython = python3.11 extras = style commands = make stylediff