From dd9fe53059ed8bc2df6e76928dad6e4f2828b9bf Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 15 Jul 2024 22:07:47 +0200 Subject: [PATCH 001/166] Initial commit --- .github/workflows/lint.yaml | 35 +++++++ .gitignore | 31 ++++++ .pre-commit-config.yaml | 18 ++++ LICENSE.txt | 7 ++ README.md | 184 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 44 +++++++++ requirements-dev.txt | 6 ++ samples/Pipfile | 15 +++ samples/pyproject.toml | 19 ++++ 9 files changed, 359 insertions(+) create mode 100644 .github/workflows/lint.yaml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 samples/Pipfile create mode 100644 samples/pyproject.toml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..7f67e80 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,35 @@ +# GitHub Action workflow enforcing our code style. + +name: Lint + +# Trigger the workflow on both push (to the main repository, on the main branch) +# and pull requests (against the main repository, but from any repo, from any branch). +on: + push: + branches: + - main + pull_request: + +# Brand new concurrency setting! This ensures that not more than one run can be triggered for the same commit. +# It is useful for pull requests coming from the main repository since both triggers will match. +concurrency: lint-${{ github.sha }} + +jobs: + lint: + runs-on: ubuntu-latest + + env: + # The Python version your project uses. Feel free to change this if required. + PYTHON_VERSION: "3.12" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..233eb87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Files generated by the interpreter +__pycache__/ +*.py[cod] + +# Environment specific +.venv +venv +.env +env + +# Unittest reports +.coverage* + +# Logs +*.log + +# PyEnv version selector +.python-version + +# Built objects +*.so +dist/ +build/ + +# IDEs +# PyCharm +.idea/ +# VSCode +.vscode/ +# MacOS +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4bccb6f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +# Pre-commit configuration. +# See https://github.com/python-discord/code-jam-template/tree/main#pre-commit-run-linting-before-committing + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.0 + hooks: + - id: ruff + - id: ruff-format diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..5a04926 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2021 Python Discord + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d50f7b7 --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# Python Discord Code Jam Repository Template + +## A primer + +Hello code jam participants! We've put together this repository template for you to use in [our code jams](https://pythondiscord.com/events/) or even other Python events! + +This document contains the following information: + +1. [What does this template contain?](#what-does-this-template-contain) +2. [How do I use this template?](#how-do-i-use-this-template) +3. [How do I adapt this template to my project?](#how-do-i-adapt-this-template-to-my-project) + +> [!TIP] +> You can also look at [our style guide](https://pythondiscord.com/events/code-jams/code-style-guide/) to get more information about what we consider a maintainable code style. + +## What does this template contain? + +Here is a quick rundown of what each file in this repository contains: + +- [`LICENSE.txt`](LICENSE.txt): [The MIT License](https://opensource.org/licenses/MIT), an OSS approved license which grants rights to everyone to use and modify your project, and limits your liability. We highly recommend you to read the license. +- [`.gitignore`](.gitignore): A list of files and directories that will be ignored by Git. Most of them are auto-generated or contain data that you wouldn't want to share publicly. +- [`requirements-dev.txt`](requirements-dev.txt): Every PyPI package used for the project's development, to ensure a common development environment. More on that [below](#using-the-default-pip-setup). +- [`pyproject.toml`](pyproject.toml): Configuration and metadata for the project, as well as the linting tool Ruff. If you're interested, you can read more about `pyproject.toml` in the [Python Packaging documentation](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/). +- [`.pre-commit-config.yaml`](.pre-commit-config.yaml): The configuration of the [pre-commit](https://pre-commit.com/) tool. +- [`.github/workflows/lint.yaml`](.github/workflows/lint.yaml): A [GitHub Actions](https://github.com/features/actions) workflow, a set of actions run by GitHub on their server after each push, to ensure the style requirements are met. + +Each of these files have comments for you to understand easily, and modify to fit your needs. + +### Ruff: general style rules + +Our first tool is Ruff. It will check your codebase and warn you about any non-conforming lines. +It is run with the command `ruff check` in the project root. + +Here is a sample output: + +```shell +$ ruff check +app.py:1:5: N802 Function name `helloWorld` should be lowercase +app.py:1:5: ANN201 Missing return type annotation for public function `helloWorld` +app.py:2:5: D400 First line should end with a period +app.py:2:5: D403 First word of the first line should be capitalized: `docstring` -> `Docstring` +app.py:3:15: W292 No newline at end of file +Found 5 errors. +``` + +Each line corresponds to an error. The first part is the file path, then the line number, and the column index. +Then comes the error code, a unique identifier of the error, and then a human-readable message. + +If, for any reason, you do not wish to comply with this specific error on a specific line, you can add `# noqa: CODE` at the end of the line. +For example: + +```python +def helloWorld(): # noqa: N802 + ... + +``` + +This will ignore the function naming issue and pass linting. + +> [!WARNING] +> We do not recommend ignoring errors unless you have a good reason to do so. + +### Ruff: formatting + +Ruff also comes with a formatter, which can be run with the command `ruff format`. +It follows the same code style enforced by [Black](https://black.readthedocs.io/en/stable/index.html), so there's no need to pick between them. + +### Pre-commit: run linting before committing + +The second tool doesn't check your code, but rather makes sure that you actually *do* check it. + +It makes use of a feature called [Git hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) which allow you to run a piece of code before running `git commit`. +The good thing about it is that it will cancel your commit if the lint doesn't pass. You won't have to wait for GitHub Actions to report issues and have a second fix commit. + +It is *installed* by running `pre-commit install` and can be run manually by calling only `pre-commit`. + +[Lint before you push!](https://soundcloud.com/lemonsaurusrex/lint-before-you-push) + +#### List of hooks + +- `check-toml`: Lints and corrects your TOML files. +- `check-yaml`: Lints and corrects your YAML files. +- `end-of-file-fixer`: Makes sure you always have an empty line at the end of your file. +- `trailing-whitespace`: Removes whitespaces at the end of each line. +- `ruff`: Runs the Ruff linter. +- `ruff-format`: Runs the Ruff formatter. + +## How do I use this template? + +### Creating your team repository + +One person in the team, preferably the leader, will have to create the repository and add other members as collaborators. + +1. In the top right corner of your screen, where **Clone** usually is, you have a **Use this template** button to click. + ![use-this-template-button](https://docs.github.com/assets/images/help/repository/use-this-template-button.png) +2. Give the repository a name and a description. + ![create-repository-name](https://docs.github.com/assets/images/help/repository/create-repository-name.png) +3. Click **Create repository from template**. +4. Click **Settings** in your newly created repository. + ![repo-actions-settings](https://docs.github.com/assets/images/help/repository/repo-actions-settings.png) +5. In the "Access" section of the sidebar, click **Collaborators**. + ![collaborators-settings](https://github.com/python-discord/code-jam-template/assets/63936253/c150110e-d1b5-4e4d-93e0-0a2cf1de352b) +6. Click **Add people**. +7. Insert the names of each of your teammates, and invite them. Once they have accepted the invitation in their email, they will have write access to the repository. + +You are now ready to go! Sit down, relax, and wait for the kickstart! + +> [!IMPORTANT] +> Don't forget to swap "Python Discord" in the [`LICENSE.txt`](LICENSE.txt) file for the name of each of your team members or the name of your team *after* the start of the code jam. + +### Using the default pip setup + +Our default setup includes a bare requirements file to be used with a [virtual environment](https://docs.python.org/3/library/venv.html). +We recommend this if you have never used any other dependency manager, although if you have, feel free to switch to it. More on that [below](#how-do-i-adapt-this-template-to-my-project). + +#### Creating the environment + +Create a virtual environment in the folder `.venv`. + +```shell +python -m venv .venv +``` + +#### Entering the environment + +It will change based on your operating system and shell. + +```shell +# Linux, Bash +$ source .venv/bin/activate +# Linux, Fish +$ source .venv/bin/activate.fish +# Linux, Csh +$ source .venv/bin/activate.csh +# Linux, PowerShell Core +$ .venv/bin/Activate.ps1 +# Windows, cmd.exe +> .venv\Scripts\activate.bat +# Windows, PowerShell +> .venv\Scripts\Activate.ps1 +``` + +#### Installing the dependencies + +Once the environment is created and activated, use this command to install the development dependencies. + +```shell +pip install -r requirements-dev.txt +``` + +#### Exiting the environment + +Interestingly enough, it is the same for every platform. + +```shell +deactivate +``` + +Once the environment is activated, all the commands listed previously should work. + +> [!IMPORTANT] +> We highly recommend that you run `pre-commit install` as soon as possible. + +## How do I adapt this template to my project? + +If you wish to use Pipenv or Poetry, you will have to move the dependencies in [`requirements-dev.txt`](requirements-dev.txt) to the development dependencies of your tool. + +We've included a porting of [`requirements-dev.txt`](requirements-dev.txt) to both [Poetry](samples/pyproject.toml) and [Pipenv](samples/Pipfile) in the [`samples` folder](samples). +If you use the Poetry setup, make sure to change the project name, description, and authors at the top of the file. +Also note that the Poetry [`pyproject.toml`](samples/pyproject.toml) file does not include the Ruff configuration, so if you simply replace the file then the Ruff configuration will be lost. + +When installing new dependencies, don't forget to [pin](https://pip.pypa.io/en/stable/topics/repeatable-installs/#pinning-the-package-versions) them by adding a version tag at the end. +For example, if I wish to install [Click](https://click.palletsprojects.com/en/8.1.x/), a quick look at [PyPI](https://pypi.org/project/click/) tells me that `8.1.7` is the latest version. +I will then add `click~=8.1`, without the last number, to my requirements file or dependency manager. + +> [!IMPORTANT] +> A code jam project is left unmaintained after the end of the event. If the dependencies aren't pinned, the project will break after any major change in an API. + +## Final words + +> [!IMPORTANT] +> Don't forget to replace this README with an actual description of your project! Images are also welcome! + +We hope this template will be helpful. Good luck in the jam! diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0880be9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[tool.ruff] +# Increase the line length. This breaks PEP8 but it is way easier to work with. +# The original reason for this limit was a standard vim terminal is only 79 characters, +# but this doesn't really apply anymore. +line-length = 119 +# Target Python 3.12. If you decide to use a different version of Python +# you will need to update this value. +target-version = "py312" +# Automatically fix auto-fixable issues. +fix = true +# The directory containing the source code. If you choose a different project layout +# you will need to update this value. +src = ["src"] + +[tool.ruff.lint] +# Enable all linting rules. +select = ["ALL"] +# Ignore some of the most obnoxious linting errors. +ignore = [ + # Missing docstrings. + "D100", + "D104", + "D105", + "D106", + "D107", + # Docstring whitespace. + "D203", + "D213", + # Docstring punctuation. + "D415", + # Docstring quotes. + "D301", + # Builtins. + "A", + # Print statements. + "T20", + # TODOs. + "TD002", + "TD003", + "FIX", + # Annotations. + "ANN101", + "ANN102", +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..d529f2e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +# This file contains all the development requirements for our linting toolchain. +# Don't forget to pin your dependencies! +# This list will have to be migrated if you wish to use another dependency manager. + +ruff~=0.5.0 +pre-commit~=3.7.1 diff --git a/samples/Pipfile b/samples/Pipfile new file mode 100644 index 0000000..27673c0 --- /dev/null +++ b/samples/Pipfile @@ -0,0 +1,15 @@ +# Sample Pipfile. + +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] +ruff = "~=0.5.0" +pre-commit = "~=3.7.1" + +[requires] +python_version = "3.12" diff --git a/samples/pyproject.toml b/samples/pyproject.toml new file mode 100644 index 0000000..835045d --- /dev/null +++ b/samples/pyproject.toml @@ -0,0 +1,19 @@ +# Sample poetry configuration. + +[tool.poetry] +name = "Name" +version = "0.1.0" +description = "Description" +authors = ["Author 1 "] +license = "MIT" + +[tool.poetry.dependencies] +python = "3.12.*" + +[tool.poetry.dev-dependencies] +ruff = "~0.5.0" +pre-commit = "~3.7.1" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" From 09348013ac6df1526727ed80650b2aa4f22b9511 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 15 Jul 2024 22:19:24 +0200 Subject: [PATCH 002/166] Update gitignore --- .gitignore | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 233eb87..e892ab6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,19 +2,18 @@ __pycache__/ *.py[cod] -# Environment specific +# Python virtual environment .venv -venv -.env -env -# Unittest reports +# Pytest reports +htmlcov/ .coverage* +coverage.xml # Logs *.log -# PyEnv version selector +# Local python version information (pyenv/rye) .python-version # Built objects @@ -22,10 +21,20 @@ env dist/ build/ -# IDEs -# PyCharm +# Editor generated files .idea/ -# VSCode .vscode/ -# MacOS -.DS_Store +.spyproject/ +.spyderproject/ +.replit +.neoconf.json + +# Folder attributes / configuration files on various platforms +.DS_STORE +[Dd]esktop.ini +.directory + +# Environmental, backup and personal files +.env +*.bak +TODO From ff04e8b227d4dfa8ff350d7e82ca0978554adbdd Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 15 Jul 2024 22:20:08 +0200 Subject: [PATCH 003/166] Enforce LF line endings --- .gitattributes | 3 +++ .pre-commit-config.yaml | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b405317 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# (CRLF sucks, it's just a waste of a byte, Windows is stupid for using it) +* text=auto eol=lf + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4bccb6f..c834f51 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,6 +10,8 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace args: [--markdown-linebreak-ext=md] + - id: mixed-line-ending + args: [--fix=lf] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.5.0 From 81b652dad1e5d33946a31d040b1a6b3b6dad3950 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 15 Jul 2024 22:20:42 +0200 Subject: [PATCH 004/166] Add editorconfig file --- .editorconfig | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e8ad015 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# Check http://editorconfig.org for more information +# This is the main config file for this project: +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.{py, pyi}] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + From f2a8192dbd5bfb8b31e11e5cce9f6a9203c6e15a Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 15 Jul 2024 22:56:33 +0200 Subject: [PATCH 005/166] Customize the template --- .github/workflows/lint.yaml | 35 --- .github/workflows/main.yml | 43 ++++ .github/workflows/unit-tests.yml | 57 +++++ .github/workflows/validation.yml | 42 ++++ .pre-commit-config.yaml | 19 +- poetry.lock | 377 +++++++++++++++++++++++++++++++ pyproject.toml | 186 ++++++++++++--- requirements-dev.txt | 6 - samples/Pipfile | 15 -- samples/pyproject.toml | 19 -- src/__init__.py | 0 tests/__init__.py | 0 12 files changed, 686 insertions(+), 113 deletions(-) delete mode 100644 .github/workflows/lint.yaml create mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/unit-tests.yml create mode 100644 .github/workflows/validation.yml create mode 100644 poetry.lock delete mode 100644 requirements-dev.txt delete mode 100644 samples/Pipfile delete mode 100644 samples/pyproject.toml create mode 100644 src/__init__.py create mode 100644 tests/__init__.py diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml deleted file mode 100644 index 7f67e80..0000000 --- a/.github/workflows/lint.yaml +++ /dev/null @@ -1,35 +0,0 @@ -# GitHub Action workflow enforcing our code style. - -name: Lint - -# Trigger the workflow on both push (to the main repository, on the main branch) -# and pull requests (against the main repository, but from any repo, from any branch). -on: - push: - branches: - - main - pull_request: - -# Brand new concurrency setting! This ensures that not more than one run can be triggered for the same commit. -# It is useful for pull requests coming from the main repository since both triggers will match. -concurrency: lint-${{ github.sha }} - -jobs: - lint: - runs-on: ubuntu-latest - - env: - # The Python version your project uses. Feel free to change this if required. - PYTHON_VERSION: "3.12" - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Run pre-commit hooks - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..9f160c3 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,43 @@ +--- +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +# Cancel already running workflows if new ones are scheduled +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validation: + uses: ./.github/workflows/validation.yml + + unit-tests: + uses: ./.github/workflows/unit-tests.yml + + # Produce a pull request payload artifact with various data about the + # pull-request event (such as the PR number, title, author, ...). + # This data is then be picked up by status-embed.yml action. + pr_artifact: + name: Produce Pull Request payload artifact + runs-on: ubuntu-latest + + steps: + - name: Prepare Pull Request Payload artifact + id: prepare-artifact + if: always() && github.event_name == 'pull_request' + continue-on-error: true + run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json + + - name: Upload a Build Artifact + if: always() && steps.prepare-artifact.outcome == 'success' + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: pull-request-payload + path: pull_request_payload.json diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..bfdd784 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,57 @@ +--- +name: Unit-Tests + +on: workflow_call + +jobs: + unit-tests: + runs-on: ${{ matrix.platform }} + + strategy: + fail-fast: false # Allows for matrix sub-jobs to fail without cancelling the rest + matrix: + platform: [ubuntu-latest, windows-latest] + python-version: ["3.11", "3.12"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup poetry + id: poetry_setup + uses: ItsDrike/setup-poetry@v1 + with: + python-version: ${{ matrix.python-version }} + install-args: "--without lint" + + - name: Run pytest + shell: bash + run: | + poetry run poe test + + python .github/scripts/normalize_coverage.py + mv .coverage .coverage.${{ matrix.platform }}.${{ matrix.python-version }} + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage.${{ matrix.platform }}.${{ matrix.python-version }} + path: .coverage.${{ matrix.platform }}.${{ matrix.python-version }} + retention-days: 1 + if-no-files-found: error + + tests-done: + needs: [unit-tests] + if: always() && !cancelled() + runs-on: ubuntu-latest + + steps: + - name: Set status based on required jobs + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + for result in $RESULTS; do + if [ "$result" != "success" ]; then + exit 1 + fi + done diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml new file mode 100644 index 0000000..f0ec220 --- /dev/null +++ b/.github/workflows/validation.yml @@ -0,0 +1,42 @@ +--- +name: Validation + +on: workflow_call + +env: + PRE_COMMIT_HOME: "/home/runner/.cache/pre-commit" + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup poetry + id: poetry_setup + uses: ItsDrike/setup-poetry@v1 + with: + python-version: 3.12 + + - name: Pre-commit Environment Caching + uses: actions/cache@v4 + with: + path: ${{ env.PRE_COMMIT_HOME }} + key: + "precommit-${{ runner.os }}-${{ steps.poetry_setup.outputs.python-version }}-\ + ${{ hashFiles('./.pre-commit-config.yaml') }}" + # Restore keys allows us to perform a cache restore even if the full cache key wasn't matched. + # That way we still end up saving new cache, but we can still make use of the cache from previous + # version. + restore-keys: "precommit-${{ runner.os }}-${{ steps.poetry_setup.outputs-python-version}}-" + + - name: Run pre-commit hooks + run: SKIP=ruff-linter,ruff-formatter pre-commit run --all-files + + - name: Run ruff linter + run: ruff check --output-format=github --show-fixes --exit-non-zero-on-fix . + + - name: Run ruff formatter + run: ruff format --diff . diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c834f51..4a87244 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,8 +13,23 @@ repos: - id: mixed-line-ending args: [--fix=lf] - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + - repo: local hooks: - id: ruff + name: ruff + description: Run ruff linter + entry: poetry run ruff check --force-exclude + language: system + types_or: [python, pyi] + require_serial: true + args: [--fix, --exit-non-zero-on-fix] + + - repo: local + hooks: - id: ruff-format + name: ruff-format + description: Run ruff formatter + entry: poetry run ruff format + language: system + types_or: [python, pyi] + require_serial: true diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..50eb4c9 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,377 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.6.0" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"}, + {file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"}, + {file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"}, + {file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"}, + {file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"}, + {file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"}, + {file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"}, + {file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"}, + {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"}, + {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"}, + {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"}, + {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"}, + {file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"}, + {file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"}, + {file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"}, + {file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"}, + {file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"}, + {file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"}, + {file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"}, + {file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"}, + {file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"}, + {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"}, +] + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + +[[package]] +name = "filelock" +version = "3.15.4" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, + {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] +typing = ["typing-extensions (>=4.8)"] + +[[package]] +name = "identify" +version = "2.6.0" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"}, + {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "3.7.1" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5"}, + {file = "pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pytest" +version = "8.2.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, + {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2.0" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.7" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_asyncio-0.23.7-py3-none-any.whl", hash = "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b"}, + {file = "pytest_asyncio-0.23.7.tar.gz", hash = "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "ruff" +version = "0.3.7" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0e8377cccb2f07abd25e84fc5b2cbe48eeb0fea9f1719cad7caedb061d70e5ce"}, + {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:15a4d1cc1e64e556fa0d67bfd388fed416b7f3b26d5d1c3e7d192c897e39ba4b"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d28bdf3d7dc71dd46929fafeec98ba89b7c3550c3f0978e36389b5631b793663"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:379b67d4f49774ba679593b232dcd90d9e10f04d96e3c8ce4a28037ae473f7bb"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c060aea8ad5ef21cdfbbe05475ab5104ce7827b639a78dd55383a6e9895b7c51"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ebf8f615dde968272d70502c083ebf963b6781aacd3079081e03b32adfe4d58a"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d48098bd8f5c38897b03604f5428901b65e3c97d40b3952e38637b5404b739a2"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da8a4fda219bf9024692b1bc68c9cff4b80507879ada8769dc7e985755d662ea"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c44e0149f1d8b48c4d5c33d88c677a4aa22fd09b1683d6a7ff55b816b5d074f"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3050ec0af72b709a62ecc2aca941b9cd479a7bf2b36cc4562f0033d688e44fa1"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a29cc38e4c1ab00da18a3f6777f8b50099d73326981bb7d182e54a9a21bb4ff7"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b15cc59c19edca917f51b1956637db47e200b0fc5e6e1878233d3a938384b0b"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e491045781b1e38b72c91247cf4634f040f8d0cb3e6d3d64d38dcf43616650b4"}, + {file = "ruff-0.3.7-py3-none-win32.whl", hash = "sha256:bc931de87593d64fad3a22e201e55ad76271f1d5bfc44e1a1887edd0903c7d9f"}, + {file = "ruff-0.3.7-py3-none-win_amd64.whl", hash = "sha256:5ef0e501e1e39f35e03c2acb1d1238c595b8bb36cf7a170e7c1df1b73da00e74"}, + {file = "ruff-0.3.7-py3-none-win_arm64.whl", hash = "sha256:789e144f6dc7019d1f92a812891c645274ed08af6037d11fc65fcbc183b7d59f"}, + {file = "ruff-0.3.7.tar.gz", hash = "sha256:d5c1aebee5162c2226784800ae031f660c350e7a3402c4d1f8ea4e97e232e3ba"}, +] + +[[package]] +name = "virtualenv" +version = "20.26.3" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, + {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "a5a77bd7b42497f36adc8c33bf1a9f87fece869157014783bf0839879e575863" diff --git a/pyproject.toml b/pyproject.toml index 0880be9..8ef87d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,44 +1,158 @@ +[tool.poetry] +name = "code-jam-2024" +version = "0.1.0" +description = "Python Discord's Code Jam 2024, Contemplative Constellations Team Project" +authors = ["Itsrike "] # TODO: Add everyone in the team +readme = "README.md" +license = "MIT" +packages = [{ include = "src" }] + +[tool.poetry.dependencies] +python = "^3.12" + +[tool.poetry.group.lint.dependencies] +ruff = "^0.3.2" +pre-commit = "^3.6.2" + +[tool.poetry.group.test.dependencies] +pytest = "^8.1.1" +pytest-asyncio = "^0.23.6" +pytest-cov = "^5.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + [tool.ruff] -# Increase the line length. This breaks PEP8 but it is way easier to work with. -# The original reason for this limit was a standard vim terminal is only 79 characters, -# but this doesn't really apply anymore. -line-length = 119 -# Target Python 3.12. If you decide to use a different version of Python -# you will need to update this value. target-version = "py312" -# Automatically fix auto-fixable issues. +line-length = 119 fix = true -# The directory containing the source code. If you choose a different project layout -# you will need to update this value. -src = ["src"] [tool.ruff.lint] -# Enable all linting rules. select = ["ALL"] -# Ignore some of the most obnoxious linting errors. + ignore = [ - # Missing docstrings. - "D100", - "D104", - "D105", - "D106", - "D107", - # Docstring whitespace. - "D203", - "D213", - # Docstring punctuation. - "D415", - # Docstring quotes. - "D301", - # Builtins. - "A", - # Print statements. - "T20", - # TODOs. - "TD002", - "TD003", - "FIX", - # Annotations. - "ANN101", - "ANN102", + "C90", # mccabe + "CPY", # flake8-copyright + "EM", # flake8-errmsg + "SLF", # flake8-self + "ARG", # flake8-unused-arguments + "TD", # flake8-todos + "FIX", # flake8-fixme + + "D100", # Missing docstring in public module + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D106", # Missing docstring in public nested class + "D107", # Missing docstring in __init__ + "D203", # Blank line required before class docstring + "D212", # Multi-line summary should start at first line (incompatible with D211) + "D301", # Use r""" if any backslashes in a docstring + "D401", # First line of docstring should be in imperative mood + "D404", # First word of the docstring should not be "This" + "D405", # Section name should be properly capitalized + "D406", # Section name should end with a newline + "D407", # Missing dashed underline after section + "D408", # Section underline should be in the line following the section's name + "D409", # Section underline should match the length of its name + "D410", # Missing blank line after section + "D411", # Missing blank line before section + "D412", # No blank lines allowed between a section header and its content + "D413", # Missing blank line after last section + "D414", # Section has no content + "D416", # Section name should end with a colon + "D417", # Missing argument description in the docstring + + "ANN101", # Missing type annotation for self in method + "ANN102", # Missing type annotation for cls in classmethod + "ANN204", # Missing return type annotation for special method + "ANN401", # Dynamically typed expressions (typing.Any) disallowed + + "SIM102", # use a single if statement instead of nested if statements + "SIM108", # Use ternary operator {contents} instead of if-else-block + + "B904", # Raise without `from` within an `except` clause + + "PLR2004", # Using unnamed numerical constants + "PGH003", # Using specific rule codes in type ignores + "E731", # Don't asign a lambda expression, use a def + "S311", # Use `secrets` for random number generation, not `random` + "TRY003", # Avoid specifying long messages outside the exception class + + # Redundant rules with ruff-format: + "E111", # Indentation of a non-multiple of 4 spaces + "E114", # Comment with indentation of a non-multiple of 4 spaces + "E117", # Cheks for over-indented code + "D206", # Checks for docstrings indented with tabs + "D300", # Checks for docstring that use ''' instead of """ + "Q000", # Checks of inline strings that use wrong quotes (' instead of ") + "Q001", # Multiline string that use wrong quotes (''' instead of """) + "Q002", # Checks for docstrings that use wrong quotes (''' instead of """) + "Q003", # Checks for avoidable escaped quotes ("\"" -> '"') + "COM812", # Missing trailing comma (in multi-line lists/tuples/...) + "COM819", # Prohibited trailing comma (in single-line lists/tuples/...) + "ISC001", # Single line implicit string concatenation ("hi" "hey" -> "hihey") + "ISC002", # Multi line implicit string concatenation +] + +[tool.ruff.lint.isort] +order-by-type = false +case-sensitive = true +combine-as-imports = true + +# Redundant rules with ruff-format +force-single-line = false # forces all imports to appear on their own line +force-wrap-aliases = false # Split imports with multiple members and at least one alias +lines-after-imports = -1 # The number of blank lines to place after imports +lines-between-types = 0 # Number of lines to place between "direct" and import from imports +split-on-trailing-comma = false # if last member of multiline import has a comma, don't fold it to single line + +[tool.ruff.lint.pylint] +max-args = 6 +max-branches = 15 +max-locals = 15 +max-nested-blocks = 5 +max-returns = 8 +max-statements = 75 + +[tool.ruff.lint.per-file-ignores] +"tests/**.py" = [ + "ANN", # annotations + "D", # docstrings + "S101", # Use of assert +] + +[tool.ruff.format] +line-ending = "lf" + +[tool.pytest.ini_options] +minversion = "6.0" +asyncio_mode = "auto" +testpaths = ["tests"] +addopts = "--strict-markers --cov --no-cov-on-fail" + +[tool.coverage.report] +precision = 2 +fail_under = 0 +show_missing = true +skip_covered = false +skip_empty = false +sort = "cover" +exclude_lines = [ + "\\#\\s*pragma: no cover", + "^\\s*if (typing\\.)?TYPE_CHECKING:", + "^\\s*@(abc\\.)?abstractmethod", + "^\\s*@(typing\\.)?overload", + "^\\s*def __repr__\\(", + "^\\s*class .*\\bProtocol\\):", + "^\\s*raise NotImplementedError", + "^\\s*return NotImplemented", + "^\\s*\\.\\.\\.", ] + +[tool.coverage.run] +relative_files = true +parallel = true +branch = true +timid = false +source = ["src"] diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index d529f2e..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,6 +0,0 @@ -# This file contains all the development requirements for our linting toolchain. -# Don't forget to pin your dependencies! -# This list will have to be migrated if you wish to use another dependency manager. - -ruff~=0.5.0 -pre-commit~=3.7.1 diff --git a/samples/Pipfile b/samples/Pipfile deleted file mode 100644 index 27673c0..0000000 --- a/samples/Pipfile +++ /dev/null @@ -1,15 +0,0 @@ -# Sample Pipfile. - -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] - -[dev-packages] -ruff = "~=0.5.0" -pre-commit = "~=3.7.1" - -[requires] -python_version = "3.12" diff --git a/samples/pyproject.toml b/samples/pyproject.toml deleted file mode 100644 index 835045d..0000000 --- a/samples/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -# Sample poetry configuration. - -[tool.poetry] -name = "Name" -version = "0.1.0" -description = "Description" -authors = ["Author 1 "] -license = "MIT" - -[tool.poetry.dependencies] -python = "3.12.*" - -[tool.poetry.dev-dependencies] -ruff = "~0.5.0" -pre-commit = "~3.7.1" - -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 From a1b1193211063e0599a2a845e0246c4dd2b3fe88 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 15 Jul 2024 23:01:09 +0200 Subject: [PATCH 006/166] Replace template readme --- README.md | 185 +----------------------------------------------------- 1 file changed, 2 insertions(+), 183 deletions(-) diff --git a/README.md b/README.md index d50f7b7..b22db8e 100644 --- a/README.md +++ b/README.md @@ -1,184 +1,3 @@ -# Python Discord Code Jam Repository Template +# Python Discord's Code Jam 2024, Contemplative Constellations Team Project -## A primer - -Hello code jam participants! We've put together this repository template for you to use in [our code jams](https://pythondiscord.com/events/) or even other Python events! - -This document contains the following information: - -1. [What does this template contain?](#what-does-this-template-contain) -2. [How do I use this template?](#how-do-i-use-this-template) -3. [How do I adapt this template to my project?](#how-do-i-adapt-this-template-to-my-project) - -> [!TIP] -> You can also look at [our style guide](https://pythondiscord.com/events/code-jams/code-style-guide/) to get more information about what we consider a maintainable code style. - -## What does this template contain? - -Here is a quick rundown of what each file in this repository contains: - -- [`LICENSE.txt`](LICENSE.txt): [The MIT License](https://opensource.org/licenses/MIT), an OSS approved license which grants rights to everyone to use and modify your project, and limits your liability. We highly recommend you to read the license. -- [`.gitignore`](.gitignore): A list of files and directories that will be ignored by Git. Most of them are auto-generated or contain data that you wouldn't want to share publicly. -- [`requirements-dev.txt`](requirements-dev.txt): Every PyPI package used for the project's development, to ensure a common development environment. More on that [below](#using-the-default-pip-setup). -- [`pyproject.toml`](pyproject.toml): Configuration and metadata for the project, as well as the linting tool Ruff. If you're interested, you can read more about `pyproject.toml` in the [Python Packaging documentation](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/). -- [`.pre-commit-config.yaml`](.pre-commit-config.yaml): The configuration of the [pre-commit](https://pre-commit.com/) tool. -- [`.github/workflows/lint.yaml`](.github/workflows/lint.yaml): A [GitHub Actions](https://github.com/features/actions) workflow, a set of actions run by GitHub on their server after each push, to ensure the style requirements are met. - -Each of these files have comments for you to understand easily, and modify to fit your needs. - -### Ruff: general style rules - -Our first tool is Ruff. It will check your codebase and warn you about any non-conforming lines. -It is run with the command `ruff check` in the project root. - -Here is a sample output: - -```shell -$ ruff check -app.py:1:5: N802 Function name `helloWorld` should be lowercase -app.py:1:5: ANN201 Missing return type annotation for public function `helloWorld` -app.py:2:5: D400 First line should end with a period -app.py:2:5: D403 First word of the first line should be capitalized: `docstring` -> `Docstring` -app.py:3:15: W292 No newline at end of file -Found 5 errors. -``` - -Each line corresponds to an error. The first part is the file path, then the line number, and the column index. -Then comes the error code, a unique identifier of the error, and then a human-readable message. - -If, for any reason, you do not wish to comply with this specific error on a specific line, you can add `# noqa: CODE` at the end of the line. -For example: - -```python -def helloWorld(): # noqa: N802 - ... - -``` - -This will ignore the function naming issue and pass linting. - -> [!WARNING] -> We do not recommend ignoring errors unless you have a good reason to do so. - -### Ruff: formatting - -Ruff also comes with a formatter, which can be run with the command `ruff format`. -It follows the same code style enforced by [Black](https://black.readthedocs.io/en/stable/index.html), so there's no need to pick between them. - -### Pre-commit: run linting before committing - -The second tool doesn't check your code, but rather makes sure that you actually *do* check it. - -It makes use of a feature called [Git hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) which allow you to run a piece of code before running `git commit`. -The good thing about it is that it will cancel your commit if the lint doesn't pass. You won't have to wait for GitHub Actions to report issues and have a second fix commit. - -It is *installed* by running `pre-commit install` and can be run manually by calling only `pre-commit`. - -[Lint before you push!](https://soundcloud.com/lemonsaurusrex/lint-before-you-push) - -#### List of hooks - -- `check-toml`: Lints and corrects your TOML files. -- `check-yaml`: Lints and corrects your YAML files. -- `end-of-file-fixer`: Makes sure you always have an empty line at the end of your file. -- `trailing-whitespace`: Removes whitespaces at the end of each line. -- `ruff`: Runs the Ruff linter. -- `ruff-format`: Runs the Ruff formatter. - -## How do I use this template? - -### Creating your team repository - -One person in the team, preferably the leader, will have to create the repository and add other members as collaborators. - -1. In the top right corner of your screen, where **Clone** usually is, you have a **Use this template** button to click. - ![use-this-template-button](https://docs.github.com/assets/images/help/repository/use-this-template-button.png) -2. Give the repository a name and a description. - ![create-repository-name](https://docs.github.com/assets/images/help/repository/create-repository-name.png) -3. Click **Create repository from template**. -4. Click **Settings** in your newly created repository. - ![repo-actions-settings](https://docs.github.com/assets/images/help/repository/repo-actions-settings.png) -5. In the "Access" section of the sidebar, click **Collaborators**. - ![collaborators-settings](https://github.com/python-discord/code-jam-template/assets/63936253/c150110e-d1b5-4e4d-93e0-0a2cf1de352b) -6. Click **Add people**. -7. Insert the names of each of your teammates, and invite them. Once they have accepted the invitation in their email, they will have write access to the repository. - -You are now ready to go! Sit down, relax, and wait for the kickstart! - -> [!IMPORTANT] -> Don't forget to swap "Python Discord" in the [`LICENSE.txt`](LICENSE.txt) file for the name of each of your team members or the name of your team *after* the start of the code jam. - -### Using the default pip setup - -Our default setup includes a bare requirements file to be used with a [virtual environment](https://docs.python.org/3/library/venv.html). -We recommend this if you have never used any other dependency manager, although if you have, feel free to switch to it. More on that [below](#how-do-i-adapt-this-template-to-my-project). - -#### Creating the environment - -Create a virtual environment in the folder `.venv`. - -```shell -python -m venv .venv -``` - -#### Entering the environment - -It will change based on your operating system and shell. - -```shell -# Linux, Bash -$ source .venv/bin/activate -# Linux, Fish -$ source .venv/bin/activate.fish -# Linux, Csh -$ source .venv/bin/activate.csh -# Linux, PowerShell Core -$ .venv/bin/Activate.ps1 -# Windows, cmd.exe -> .venv\Scripts\activate.bat -# Windows, PowerShell -> .venv\Scripts\Activate.ps1 -``` - -#### Installing the dependencies - -Once the environment is created and activated, use this command to install the development dependencies. - -```shell -pip install -r requirements-dev.txt -``` - -#### Exiting the environment - -Interestingly enough, it is the same for every platform. - -```shell -deactivate -``` - -Once the environment is activated, all the commands listed previously should work. - -> [!IMPORTANT] -> We highly recommend that you run `pre-commit install` as soon as possible. - -## How do I adapt this template to my project? - -If you wish to use Pipenv or Poetry, you will have to move the dependencies in [`requirements-dev.txt`](requirements-dev.txt) to the development dependencies of your tool. - -We've included a porting of [`requirements-dev.txt`](requirements-dev.txt) to both [Poetry](samples/pyproject.toml) and [Pipenv](samples/Pipfile) in the [`samples` folder](samples). -If you use the Poetry setup, make sure to change the project name, description, and authors at the top of the file. -Also note that the Poetry [`pyproject.toml`](samples/pyproject.toml) file does not include the Ruff configuration, so if you simply replace the file then the Ruff configuration will be lost. - -When installing new dependencies, don't forget to [pin](https://pip.pypa.io/en/stable/topics/repeatable-installs/#pinning-the-package-versions) them by adding a version tag at the end. -For example, if I wish to install [Click](https://click.palletsprojects.com/en/8.1.x/), a quick look at [PyPI](https://pypi.org/project/click/) tells me that `8.1.7` is the latest version. -I will then add `click~=8.1`, without the last number, to my requirements file or dependency manager. - -> [!IMPORTANT] -> A code jam project is left unmaintained after the end of the event. If the dependencies aren't pinned, the project will break after any major change in an API. - -## Final words - -> [!IMPORTANT] -> Don't forget to replace this README with an actual description of your project! Images are also welcome! - -We hope this template will be helpful. Good luck in the jam! +This repository houses the source code of the team project created for **Python Discord's Code Jam 2024**, developed by **Contemplative Constellations** team. From 38882db014a88290d03e5ab011224cc49bde3db9 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 15 Jul 2024 23:03:10 +0200 Subject: [PATCH 007/166] Only run unit-tests workflow on 3.12 --- .github/workflows/unit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index bfdd784..cd24404 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -11,7 +11,7 @@ jobs: fail-fast: false # Allows for matrix sub-jobs to fail without cancelling the rest matrix: platform: [ubuntu-latest, windows-latest] - python-version: ["3.11", "3.12"] + python-version: ["3.12"] steps: - name: Checkout repository From 3fe1458869dc53144868536cbbcdef8621746ac2 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 15 Jul 2024 23:04:10 +0200 Subject: [PATCH 008/166] Run pytest directly in unit-tests workflow --- .github/workflows/unit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index cd24404..0166ff1 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -27,7 +27,7 @@ jobs: - name: Run pytest shell: bash run: | - poetry run poe test + poetry run pytest -v python .github/scripts/normalize_coverage.py mv .coverage .coverage.${{ matrix.platform }}.${{ matrix.python-version }} From 96cdda18a684f7b74c64362abbd9756139a24a44 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Tue, 16 Jul 2024 10:30:44 +0200 Subject: [PATCH 009/166] Remove empty blank lines at the end of files --- .editorconfig | 1 - .gitattributes | 1 - 2 files changed, 2 deletions(-) diff --git a/.editorconfig b/.editorconfig index e8ad015..9c2f900 100644 --- a/.editorconfig +++ b/.editorconfig @@ -15,4 +15,3 @@ indent_size = 4 [*.md] trim_trailing_whitespace = false - diff --git a/.gitattributes b/.gitattributes index b405317..521ac1b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,2 @@ # (CRLF sucks, it's just a waste of a byte, Windows is stupid for using it) * text=auto eol=lf - From 296b705f92ac332bb91c330d09260c0066c1310d Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Tue, 16 Jul 2024 14:23:05 +0200 Subject: [PATCH 010/166] Add vscode suggested extension: ruff --- .gitignore | 3 ++- .vscode/extensions.json | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .vscode/extensions.json diff --git a/.gitignore b/.gitignore index e892ab6..6d08a49 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,8 @@ build/ # Editor generated files .idea/ -.vscode/ +.vscode/* +!.vscode/extensions.json .spyproject/ .spyderproject/ .replit diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..37b9f37 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["charliermarsh.ruff"] +} From ee7d4eb92c826964709f15675b43f8f105864d46 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Tue, 16 Jul 2024 16:36:41 +0200 Subject: [PATCH 011/166] Ignore no tests found pytest error --- .github/workflows/unit-tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 0166ff1..608c52d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -27,7 +27,8 @@ jobs: - name: Run pytest shell: bash run: | - poetry run pytest -v + # Ignore exit code 5 (no tests found) + poetry run pytest -v || ([ $? = 5 ] && exit 0 || exit $?) python .github/scripts/normalize_coverage.py mv .coverage .coverage.${{ matrix.platform }}.${{ matrix.python-version }} From e9e2f67c21578634a0e0b69a2684305a6cadd39a Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Tue, 16 Jul 2024 16:40:25 +0200 Subject: [PATCH 012/166] Add coverage normalization script --- .github/scripts/normalize_coverage.py | 9 +++++++++ pyproject.toml | 3 +++ 2 files changed, 12 insertions(+) create mode 100644 .github/scripts/normalize_coverage.py diff --git a/.github/scripts/normalize_coverage.py b/.github/scripts/normalize_coverage.py new file mode 100644 index 0000000..9c056fe --- /dev/null +++ b/.github/scripts/normalize_coverage.py @@ -0,0 +1,9 @@ +import sqlite3 + +connection = sqlite3.connect(".coverage") + +# Normalize windows paths +connection.execute("UPDATE file SET path = REPLACE(path, '\\', '/')") + +connection.commit() +connection.close() diff --git a/pyproject.toml b/pyproject.toml index 8ef87d5..db9aef3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,6 +121,9 @@ max-statements = 75 "D", # docstrings "S101", # Use of assert ] +".github/scripts/**.py" = [ + "INP001", # Implicit namespace package +] [tool.ruff.format] line-ending = "lf" From f9ad24c866203e7345ab3df0921b8b7b4c7dc6c5 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Tue, 16 Jul 2024 14:47:47 +0000 Subject: [PATCH 013/166] Add basedpyright (#1) --- .github/workflows/validation.yml | 5 ++++- .pre-commit-config.yaml | 10 ++++++++++ .vscode/extensions.json | 5 ++++- poetry.lock | 32 +++++++++++++++++++++++++++++++- pyproject.toml | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 3 deletions(-) diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index f0ec220..6d09287 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -33,10 +33,13 @@ jobs: restore-keys: "precommit-${{ runner.os }}-${{ steps.poetry_setup.outputs-python-version}}-" - name: Run pre-commit hooks - run: SKIP=ruff-linter,ruff-formatter pre-commit run --all-files + run: SKIP=ruff-linter,ruff-formatter,basedpyright pre-commit run --all-files - name: Run ruff linter run: ruff check --output-format=github --show-fixes --exit-non-zero-on-fix . - name: Run ruff formatter run: ruff format --diff . + + - name: Run basedpyright type checker + run: basedpyright --warnings . diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4a87244..8e6e7d0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,3 +33,13 @@ repos: language: system types_or: [python, pyi] require_serial: true + + - repo: local + hooks: + - id: basedpyright + name: Based Pyright + description: Run basedpyright type checker + entry: poetry run basedpyright --warnings + language: system + types: [python] + pass_filenames: false # pyright runs for the entire project, it can't run for single files diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 37b9f37..c6994d5 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,6 @@ { - "recommendations": ["charliermarsh.ruff"] + "recommendations": [ + "charliermarsh.ruff", + "detachhead.basedpyright" + ] } diff --git a/poetry.lock b/poetry.lock index 50eb4c9..5858ad7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,19 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "basedpyright" +version = "1.13.3" +description = "static type checking for Python (but based)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "basedpyright-1.13.3-py3-none-any.whl", hash = "sha256:3162c5a5f4fc99f9d53d76cbd8e24d31ad4b28b4fb26a58ab8be6e8b634c99a7"}, + {file = "basedpyright-1.13.3.tar.gz", hash = "sha256:728d7098250db8d18bc4b48df8f93dfd9c79d155c3c99d41256a6caa6a21232e"}, +] + +[package.dependencies] +nodejs-wheel-binaries = ">=20.13.1" + [[package]] name = "cfgv" version = "3.4.0" @@ -149,6 +163,22 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +[[package]] +name = "nodejs-wheel-binaries" +version = "20.15.1" +description = "unoffical Node.js package" +optional = false +python-versions = ">=3.7" +files = [ + {file = "nodejs_wheel_binaries-20.15.1-py2.py3-none-macosx_10_15_x86_64.whl", hash = "sha256:a04537555f59e53021f8a2b07fa7aaac29d7793b7fae7fbf561bf9a859f4c67a"}, + {file = "nodejs_wheel_binaries-20.15.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:b5ff04efa56a3fcd1fd09b30f5236c12bd84c10fcb222f3c0e04e1d497342b70"}, + {file = "nodejs_wheel_binaries-20.15.1-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c3e172e3fde3c13e7509312c81700736304dbd250745d87f00e7506065f3a5"}, + {file = "nodejs_wheel_binaries-20.15.1-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9740f7456a43cb09521a1ac93a4355dc8282c41420f2d61ff631a01f39e2aa18"}, + {file = "nodejs_wheel_binaries-20.15.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bf5e239676efabb2fbaeff2f36d0bad8e2379f260ef44e13ef2151d037e40af3"}, + {file = "nodejs_wheel_binaries-20.15.1-py2.py3-none-win_amd64.whl", hash = "sha256:624936171b1aa2e1cc6d1718b1caa089e943b54df16568fa2f4576d145ac279a"}, + {file = "nodejs_wheel_binaries-20.15.1.tar.gz", hash = "sha256:b2f25b4f0e9a827ae1af8218ab13a385e279c236faf7b7c821e969bb8f6b25e8"}, +] + [[package]] name = "packaging" version = "24.1" @@ -374,4 +404,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "a5a77bd7b42497f36adc8c33bf1a9f87fece869157014783bf0839879e575863" +content-hash = "10c07e6a94506dc4ad46927e148980902b1f64be7df2a05265ce32e0543eb01e" diff --git a/pyproject.toml b/pyproject.toml index db9aef3..0e9a798 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ python = "^3.12" [tool.poetry.group.lint.dependencies] ruff = "^0.3.2" pre-commit = "^3.6.2" +basedpyright = "^1.13.3" [tool.poetry.group.test.dependencies] pytest = "^8.1.1" @@ -128,6 +129,37 @@ max-statements = 75 [tool.ruff.format] line-ending = "lf" +[tool.basedpyright] +pythonPlatform = "All" +pythonVersion = "3.12" +typeCheckingMode = "all" + +# Diagnostic behavior settings +strictListInference = false +strictDictionaryInference = false +strictSetInference = false +analyzeUnannotatedFunctions = true +strictParameterNoneValue = true +enableTypeIgnoreComments = true +deprecateTypingAliases = true +enableExperimentalFeatures = false +disableBytesTypePromotions = true + +# Diagnostic rules +reportAny = false +reportImplicitStringConcatenation = false +reportUnreachable = "information" +reportMissingTypeStubs = "information" +reportUninitializedInstanceVariable = false # until https://github.com/DetachHead/basedpyright/issues/491 +reportMissingParameterType = false # ruff's flake8-annotations (ANN) already covers this + gives us more control + +# Unknown type reporting rules (too strict for most code-bases) +reportUnknownArgumentType = false +reportUnknownVariableType = false +reportUnknownMemberType = false +reportUnknownParameterType = false +reportUnknownLambdaType = false + [tool.pytest.ini_options] minversion = "6.0" asyncio_mode = "auto" From 5ce74cc45ed49993293fcf4e85c8599997dafa8a Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Wed, 17 Jul 2024 20:15:38 +0200 Subject: [PATCH 014/166] Enforce multi-line docstring starting at first line --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0e9a798..41c6aa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ ignore = [ "D106", # Missing docstring in public nested class "D107", # Missing docstring in __init__ "D203", # Blank line required before class docstring - "D212", # Multi-line summary should start at first line (incompatible with D211) + "D213", # Multi-line summary should start at the second line (incompatible with D212) "D301", # Use r""" if any backslashes in a docstring "D401", # First line of docstring should be in imperative mood "D404", # First word of the docstring should not be "This" From c997e6591807b6727d2469e4947d648dbe593ee2 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Wed, 17 Jul 2024 20:15:53 +0200 Subject: [PATCH 015/166] Add py.typed --- src/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/py.typed diff --git a/src/py.typed b/src/py.typed new file mode 100644 index 0000000..e69de29 From 8f920be33c7c2481fc66e363b8b54f2da4066a5f Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 18 Jul 2024 13:56:31 +0200 Subject: [PATCH 016/166] Remove TODO on adding project authors --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 41c6aa7..a4799e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "code-jam-2024" version = "0.1.0" description = "Python Discord's Code Jam 2024, Contemplative Constellations Team Project" -authors = ["Itsrike "] # TODO: Add everyone in the team +authors = ["Itsrike "] readme = "README.md" license = "MIT" packages = [{ include = "src" }] From bedd14c9a144aea2bda7bbbec5cdc36ec39f8ec5 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 18 Jul 2024 14:02:57 +0200 Subject: [PATCH 017/166] Fix typo in project authors (name) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a4799e6..ab7c6c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "code-jam-2024" version = "0.1.0" description = "Python Discord's Code Jam 2024, Contemplative Constellations Team Project" -authors = ["Itsrike "] +authors = ["ItsDrike "] readme = "README.md" license = "MIT" packages = [{ include = "src" }] From 10b45cb967c051335b5a4f186fa44e438c09f0ab Mon Sep 17 00:00:00 2001 From: dragon64 <75382194+dragonblz@users.noreply.github.com> Date: Thu, 18 Jul 2024 15:44:55 +0300 Subject: [PATCH 018/166] Benjiguy (#2) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ab7c6c8..e94e1d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "code-jam-2024" version = "0.1.0" description = "Python Discord's Code Jam 2024, Contemplative Constellations Team Project" -authors = ["ItsDrike "] +authors = ["ItsDrike ", "Benji "] readme = "README.md" license = "MIT" packages = [{ include = "src" }] From 336c4a15765b0f3b2b98ac07de0f16bd3b6617d0 Mon Sep 17 00:00:00 2001 From: V33010 <95171036+V33010@users.noreply.github.com> Date: Thu, 18 Jul 2024 20:17:22 +0530 Subject: [PATCH 019/166] add v33010 author in pyproject.toml (#4) Co-authored-by: Paillat --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e94e1d9..5e4a745 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "code-jam-2024" version = "0.1.0" description = "Python Discord's Code Jam 2024, Contemplative Constellations Team Project" -authors = ["ItsDrike ", "Benji "] +authors = ["ItsDrike ", "Benji ", "v33010 "] readme = "README.md" license = "MIT" packages = [{ include = "src" }] From 9cf163352928350ed437d3dfb1e7b7d572759268 Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 18 Jul 2024 17:33:35 +0200 Subject: [PATCH 020/166] Add Paillat author (#3) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5e4a745..01de79f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "code-jam-2024" version = "0.1.0" description = "Python Discord's Code Jam 2024, Contemplative Constellations Team Project" -authors = ["ItsDrike ", "Benji ", "v33010 "] +authors = ["ItsDrike ", "Benji ", "Paillat-dev ", "v33010 "] readme = "README.md" license = "MIT" packages = [{ include = "src" }] From a179508a53c240690e1ce3bab1656b97dae2d158 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 18 Jul 2024 19:15:48 +0200 Subject: [PATCH 021/166] Add very basic bot implementation --- poetry.lock | 448 +++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + src/__main__.py | 25 +++ 3 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 src/__main__.py diff --git a/poetry.lock b/poetry.lock index 5858ad7..ae42a23 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,133 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "aiohttp" +version = "3.9.5" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"}, + {file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"}, + {file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"}, + {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"}, + {file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"}, + {file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"}, + {file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"}, + {file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"}, + {file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"}, + {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"}, +] + +[package.dependencies] +aiosignal = ">=1.1.2" +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns", "brotlicffi"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "attrs" +version = "23.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] + [[package]] name = "basedpyright" version = "1.13.3" @@ -127,6 +255,92 @@ docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1 testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] typing = ["typing-extensions (>=4.8)"] +[[package]] +name = "frozenlist" +version = "1.4.1" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.8" +files = [ + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, + {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, + {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, + {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, + {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, + {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, + {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, + {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, + {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, + {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, + {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, + {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, + {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, +] + [[package]] name = "identify" version = "2.6.0" @@ -141,6 +355,17 @@ files = [ [package.extras] license = ["ukkonen"] +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -152,6 +377,105 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "multidict" +version = "6.0.5" +description = "multidict implementation" +optional = false +python-versions = ">=3.7" +files = [ + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"}, + {file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"}, + {file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"}, + {file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"}, + {file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"}, + {file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"}, + {file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"}, + {file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"}, + {file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"}, + {file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"}, + {file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"}, + {file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"}, + {file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"}, + {file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"}, + {file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"}, + {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -239,6 +563,25 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "py-cord" +version = "2.6.0" +description = "A Python wrapper for the Discord API" +optional = false +python-versions = ">=3.8" +files = [ + {file = "py_cord-2.6.0-py3-none-any.whl", hash = "sha256:906cc077904a4d478af0264ae4374b0412ed9f9ab950d0162c4f31239a906d26"}, + {file = "py_cord-2.6.0.tar.gz", hash = "sha256:bbc0349542965d05e4b18cc4424136206430a8cc911fda12a0a57df6fdf9cd9c"}, +] + +[package.dependencies] +aiohttp = ">=3.6.0,<4.0" + +[package.extras] +docs = ["furo (==2023.3.23)", "myst-parser (==1.0.0)", "sphinx (==5.3.0)", "sphinx-autodoc-typehints (==1.23.0)", "sphinx-copybutton (==0.5.2)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport (==1.2.4)", "sphinxext-opengraph (==0.9.1)"] +speed = ["aiohttp[speedups]", "msgspec (>=0.18.6,<0.19.0)"] +voice = ["PyNaCl (>=1.3.0,<1.6)"] + [[package]] name = "pytest" version = "8.2.2" @@ -401,7 +744,110 @@ platformdirs = ">=3.9.1,<5" docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +[[package]] +name = "yarl" +version = "1.9.4" +description = "Yet another URL library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"}, + {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"}, + {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"}, + {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"}, + {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, + {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, + {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, + {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"}, + {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"}, + {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"}, + {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"}, + {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"}, + {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"}, + {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"}, + {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, + {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "10c07e6a94506dc4ad46927e148980902b1f64be7df2a05265ce32e0543eb01e" +content-hash = "b6bcaf2c7727a0371fea42a806a099b9132f51cd16c1789e2fc61eb0f95b5ba7" diff --git a/pyproject.toml b/pyproject.toml index 01de79f..a8d09b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ packages = [{ include = "src" }] [tool.poetry.dependencies] python = "^3.12" +py-cord = "^2.6.0" [tool.poetry.group.lint.dependencies] ruff = "^0.3.2" diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..650ee72 --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,25 @@ +# ruff: noqa: T201 +import os + +import discord + +bot = discord.Bot() + + +BOT_TOKEN = os.environ["BOT_TOKEN"] + + +@bot.event +async def on_ready() -> None: + """Function called once the bot is ready and online.""" + print(f"{bot.user} is ready and online!") + + +@bot.slash_command() +async def ping(ctx: discord.ApplicationContext) -> None: + """Test out bot's latency.""" + _ = await ctx.respond(f"Pong! ({(bot.latency * 1000):.2f}ms)") + + +if __name__ == "__main__": + bot.run(BOT_TOKEN) From a7a335c2795816ddede48c27ac57d20cc1d3101c Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 18 Jul 2024 19:21:09 +0200 Subject: [PATCH 022/166] Use python-decouple to read env vars --- poetry.lock | 25 ++++++++---- pyproject.toml | 1 + src/__main__.py | 7 +--- src/settings.py | 3 ++ src/utils/__init__.py | 0 src/utils/config.py | 94 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 src/settings.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/config.py diff --git a/poetry.lock b/poetry.lock index ae42a23..0eae837 100644 --- a/poetry.lock +++ b/poetry.lock @@ -130,13 +130,13 @@ tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "p [[package]] name = "basedpyright" -version = "1.13.3" +version = "1.14.0" description = "static type checking for Python (but based)" optional = false python-versions = ">=3.8" files = [ - {file = "basedpyright-1.13.3-py3-none-any.whl", hash = "sha256:3162c5a5f4fc99f9d53d76cbd8e24d31ad4b28b4fb26a58ab8be6e8b634c99a7"}, - {file = "basedpyright-1.13.3.tar.gz", hash = "sha256:728d7098250db8d18bc4b48df8f93dfd9c79d155c3c99d41256a6caa6a21232e"}, + {file = "basedpyright-1.14.0-py3-none-any.whl", hash = "sha256:ca29ae9c9dd04d718866b9d3cc737a31f084ce954a9afc9f00eafac9419e0046"}, + {file = "basedpyright-1.14.0.tar.gz", hash = "sha256:ebbbb44484e269c441d48129bf43619aa8ff54966706e13732cd4412408d1477"}, ] [package.dependencies] @@ -604,13 +604,13 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.23.7" +version = "0.23.8" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" files = [ - {file = "pytest_asyncio-0.23.7-py3-none-any.whl", hash = "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b"}, - {file = "pytest_asyncio-0.23.7.tar.gz", hash = "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"}, + {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, + {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, ] [package.dependencies] @@ -638,6 +638,17 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "python-decouple" +version = "3.8" +description = "Strict separation of settings from code." +optional = false +python-versions = "*" +files = [ + {file = "python-decouple-3.8.tar.gz", hash = "sha256:ba6e2657d4f376ecc46f77a3a615e058d93ba5e465c01bbe57289bfb7cce680f"}, + {file = "python_decouple-3.8-py3-none-any.whl", hash = "sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66"}, +] + [[package]] name = "pyyaml" version = "6.0.1" @@ -850,4 +861,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "b6bcaf2c7727a0371fea42a806a099b9132f51cd16c1789e2fc61eb0f95b5ba7" +content-hash = "66527397167d509a93f2c6361f80c4cc1d9edcebdfae99fb254bf3ae913ab48d" diff --git a/pyproject.toml b/pyproject.toml index a8d09b7..c99d1bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ packages = [{ include = "src" }] [tool.poetry.dependencies] python = "^3.12" py-cord = "^2.6.0" +python-decouple = "^3.8" [tool.poetry.group.lint.dependencies] ruff = "^0.3.2" diff --git a/src/__main__.py b/src/__main__.py index 650ee72..028c462 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,12 +1,9 @@ # ruff: noqa: T201 -import os - import discord -bot = discord.Bot() - +from src.settings import BOT_TOKEN -BOT_TOKEN = os.environ["BOT_TOKEN"] +bot = discord.Bot() @bot.event diff --git a/src/settings.py b/src/settings.py new file mode 100644 index 0000000..4a93771 --- /dev/null +++ b/src/settings.py @@ -0,0 +1,3 @@ +from src.utils.config import get_config + +BOT_TOKEN = get_config("BOT_TOKEN") diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/config.py b/src/utils/config.py new file mode 100644 index 0000000..b317740 --- /dev/null +++ b/src/utils/config.py @@ -0,0 +1,94 @@ +"""File containing a typed wrapper function around ``decouple.config``.""" + +from __future__ import annotations + +from typing import Any, NewType, TYPE_CHECKING, TypeVar, cast, overload + +from decouple import UndefinedValueError, config # pyright: ignore[reportMissingTypeStubs] + +if TYPE_CHECKING: + from collections.abc import Callable + + +__all__ = ["get_config"] + +T = TypeVar("T") +U = TypeVar("U") +Sentinel = NewType("Sentinel", object) +_MISSING = cast(Sentinel, object()) + + +@overload +def get_config( + search_path: str, + *, + cast: None = None, + default: U | Sentinel = _MISSING, +) -> str | U: ... + + +@overload +def get_config( + search_path: str, + *, + cast: Callable[[str], T], + default: U | Sentinel = _MISSING, +) -> T | U: ... + + +def get_config( + search_path: str, + *, + cast: Callable[[str], object] | None = None, + default: object = _MISSING, +) -> object: + """Typed wrapper around ``decouple.config`` for static type analysis.""" + try: + val = config(search_path) + except UndefinedValueError as exc: + if default is not _MISSING: + return default + raise exc from exc + + # Treat empty strings as unset values + if val == "": + if default is not _MISSING: + return default + + raise UndefinedValueError( + f"{search_path} was found, but the content was an empty string. " + "Set a non-empty value for the envvar or define a default value." + ) + + # We run this again, this time with a cast function. + # the reason we don't do this immediately is that the empty strings might not + # work with the cast function, which could raise various exceptions. + if cast is None: + cast = lambda x: x + return config(search_path, cast=cast) + + +@overload +def config_cast_list(cast: None = None) -> Callable[[str], list[str]]: ... + + +@overload +def config_cast_list(cast: Callable[[str], T]) -> Callable[[str], list[T]]: ... + + +def config_cast_list(cast: Callable[[str], object] | None = None) -> Callable[[str], list[Any]]: + """Cast function to convert the content of an environmental variable to a list of values. + + This works by splitting the contents of the environmental variable on `,` characters. + Currently, there is not support for escaping here, so list variables that require `,` + symbol to be present will not work. + + You can use this function in :func:`get_config` for the ``cast`` argument. + """ + if cast is None or cast is str: + cast = lambda x: x + + def inner(raw_value: str) -> list[Any]: + return [cast(x) for x in raw_value.split(",") if x] + + return inner From af247a34dbaf0a1b9ceb3b9650a1b0ce17865387 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 18 Jul 2024 19:27:47 +0200 Subject: [PATCH 023/166] Add proper logging support (with colors) --- poetry.lock | 44 ++++++++++- pyproject.toml | 5 ++ src/__main__.py | 6 +- src/utils/log.py | 196 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 src/utils/log.py diff --git a/poetry.lock b/poetry.lock index 0eae837..b6d2de7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -164,6 +164,23 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coloredlogs" +version = "15.0.1" +description = "Colored terminal output for Python's logging module" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"}, + {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"}, +] + +[package.dependencies] +humanfriendly = ">=9.1" + +[package.extras] +cron = ["capturer (>=2.4)"] + [[package]] name = "coverage" version = "7.6.0" @@ -341,6 +358,20 @@ files = [ {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, ] +[[package]] +name = "humanfriendly" +version = "10.0" +description = "Human friendly output for text interfaces using Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, + {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, +] + +[package.dependencies] +pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""} + [[package]] name = "identify" version = "2.6.0" @@ -582,6 +613,17 @@ docs = ["furo (==2023.3.23)", "myst-parser (==1.0.0)", "sphinx (==5.3.0)", "sphi speed = ["aiohttp[speedups]", "msgspec (>=0.18.6,<0.19.0)"] voice = ["PyNaCl (>=1.3.0,<1.6)"] +[[package]] +name = "pyreadline3" +version = "3.4.1" +description = "A python implementation of GNU readline." +optional = false +python-versions = "*" +files = [ + {file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"}, + {file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"}, +] + [[package]] name = "pytest" version = "8.2.2" @@ -861,4 +903,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "66527397167d509a93f2c6361f80c4cc1d9edcebdfae99fb254bf3ae913ab48d" +content-hash = "da0615a624507e4c68b58731f5e361fb86f451b7081ecd04203b0cdf08320cfe" diff --git a/pyproject.toml b/pyproject.toml index c99d1bf..c12c4c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ packages = [{ include = "src" }] python = "^3.12" py-cord = "^2.6.0" python-decouple = "^3.8" +coloredlogs = "^15.0.1" [tool.poetry.group.lint.dependencies] ruff = "^0.3.2" @@ -74,6 +75,10 @@ ignore = [ "SIM102", # use a single if statement instead of nested if statements "SIM108", # Use ternary operator {contents} instead of if-else-block + "G001", # Logging statement uses str.format + "G004", # Logging statement uses f-string + "G003", # Logging statement uses + + "B904", # Raise without `from` within an `except` clause "PLR2004", # Using unnamed numerical constants diff --git a/src/__main__.py b/src/__main__.py index 028c462..36277d8 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,7 +1,9 @@ -# ruff: noqa: T201 import discord from src.settings import BOT_TOKEN +from src.utils.log import get_logger + +log = get_logger(__name__) bot = discord.Bot() @@ -9,7 +11,7 @@ @bot.event async def on_ready() -> None: """Function called once the bot is ready and online.""" - print(f"{bot.user} is ready and online!") + log.info(f"{bot.user} is ready and online!") @bot.slash_command() diff --git a/src/utils/log.py b/src/utils/log.py new file mode 100644 index 0000000..88255f2 --- /dev/null +++ b/src/utils/log.py @@ -0,0 +1,196 @@ +"""Logging configuration for the project. + +Note: + Whenever logging is needed, the `get_logger` function from this module should be used. + Do not use the default `logging.getLogger` function, as it does not return the correct logger type. +""" + +import logging +import logging.handlers +import os +import sys +from pathlib import Path +from typing import Any, TYPE_CHECKING, cast + +import coloredlogs # pyright: ignore[reportMissingTypeStubs] + +from src.utils.config import get_config + +# We set these values here instead of getting them from +DEBUG = get_config("DEBUG", cast=bool, default=False) +LOG_FILE = get_config("LOG_FILE", cast=Path, default=None) +TRACE_LEVEL_FILTER = get_config("TRACE_LEVEL_FILTER", default=None) + +LOG_FORMAT = "%(asctime)s | %(name)s | %(levelname)7s | %(message)s" +TRACE_LEVEL = 5 + + +if TYPE_CHECKING: + LoggerClass = logging.Logger +else: + LoggerClass = logging.getLoggerClass() + + +class CustomLogger(LoggerClass): + """Custom implementation of the `Logger` class with an added `trace` method.""" + + def trace(self, msg: str, *args: object, **kwargs: Any) -> None: + """Log 'msg % args' with severity 'TRACE'. + + To pass exception information, use the keyword argument exc_info with + a true value, e.g. + + logger.trace("Houston, we have an %s", "interesting problem", exc_info=1) + """ + if self.isEnabledFor(TRACE_LEVEL): + self.log(TRACE_LEVEL, msg, *args, **kwargs) + + +def get_logger(name: str | None = None, *, skip_class_check: bool = False) -> CustomLogger: + """Utility to make the type checker recognise that logger is of type `CustomLogger`. + + Additionally, in case logging isn't already set up, :meth:`.setup_logging` is ran. + + This is necessary as this function is lying to the type-checker by using explicit + :meth:`cast`, specifying that the logger is of the :class:`CustomLogger` type, + when in fact it might not be. + + :param skip_class_check: + When ``True``, the logger class check, which ensures logging was already set up + will be skipped. + + Do know that disabling this check can be dangerous, as it might result in this + function returning a regular logger, with typing information of custom logger, + leading to issues like ``get_logger().trace`` producing an :exc:`AttributeError`. + """ + if not skip_class_check and logging.getLoggerClass() is not CustomLogger: + setup_logging() + + # Ideally, we would log this before running the setup_logging function, however + # we that would produce an unformatted (default) log, which is not what we want. + log = logging.getLogger(__name__) + log.debug("Ran setup_log (logger was requested).") + + return cast(CustomLogger, logging.getLogger(name)) + + +def setup_logging() -> None: + """Sets up logging library to use our logging configuration. + + This function only needs to be called once, at the program start. + """ + # This indicates that logging was already set up, no need to do it again + if logging.getLoggerClass() is CustomLogger: + log = get_logger(__name__) + log.debug("Attempted to setup logging, when it was already set up") + return + + # Setup log levels first, so that get_logger will not attempt to call setup_logging itself. + _setup_trace_level() + + root_log = get_logger() + _setup_coloredlogs(root_log) + _setup_logfile(root_log) + _setup_log_levels(root_log) + _setup_external_log_levels(root_log) + + +def _setup_trace_level() -> None: + """Setup logging to recognize our new TRACE level.""" + logging.TRACE = TRACE_LEVEL # pyright: ignore[reportAttributeAccessIssue] + logging.addLevelName(TRACE_LEVEL, "TRACE") + logging.setLoggerClass(CustomLogger) + + +def _setup_coloredlogs(root_log: LoggerClass) -> None: + """Install coloredlogs and set it up to use our log format.""" + if "COLOREDLOGS_LOG_FORMAT" not in os.environ: + coloredlogs.DEFAULT_LOG_FORMAT = LOG_FORMAT + + if "COLOREDLOGS_LEVEL_STYLES" not in os.environ: + coloredlogs.DEFAULT_LEVEL_STYLES = { + **coloredlogs.DEFAULT_LEVEL_STYLES, + "trace": {"color": 246}, + "critical": {"background": "red"}, + } + + if "COLOREDLOGS_DEFAULT_FIELD_STYLES" not in os.environ: + coloredlogs.DEFAULT_FIELD_STYLES = { + **coloredlogs.DEFAULT_FIELD_STYLES, + "levelname": {"color": "magenta", "bold": True}, + } + + # The log level here is set to TRACE, so that coloredlogs covers all messages. + # This however doesn't mean that our log level will actually be set to TRACE, + # that's configured by setting the root log's log level directly. + coloredlogs.install(level=TRACE_LEVEL, logger=root_log, stream=sys.stdout) + + +def _setup_logfile(root_log: LoggerClass) -> None: + """Setup a file handle for logging using our log format.""" + if LOG_FILE is None: + return + + LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + + file_handler = logging.FileHandler(LOG_FILE) + log_formatter = logging.Formatter(LOG_FORMAT) + file_handler.setFormatter(log_formatter) + root_log.addHandler(file_handler) + + +def _setup_log_levels(root_log: LoggerClass) -> None: + """Set loggers to the log levels according to the value from the TRACE_LEVEL_FILTER and DEBUG env vars. + + DEBUG env var: + - When set to a truthy value (1,true,yes), the root log level will be set to DEBUG. + - Otherwise (including if not set at all), the root log level will be set to INFO. + + TRACE_LEVEL_FILTER env var: + This variable is ignored if DEBUG is not set to a truthy value! + + - If not set, no trace logs will appear and root log will be set to DEBUG. + - If set to "*", the root logger will be set to the TRACE level. All trace logs will appear. + - When set to a list of logger names, delimited by a comma, each of the listed loggers will + be set to the TRACE level. The root logger will retain the DEBUG log level. + - If this list is prefixed by a "!", the root logger is set to TRACE level, with all of the + listed loggers set to a DEBUG log level. + """ + # DEBUG wasn't specified, no DEBUG level logs (INFO log level) + if not DEBUG: + root_log.setLevel(logging.INFO) + return + + # TRACE_LEVEL_FILTER wasn't specified, no TRACE level logs (DEBUG log level) + if TRACE_LEVEL_FILTER is None: + root_log.setLevel(logging.DEBUG) + return + + # TRACE_LEVEL_FILTER enables all TRACE loggers + if TRACE_LEVEL_FILTER == "*": + root_log.setLevel(TRACE_LEVEL) + return + + # TRACE_LEVEL_FILTER is a list of loggers to not set to TRACE level (default is TRACE) + if TRACE_LEVEL_FILTER.startswith("!"): + root_log.setLevel(TRACE_LEVEL) + for logger_name in TRACE_LEVEL_FILTER.removeprefix("!").strip(",").split(","): + get_logger(logger_name).setLevel(logging.DEBUG) + return + + # TRACE_LEVEL_FILTER is a list of loggers to set to TRACE level + root_log.setLevel(logging.DEBUG) + for logger_name in TRACE_LEVEL_FILTER.strip(",").split(","): + get_logger(logger_name).setLevel(TRACE_LEVEL) + + +def _setup_external_log_levels(root_log: LoggerClass) -> None: + """Set log levels of some external libraries explicitly. + + Some libraries produce a lot of logs which we don't necessarily need to see, + and they often tend to clutter our own. These libraries have their log levels + set explicitly here, avoiding unneeded spammy logs. + """ + get_logger("asyncio").setLevel(logging.INFO) + + get_logger("parso").setLevel(logging.WARNING) # For usage in IPython From 34f64ae364c2ff0208d68b6b6f781aa3ecee2955 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 18 Jul 2024 19:38:32 +0200 Subject: [PATCH 024/166] Add support for extensions (cogs) --- src/__main__.py | 23 ++++++++++++++++++----- src/exts/ping/__init__.py | 3 +++ src/exts/ping/ping.py | 22 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 src/exts/ping/__init__.py create mode 100644 src/exts/ping/ping.py diff --git a/src/__main__.py b/src/__main__.py index 36277d8..ab747b7 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,3 +1,5 @@ +import asyncio + import discord from src.settings import BOT_TOKEN @@ -5,6 +7,10 @@ log = get_logger(__name__) +EXTENSIONS = [ + "src.exts.ping", +] + bot = discord.Bot() @@ -14,11 +20,18 @@ async def on_ready() -> None: log.info(f"{bot.user} is ready and online!") -@bot.slash_command() -async def ping(ctx: discord.ApplicationContext) -> None: - """Test out bot's latency.""" - _ = await ctx.respond(f"Pong! ({(bot.latency * 1000):.2f}ms)") +async def main() -> None: + """Main entrypoint of the application. + + This will load all of the extensions and start the bot. + """ + log.info("Loading extneions...") + _ = bot.load_extensions(*EXTENSIONS) + + log.info("Starting the bot...") + async with bot: + await bot.start(BOT_TOKEN) if __name__ == "__main__": - bot.run(BOT_TOKEN) + asyncio.run(main()) diff --git a/src/exts/ping/__init__.py b/src/exts/ping/__init__.py new file mode 100644 index 0000000..6e9b9dd --- /dev/null +++ b/src/exts/ping/__init__.py @@ -0,0 +1,3 @@ +from .ping import setup + +__all__ = ["setup"] diff --git a/src/exts/ping/ping.py b/src/exts/ping/ping.py new file mode 100644 index 0000000..ecf3fe1 --- /dev/null +++ b/src/exts/ping/ping.py @@ -0,0 +1,22 @@ +from discord import ApplicationContext, Bot, Cog, slash_command + +from src.utils.log import get_logger + +log = get_logger(__name__) + + +class PingCog(Cog): + """Cog to verify the bot is working.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @slash_command() + async def ping(self, ctx: ApplicationContext) -> None: + """Test out bot's latency.""" + _ = await ctx.respond(f"Pong! ({(self.bot.latency * 1000):.2f}ms)") + + +def setup(bot: Bot) -> None: + """Register the PingCog cog.""" + bot.add_cog(PingCog(bot)) From dcd61ac98772bb25ef1c1a195638300ef51c5e73 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 18 Jul 2024 19:39:07 +0200 Subject: [PATCH 025/166] Add setup guide to README --- README.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b22db8e..5f13db9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,36 @@ + + # Python Discord's Code Jam 2024, Contemplative Constellations Team Project -This repository houses the source code of the team project created for **Python Discord's Code Jam 2024**, developed by **Contemplative Constellations** team. +This repository houses the source code of the team project created for **Python Discord's Code Jam 2024**, developed by +**Contemplative Constellations** team. + +## Running the bot + +To run the bot, you'll first want to install all of the project's dependencies. This is done using the +[`poetry`](https://python-poetry.org/docs/) package manager. You may need to download poetry if you don't already have +it. + +To install the dependencies, you can run `poetry install` command. If you only want to run the bot and you're not +interested in also developing / contributing, you can also run `poetry install --only-root`, which will skip the +development dependencies (tools for linting and testing). + +Once done, you will want to activate the virtual environment that poetry has just created for the project. To do so, +simply run `poetry shell`. + +You'll now need to configure the bot. See the [configuring section](#configuring-the-bot) + +Finally, you can start the bot with `python -m src`. + +## Configuring the bot + +The bot is configured using environment variables. You can either create a `.env` file and define these variables +there, or you can set / export them manually. Using the `.env` file is generally a better idea and will likely be more +convenient. + +| Variable name | Type | Description | +| ------------- | ------ | --------------------------------------------------------------------------------------------------- | +| `BOT_TOKEN` | string | Bot token of the discord application (see: [this guide][bot-token-guide] if you don't have one yet) | + +[bot-token-guide]: https://guide.pycord.dev/getting-started/creating-your-first-bot#creating-the-bot-application From da740ce551f272ddb2f6c3a97d404b6a1fa64db4 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 18 Jul 2024 19:40:48 +0200 Subject: [PATCH 026/166] Split authors list to multiple lines (shorter line length) --- pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c12c4c6..d9c5740 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,12 @@ name = "code-jam-2024" version = "0.1.0" description = "Python Discord's Code Jam 2024, Contemplative Constellations Team Project" -authors = ["ItsDrike ", "Benji ", "Paillat-dev ", "v33010 "] +authors = [ + "ItsDrike ", + "Benji ", + "Paillat-dev ", + "v33010 ", +] readme = "README.md" license = "MIT" packages = [{ include = "src" }] From 79bad04062159520f061502cce31d775545cf563 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 18 Jul 2024 20:17:02 +0200 Subject: [PATCH 027/166] Fix typo in log message Co-authored-by: Paillat --- src/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__main__.py b/src/__main__.py index ab747b7..ebd4192 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -25,7 +25,7 @@ async def main() -> None: This will load all of the extensions and start the bot. """ - log.info("Loading extneions...") + log.info("Loading extensions...") _ = bot.load_extensions(*EXTENSIONS) log.info("Starting the bot...") From e5a679203fd950af2d8961cb4dd9ea54f43ab5e7 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 18 Jul 2024 20:17:43 +0200 Subject: [PATCH 028/166] Fix wording in README Co-authored-by: Paillat --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f13db9..6201cd7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ To run the bot, you'll first want to install all of the project's dependencies. [`poetry`](https://python-poetry.org/docs/) package manager. You may need to download poetry if you don't already have it. -To install the dependencies, you can run `poetry install` command. If you only want to run the bot and you're not +To install the dependencies, you can run the `poetry install` command. If you only want to run the bot and you're not interested in also developing / contributing, you can also run `poetry install --only-root`, which will skip the development dependencies (tools for linting and testing). From f91dea715d24956781fc9fef807b2d64e1e9d86e Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 18 Jul 2024 20:16:25 +0200 Subject: [PATCH 029/166] Document logging variables --- README.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6201cd7..ae9c74f 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,25 @@ The bot is configured using environment variables. You can either create a `.env there, or you can set / export them manually. Using the `.env` file is generally a better idea and will likely be more convenient. -| Variable name | Type | Description | -| ------------- | ------ | --------------------------------------------------------------------------------------------------- | -| `BOT_TOKEN` | string | Bot token of the discord application (see: [this guide][bot-token-guide] if you don't have one yet) | +| Variable name | Type | Description | +| -------------------- | ------ | --------------------------------------------------------------------------------------------------- | +| `BOT_TOKEN` | string | Bot token of the discord application (see: [this guide][bot-token-guide] if you don't have one yet) | +| `DEBUG` | bool | If `1`, debug logs will be enabled, if `0` only info logs and above will be shown | +| `LOG_FILE` | path | If set, also write the logs into given file, otherwise, only print them | +| `TRACE_LEVEL_FILTER` | custom | Configuration for trace level logging, see: [trace logs config section](#trace-logs-config) | [bot-token-guide]: https://guide.pycord.dev/getting-started/creating-your-first-bot#creating-the-bot-application + +### Trace logs config + +We have a custom `trace` log level for the bot, which can be used for debugging purposes. This level is below `debug` +and can only be enabled if `DEBUG=1`. This log level is controlled through the `TRACE_LEVEL_FILTER` environment +variable. It works in the following way: + +- If `DEBUG=0`, the `TRACE_LEVEL_FILTER` variable is ignored, regardless of it's value. +- If `TRACE_LEVEL_FILTER` is not set, no trace logs will appear (debug logs only). +- If `TRACE_LEVEL_FILTER` is set to `*`, the root logger will be set to `TRACE` level. All trace logs will appear. +- When `TRACE_LEVEL_FILTER` is set to a list of logger names, delimited by a comma, each of the specified loggers will + be set to `TRACE` level, leaving the rest at `DEBUG` level. For example: `TRACE_LEVEL_FILTER="src.exts.foo.foo,src.exts.bar.bar"` +- When `TRACE_LEVEL_FILTER` starts with a `!` symbol, followed by a list of loggers, the root logger will be set to + `TRACE` level, with the specified loggers being set to `DEBUG` level. From 349106dfafbe3a1af80004101c1b449bf958eaf0 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Fri, 19 Jul 2024 07:21:31 +0000 Subject: [PATCH 030/166] Lift some typing requirements (#7) * Don't report missing type stubs * Don't report incompatible overrides * Ignore unused call result rule --- pyproject.toml | 6 +++++- src/__main__.py | 2 +- src/exts/ping/ping.py | 2 +- src/utils/config.py | 2 +- src/utils/log.py | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d9c5740..a754a87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,10 +161,14 @@ disableBytesTypePromotions = true reportAny = false reportImplicitStringConcatenation = false reportUnreachable = "information" -reportMissingTypeStubs = "information" +reportMissingTypeStubs = false reportUninitializedInstanceVariable = false # until https://github.com/DetachHead/basedpyright/issues/491 reportMissingParameterType = false # ruff's flake8-annotations (ANN) already covers this + gives us more control +# Too strict for py-cord codebases +reportIncompatibleMethodOverride = false +reportUnusedCallResult = false + # Unknown type reporting rules (too strict for most code-bases) reportUnknownArgumentType = false reportUnknownVariableType = false diff --git a/src/__main__.py b/src/__main__.py index ebd4192..dd5dd0e 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -26,7 +26,7 @@ async def main() -> None: This will load all of the extensions and start the bot. """ log.info("Loading extensions...") - _ = bot.load_extensions(*EXTENSIONS) + bot.load_extensions(*EXTENSIONS) log.info("Starting the bot...") async with bot: diff --git a/src/exts/ping/ping.py b/src/exts/ping/ping.py index ecf3fe1..cbd1d95 100644 --- a/src/exts/ping/ping.py +++ b/src/exts/ping/ping.py @@ -14,7 +14,7 @@ def __init__(self, bot: Bot) -> None: @slash_command() async def ping(self, ctx: ApplicationContext) -> None: """Test out bot's latency.""" - _ = await ctx.respond(f"Pong! ({(self.bot.latency * 1000):.2f}ms)") + await ctx.respond(f"Pong! ({(self.bot.latency * 1000):.2f}ms)") def setup(bot: Bot) -> None: diff --git a/src/utils/config.py b/src/utils/config.py index b317740..fc69566 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -4,7 +4,7 @@ from typing import Any, NewType, TYPE_CHECKING, TypeVar, cast, overload -from decouple import UndefinedValueError, config # pyright: ignore[reportMissingTypeStubs] +from decouple import UndefinedValueError, config if TYPE_CHECKING: from collections.abc import Callable diff --git a/src/utils/log.py b/src/utils/log.py index 88255f2..8c14ca1 100644 --- a/src/utils/log.py +++ b/src/utils/log.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any, TYPE_CHECKING, cast -import coloredlogs # pyright: ignore[reportMissingTypeStubs] +import coloredlogs from src.utils.config import get_config From ec44c9f5b06ea7e9074d7786540ac8411442dc33 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Fri, 19 Jul 2024 08:32:27 +0000 Subject: [PATCH 031/166] Add basic error handler (#6) --- pyproject.toml | 1 + src/__main__.py | 5 +- src/exts/error_handler/__init__.py | 3 + src/exts/error_handler/error_handler.py | 141 ++++++++++++++++++++++++ src/settings.py | 1 + 5 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/exts/error_handler/__init__.py create mode 100644 src/exts/error_handler/error_handler.py diff --git a/pyproject.toml b/pyproject.toml index a754a87..02acee5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ ignore = [ "B904", # Raise without `from` within an `except` clause + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` "PLR2004", # Using unnamed numerical constants "PGH003", # Using specific rule codes in type ignores "E731", # Don't asign a lambda expression, use a def diff --git a/src/__main__.py b/src/__main__.py index dd5dd0e..53aa4d0 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -9,9 +9,12 @@ EXTENSIONS = [ "src.exts.ping", + "src.exts.error_handler", ] -bot = discord.Bot() +intents = discord.Intents().default() +intents.message_content = True +bot = discord.Bot(intents=intents) @bot.event diff --git a/src/exts/error_handler/__init__.py b/src/exts/error_handler/__init__.py new file mode 100644 index 0000000..d8085bc --- /dev/null +++ b/src/exts/error_handler/__init__.py @@ -0,0 +1,3 @@ +from .error_handler import setup + +__all__ = ["setup"] diff --git a/src/exts/error_handler/error_handler.py b/src/exts/error_handler/error_handler.py new file mode 100644 index 0000000..9d0a8dd --- /dev/null +++ b/src/exts/error_handler/error_handler.py @@ -0,0 +1,141 @@ +import textwrap +from typing import cast + +from discord import Any, ApplicationContext, Bot, Cog, Colour, Embed, errors +from discord.ext.commands import errors as commands_errors + +from src.settings import GITHUB_REPO +from src.utils.log import get_logger + +log = get_logger(__name__) + + +class ErrorHandler(Cog): + """Cog to handle any errors invoked from commands.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + async def send_error_embed( + self, + ctx: ApplicationContext, + *, + title: str | None = None, + description: str | None = None, + ) -> None: + """Send an embed regarding the unhandled exception that occurred.""" + if title is None and description is None: + raise ValueError("You need to provide either a title or a description.") + + embed = Embed(title=title, description=description, color=Colour.red()) + await ctx.respond(f"Sorry, {ctx.author.mention}", embed=embed) + + async def send_unhandled_embed(self, ctx: ApplicationContext, exc: BaseException) -> None: + """Send an embed regarding the unhandled exception that occurred.""" + msg = f"Exception {exc!r} has occurred from command {ctx.command.qualified_name} invoked by {ctx.author.id}" + if ctx.message: + msg += f" in message {ctx.message.content!r}" + if ctx.guild: + msg += f" on guild {ctx.guild.id}" + log.warning(msg) + + await self.send_error_embed( + ctx, + title="Unhandled exception", + description=textwrap.dedent( + f""" + Unknown error has occurred without being properly handled. + Please report this at the [GitHub repository]({GITHUB_REPO}) + + **Command**: `{ctx.command.qualified_name}` + **Exception details**: ```{exc.__class__.__name__}: {exc}``` + """ + ), + ) + + async def _handle_check_failure( + self, + ctx: ApplicationContext, + exc: errors.CheckFailure | commands_errors.CheckFailure, + ) -> None: + if isinstance(exc, commands_errors.CheckAnyFailure): + # We don't really care that all of the checks have failed, we need to produce an error message here, + # so just take the first failure and work with that as the error. + + # Even though the docstring says that exc.errors should contain the CheckFailure exceptions, + # the type-hint says that the errors should be in exc.checks... Cast the type away to Any + # and just check both, see where the error actually is and use that + errors1 = cast(list[Any], exc.errors) + errors2 = cast(list[Any], exc.checks) + + if len(errors1) > 0 and isinstance(errors1[0], (errors.CheckFailure, commands_errors.CheckFailure)): + exc = errors1[0] + elif len(errors2) > 0 and isinstance(errors2[0], (errors.CheckFailure, commands_errors.CheckFailure)): + exc = errors2[0] + else: + # Just in case, this library is a mess... + raise ValueError("Never (hopefully), here's some random code: 0xd1ff0aaac") + + if isinstance(exc, commands_errors.NotOwner): + await self.send_error_embed(ctx, description="❌ This command is limited to the bot owner.") + return + + if isinstance( + exc, + ( + commands_errors.MissingPermissions, + commands_errors.MissingRole, + commands_errors.MissingAnyRole, + ), + ): + await self.send_error_embed(ctx, description="❌ You don't have permission to run this command.") + return + + if isinstance( + exc, + ( + commands_errors.BotMissingRole, + commands_errors.BotMissingAnyRole, + commands_errors.BotMissingPermissions, + ), + ): + await self.send_error_embed( + ctx, + description="❌ I don't have the necessary permissions to perform this action.", + ) + + if isinstance(exc, commands_errors.NoPrivateMessage): + await self.send_error_embed(ctx, description="❌ This command can only be used in a server.") + return + + if isinstance(exc, commands_errors.PrivateMessageOnly): + await self.send_error_embed(ctx, description="❌ This command can only be used in a DM.") + return + + if isinstance(exc, commands_errors.NSFWChannelRequired): + await self.send_error_embed(ctx, description="❌ This command can only be used in an NSFW channel.") + return + + await self.send_unhandled_embed(ctx, exc) + + @Cog.listener() + async def on_application_command_error(self, ctx: ApplicationContext, exc: errors.DiscordException) -> None: + """Handle exceptions that have occurred while running some command.""" + if isinstance(exc, (errors.CheckFailure, commands_errors.CheckFailure)): + await self._handle_check_failure(ctx, exc) + return + + if isinstance(exc, errors.ApplicationCommandInvokeError): + original_exception = exc.__cause__ + + if original_exception is not None: + await self.send_unhandled_embed(ctx, original_exception) + log.exception("Unhandled exception occurred.", exc_info=original_exception) + return + + await self.send_unhandled_embed(ctx, exc) + + +def setup(bot: Bot) -> None: + """Register the ErrorHandler cog.""" + bot.add_cog(ErrorHandler(bot)) diff --git a/src/settings.py b/src/settings.py index 4a93771..7c18cf4 100644 --- a/src/settings.py +++ b/src/settings.py @@ -1,3 +1,4 @@ from src.utils.config import get_config +GITHUB_REPO = "https://github.com/ItsDrike/code-jam-2024" BOT_TOKEN = get_config("BOT_TOKEN") From 3e51b0338c8e998d92f6984bfd35d3f30ca8df6b Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Fri, 19 Jul 2024 09:06:45 +0000 Subject: [PATCH 032/166] Add sudo / debug cog (#8) --- src/__main__.py | 1 + src/converters/__init__.py | 0 src/converters/bot_extension.py | 42 +++++++++++++++++ src/exts/sudo/__init__.py | 3 ++ src/exts/sudo/sudo.py | 83 +++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+) create mode 100644 src/converters/__init__.py create mode 100644 src/converters/bot_extension.py create mode 100644 src/exts/sudo/__init__.py create mode 100644 src/exts/sudo/sudo.py diff --git a/src/__main__.py b/src/__main__.py index 53aa4d0..8991583 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -10,6 +10,7 @@ EXTENSIONS = [ "src.exts.ping", "src.exts.error_handler", + "src.exts.sudo", ] intents = discord.Intents().default() diff --git a/src/converters/__init__.py b/src/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/converters/bot_extension.py b/src/converters/bot_extension.py new file mode 100644 index 0000000..4377ea8 --- /dev/null +++ b/src/converters/bot_extension.py @@ -0,0 +1,42 @@ +import importlib +import inspect +from typing import override + +from discord.ext.commands import Bot, Context, Converter + + +class ValidBotExtension(Converter[str]): + """Convert given extension name to a fully qualified path to extension.""" + + @staticmethod + def valid_extension_path(extension_name: str) -> str: + """Get the fully qualified path to a valid bot extension. + + The `extension_name` can be: + - A fully qualified path (e.g. 'src.exts.ping'), + - A suffix component of a fully qualified path (e.g. 'exts.ping', or just 'ping') + + The suffix must still be an entire path component, so while 'ping' is valid, 'pi' or 'ng' is not. + + If the `extension_name` doesn't point to a valid extension a ValueError will be raised. + """ + extension_name = extension_name.removeprefix("src.exts.").removeprefix("exts.") + extension_name = f"src.exts.{extension_name}" + + # This could technically be a vulnerability, but this converter can only + # be used by the bot owner + try: + imported = importlib.import_module(extension_name) + except ModuleNotFoundError: + raise ValueError(f"Unable to import '{extension_name}'.") + + # If it lacks a setup function, it's not an extension + if not inspect.isfunction(getattr(imported, "setup", None)): + raise ValueError(f"'{extension_name}' is not a valid extension.") + + return extension_name + + @override + async def convert(self, ctx: Context[Bot], argument: str) -> str: + """Try to match given `argument` to a valid extension within the bot project.""" + return self.valid_extension_path(argument) diff --git a/src/exts/sudo/__init__.py b/src/exts/sudo/__init__.py new file mode 100644 index 0000000..988bfaf --- /dev/null +++ b/src/exts/sudo/__init__.py @@ -0,0 +1,3 @@ +from .sudo import setup + +__all__ = ["setup"] diff --git a/src/exts/sudo/sudo.py b/src/exts/sudo/sudo.py new file mode 100644 index 0000000..bf02038 --- /dev/null +++ b/src/exts/sudo/sudo.py @@ -0,0 +1,83 @@ +from typing import cast, override + +from discord import ( + ApplicationContext, + Bot, + Cog, + ExtensionAlreadyLoaded, + ExtensionNotLoaded, + SlashCommandGroup, + User, + option, +) +from discord.ext.commands.errors import NotOwner + +from src.converters.bot_extension import ValidBotExtension + + +class Sudo(Cog): + """Cog that allows the bot owner to perform various privileged actions.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + sudo = SlashCommandGroup(name="sudo", description="Commands for the bot owner.") + + @sudo.command() + @option("extension", ValidBotExtension) + async def load(self, ctx: ApplicationContext, extension: str) -> None: + """Dynamically load a requested bot extension. + + This can be very useful for debugging and testing new features without having to restart the bot. + """ + try: + self.bot.load_extension(extension) + except ExtensionAlreadyLoaded: + await ctx.respond("❌ Extension is already loaded") + return + await ctx.respond(f"✅ Extension `{extension}` loaded") + + @sudo.command() + @option("extension", ValidBotExtension) + async def unload(self, ctx: ApplicationContext, extension: str) -> None: + """Dynamically unload a requested bot extension. + + This can be very useful for debugging and testing new features without having to restart the bot. + """ + try: + self.bot.unload_extension(extension) + except ExtensionNotLoaded: + await ctx.respond("❌ Extension is not loaded") + return + await ctx.respond(f"✅ Extension `{extension}` unloaded") + + @sudo.command() + @option("extension", ValidBotExtension) + async def reload(self, ctx: ApplicationContext, extension: str) -> None: + """Dynamically reload a requested bot extension. + + This can be very useful for debugging and testing new features without having to restart the bot. + """ + try: + self.bot.unload_extension(extension) + except ExtensionNotLoaded: + already_loaded = False + else: + already_loaded = True + self.bot.load_extension(extension) + + action = "reloaded" if already_loaded else "loaded" + await ctx.respond(f"✅ Extension `{extension}` {action}") + + @override + async def cog_check(self, ctx: ApplicationContext) -> bool: + """Only the bot owners can use this cog.""" + if not await self.bot.is_owner(cast(User, ctx.author)): + raise NotOwner + + return super().cog_check(ctx) + + +def setup(bot: Bot) -> None: + """Load the Reloader cog.""" + bot.add_cog(Sudo(bot)) From 088b9ea8b67b9c7d4f381f9891338cbdbef493c2 Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 18 Jul 2024 21:03:20 +0200 Subject: [PATCH 033/166] :construction_worker: Add Dockerfile --- Dockerfile | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..753a430 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.12-slim-bookworm + +ENV POETRY_VERSION=1.3.1 \ + POETRY_HOME="/opt/poetry/home" \ + POETRY_CACHE_DIR="/opt/poetry/cache" \ + POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_IN_PROJECT=false + +ENV PATH="$POETRY_HOME/bin:$PATH" + +RUN apt-get update \ + && apt-get -y upgrade \ + && apt-get install --no-install-recommends -y curl \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Install poetry using the official installer +RUN curl -sSL https://install.python-poetry.org | python + +# Limit amount of concurrent install requests, to avoid hitting pypi rate-limits +RUN poetry config installer.max-workers 10 + +# Install project dependencies +WORKDIR /scraper +COPY pyproject.toml poetry.lock ./ +RUN poetry install --only main --no-interaction --no-ansi -vvv + +# Copy the source code in last to optimize rebuilding the image +COPY . . + +ENTRYPOINT ["poetry"] +CMD ["run", "python", "-m", "src"] From e40ddb017f818eda7be8f12108b9db1cd9407eaa Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 18 Jul 2024 21:03:37 +0200 Subject: [PATCH 034/166] :construction_worker: Add docker-compose.yaml --- docker-compose.yaml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docker-compose.yaml diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..de095d5 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,7 @@ +version: '3.9' + +services: + bot: + image: ghcr.io/itsdrike/code-jam-2024:main + env_file: + - .env From 2faac6338286ecd0da637f3787639ffb4fe414da Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 18 Jul 2024 21:04:07 +0200 Subject: [PATCH 035/166] :construction_worker: Add .dockerignore --- .dockerignore | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5571eb2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.github/ +.tests/ +.vscode/ +.editorconfig +.gitattributes +.gitignore +.pre-commit-config.yaml +LICENSE.txt +README.md From 9239e8c52a854edbf517e03d5547733f5f37bb61 Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 19 Jul 2024 10:59:46 +0200 Subject: [PATCH 036/166] :pushpin: Pin poetry version and change app directory --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 753a430..42bf739 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.12-slim-bookworm -ENV POETRY_VERSION=1.3.1 \ +ENV POETRY_VERSION=1.8.3 \ POETRY_HOME="/opt/poetry/home" \ POETRY_CACHE_DIR="/opt/poetry/cache" \ POETRY_NO_INTERACTION=1 \ @@ -20,7 +20,7 @@ RUN curl -sSL https://install.python-poetry.org | python RUN poetry config installer.max-workers 10 # Install project dependencies -WORKDIR /scraper +WORKDIR /app COPY pyproject.toml poetry.lock ./ RUN poetry install --only main --no-interaction --no-ansi -vvv From bf5bb67d343172d6163b9f821a6f089fcd47a9a7 Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 19 Jul 2024 11:05:34 +0200 Subject: [PATCH 037/166] :construction_worker: Add docker action --- .github/workflows/docker.yml | 89 ++++++++++++++++++++++++++++++++++++ .github/workflows/main.yml | 8 ++++ 2 files changed, 97 insertions(+) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..8302526 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,89 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: workflow_call + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Install the cosign tool except on PR + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 + with: + cosign-release: 'v2.2.4' + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # Sign the resulting Docker image digest except on PRs. + # This will only write to the public Rekor transparency log when the Docker + # repository is public to avoid leaking data. If you would like to publish + # transparency data even for private images, pass --force to cosign below. + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9f160c3..2c6ef3a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,6 +20,14 @@ jobs: unit-tests: uses: ./.github/workflows/unit-tests.yml + docker: + uses: ./.github/workflows/docker.yml + needs: [unit-tests, validation] + permissions: + packages: write + contents: read + id-token: write + # Produce a pull request payload artifact with various data about the # pull-request event (such as the PR number, title, author, ...). # This data is then be picked up by status-embed.yml action. From 1e07c03b53c6cd4e9e3ef4b37618d899572f165f Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 19 Jul 2024 11:21:03 +0200 Subject: [PATCH 038/166] :page_facing_up: Keep License Co-authored-by: ItsDrike --- .dockerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 5571eb2..89e464c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,5 +5,4 @@ .gitattributes .gitignore .pre-commit-config.yaml -LICENSE.txt README.md From 9ef424c627588319d9f6e15c4724b8254acc5a2c Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 19 Jul 2024 11:22:39 +0200 Subject: [PATCH 039/166] :see_no_evil: ignore `.git/` --- .dockerignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.dockerignore b/.dockerignore index 89e464c..817b24e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ .github/ +.git/ .tests/ .vscode/ .editorconfig From 6ffb0556d7ab4e53962dfd168a53500866e0d31a Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 19 Jul 2024 11:27:01 +0200 Subject: [PATCH 040/166] :construction_worker: Use Cosign version instead of hash --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8302526..d7af1ef 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -32,7 +32,7 @@ jobs: # https://github.com/sigstore/cosign-installer - name: Install cosign if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 + uses: sigstore/cosign-installer@v3.5.0 with: cosign-release: 'v2.2.4' From ce2e7797857216793547b44e4f85b7f6a241594c Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 19 Jul 2024 17:46:51 +0200 Subject: [PATCH 041/166] :rocket: Add ci-cd automatic deployment --- .github/workflows/main.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2c6ef3a..823d1be 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,6 +28,21 @@ jobs: contents: read id-token: write + deploy-portainer: + runs-on: ubuntu-latest + needs: [docker] + env: + WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }} + if: env.WEBHOOK != '' && github.event == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Trigger Portainer Webhook + run: | + response=$(curl -s -o /dev/null -w "%{http_code}" ${{ secrets.PORTAINER_WEBHOOK }}) + if [[ "$response" -lt 200 || "$response" -ge 300 ]]; then + echo "Webhook trigger failed with response code $response" + exit 1 + fi + # Produce a pull request payload artifact with various data about the # pull-request event (such as the PR number, title, author, ...). # This data is then be picked up by status-embed.yml action. From f1417eaa5e4f40216da470b09a7cbe8bd24f7e87 Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 19 Jul 2024 17:58:42 +0200 Subject: [PATCH 042/166] :rocket: Add ci-cd automatic deployment --- .github/workflows/main.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 823d1be..982587c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,9 +33,10 @@ jobs: needs: [docker] env: WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }} - if: env.WEBHOOK != '' && github.event == 'push' && github.ref == 'refs/heads/main' + if: github.event == 'push' && github.ref == 'refs/heads/main' steps: - name: Trigger Portainer Webhook + if: env.WEBHOOK != '' run: | response=$(curl -s -o /dev/null -w "%{http_code}" ${{ secrets.PORTAINER_WEBHOOK }}) if [[ "$response" -lt 200 || "$response" -ge 300 ]]; then From d7866ac2abc8c50fe73ae5b3ac83e8f2684a111b Mon Sep 17 00:00:00 2001 From: dragon64 <75382194+dragonblz@users.noreply.github.com> Date: Fri, 19 Jul 2024 20:48:53 +0300 Subject: [PATCH 043/166] start help cog (#10) --- src/__main__.py | 1 + src/exts/help/__init__.py | 3 +++ src/exts/help/help.py | 37 ++++++++++++++++++++++++++++++++++++ src/utils/__init__.py | 3 +++ src/utils/mention_command.py | 9 +++++++++ 5 files changed, 53 insertions(+) create mode 100644 src/exts/help/__init__.py create mode 100644 src/exts/help/help.py create mode 100644 src/utils/mention_command.py diff --git a/src/__main__.py b/src/__main__.py index 8991583..75ef4f0 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -11,6 +11,7 @@ "src.exts.ping", "src.exts.error_handler", "src.exts.sudo", + "src.exts.help", ] intents = discord.Intents().default() diff --git a/src/exts/help/__init__.py b/src/exts/help/__init__.py new file mode 100644 index 0000000..e01a799 --- /dev/null +++ b/src/exts/help/__init__.py @@ -0,0 +1,3 @@ +from .help import setup + +__all__ = ["setup"] diff --git a/src/exts/help/help.py b/src/exts/help/help.py new file mode 100644 index 0000000..eee4e51 --- /dev/null +++ b/src/exts/help/help.py @@ -0,0 +1,37 @@ +import aiohttp +from discord import ApplicationContext, Bot, Cog, Embed, slash_command + +from src.utils import mention_command +from src.utils.log import get_logger + +log = get_logger(__name__) +CAT_URL: str = "https://api.thecatapi.com/v1/images/search" + + +class HelpCog(Cog): + """Cog to verify the bot is working.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @slash_command() + async def help(self, ctx: ApplicationContext) -> None: + """Help command shows available commands.""" + async with aiohttp.ClientSession().get(CAT_URL) as response: + response.raise_for_status() + data = await response.json() + url: str = data[0]["url"] + + embed = Embed( + title="Help command", + image=url, + ) + embed.add_field(name=mention_command("ping", self.bot), value="sends a response with pong", inline=False) + embed.add_field(name=mention_command("help", self.bot), value="gives a list of available commands for users") + embed.add_field(name="", value="") + await ctx.respond(embed=embed) + + +def setup(bot: Bot) -> None: + """Register the HelpCog cog.""" + bot.add_cog(HelpCog(bot)) diff --git a/src/utils/__init__.py b/src/utils/__init__.py index e69de29..e56865e 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -0,0 +1,3 @@ +from .mention_command import mention_command + +__all__ = ["mention_command"] diff --git a/src/utils/mention_command.py b/src/utils/mention_command.py new file mode 100644 index 0000000..007e775 --- /dev/null +++ b/src/utils/mention_command.py @@ -0,0 +1,9 @@ +import discord + + +def mention_command(command: str, bot: discord.Bot) -> str: + """Mentions the command.""" + discord_command = bot.get_command(command) + if not discord_command: + raise ValueError("command not found") + return f"" From 38ca2ad4caca6891a66054c85fd9101e57385cbe Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 19 Jul 2024 20:08:20 +0200 Subject: [PATCH 044/166] :green_heart: Improve CD by also deploying on workflow dispatch --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 982587c..881bf39 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,7 +33,7 @@ jobs: needs: [docker] env: WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }} - if: github.event == 'push' && github.ref == 'refs/heads/main' + if: (github.event_name == 'push' || github.event == 'workflow_dispatch') && github.ref == 'refs/heads/main' steps: - name: Trigger Portainer Webhook if: env.WEBHOOK != '' From a070ab54d83248f433bc96849d26c4479198fec1 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Fri, 19 Jul 2024 20:29:57 +0200 Subject: [PATCH 045/166] Fix aiohttp client not getting properly closed --- src/exts/help/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/exts/help/help.py b/src/exts/help/help.py index eee4e51..1889ac0 100644 --- a/src/exts/help/help.py +++ b/src/exts/help/help.py @@ -17,7 +17,7 @@ def __init__(self, bot: Bot) -> None: @slash_command() async def help(self, ctx: ApplicationContext) -> None: """Help command shows available commands.""" - async with aiohttp.ClientSession().get(CAT_URL) as response: + async with aiohttp.ClientSession() as client, client.get(CAT_URL) as response: response.raise_for_status() data = await response.json() url: str = data[0]["url"] From 6bf439c6e53dfa2d4e6d3b04407fbde66237277f Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Fri, 19 Jul 2024 20:32:37 +0200 Subject: [PATCH 046/166] Fix wording in docstrings within the help cog --- src/exts/help/help.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/exts/help/help.py b/src/exts/help/help.py index 1889ac0..13706cf 100644 --- a/src/exts/help/help.py +++ b/src/exts/help/help.py @@ -9,14 +9,14 @@ class HelpCog(Cog): - """Cog to verify the bot is working.""" + """Cog to provide help info for all available bot commands.""" def __init__(self, bot: Bot) -> None: self.bot = bot @slash_command() async def help(self, ctx: ApplicationContext) -> None: - """Help command shows available commands.""" + """Shows help for all available commands.""" async with aiohttp.ClientSession() as client, client.get(CAT_URL) as response: response.raise_for_status() data = await response.json() From 2a140109032c1beb176ce66c8a14146af794ebf1 Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 19 Jul 2024 20:39:13 +0200 Subject: [PATCH 047/166] :green_heart: Use POST request to deploy on portainer --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 881bf39..080e796 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -38,7 +38,7 @@ jobs: - name: Trigger Portainer Webhook if: env.WEBHOOK != '' run: | - response=$(curl -s -o /dev/null -w "%{http_code}" ${{ secrets.PORTAINER_WEBHOOK }}) + response=$(curl -s -X POST -o /dev/null -w "%{http_code}" ${{ secrets.PORTAINER_WEBHOOK }}) if [[ "$response" -lt 200 || "$response" -ge 300 ]]; then echo "Webhook trigger failed with response code $response" exit 1 From 36758993de2eadb38bcd20f3d185015ab081f253 Mon Sep 17 00:00:00 2001 From: dragon64 <75382194+dragonblz@users.noreply.github.com> Date: Sat, 20 Jul 2024 00:38:49 +0300 Subject: [PATCH 048/166] improve help command (#16) * Move random cat image generation to its own function * Fix cat function name as requested * Dynamically detect all commands --- src/exts/help/help.py | 38 ++++++++++++++++++++++++------------ src/utils/__init__.py | 3 ++- src/utils/cat_api.py | 11 +++++++++++ src/utils/mention_command.py | 9 ++++----- 4 files changed, 42 insertions(+), 19 deletions(-) create mode 100644 src/utils/cat_api.py diff --git a/src/exts/help/help.py b/src/exts/help/help.py index 13706cf..ef36e80 100644 --- a/src/exts/help/help.py +++ b/src/exts/help/help.py @@ -1,11 +1,10 @@ -import aiohttp -from discord import ApplicationContext, Bot, Cog, Embed, slash_command +from discord import ApplicationContext, Bot, CheckFailure, Cog, Embed, SlashCommand, SlashCommandGroup, slash_command +from discord.ext.commands import CheckFailure as CommandCheckFailure -from src.utils import mention_command +from src.utils import get_cat_image_url, mention_command from src.utils.log import get_logger log = get_logger(__name__) -CAT_URL: str = "https://api.thecatapi.com/v1/images/search" class HelpCog(Cog): @@ -17,18 +16,31 @@ def __init__(self, bot: Bot) -> None: @slash_command() async def help(self, ctx: ApplicationContext) -> None: """Shows help for all available commands.""" - async with aiohttp.ClientSession() as client, client.get(CAT_URL) as response: - response.raise_for_status() - data = await response.json() - url: str = data[0]["url"] - embed = Embed( title="Help command", - image=url, + image=await get_cat_image_url(), ) - embed.add_field(name=mention_command("ping", self.bot), value="sends a response with pong", inline=False) - embed.add_field(name=mention_command("help", self.bot), value="gives a list of available commands for users") - embed.add_field(name="", value="") + for command in self.bot.commands: + try: + can_run = await command.can_run(ctx) + except (CheckFailure, CommandCheckFailure): + can_run = False + if not can_run: + continue + if isinstance(command, SlashCommand): + embed.add_field(name=mention_command(command, self.bot), value=command.description, inline=False) + if isinstance(command, SlashCommandGroup): + embed.add_field( + name=f"{mention_command(command, self.bot)} group", + value=command.description + + "\n\n" + + "\n".join( + f"{mention_command(subcommand, self.bot)}: {subcommand.description}" + for subcommand in command.subcommands + ), + inline=False, + ) + embed.add_field(name="", value="") await ctx.respond(embed=embed) diff --git a/src/utils/__init__.py b/src/utils/__init__.py index e56865e..4ec19f1 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -1,3 +1,4 @@ +from .cat_api import get_cat_image_url from .mention_command import mention_command -__all__ = ["mention_command"] +__all__ = ["mention_command", "get_cat_image_url"] diff --git a/src/utils/cat_api.py b/src/utils/cat_api.py new file mode 100644 index 0000000..96048ad --- /dev/null +++ b/src/utils/cat_api.py @@ -0,0 +1,11 @@ +import aiohttp + +CAT_URL: str = "https://api.thecatapi.com/v1/images/search" + + +async def get_cat_image_url() -> str: + """Get an image of a random cat.""" + async with aiohttp.ClientSession() as client, client.get(CAT_URL) as response: + response.raise_for_status() + data = await response.json() + return data[0]["url"] diff --git a/src/utils/mention_command.py b/src/utils/mention_command.py index 007e775..c02b507 100644 --- a/src/utils/mention_command.py +++ b/src/utils/mention_command.py @@ -1,9 +1,8 @@ +from typing import Any + import discord -def mention_command(command: str, bot: discord.Bot) -> str: +def mention_command(command: discord.ApplicationCommand[Any, ..., Any], bot: discord.Bot) -> str: """Mentions the command.""" - discord_command = bot.get_command(command) - if not discord_command: - raise ValueError("command not found") - return f"" + return f"" From a259b3a4ca0deb0f14160e2291357fe3810b852a Mon Sep 17 00:00:00 2001 From: Ashtik Mahapatra Date: Sat, 20 Jul 2024 12:25:16 +0530 Subject: [PATCH 049/166] Add ash8121 to authors --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 02acee5..ff05ff4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ authors = [ "Benji ", "Paillat-dev ", "v33010 ", + "Ash8121 " ] readme = "README.md" license = "MIT" From 8fd4d38bf06526ff64faa49c1350bb3c05ed0343 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 20 Jul 2024 10:59:19 +0200 Subject: [PATCH 050/166] Clean up some code --- src/exts/help/help.py | 1 + src/utils/cat_api.py | 6 +++--- src/utils/mention_command.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/exts/help/help.py b/src/exts/help/help.py index ef36e80..1835569 100644 --- a/src/exts/help/help.py +++ b/src/exts/help/help.py @@ -27,6 +27,7 @@ async def help(self, ctx: ApplicationContext) -> None: can_run = False if not can_run: continue + if isinstance(command, SlashCommand): embed.add_field(name=mention_command(command, self.bot), value=command.description, inline=False) if isinstance(command, SlashCommandGroup): diff --git a/src/utils/cat_api.py b/src/utils/cat_api.py index 96048ad..be67f4c 100644 --- a/src/utils/cat_api.py +++ b/src/utils/cat_api.py @@ -1,11 +1,11 @@ import aiohttp -CAT_URL: str = "https://api.thecatapi.com/v1/images/search" +CAT_API_URL: str = "https://api.thecatapi.com/v1/images/search" async def get_cat_image_url() -> str: - """Get an image of a random cat.""" - async with aiohttp.ClientSession() as client, client.get(CAT_URL) as response: + """Get a URL for a random cat image.""" + async with aiohttp.ClientSession() as client, client.get(CAT_API_URL) as response: response.raise_for_status() data = await response.json() return data[0]["url"] diff --git a/src/utils/mention_command.py b/src/utils/mention_command.py index c02b507..7b99bc3 100644 --- a/src/utils/mention_command.py +++ b/src/utils/mention_command.py @@ -4,5 +4,5 @@ def mention_command(command: discord.ApplicationCommand[Any, ..., Any], bot: discord.Bot) -> str: - """Mentions the command.""" + """Mentions the command using discord markdown.""" return f"" From 23d79a0b45554b4faa75a22ada164b166af1a1cb Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 20 Jul 2024 11:01:55 +0200 Subject: [PATCH 051/166] Rename mention_command.py to markdown.py Use a more general name for this file in utils, as it will allow it to potentially also contain some other functions / functionalities in the future. Having single-purpose files named the same as the function that they contain is an overkill in this case and would likely soon result in a lot of clutter in the utils/ directory. Additionally, having the file named the same as a function, that is then re-exported in `__init__.py` causes some issues if someone would want to import this file explicitly as a module, since the re-exported function would take precedence. --- src/utils/__init__.py | 2 +- src/utils/{mention_command.py => markdown.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/utils/{mention_command.py => markdown.py} (100%) diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 4ec19f1..f5a01c7 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -1,4 +1,4 @@ from .cat_api import get_cat_image_url -from .mention_command import mention_command +from .markdown import mention_command __all__ = ["mention_command", "get_cat_image_url"] diff --git a/src/utils/mention_command.py b/src/utils/markdown.py similarity index 100% rename from src/utils/mention_command.py rename to src/utils/markdown.py From 31ea0a41561ef563eb37d9cd43a73e6dfc84aa9a Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 20 Jul 2024 11:05:50 +0200 Subject: [PATCH 052/166] Remove unused bot parameter from mention_command func This parameter was left there from when the function still only accepted strings for the command name and performed a search for the command object using the bot instance. --- src/exts/help/help.py | 6 +++--- src/utils/markdown.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/exts/help/help.py b/src/exts/help/help.py index 1835569..a978b12 100644 --- a/src/exts/help/help.py +++ b/src/exts/help/help.py @@ -29,14 +29,14 @@ async def help(self, ctx: ApplicationContext) -> None: continue if isinstance(command, SlashCommand): - embed.add_field(name=mention_command(command, self.bot), value=command.description, inline=False) + embed.add_field(name=mention_command(command), value=command.description, inline=False) if isinstance(command, SlashCommandGroup): embed.add_field( - name=f"{mention_command(command, self.bot)} group", + name=f"{mention_command(command)} group", value=command.description + "\n\n" + "\n".join( - f"{mention_command(subcommand, self.bot)}: {subcommand.description}" + f"{mention_command(subcommand)}: {subcommand.description}" for subcommand in command.subcommands ), inline=False, diff --git a/src/utils/markdown.py b/src/utils/markdown.py index 7b99bc3..033694d 100644 --- a/src/utils/markdown.py +++ b/src/utils/markdown.py @@ -3,6 +3,6 @@ import discord -def mention_command(command: discord.ApplicationCommand[Any, ..., Any], bot: discord.Bot) -> str: +def mention_command(command: discord.ApplicationCommand[Any, ..., Any]) -> str: """Mentions the command using discord markdown.""" return f"" From f94ddb0b240d3c55e60525660f442c4ad6d69702 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 20 Jul 2024 12:30:46 +0200 Subject: [PATCH 053/166] Isolate fail & success emojis to project constants --- src/exts/error_handler/error_handler.py | 23 ++++++++++++++++------- src/exts/sudo/sudo.py | 11 ++++++----- src/settings.py | 3 +++ 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/exts/error_handler/error_handler.py b/src/exts/error_handler/error_handler.py index 9d0a8dd..29fe410 100644 --- a/src/exts/error_handler/error_handler.py +++ b/src/exts/error_handler/error_handler.py @@ -4,7 +4,7 @@ from discord import Any, ApplicationContext, Bot, Cog, Colour, Embed, errors from discord.ext.commands import errors as commands_errors -from src.settings import GITHUB_REPO +from src.settings import FAIL_EMOJI, GITHUB_REPO from src.utils.log import get_logger log = get_logger(__name__) @@ -77,7 +77,10 @@ async def _handle_check_failure( raise ValueError("Never (hopefully), here's some random code: 0xd1ff0aaac") if isinstance(exc, commands_errors.NotOwner): - await self.send_error_embed(ctx, description="❌ This command is limited to the bot owner.") + await self.send_error_embed( + ctx, + description=f"{FAIL_EMOJI} This command is limited to the bot owner.", + ) return if isinstance( @@ -88,7 +91,10 @@ async def _handle_check_failure( commands_errors.MissingAnyRole, ), ): - await self.send_error_embed(ctx, description="❌ You don't have permission to run this command.") + await self.send_error_embed( + ctx, + description=f"{FAIL_EMOJI} You don't have permission to run this command.", + ) return if isinstance( @@ -101,19 +107,22 @@ async def _handle_check_failure( ): await self.send_error_embed( ctx, - description="❌ I don't have the necessary permissions to perform this action.", + description=f"{FAIL_EMOJI} I don't have the necessary permissions to perform this action.", ) if isinstance(exc, commands_errors.NoPrivateMessage): - await self.send_error_embed(ctx, description="❌ This command can only be used in a server.") + await self.send_error_embed(ctx, description=f"{FAIL_EMOJI} This command can only be used in a server.") return if isinstance(exc, commands_errors.PrivateMessageOnly): - await self.send_error_embed(ctx, description="❌ This command can only be used in a DM.") + await self.send_error_embed(ctx, description=f"{FAIL_EMOJI} This command can only be used in a DM.") return if isinstance(exc, commands_errors.NSFWChannelRequired): - await self.send_error_embed(ctx, description="❌ This command can only be used in an NSFW channel.") + await self.send_error_embed( + ctx, + description=f"{FAIL_EMOJI} This command can only be used in an NSFW channel.", + ) return await self.send_unhandled_embed(ctx, exc) diff --git a/src/exts/sudo/sudo.py b/src/exts/sudo/sudo.py index bf02038..2381c26 100644 --- a/src/exts/sudo/sudo.py +++ b/src/exts/sudo/sudo.py @@ -13,6 +13,7 @@ from discord.ext.commands.errors import NotOwner from src.converters.bot_extension import ValidBotExtension +from src.settings import FAIL_EMOJI, SUCCESS_EMOJI class Sudo(Cog): @@ -33,9 +34,9 @@ async def load(self, ctx: ApplicationContext, extension: str) -> None: try: self.bot.load_extension(extension) except ExtensionAlreadyLoaded: - await ctx.respond("❌ Extension is already loaded") + await ctx.respond(f"{FAIL_EMOJI} Extension is already loaded") return - await ctx.respond(f"✅ Extension `{extension}` loaded") + await ctx.respond(f"{SUCCESS_EMOJI} Extension `{extension}` loaded") @sudo.command() @option("extension", ValidBotExtension) @@ -47,9 +48,9 @@ async def unload(self, ctx: ApplicationContext, extension: str) -> None: try: self.bot.unload_extension(extension) except ExtensionNotLoaded: - await ctx.respond("❌ Extension is not loaded") + await ctx.respond(f"{FAIL_EMOJI} Extension is not loaded") return - await ctx.respond(f"✅ Extension `{extension}` unloaded") + await ctx.respond(f"{SUCCESS_EMOJI} Extension `{extension}` unloaded") @sudo.command() @option("extension", ValidBotExtension) @@ -67,7 +68,7 @@ async def reload(self, ctx: ApplicationContext, extension: str) -> None: self.bot.load_extension(extension) action = "reloaded" if already_loaded else "loaded" - await ctx.respond(f"✅ Extension `{extension}` {action}") + await ctx.respond(f"{SUCCESS_EMOJI} Extension `{extension}` {action}") @override async def cog_check(self, ctx: ApplicationContext) -> bool: diff --git a/src/settings.py b/src/settings.py index 7c18cf4..c2b1fe9 100644 --- a/src/settings.py +++ b/src/settings.py @@ -2,3 +2,6 @@ GITHUB_REPO = "https://github.com/ItsDrike/code-jam-2024" BOT_TOKEN = get_config("BOT_TOKEN") + +FAIL_EMOJI = "❌" +SUCCESS_EMOJI = "✅" From c9222e9c4f89a5340645363ba9d71992815a683a Mon Sep 17 00:00:00 2001 From: dragon64 <75382194+dragonblz@users.noreply.github.com> Date: Sat, 20 Jul 2024 22:56:17 +0300 Subject: [PATCH 054/166] Help command (#23) * Add support for pagination of the help command. * Various other improvements to the help command related logic --- src/exts/help/help.py | 38 +++++++++++++++++++++++--------------- src/utils/cat_api.py | 13 ++++++++----- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/exts/help/help.py b/src/exts/help/help.py index a978b12..5cc5d4b 100644 --- a/src/exts/help/help.py +++ b/src/exts/help/help.py @@ -1,7 +1,10 @@ -from discord import ApplicationContext, Bot, CheckFailure, Cog, Embed, SlashCommand, SlashCommandGroup, slash_command +import discord +from discord import ApplicationContext, Bot, CheckFailure, Cog, SlashCommand, SlashCommandGroup, slash_command from discord.ext.commands import CheckFailure as CommandCheckFailure +from discord.ext.pages import Page, Paginator -from src.utils import get_cat_image_url, mention_command +from src.utils import mention_command +from src.utils.cat_api import get_cat_image_url from src.utils.log import get_logger log = get_logger(__name__) @@ -16,10 +19,9 @@ def __init__(self, bot: Bot) -> None: @slash_command() async def help(self, ctx: ApplicationContext) -> None: """Shows help for all available commands.""" - embed = Embed( - title="Help command", - image=await get_cat_image_url(), - ) + cat_image_url = await get_cat_image_url() + fields: list[tuple[str, str]] = [] + for command in self.bot.commands: try: can_run = await command.can_run(ctx) @@ -27,22 +29,28 @@ async def help(self, ctx: ApplicationContext) -> None: can_run = False if not can_run: continue - if isinstance(command, SlashCommand): - embed.add_field(name=mention_command(command), value=command.description, inline=False) + fields.append((mention_command(command), command.description)) if isinstance(command, SlashCommandGroup): - embed.add_field( - name=f"{mention_command(command)} group", - value=command.description + value = ( + command.description + "\n\n" + "\n".join( f"{mention_command(subcommand)}: {subcommand.description}" for subcommand in command.subcommands - ), - inline=False, + ) ) - embed.add_field(name="", value="") - await ctx.respond(embed=embed) + fields.append((f"{mention_command(command)} group", value)) + + new_embed = lambda url: discord.Embed(title="help command").set_thumbnail(url=url) + + embeds: list[discord.Embed] = [new_embed(cat_image_url)] + for name, value in fields: + if len(embeds[-1].fields) >= 5: + embeds.append(new_embed(cat_image_url)) + embeds[-1].add_field(name=name, value=value, inline=False) + paginator = Paginator([Page(embeds=[embed]) for embed in embeds]) + await paginator.respond(ctx.interaction) def setup(bot: Bot) -> None: diff --git a/src/utils/cat_api.py b/src/utils/cat_api.py index be67f4c..83e060a 100644 --- a/src/utils/cat_api.py +++ b/src/utils/cat_api.py @@ -1,11 +1,14 @@ import aiohttp -CAT_API_URL: str = "https://api.thecatapi.com/v1/images/search" +CAT_API_URL = "https://api.thecatapi.com/v1/images/search" async def get_cat_image_url() -> str: - """Get a URL for a random cat image.""" - async with aiohttp.ClientSession() as client, client.get(CAT_API_URL) as response: - response.raise_for_status() - data = await response.json() + """Get a URL for a random cat image. + + The produced image can also be a GIF. + """ + async with aiohttp.ClientSession() as session, session.get(CAT_API_URL) as resp: + resp.raise_for_status() + data = await resp.json() return data[0]["url"] From b0b42e8d8bb96af25fe15d5330840644d50d376d Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 12:09:58 +0000 Subject: [PATCH 055/166] Use a custom bot subclass to hold state (#28) --- src/__main__.py | 33 ++++++---------- src/bot.py | 50 +++++++++++++++++++++++++ src/exts/error_handler/error_handler.py | 3 +- src/exts/help/help.py | 5 ++- src/exts/ping/ping.py | 3 +- src/exts/sudo/sudo.py | 2 +- src/utils/cat_api.py | 6 +-- 7 files changed, 72 insertions(+), 30 deletions(-) create mode 100644 src/bot.py diff --git a/src/__main__.py b/src/__main__.py index 75ef4f0..09d0a58 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,41 +1,30 @@ import asyncio +import aiohttp import discord +from src.bot import Bot from src.settings import BOT_TOKEN from src.utils.log import get_logger log = get_logger(__name__) -EXTENSIONS = [ - "src.exts.ping", - "src.exts.error_handler", - "src.exts.sudo", - "src.exts.help", -] - -intents = discord.Intents().default() -intents.message_content = True -bot = discord.Bot(intents=intents) - - -@bot.event -async def on_ready() -> None: - """Function called once the bot is ready and online.""" - log.info(f"{bot.user} is ready and online!") - async def main() -> None: """Main entrypoint of the application. This will load all of the extensions and start the bot. """ - log.info("Loading extensions...") - bot.load_extensions(*EXTENSIONS) + intents = discord.Intents().default() + intents.message_content = True + + async with aiohttp.ClientSession() as http_session: + bot = Bot(intents=intents, http_session=http_session) + bot.load_all_extensions() - log.info("Starting the bot...") - async with bot: - await bot.start(BOT_TOKEN) + log.info("Starting the bot...") + async with bot as bot_: + await bot_.start(BOT_TOKEN) if __name__ == "__main__": diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..0680614 --- /dev/null +++ b/src/bot.py @@ -0,0 +1,50 @@ +from collections.abc import Sequence +from sys import exception +from typing import Any, ClassVar, override + +import aiohttp +import discord + +from src.utils.log import get_logger + +log = get_logger(__name__) + + +class Bot(discord.Bot): + """Bot subclass that holds the state of the application. + + The bot instance is available throughout the application, which makes it + suitable to store various state variables that are needed in multiple places, + such as database connections. + """ + + EXTENSIONS: ClassVar[Sequence[str]] = [ + "src.exts.ping", + "src.exts.error_handler", + "src.exts.sudo", + "src.exts.help", + ] + + def __init__(self, *args: object, http_session: aiohttp.ClientSession, **kwargs: object) -> None: + """Initialize the bot instance, containing various state variables.""" + super().__init__(*args, **kwargs) + self.http_session = http_session + + self.event(self.on_ready) + + async def on_ready(self) -> None: + """The `on_ready` event handler.""" + log.info(f"{self.user} is ready and online!") + + def load_all_extensions(self) -> None: + """Load all of our bot extensions. + + This relies on the `EXTENSIONS` class variable. + """ + log.info("Loading extensions...") + self.load_extensions(*self.EXTENSIONS) + + @override + def on_error(self, event_method: str, *args: Any, **kwargs: Any) -> None: + """Log errors raised in commands and events properly, rather than just printing them to stderr.""" + log.exception(f"Unhandled exception in {event_method}", exc_info=exception()) diff --git a/src/exts/error_handler/error_handler.py b/src/exts/error_handler/error_handler.py index 29fe410..6c3a6e5 100644 --- a/src/exts/error_handler/error_handler.py +++ b/src/exts/error_handler/error_handler.py @@ -1,9 +1,10 @@ import textwrap from typing import cast -from discord import Any, ApplicationContext, Bot, Cog, Colour, Embed, errors +from discord import Any, ApplicationContext, Cog, Colour, Embed, errors from discord.ext.commands import errors as commands_errors +from src.bot import Bot from src.settings import FAIL_EMOJI, GITHUB_REPO from src.utils.log import get_logger diff --git a/src/exts/help/help.py b/src/exts/help/help.py index 5cc5d4b..fa631b3 100644 --- a/src/exts/help/help.py +++ b/src/exts/help/help.py @@ -1,8 +1,9 @@ import discord -from discord import ApplicationContext, Bot, CheckFailure, Cog, SlashCommand, SlashCommandGroup, slash_command +from discord import ApplicationContext, CheckFailure, Cog, SlashCommand, SlashCommandGroup, slash_command from discord.ext.commands import CheckFailure as CommandCheckFailure from discord.ext.pages import Page, Paginator +from src.bot import Bot from src.utils import mention_command from src.utils.cat_api import get_cat_image_url from src.utils.log import get_logger @@ -19,7 +20,7 @@ def __init__(self, bot: Bot) -> None: @slash_command() async def help(self, ctx: ApplicationContext) -> None: """Shows help for all available commands.""" - cat_image_url = await get_cat_image_url() + cat_image_url = await get_cat_image_url(self.bot.http_session) fields: list[tuple[str, str]] = [] for command in self.bot.commands: diff --git a/src/exts/ping/ping.py b/src/exts/ping/ping.py index cbd1d95..11f8914 100644 --- a/src/exts/ping/ping.py +++ b/src/exts/ping/ping.py @@ -1,5 +1,6 @@ -from discord import ApplicationContext, Bot, Cog, slash_command +from discord import ApplicationContext, Cog, slash_command +from src.bot import Bot from src.utils.log import get_logger log = get_logger(__name__) diff --git a/src/exts/sudo/sudo.py b/src/exts/sudo/sudo.py index 2381c26..533e49a 100644 --- a/src/exts/sudo/sudo.py +++ b/src/exts/sudo/sudo.py @@ -2,7 +2,6 @@ from discord import ( ApplicationContext, - Bot, Cog, ExtensionAlreadyLoaded, ExtensionNotLoaded, @@ -12,6 +11,7 @@ ) from discord.ext.commands.errors import NotOwner +from src.bot import Bot from src.converters.bot_extension import ValidBotExtension from src.settings import FAIL_EMOJI, SUCCESS_EMOJI diff --git a/src/utils/cat_api.py b/src/utils/cat_api.py index 83e060a..caee876 100644 --- a/src/utils/cat_api.py +++ b/src/utils/cat_api.py @@ -1,14 +1,14 @@ -import aiohttp +from discord import aiohttp CAT_API_URL = "https://api.thecatapi.com/v1/images/search" -async def get_cat_image_url() -> str: +async def get_cat_image_url(http_session: aiohttp.ClientSession) -> str: """Get a URL for a random cat image. The produced image can also be a GIF. """ - async with aiohttp.ClientSession() as session, session.get(CAT_API_URL) as resp: + async with http_session.get(CAT_API_URL) as resp: resp.raise_for_status() data = await resp.json() return data[0]["url"] From 961c241a2bed883cf15d243ce84f3e40398ebfc3 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 21 Jul 2024 15:24:36 +0200 Subject: [PATCH 056/166] :construction_worker: Build docker arm64 as well --- .github/workflows/docker.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d7af1ef..dc9fcdc 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -42,6 +42,9 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + # Login against a Docker registry except on PR # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} @@ -64,8 +67,9 @@ jobs: # https://github.com/docker/build-push-action - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: + platforms: linux/amd64,linux/arm64 context: . push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} From c0923e66cd7410d167b04b9c6d6aa59d699a4edc Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 15:35:48 +0200 Subject: [PATCH 057/166] ruff: Ignore pandas-vet (PD) Some of the pandas rules are way too trigger happy and there's no sense in keeping this enabled for us, considering we don't even use pandas in this project. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index ff05ff4..8d8e930 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ ignore = [ "ARG", # flake8-unused-arguments "TD", # flake8-todos "FIX", # flake8-fixme + "PD", # pandas-vet "D100", # Missing docstring in public module "D104", # Missing docstring in public package From 3bd70cd5dab076f6b7c652f9aaf1cd715a6c814a Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 21 Jul 2024 15:29:13 +0200 Subject: [PATCH 058/166] =?UTF-8?q?=F0=9F=92=A1=20Add=20explanatory=20comm?= =?UTF-8?q?ent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index dc9fcdc..ce77303 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -42,6 +42,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # Allows us to build images with different cpu architectures than the github runner (arm64) - name: Set up QEMU uses: docker/setup-qemu-action@v3 From c2c0117ae306553ae8317d182bd73bb4719b5293 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 20 Jul 2024 19:28:51 +0200 Subject: [PATCH 059/166] Add docker-compose that builds the image locally Currently, the project only contains a docker-compose file that relies on ghcr images, which are released automatically using a CI action when an update comes out in the project's `main` branch. However, in some situations, it can be useful to allow people to quickly use docker-compose that will build the image for them locally, to allow for testing something in a system agnostic way within the container. --- docker-compose.local.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docker-compose.local.yaml diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml new file mode 100644 index 0000000..c01d1f8 --- /dev/null +++ b/docker-compose.local.yaml @@ -0,0 +1,9 @@ +version: "3.9" + +services: + bot: + build: + context: . + dockerfile: Dockerfile + env_file: + - .env From d7b40fe0250d2b3902c87b7e44587a49d7e37cec Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 20 Jul 2024 19:38:20 +0200 Subject: [PATCH 060/166] Add documentation on using docker --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index ae9c74f..69be458 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,25 @@ You'll now need to configure the bot. See the [configuring section](#configuring Finally, you can start the bot with `python -m src`. +### Using docker + +The project also supports [docker](https://www.docker.com/) installation, which should allow running the project +anywhere, without installing all of the dependencies manually. This is a lot more convenient way to run the bot, if you +just want to run it and you don't wish to do any actual development. + +To use docker, you can check out the images that are automatically built after each update to the `main` branch on +[ghcr](https://github.com/itsdrike/code-jam-2024/pkgs/container/code-jam-2024). You can also use [`docker +compose`](https://docs.docker.com/compose/) with the [`docker-compose.yaml`](./docker-compose.yaml) file, which will +pull this image from ghcr. + +If you want to build the image locally (to include some other changes that aren't yet in the main branch, maybe during +development or to customize something when deploying), you can also use the +[`docker-compose.local.yaml`](./docker-compose.local.yaml), which defines an image building step from our +[`Dockerfile`](./Dockerfile). + +Note that you will still need to create a `.env` file with all of the configuration variables (see [the configuring +section](#configuring-the-bot) + ## Configuring the bot The bot is configured using environment variables. You can either create a `.env` file and define these variables From aa1c861995bc4004993e24e614a742e849aa247d Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 15:52:34 +0200 Subject: [PATCH 061/166] Add command examples on using docker-compose --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 69be458..d9137d9 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,20 @@ just want to run it and you don't wish to do any actual development. To use docker, you can check out the images that are automatically built after each update to the `main` branch on [ghcr](https://github.com/itsdrike/code-jam-2024/pkgs/container/code-jam-2024). You can also use [`docker compose`](https://docs.docker.com/compose/) with the [`docker-compose.yaml`](./docker-compose.yaml) file, which will -pull this image from ghcr. +pull this image from ghcr. To run the container using this file, you can use the following command: + +```bash +docker compose up +``` If you want to build the image locally (to include some other changes that aren't yet in the main branch, maybe during development or to customize something when deploying), you can also use the [`docker-compose.local.yaml`](./docker-compose.local.yaml), which defines an image building step from our -[`Dockerfile`](./Dockerfile). +[`Dockerfile`](./Dockerfile). To run this local version of docker-compose, you can use the following command: + +```bash +docker compose -f ./docker-compose.local.yaml up +``` Note that you will still need to create a `.env` file with all of the configuration variables (see [the configuring section](#configuring-the-bot) From 3101640891c0c49aafc2e200be062a498ff10fd2 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Fri, 19 Jul 2024 13:49:28 +0200 Subject: [PATCH 062/166] Add basic structure for tvdb support --- README.md | 2 ++ src/settings.py | 1 + src/tvdb/__init__.py | 0 3 files changed, 3 insertions(+) create mode 100644 src/tvdb/__init__.py diff --git a/README.md b/README.md index ae9c74f..585eb02 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,13 @@ convenient. | Variable name | Type | Description | | -------------------- | ------ | --------------------------------------------------------------------------------------------------- | | `BOT_TOKEN` | string | Bot token of the discord application (see: [this guide][bot-token-guide] if you don't have one yet) | +| `TVDB_API_KEY` | string | API key for TVDB (see [this page][tvdb-api-page] if you don't have one yet) | | `DEBUG` | bool | If `1`, debug logs will be enabled, if `0` only info logs and above will be shown | | `LOG_FILE` | path | If set, also write the logs into given file, otherwise, only print them | | `TRACE_LEVEL_FILTER` | custom | Configuration for trace level logging, see: [trace logs config section](#trace-logs-config) | [bot-token-guide]: https://guide.pycord.dev/getting-started/creating-your-first-bot#creating-the-bot-application +[tvdb-api-page]: https://www.thetvdb.com/api-information ### Trace logs config diff --git a/src/settings.py b/src/settings.py index c2b1fe9..93ec726 100644 --- a/src/settings.py +++ b/src/settings.py @@ -2,6 +2,7 @@ GITHUB_REPO = "https://github.com/ItsDrike/code-jam-2024" BOT_TOKEN = get_config("BOT_TOKEN") +TVDB_API_KEY = get_config("TVDB_API_KEY") FAIL_EMOJI = "❌" SUCCESS_EMOJI = "✅" diff --git a/src/tvdb/__init__.py b/src/tvdb/__init__.py new file mode 100644 index 0000000..e69de29 From 36de3606296ef0fd36fe4d32a68a0a72bc3ca601 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Fri, 19 Jul 2024 19:25:29 +0200 Subject: [PATCH 063/166] Add tvdb client --- src/tvdb/__init__.py | 6 ++++ src/tvdb/client.py | 72 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 src/tvdb/client.py diff --git a/src/tvdb/__init__.py b/src/tvdb/__init__.py index e69de29..2d6d393 100644 --- a/src/tvdb/__init__.py +++ b/src/tvdb/__init__.py @@ -0,0 +1,6 @@ +from .client import InvalidApiKeyError, TvdbClient + +__all__ = [ + "TvdbClient", + "InvalidApiKeyError", +] diff --git a/src/tvdb/client.py b/src/tvdb/client.py new file mode 100644 index 0000000..8cdc7f6 --- /dev/null +++ b/src/tvdb/client.py @@ -0,0 +1,72 @@ +from typing import ClassVar, Literal, overload + +import aiohttp +from yarl import URL + +from src.settings import TVDB_API_KEY +from src.utils.log import get_logger + +log = get_logger(__name__) + +type JSON_DATA = dict[str, JSON_DATA] | list[JSON_DATA] | str | int | float | bool | None + + +class InvalidApiKeyError(Exception): + """Exception raised when the TVDB API key used was invalid.""" + + def __init__(self, response: aiohttp.ClientResponse, response_txt: str): + self.response = response + self.response_txt = response_txt + super().__init__("Invalid TVDB API key.") + + +class TvdbClient: + """Class to interact with the TVDB API.""" + + BASE_URL: ClassVar[URL] = URL("https://api4.thetvdb.com/v4/") + + def __init__(self, http_session: aiohttp.ClientSession): + self.http_session = http_session + self.auth_token = None + + @overload + async def request(self, method: Literal["GET"], endpoint: str, body: None = None) -> JSON_DATA: ... + + @overload + async def request(self, method: Literal["POST"], endpoint: str, body: JSON_DATA) -> JSON_DATA: ... + + async def request(self, method: Literal["GET", "POST"], endpoint: str, body: JSON_DATA = None) -> JSON_DATA: + """Make an authorized request to the TVDB API.""" + log.trace(f"Making TVDB {method} request to {endpoint}") + + if self.auth_token is None: + log.trace("No auth token found, requesting initial login.") + await self._login() + headers = {"Authorization": f"Bearer {self.auth_token}"} + + url = self.BASE_URL / endpoint.removeprefix("/") + async with self.http_session.request(method, url, headers=headers, json=body) as response: + if response.status == 401: + log.debug("TVDB API token expired, requesting new token.") + self.auth_token = None + return await self.request(method, endpoint, body) # pyright: ignore[reportCallIssue,reportArgumentType] + + response.raise_for_status() + return await response.json() + + async def _login(self) -> None: + """Obtain the auth token from the TVDB API. + + This token has one month of validity. + """ + log.debug("Requesting TVDB API login") + url = self.BASE_URL / "login" + async with self.http_session.post(url, json={"apikey": TVDB_API_KEY}) as response: + if response.status == 401: + log.error("Invalid TVDB API key, login request failed.") + response_txt = await response.text() + raise InvalidApiKeyError(response, response_txt) + + response.raise_for_status() + data = await response.json() + self.auth_token = data["data"]["token"] From a7f2739e3690c316202d071c28c8fb9d89049dc1 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 20 Jul 2024 10:26:22 +0200 Subject: [PATCH 064/166] :heavy_plus_sign: Add pydantic --- poetry.lock | 147 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index b6d2de7..2f37ffc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -109,6 +109,17 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "attrs" version = "23.2.0" @@ -613,6 +624,129 @@ docs = ["furo (==2023.3.23)", "myst-parser (==1.0.0)", "sphinx (==5.3.0)", "sphi speed = ["aiohttp[speedups]", "msgspec (>=0.18.6,<0.19.0)"] voice = ["PyNaCl (>=1.3.0,<1.6)"] +[[package]] +name = "pydantic" +version = "2.8.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.20.1" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.20.1" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, + {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, + {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, + {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, + {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, + {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, + {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, + {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, + {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pyreadline3" version = "3.4.1" @@ -777,6 +911,17 @@ files = [ {file = "ruff-0.3.7.tar.gz", hash = "sha256:d5c1aebee5162c2226784800ae031f660c350e7a3402c4d1f8ea4e97e232e3ba"}, ] +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + [[package]] name = "virtualenv" version = "20.26.3" @@ -903,4 +1048,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "da0615a624507e4c68b58731f5e361fb86f451b7081ecd04203b0cdf08320cfe" +content-hash = "411d23a2f159cff523da16533a09bf10fd1aae5defce21285295a34c5fa6656d" diff --git a/pyproject.toml b/pyproject.toml index 8d8e930..4cd51d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ python = "^3.12" py-cord = "^2.6.0" python-decouple = "^3.8" coloredlogs = "^15.0.1" +pydantic = "^2.8.2" [tool.poetry.group.lint.dependencies] ruff = "^0.3.2" From 4e29d212339df181f1c7ddddd85db1022ead516c Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 20 Jul 2024 11:51:28 +0200 Subject: [PATCH 065/166] :construction: Add pydantic models for /search --- src/tvdb/models.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/tvdb/models.py diff --git a/src/tvdb/models.py b/src/tvdb/models.py new file mode 100644 index 0000000..6fa1349 --- /dev/null +++ b/src/tvdb/models.py @@ -0,0 +1,66 @@ +from pydantic import BaseModel + + +class _RemoteID(BaseModel): + id: str + type: int + sourceName: str # noqa: N815 + + +class _Translation(BaseModel): + ara: str | None = None + ces: str | None = None + deu: str | None = None + + +class SearchData(BaseModel): + """Model for the data objects returned by the TVDB search endpoint.""" + + aliases: list[str] + companies: list[str] | None = None + companyType: str | None = None # noqa: N815 + country: str + director: str | None = None + first_air_time: str + genres: list[str] | None = None + id: str + image_url: str + name: str + is_official: bool | None = None + name_translated: str | None = None + network: str + objectID: str # noqa: N815 + officialList: str | None = None # noqa: N815 + overview: str + overviews: _Translation + overview_translated: list[str] | None = None + poster: str | None = None + posters: list[str] | None = None + primary_language: str + remote_ids: list[_RemoteID] + status: str + slug: str + studios: list[str] | None = None + title: str | None = None + thumbnail: str + translations: _Translation + translationsWithLang: list[str] | None = None # noqa: N815 + tvdb_id: str + type: str + year: str + + +class _Links(BaseModel): + prev: str | None = None # none if on the first page + self: str + next: str + total_items: int + page_size: int + + +class SearchResponse(BaseModel): + """Model for the response from the TVDB search endpoint.""" + + data: list[SearchData] + status: str + links: _Links From dab68119ad1a5c6f1e7e109267603809c74baaf4 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 20 Jul 2024 15:04:12 +0200 Subject: [PATCH 066/166] :heavy_plus_sign: Add `datamodel-code-generator` to dev dependency group --- poetry.lock | 286 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 4 + 2 files changed, 289 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 2f37ffc..deb3728 100644 --- a/poetry.lock +++ b/poetry.lock @@ -120,6 +120,20 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "argcomplete" +version = "3.4.0" +description = "Bash tab completion for argparse" +optional = false +python-versions = ">=3.8" +files = [ + {file = "argcomplete-3.4.0-py3-none-any.whl", hash = "sha256:69a79e083a716173e5532e0fa3bef45f793f4e61096cf52b5a42c0211c8b8aa5"}, + {file = "argcomplete-3.4.0.tar.gz", hash = "sha256:c2abcdfe1be8ace47ba777d4fce319eb13bf8ad9dace8d085dcad6eded88057f"}, +] + +[package.extras] +test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] + [[package]] name = "attrs" version = "23.2.0" @@ -153,6 +167,50 @@ files = [ [package.dependencies] nodejs-wheel-binaries = ">=20.13.1" +[[package]] +name = "black" +version = "24.4.2" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, + {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, + {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, + {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, + {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, + {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, + {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, + {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, + {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, + {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, + {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, + {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, + {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, + {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, + {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, + {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, + {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, + {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, + {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, + {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, + {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, + {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + [[package]] name = "cfgv" version = "3.4.0" @@ -164,6 +222,20 @@ files = [ {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, ] +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "colorama" version = "0.4.6" @@ -256,6 +328,34 @@ files = [ [package.extras] toml = ["tomli"] +[[package]] +name = "datamodel-code-generator" +version = "0.25.8" +description = "Datamodel Code Generator" +optional = false +python-versions = "<4.0,>=3.7" +files = [ + {file = "datamodel_code_generator-0.25.8-py3-none-any.whl", hash = "sha256:f9b216efad84d8dcb517273d2728875b6052b7e8dc4e5c13a597441cef236f6e"}, + {file = "datamodel_code_generator-0.25.8.tar.gz", hash = "sha256:b7838122b8133dae6e46f36a1cf25c0ccc66745da057988f490d00ab71121de7"}, +] + +[package.dependencies] +argcomplete = ">=1.10,<4.0" +black = ">=19.10b0" +genson = ">=1.2.1,<2.0" +inflect = ">=4.1.0,<6.0" +isort = ">=4.3.21,<6.0" +jinja2 = ">=2.10.1,<4.0" +packaging = "*" +pydantic = {version = ">=1.10.0,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.4.0 || >2.4.0,<3.0", extras = ["email"], markers = "python_version >= \"3.12\" and python_version < \"4.0\""} +pyyaml = ">=6.0.1" + +[package.extras] +debug = ["PySnooper (>=0.4.1,<2.0.0)"] +graphql = ["graphql-core (>=3.2.3,<4.0.0)"] +http = ["httpx"] +validation = ["openapi-spec-validator (>=0.2.8,<0.7.0)", "prance (>=0.18.2)"] + [[package]] name = "distlib" version = "0.3.8" @@ -267,6 +367,41 @@ files = [ {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] +[[package]] +name = "dnspython" +version = "2.6.1" +description = "DNS toolkit" +optional = false +python-versions = ">=3.8" +files = [ + {file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, + {file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, +] + +[package.extras] +dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "sphinx (>=7.2.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] +dnssec = ["cryptography (>=41)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] +doq = ["aioquic (>=0.9.25)"] +idna = ["idna (>=3.6)"] +trio = ["trio (>=0.23)"] +wmi = ["wmi (>=1.5.1)"] + +[[package]] +name = "email-validator" +version = "2.2.0" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"}, + {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + [[package]] name = "filelock" version = "3.15.4" @@ -369,6 +504,17 @@ files = [ {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, ] +[[package]] +name = "genson" +version = "1.3.0" +description = "GenSON is a powerful, user-friendly JSON Schema generator." +optional = false +python-versions = "*" +files = [ + {file = "genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7"}, + {file = "genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37"}, +] + [[package]] name = "humanfriendly" version = "10.0" @@ -408,6 +554,21 @@ files = [ {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] +[[package]] +name = "inflect" +version = "5.6.2" +description = "Correctly generate plurals, singular nouns, ordinals, indefinite articles; convert numbers to words" +optional = false +python-versions = ">=3.7" +files = [ + {file = "inflect-5.6.2-py3-none-any.whl", hash = "sha256:b45d91a4a28a4e617ff1821117439b06eaa86e2a4573154af0149e9be6687238"}, + {file = "inflect-5.6.2.tar.gz", hash = "sha256:aadc7ed73928f5e014129794bbac03058cca35d0a973a5fc4eb45c7fa26005f9"}, +] + +[package.extras] +docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] +testing = ["pygments", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] + [[package]] name = "iniconfig" version = "2.0.0" @@ -419,6 +580,106 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + [[package]] name = "multidict" version = "6.0.5" @@ -518,6 +779,17 @@ files = [ {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, ] +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -556,6 +828,17 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + [[package]] name = "platformdirs" version = "4.2.2" @@ -637,6 +920,7 @@ files = [ [package.dependencies] annotated-types = ">=0.4.0" +email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} pydantic-core = "2.20.1" typing-extensions = [ {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, @@ -1048,4 +1332,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "411d23a2f159cff523da16533a09bf10fd1aae5defce21285295a34c5fa6656d" +content-hash = "28ca88a0d5c02aee82e5c6dbc9fab9eace69ce0edecd230f735790a76588e995" diff --git a/pyproject.toml b/pyproject.toml index 4cd51d8..9e5625c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,10 @@ pytest = "^8.1.1" pytest-asyncio = "^0.23.6" pytest-cov = "^5.0.0" + +[tool.poetry.group.dev.dependencies] +datamodel-code-generator = "^0.25.8" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" From ed2541bd1d2f4e1a4343b86d7101469e6e118eca Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 20 Jul 2024 15:06:59 +0200 Subject: [PATCH 067/166] :sparkles: Add base tvdb client (incomplete) --- src/tvdb/client.py | 120 +- src/tvdb/generated_models.py | 745 ++++++ src/tvdb/models.py | 82 +- src/tvdb/swagger.yml | 4264 ++++++++++++++++++++++++++++++++++ 4 files changed, 5146 insertions(+), 65 deletions(-) create mode 100644 src/tvdb/generated_models.py create mode 100644 src/tvdb/swagger.yml diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 8cdc7f6..61cdb31 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -1,14 +1,89 @@ -from typing import ClassVar, Literal, overload +from abc import ABC, abstractmethod +from typing import Any, ClassVar, Literal, overload, override import aiohttp from yarl import URL from src.settings import TVDB_API_KEY +from src.tvdb.generated_models import SearchResult, SeriesBaseRecord +from src.tvdb.models import SearchResponse, SeriesResponse from src.utils.log import get_logger log = get_logger(__name__) -type JSON_DATA = dict[str, JSON_DATA] | list[JSON_DATA] | str | int | float | bool | None +type JSON_DATA = dict[str, JSON_DATA] | list[JSON_DATA] | str | int | float | bool | None # noice + + +class _Media(ABC): + def __init__(self, client: "TvdbClient"): + self.client = client + self._id: int | None = None + + @property + def id(self) -> int | str | None: + return self._id + + @id.setter + def id(self, value: int | str) -> None: + self._id = int(str(value).split("-")[1]) + + def __call__(self, media_id: str) -> "_Media": + self.id = media_id + return self + + @abstractmethod + async def search(self, search_query: str, limit: int = 1) -> list[Any]: ... + + @abstractmethod + async def fetch(self, *, extended: bool = False) -> Any: ... + + +class Series(_Media): + """Class to interact with the TVDB API for series.""" + + @override + async def search(self, search_query: str, limit: int = 1) -> list[SearchResult]: + """Search for a series in the TVDB database. + + :param search_query: + :param limit: + :return: + """ + return await self.client.search(search_query, "series", limit) + + @override + async def fetch(self, *, extended: bool = False) -> SeriesBaseRecord: + """Fetch a series by its ID. + + :param extended: + :return: + """ + data = await self.client.request("GET", f"series/{self.id}" + ("/extended" if extended else "")) + return SeriesResponse(**data).data # pyright: ignore[reportCallIssue] + + +class Movies(_Media): + """Class to interact with the TVDB API for movies.""" + + @override + async def search(self, search_query: str, limit: int = 1) -> list[SearchResult]: + """Search for a movie in the TVDB database. + + :param search_query: + :param limit: + :return: list[SearchResult] + """ + return await self.client.search(search_query, "movie", limit) + + @override + async def fetch(self, *, extended: bool = False) -> SeriesBaseRecord: + """Fetch a movie by its ID. + + :param extended: Whether to fetch extended information. + :return: + """ + data = await self.client.request("GET", f"movies/{self.id}" + ("/extended" if extended else "")) + return SeriesResponse(**data).data # pyright: ignore[reportCallIssue] class InvalidApiKeyError(Exception): @@ -28,32 +103,61 @@ class TvdbClient: def __init__(self, http_session: aiohttp.ClientSession): self.http_session = http_session self.auth_token = None + self.series = Series(self) + self.movies = Movies(self) @overload - async def request(self, method: Literal["GET"], endpoint: str, body: None = None) -> JSON_DATA: ... + async def request( + self, + method: Literal["GET"], + endpoint: str, + body: None = None, + query: dict[str, str] | None = None, + ) -> JSON_DATA: ... @overload - async def request(self, method: Literal["POST"], endpoint: str, body: JSON_DATA) -> JSON_DATA: ... - - async def request(self, method: Literal["GET", "POST"], endpoint: str, body: JSON_DATA = None) -> JSON_DATA: + async def request( + self, method: Literal["POST"], endpoint: str, body: JSON_DATA, query: None = None + ) -> JSON_DATA: ... + + async def request( + self, + method: Literal["GET", "POST"], + endpoint: str, + body: JSON_DATA = None, + query: dict[str, str] | None = None, + ) -> JSON_DATA: """Make an authorized request to the TVDB API.""" log.trace(f"Making TVDB {method} request to {endpoint}") if self.auth_token is None: log.trace("No auth token found, requesting initial login.") await self._login() - headers = {"Authorization": f"Bearer {self.auth_token}"} + headers = {"Authorization": f"Bearer {self.auth_token}", "Accept": "application/json"} url = self.BASE_URL / endpoint.removeprefix("/") + if method == "GET" and query: + url = url.with_query(query) async with self.http_session.request(method, url, headers=headers, json=body) as response: if response.status == 401: log.debug("TVDB API token expired, requesting new token.") self.auth_token = None + # TODO: might be an infinite loop return await self.request(method, endpoint, body) # pyright: ignore[reportCallIssue,reportArgumentType] - response.raise_for_status() return await response.json() + async def search( + self, search_query: str, entity_type: Literal["series", "movie", "all"] = "all", limit: int = 1 + ) -> list[SearchResult]: + """Search for a series or movie in the TVDB database.""" + query: dict[str, str] = {"query": search_query, "limit": str(limit)} + if entity_type != "all": + query["type"] = entity_type + data = await self.request("GET", "search", query=query) + response = SearchResponse(**data) # pyright: ignore[reportCallIssue] + return response.data + async def _login(self) -> None: """Obtain the auth token from the TVDB API. diff --git a/src/tvdb/generated_models.py b/src/tvdb/generated_models.py new file mode 100644 index 0000000..d0af6a3 --- /dev/null +++ b/src/tvdb/generated_models.py @@ -0,0 +1,745 @@ +# generated by datamodel-codegen: +# filename: swagger.yml +# timestamp: 2024-07-20T12:48:32+00:00 +# ruff: noqa: N815, ERA001, D101 # allow camelCase, disable check for commented out code, and allow undocumented class docstring +from __future__ import annotations + +from pydantic import BaseModel, Field, RootModel + + +class Alias(BaseModel): + language: str | None = Field( + None, + description="A 3-4 character string indicating the language of the alias, as defined in Language.", + max_length=4, + ) + name: str | None = Field(None, description="A string containing the alias itself.", max_length=100) + + +class ArtworkBaseRecord(BaseModel): + height: int | None = None + id: int | None = None + image: str | None = None + includesText: bool | None = None + language: str | None = None + score: float | None = None + thumbnail: str | None = None + type: int | None = Field( + None, + description="The artwork type corresponds to the ids from the /artwork/types endpoint.", + ) + width: int | None = None + + +class ArtworkStatus(BaseModel): + id: int | None = None + name: str | None = None + + +class ArtworkType(BaseModel): + height: int | None = None + id: int | None = None + imageFormat: str | None = None + name: str | None = None + recordType: str | None = None + slug: str | None = None + thumbHeight: int | None = None + thumbWidth: int | None = None + width: int | None = None + + +class AwardBaseRecord(BaseModel): + id: int | None = None + name: str | None = None + + +class AwardCategoryBaseRecord(BaseModel): + allowCoNominees: bool | None = None + award: AwardBaseRecord | None = None + forMovies: bool | None = None + forSeries: bool | None = None + id: int | None = None + name: str | None = None + + +class AwardExtendedRecord(BaseModel): + categories: list[AwardCategoryBaseRecord] | None = None + id: int | None = None + name: str | None = None + score: int | None = None + + +class Biography(BaseModel): + biography: str | None = None + language: str | None = None + + +class CompanyRelationShip(BaseModel): + id: int | None = None + typeName: str | None = None + + +class CompanyType(BaseModel): + companyTypeId: int | None = None + companyTypeName: str | None = None + + +class ContentRating(BaseModel): + id: int | None = None + name: str | None = None + description: str | None = None + country: str | None = None + contentType: str | None = None + order: int | None = None + fullName: str | None = None + + +class Country(BaseModel): + id: str | None = None + name: str | None = None + shortCode: str | None = None + + +class Entity(BaseModel): + movieId: int | None = None + order: int | None = None + seriesId: int | None = None + + +class EntityType(BaseModel): + id: int | None = None + name: str | None = None + hasSpecials: bool | None = None + + +class EntityUpdate(BaseModel): + entityType: str | None = None + methodInt: int | None = None + method: str | None = None + extraInfo: str | None = None + userId: int | None = None + recordType: str | None = None + recordId: int | None = None + timeStamp: int | None = None + seriesId: int | None = Field(None, description="Only present for episodes records") + mergeToId: int | None = None + mergeToEntityType: str | None = None + + +class Favorites(BaseModel): + series: list[int] | None = None + movies: list[int] | None = None + episodes: list[int] | None = None + artwork: list[int] | None = None + people: list[int] | None = None + lists: list[int] | None = None + + +class FavoriteRecord(BaseModel): + series: int | None = None + movie: int | None = None + episode: int | None = None + artwork: int | None = None + people: int | None = None + list: int | None = None + + +class Gender(BaseModel): + id: int | None = None + name: str | None = None + + +class GenreBaseRecord(BaseModel): + id: int | None = None + name: str | None = None + slug: str | None = None + + +class Language(BaseModel): + id: str | None = None + name: str | None = None + nativeName: str | None = None + shortCode: str | None = None + + +class ListExtendedRecord(BaseModel): + aliases: list[Alias] | None = None + entities: list[Entity] | None = None + id: int | None = None + image: str | None = None + imageIsFallback: bool | None = None + isOfficial: bool | None = None + name: str | None = None + nameTranslations: list[str] | None = None + overview: str | None = None + overviewTranslations: list[str] | None = None + score: int | None = None + url: str | None = None + + +class PeopleBaseRecord(BaseModel): + aliases: list[Alias] | None = None + id: int | None = None + image: str | None = None + lastUpdated: str | None = None + name: str | None = None + nameTranslations: list[str] | None = None + overviewTranslations: list[str] | None = None + score: int | None = None + + +class PeopleType(BaseModel): + id: int | None = None + name: str | None = None + + +class Race(BaseModel): + pass + + +class RecordInfo(BaseModel): + image: str | None = None + name: str | None = None + year: str | None = None + + +class Release(BaseModel): + country: str | None = None + date: str | None = None + detail: str | None = None + + +class RemoteID(BaseModel): + id: str | None = None + type: int | None = None + sourceName: str | None = None + + +class SeasonType(BaseModel): + alternateName: str | None = None + id: int | None = None + name: str | None = None + type: str | None = None + + +class SeriesAirsDays(BaseModel): + friday: bool | None = None + monday: bool | None = None + saturday: bool | None = None + sunday: bool | None = None + thursday: bool | None = None + tuesday: bool | None = None + wednesday: bool | None = None + + +class SourceType(BaseModel): + id: int | None = None + name: str | None = None + postfix: str | None = None + prefix: str | None = None + slug: str | None = None + sort: int | None = None + + +class Status(BaseModel): + id: int | None = None + keepUpdated: bool | None = None + name: str | None = None + recordType: str | None = None + + +class StudioBaseRecord(BaseModel): + id: int | None = None + name: str | None = None + parentStudio: int | None = None + + +class TagOption(BaseModel): + helpText: str | None = None + id: int | None = None + name: str | None = None + tag: int | None = None + tagName: str | None = None + + +class Trailer(BaseModel): + id: int | None = None + language: str | None = None + name: str | None = None + url: str | None = None + runtime: int | None = None + + +class Translation(BaseModel): + aliases: list[str] | None = None + isAlias: bool | None = None + isPrimary: bool | None = None + language: str | None = None + name: str | None = None + overview: str | None = None + tagline: str | None = Field( + None, + description="Only populated for movie translations. We disallow taglines without a title.", + ) + + +class TranslationSimple(RootModel[dict[str, str] | None]): + root: dict[str, str] | None = None + + +class TranslationExtended(BaseModel): + nameTranslations: list[Translation] | None = None + overviewTranslations: list[Translation] | None = None + alias: list[str] | None = None + + +class TagOptionEntity(BaseModel): + name: str | None = None + tagName: str | None = None + tagId: int | None = None + + +class UserInfo(BaseModel): + id: int | None = None + language: str | None = None + name: str | None = None + type: str | None = None + + +class Inspiration(BaseModel): + id: int | None = None + type: str | None = None + type_name: str | None = None + url: str | None = None + + +class InspirationType(BaseModel): + id: int | None = None + name: str | None = None + description: str | None = None + reference_name: str | None = None + url: str | None = None + + +class ProductionCountry(BaseModel): + id: int | None = None + country: str | None = None + name: str | None = None + + +class Links(BaseModel): + prev: str | None = None + self: str | None = None + next: str | None = None + total_items: int | None = None + page_size: int | None = None + + +class ArtworkExtendedRecord(BaseModel): + episodeId: int | None = None + height: int | None = None + id: int | None = None + image: str | None = None + includesText: bool | None = None + language: str | None = None + movieId: int | None = None + networkId: int | None = None + peopleId: int | None = None + score: float | None = None + seasonId: int | None = None + seriesId: int | None = None + seriesPeopleId: int | None = None + status: ArtworkStatus | None = None + tagOptions: list[TagOption] | None = None + thumbnail: str | None = None + thumbnailHeight: int | None = None + thumbnailWidth: int | None = None + type: int | None = Field( + None, + description="The artwork type corresponds to the ids from the /artwork/types endpoint.", + ) + updatedAt: int | None = None + width: int | None = None + + +class Character(BaseModel): + aliases: list[Alias] | None = None + episode: RecordInfo | None = None + episodeId: int | None = None + id: int | None = None + image: str | None = None + isFeatured: bool | None = None + movieId: int | None = None + movie: RecordInfo | None = None + name: str | None = None + nameTranslations: list[str] | None = None + overviewTranslations: list[str] | None = None + peopleId: int | None = None + personImgURL: str | None = None + peopleType: str | None = None + seriesId: int | None = None + series: RecordInfo | None = None + sort: int | None = None + tagOptions: list[TagOption] | None = None + type: int | None = None + url: str | None = None + personName: str | None = None + + +class ParentCompany(BaseModel): + id: int | None = None + name: str | None = None + relation: CompanyRelationShip | None = None + + +class ListBaseRecord(BaseModel): + aliases: list[Alias] | None = None + id: int | None = None + image: str | None = None + imageIsFallback: bool | None = None + isOfficial: bool | None = None + name: str | None = None + nameTranslations: list[str] | None = None + overview: str | None = None + overviewTranslations: list[str] | None = None + remoteIds: list[RemoteID] | None = None + tags: list[TagOption] | None = None + score: int | None = None + url: str | None = None + + +class MovieBaseRecord(BaseModel): + aliases: list[Alias] | None = None + id: int | None = None + image: str | None = None + lastUpdated: str | None = None + name: str | None = None + nameTranslations: list[str] | None = None + overviewTranslations: list[str] | None = None + score: float | None = None + slug: str | None = None + status: Status | None = None + runtime: int | None = None + year: str | None = None + + +class PeopleExtendedRecord(BaseModel): + aliases: list[Alias] | None = None + awards: list[AwardBaseRecord] | None = None + biographies: list[Biography] | None = None + birth: str | None = None + birthPlace: str | None = None + characters: list[Character] | None = None + death: str | None = None + gender: int | None = None + id: int | None = None + image: str | None = None + lastUpdated: str | None = None + name: str | None = None + nameTranslations: list[str] | None = None + overviewTranslations: list[str] | None = None + races: list[Race] | None = None + remoteIds: list[RemoteID] | None = None + score: int | None = None + slug: str | None = None + tagOptions: list[TagOption] | None = None + translations: TranslationExtended | None = None + + +class SearchResult(BaseModel): + aliases: list[str] | None = None + companies: list[str] | None = None + companyType: str | None = None + country: str | None = None + director: str | None = None + first_air_time: str | None = None + genres: list[str] | None = None + id: str | None = None + image_url: str | None = None + name: str | None = None + is_official: bool | None = None + name_translated: str | None = None + network: str | None = None + objectID: str | None = None + officialList: str | None = None + overview: str | None = None + overviews: TranslationSimple | None = None + overview_translated: list[str] | None = None + poster: str | None = None + posters: list[str] | None = None + primary_language: str | None = None + remote_ids: list[RemoteID] | None = None + status: str | None = None + slug: str | None = None + studios: list[str] | None = None + title: str | None = None + thumbnail: str | None = None + translations: TranslationSimple | None = None + translationsWithLang: list[str] | None = None + tvdb_id: str | None = None + type: str | None = None + year: str | None = None + + +class Tag(BaseModel): + allowsMultiple: bool | None = None + helpText: str | None = None + id: int | None = None + name: str | None = None + options: list[TagOption] | None = None + + +class Company(BaseModel): + activeDate: str | None = None + aliases: list[Alias] | None = None + country: str | None = None + id: int | None = None + inactiveDate: str | None = None + name: str | None = None + nameTranslations: list[str] | None = None + overviewTranslations: list[str] | None = None + primaryCompanyType: int | None = None + slug: str | None = None + parentCompany: ParentCompany | None = None + tagOptions: list[TagOption] | None = None + + +class Companies(BaseModel): + studio: list[Company] | None = None + network: list[Company] | None = None + production: list[Company] | None = None + distributor: list[Company] | None = None + special_effects: list[Company] | None = None + + +class MovieExtendedRecord(BaseModel): + aliases: list[Alias] | None = None + artworks: list[ArtworkBaseRecord] | None = None + audioLanguages: list[str] | None = None + awards: list[AwardBaseRecord] | None = None + boxOffice: str | None = None + boxOfficeUS: str | None = None + budget: str | None = None + characters: list[Character] | None = None + companies: Companies | None = None + contentRatings: list[ContentRating] | None = None + first_release: Release | None = None + genres: list[GenreBaseRecord] | None = None + id: int | None = None + image: str | None = None + inspirations: list[Inspiration] | None = None + lastUpdated: str | None = None + lists: list[ListBaseRecord] | None = None + name: str | None = None + nameTranslations: list[str] | None = None + originalCountry: str | None = None + originalLanguage: str | None = None + overviewTranslations: list[str] | None = None + production_countries: list[ProductionCountry] | None = None + releases: list[Release] | None = None + remoteIds: list[RemoteID] | None = None + runtime: int | None = None + score: float | None = None + slug: str | None = None + spoken_languages: list[str] | None = None + status: Status | None = None + studios: list[StudioBaseRecord] | None = None + subtitleLanguages: list[str] | None = None + tagOptions: list[TagOption] | None = None + trailers: list[Trailer] | None = None + translations: TranslationExtended | None = None + year: str | None = None + + +class SeasonBaseRecord(BaseModel): + id: int | None = None + image: str | None = None + imageType: int | None = None + lastUpdated: str | None = None + name: str | None = None + nameTranslations: list[str] | None = None + number: int | None = None + overviewTranslations: list[str] | None = None + companies: Companies | None = None + seriesId: int | None = None + type: SeasonType | None = None + year: str | None = None + + +class EpisodeBaseRecord(BaseModel): + absoluteNumber: int | None = None + aired: str | None = None + airsAfterSeason: int | None = None + airsBeforeEpisode: int | None = None + airsBeforeSeason: int | None = None + finaleType: str | None = Field(None, description="season, midseason, or series") + id: int | None = None + image: str | None = None + imageType: int | None = None + isMovie: int | None = None + lastUpdated: str | None = None + linkedMovie: int | None = None + name: str | None = None + nameTranslations: list[str] | None = None + number: int | None = None + overview: str | None = None + overviewTranslations: list[str] | None = None + runtime: int | None = None + seasonNumber: int | None = None + seasons: list[SeasonBaseRecord] | None = None + seriesId: int | None = None + seasonName: str | None = None + year: str | None = None + + +class SeasonExtendedRecord(BaseModel): + artwork: list[ArtworkBaseRecord] | None = None + companies: Companies | None = None + episodes: list[EpisodeBaseRecord] | None = None + id: int | None = None + image: str | None = None + imageType: int | None = None + lastUpdated: str | None = None + name: str | None = None + nameTranslations: list[str] | None = None + number: int | None = None + overviewTranslations: list[str] | None = None + seriesId: int | None = None + trailers: list[Trailer] | None = None + type: SeasonType | None = None + tagOptions: list[TagOption] | None = None + translations: list[Translation] | None = None + year: str | None = None + + +class SeriesBaseRecord(BaseModel): + aliases: list[Alias] | None = None + averageRuntime: int | None = None + country: str | None = None + defaultSeasonType: int | None = None + episodes: list[EpisodeBaseRecord] | None = None + firstAired: str | None = None + id: int | None = None + image: str | None = None + isOrderRandomized: bool | None = None + lastAired: str | None = None + lastUpdated: str | None = None + name: str | None = None + nameTranslations: list[str] | None = None + nextAired: str | None = None + originalCountry: str | None = None + originalLanguage: str | None = None + overviewTranslations: list[str] | None = None + score: float | None = None + slug: str | None = None + status: Status | None = None + year: str | None = None + + +class SeriesExtendedRecord(BaseModel): + abbreviation: str | None = None + airsDays: SeriesAirsDays | None = None + airsTime: str | None = None + aliases: list[Alias] | None = None + artworks: list[ArtworkExtendedRecord] | None = None + averageRuntime: int | None = None + characters: list[Character] | None = None + contentRatings: list[ContentRating] | None = None + country: str | None = None + defaultSeasonType: int | None = None + episodes: list[EpisodeBaseRecord] | None = None + firstAired: str | None = None + lists: list[ListBaseRecord] | None = None + genres: list[GenreBaseRecord] | None = None + id: int | None = None + image: str | None = None + isOrderRandomized: bool | None = None + lastAired: str | None = None + lastUpdated: str | None = None + name: str | None = None + nameTranslations: list[str] | None = None + companies: list[Company] | None = None + nextAired: str | None = None + originalCountry: str | None = None + originalLanguage: str | None = None + originalNetwork: Company | None = None + overview: str | None = None + latestNetwork: Company | None = None + overviewTranslations: list[str] | None = None + remoteIds: list[RemoteID] | None = None + score: float | None = None + seasons: list[SeasonBaseRecord] | None = None + seasonTypes: list[SeasonType] | None = None + slug: str | None = None + status: Status | None = None + tags: list[TagOption] | None = None + trailers: list[Trailer] | None = None + translations: TranslationExtended | None = None + year: str | None = None + + +class AwardNomineeBaseRecord(BaseModel): + character: Character | None = None + details: str | None = None + episode: EpisodeBaseRecord | None = None + id: int | None = None + isWinner: bool | None = None + movie: MovieBaseRecord | None = None + series: SeriesBaseRecord | None = None + year: str | None = None + category: str | None = None + name: str | None = None + + +class EpisodeExtendedRecord(BaseModel): + aired: str | None = None + airsAfterSeason: int | None = None + airsBeforeEpisode: int | None = None + airsBeforeSeason: int | None = None + awards: list[AwardBaseRecord] | None = None + characters: list[Character] | None = None + companies: list[Company] | None = None + contentRatings: list[ContentRating] | None = None + finaleType: str | None = Field(None, description="season, midseason, or series") + id: int | None = None + image: str | None = None + imageType: int | None = None + isMovie: int | None = None + lastUpdated: str | None = None + linkedMovie: int | None = None + name: str | None = None + nameTranslations: list[str] | None = None + networks: list[Company] | None = None + nominations: list[AwardNomineeBaseRecord] | None = None + number: int | None = None + overview: str | None = None + overviewTranslations: list[str] | None = None + productionCode: str | None = None + remoteIds: list[RemoteID] | None = None + runtime: int | None = None + seasonNumber: int | None = None + seasons: list[SeasonBaseRecord] | None = None + seriesId: int | None = None + studios: list[Company] | None = None + tagOptions: list[TagOption] | None = None + trailers: list[Trailer] | None = None + translations: TranslationExtended | None = None + year: str | None = None + + +class SearchByRemoteIdResult(BaseModel): + series: SeriesBaseRecord | None = None + people: PeopleBaseRecord | None = None + movie: MovieBaseRecord | None = None + episode: EpisodeBaseRecord | None = None + company: Company | None = None + + +class AwardCategoryExtendedRecord(BaseModel): + allowCoNominees: bool | None = None + award: AwardBaseRecord | None = None + forMovies: bool | None = None + forSeries: bool | None = None + id: int | None = None + name: str | None = None + nominees: list[AwardNomineeBaseRecord] | None = None diff --git a/src/tvdb/models.py b/src/tvdb/models.py index 6fa1349..ecb0811 100644 --- a/src/tvdb/models.py +++ b/src/tvdb/models.py @@ -1,66 +1,34 @@ from pydantic import BaseModel +from src.tvdb.generated_models import Links, SearchResult, SeriesBaseRecord, SeriesExtendedRecord + + +class Response(BaseModel): + """Model for any response of the TVDB API.""" -class _RemoteID(BaseModel): - id: str - type: int - sourceName: str # noqa: N815 - - -class _Translation(BaseModel): - ara: str | None = None - ces: str | None = None - deu: str | None = None - - -class SearchData(BaseModel): - """Model for the data objects returned by the TVDB search endpoint.""" - - aliases: list[str] - companies: list[str] | None = None - companyType: str | None = None # noqa: N815 - country: str - director: str | None = None - first_air_time: str - genres: list[str] | None = None - id: str - image_url: str - name: str - is_official: bool | None = None - name_translated: str | None = None - network: str - objectID: str # noqa: N815 - officialList: str | None = None # noqa: N815 - overview: str - overviews: _Translation - overview_translated: list[str] | None = None - poster: str | None = None - posters: list[str] | None = None - primary_language: str - remote_ids: list[_RemoteID] status: str - slug: str - studios: list[str] | None = None - title: str | None = None - thumbnail: str - translations: _Translation - translationsWithLang: list[str] | None = None # noqa: N815 - tvdb_id: str - type: str - year: str -class _Links(BaseModel): - prev: str | None = None # none if on the first page - self: str - next: str - total_items: int - page_size: int +class PaginatedResponse(Response): + """Model for the response of the search endpoint of the TVDB API.""" + links: Links -class SearchResponse(BaseModel): - """Model for the response from the TVDB search endpoint.""" - data: list[SearchData] - status: str - links: _Links +class SearchResponse(PaginatedResponse): + """Model for the response of the search endpoint of the TVDB API.""" + + data: list[SearchResult] + + +class SeriesResponse(Response): + """Model for the response of the series/{id} endpoint of the TVDB API.""" + + data: SeriesBaseRecord + + +class SeriesExtendedResponse(Response): + """Model for the response of the series/{id}/extended endpoint of the TVDB API.""" + + data: SeriesExtendedRecord + episodes: list[dict[str, str]] diff --git a/src/tvdb/swagger.yml b/src/tvdb/swagger.yml new file mode 100644 index 0000000..fd40947 --- /dev/null +++ b/src/tvdb/swagger.yml @@ -0,0 +1,4264 @@ +openapi: 3.0.0 +info: + description: | + Documentation of [TheTVDB](https://thetvdb.com/) API V4. All related information is linked from our [Github repo](https://github.com/thetvdb/v4-api). You might also want to use our [Postman collection] (https://www.getpostman.com/collections/7a9397ce69ff246f74d0) + ## Authentication + 1. Use the /login endpoint and provide your API key as "apikey". If you have a user-supported key, also provide your subscriber PIN as "pin". Otherwise completely remove "pin" from your call. + 2. Executing this call will provide you with a bearer token, which is valid for 1 month. + 3. Provide your bearer token for subsequent API calls by clicking Authorize below or including in the header of all direct API calls: `Authorization: Bearer [your-token]` + + ## Notes + 1. "score" is a field across almost all entities. We generate scores for different types of entities in various ways, so no assumptions should be made about the meaning of this value. It is simply used to hint at relative popularity for sorting purposes. + title: TVDB API V4 + version: 4.7.10 +security: + - bearerAuth: [ ] +paths: + /login: + post: + summary: create an auth token. The token has one month validation length. + requestBody: + content: + application/json: + schema: + type: object + required: + - apikey + properties: + apikey: + type: string + pin: + type: string + required: true + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + properties: + token: + type: string + type: object + status: + type: string + type: object + '401': + description: invalid credentials + tags: + - Login + '/artwork/{id}': + get: + description: Returns a single artwork base record. + operationId: getArtworkBase + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/ArtworkBaseRecord' + status: + type: string + type: object + '400': + description: Invalid artwork id + '401': + description: Unauthorized + '404': + description: Artwork not found + tags: + - Artwork + + '/artwork/{id}/extended': + get: + description: Returns a single artwork extended record. + operationId: getArtworkExtended + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/ArtworkExtendedRecord' + status: + type: string + type: object + '400': + description: Invalid artwork id + '401': + description: Unauthorized + '404': + description: Artwork not found + tags: + - Artwork + + '/artwork/statuses': + get: + description: Returns list of artwork status records. + operationId: getAllArtworkStatuses + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/ArtworkStatus' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - Artwork Statuses + + '/artwork/types': + get: + description: Returns a list of artworkType records + operationId: getAllArtworkTypes + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/ArtworkType' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - Artwork Types + + /awards: + get: + description: Returns a list of award base records + operationId: getAllAwards + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/AwardBaseRecord' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - Awards + + '/awards/{id}': + get: + description: Returns a single award base record + operationId: getAward + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/AwardBaseRecord' + status: + type: string + type: object + '400': + description: Invalid awards id + '401': + description: Unauthorized + '404': + description: Awards not found + tags: + - Awards + + '/awards/{id}/extended': + get: + description: Returns a single award extended record + operationId: getAwardExtended + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/AwardExtendedRecord' + status: + type: string + type: object + '400': + description: Invalid awards id + '401': + description: Unauthorized + '404': + description: Awards not found + tags: + - Awards + + '/awards/categories/{id}': + get: + description: Returns a single award category base record + operationId: getAwardCategory + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/AwardCategoryBaseRecord' + status: + type: string + type: object + '400': + description: Invalid category id + '401': + description: Unauthorized + '404': + description: Category not found + tags: + - Award Categories + + '/awards/categories/{id}/extended': + get: + description: Returns a single award category extended record + operationId: getAwardCategoryExtended + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/AwardCategoryExtendedRecord' + status: + type: string + type: object + '400': + description: Invalid category id + '401': + description: Unauthorized + '404': + description: Category not found + tags: + - Award Categories + + '/characters/{id}': + get: + description: Returns character base record + operationId: getCharacterBase + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/Character' + status: + type: string + type: object + '400': + description: Invalid character id + '401': + description: Unauthorized + '404': + description: Character not found + tags: + - Characters + /companies: + get: + description: returns a paginated list of company records + operationId: getAllCompanies + parameters: + - description: name + in: query + name: page + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/Company' + type: array + status: + type: string + links: + $ref: '#/components/schemas/Links' + type: object + '401': + description: Unauthorized + tags: + - Companies + '/companies/types': + get: + description: returns all company type records + operationId: getCompanyTypes + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + type: array + items: + $ref: '#/components/schemas/CompanyType' + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - Companies + '/companies/{id}': + get: + description: returns a company record + operationId: getCompany + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/Company' + status: + type: string + type: object + '400': + description: Invalid company id + '401': + description: Unauthorized + '404': + description: Company not found + tags: + - Companies + /content/ratings: + get: + description: returns list content rating records + operationId: getAllContentRatings + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/ContentRating' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - Content Ratings + /countries: + get: + description: returns list of country records + operationId: getAllCountries + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/Country' + type: array + status: + type: string + type: object + tags: + - Countries + '/entities': + get: + description: returns the active entity types + operationId: getEntityTypes + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/EntityType' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - Entity Types + '/episodes': + get: + description: Returns a list of episodes base records with the basic attributes.
Note that all episodes are returned, even those that may not be included in a series' default season order. + operationId: getAllEpisodes + parameters: + - description: page number + in: query + name: page + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/EpisodeBaseRecord' + type: array + status: + type: string + links: + $ref: '#/components/schemas/Links' + type: object + '401': + description: Unauthorized + tags: + - Episodes + '/episodes/{id}': + get: + description: Returns episode base record + operationId: getEpisodeBase + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/EpisodeBaseRecord' + status: + type: string + type: object + '400': + description: Invalid episode id + '401': + description: Unauthorized + '404': + description: Episode not found + tags: + - Episodes + '/episodes/{id}/extended': + get: + description: Returns episode extended record + operationId: getEpisodeExtended + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + - description: meta + in: query + name: meta + required: false + schema: + type: string + enum: [ translations ] + example: translations + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/EpisodeExtendedRecord' + status: + type: string + type: object + '400': + description: Invalid episode id + '401': + description: Unauthorized + '404': + description: Episode not found + tags: + - Episodes + '/episodes/{id}/translations/{language}': + get: + description: Returns episode translation record + operationId: getEpisodeTranslation + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + - description: language + in: path + name: language + required: true + schema: + type: string + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/Translation' + status: + type: string + type: object + '400': + description: Invalid episode id. Invalid language. + '401': + description: Unauthorized + '404': + description: Episode not found + tags: + - Episodes + + /genders: + get: + description: returns list of gender records + operationId: getAllGenders + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/Gender' + type: array + status: + type: string + type: object + tags: + - Genders + + /genres: + get: + description: returns list of genre records + operationId: getAllGenres + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/GenreBaseRecord' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + + tags: + - Genres + + '/genres/{id}': + get: + description: Returns genre record + operationId: getGenreBase + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/GenreBaseRecord' + status: + type: string + type: object + '400': + description: Invalid genre id + '401': + description: Unauthorized + '404': + description: Genre not found + tags: + - Genres + /inspiration/types: + get: + description: returns list of inspiration types records + operationId: getAllInspirationTypes + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/InspirationType' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - InspirationTypes + /languages: + get: + description: returns list of language records + operationId: getAllLanguages + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/Language' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - Languages + /lists: + get: + description: returns list of list base records + operationId: getAllLists + parameters: + - description: page number + in: query + name: page + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/ListBaseRecord' + type: array + status: + type: string + links: + $ref: '#/components/schemas/Links' + '401': + description: Unauthorized + tags: + - Lists + + '/lists/{id}': + get: + description: returns an list base record + operationId: getList + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/ListBaseRecord' + status: + type: string + type: object + '400': + description: Invalid list id + '401': + description: Unauthorized + '404': + description: List not found + tags: + - Lists + '/lists/slug/{slug}': + get: + description: returns an list base record search by slug + operationId: getListBySlug + parameters: + - description: slug + in: path + name: slug + required: true + schema: + type: string + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/ListBaseRecord' + status: + type: string + type: object + '400': + description: Invalid list slug + '401': + description: Unauthorized + '404': + description: List not found + tags: + - Lists + '/lists/{id}/extended': + get: + description: returns a list extended record + operationId: getListExtended + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/ListExtendedRecord' + status: + type: string + type: object + '400': + description: Invalid list id + '401': + description: Unauthorized + '404': + description: Lists not found + tags: + - Lists + '/lists/{id}/translations/{language}': + get: + description: Returns list translation record + operationId: getListTranslation + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + - description: language + in: path + name: language + required: true + schema: + type: string + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/Translation' + type: array + status: + type: string + type: object + '400': + description: Invalid lists id + '401': + description: Unauthorized + '404': + description: Lists not found + tags: + - Lists + + /movies: + get: + description: returns list of movie base records + operationId: getAllMovie + parameters: + - description: page number + in: query + name: page + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/MovieBaseRecord' + type: array + status: + type: string + links: + $ref: '#/components/schemas/Links' + type: object + '401': + description: Unauthorized + tags: + - Movies + '/movies/{id}': + get: + description: Returns movie base record + operationId: getMovieBase + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/MovieBaseRecord' + status: + type: string + type: object + '400': + description: Invalid movie id + '401': + description: Unauthorized + '404': + description: Movie not found + tags: + - Movies + '/movies/{id}/extended': + get: + description: Returns movie extended record + operationId: getMovieExtended + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + - description: meta + in: query + name: meta + required: false + schema: + type: string + enum: [ translations ] + example: translations + - description: reduce the payload and returns the short version of this record without characters, artworks and trailers. + in: query + name: short + required: false + schema: + type: boolean + enum: [ true, false ] + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/MovieExtendedRecord' + status: + type: string + type: object + '400': + description: Invalid movie id + '401': + description: Unauthorized + '404': + description: Movie not found + tags: + - Movies + '/movies/filter': + get: + description: Search movies based on filter parameters + operationId: getMoviesFilter + parameters: + - description: production company + in: query + name: company + required: false + schema: + type: number + example: 1 + - description: content rating id base on a country + in: query + name: contentRating + required: false + schema: + type: number + example: 245 + - description: country of origin + in: query + name: country + required: true + schema: + type: string + example: usa + - description: genre + in: query + name: genre + required: false + schema: + type: number + example: 3 + enum: [ 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36 ] + - description: original language + in: query + name: lang + required: true + schema: + type: string + example: eng + - description: sort by results + in: query + name: sort + required: false + schema: + type: string + enum: [ score,firstAired,name ] + - description: status + in: query + name: status + required: false + schema: + type: number + enum: [ 1,2,3 ] + - description: release year + in: query + name: year + required: false + schema: + type: number + example: 2020 + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/MovieBaseRecord' + type: array + status: + type: string + type: object + '400': + description: Invalid format parameter. + '401': + description: Unauthorized + tags: + - Movies + '/movies/slug/{slug}': + get: + description: Returns movie base record search by slug + operationId: getMovieBaseBySlug + parameters: + - description: slug + in: path + name: slug + required: true + schema: + type: string + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/MovieBaseRecord' + status: + type: string + type: object + '400': + description: Invalid movie slug + '401': + description: Unauthorized + '404': + description: Movie not found + tags: + - Movies + '/movies/{id}/translations/{language}': + get: + description: Returns movie translation record + operationId: getMovieTranslation + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + - description: language + in: path + name: language + required: true + schema: + type: string + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/Translation' + status: + type: string + type: object + '400': + description: Invalid movie id, invalid language. + '401': + description: Unauthorized + '404': + description: Movie not found + tags: + - Movies + /movies/statuses: + get: + description: returns list of status records + operationId: getAllMovieStatuses + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/Status' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - Movie Statuses + '/people': + get: + description: Returns a list of people base records with the basic attributes. + operationId: getAllPeople + parameters: + - description: page number + in: query + name: page + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/PeopleBaseRecord' + type: array + status: + type: string + links: + $ref: '#/components/schemas/Links' + type: object + '401': + description: Unauthorized + tags: + - People + + '/people/{id}': + get: + description: Returns people base record + operationId: getPeopleBase + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/PeopleBaseRecord' + status: + type: string + type: object + '400': + description: Invalid people id + '401': + description: Unauthorized + '404': + description: People not found + tags: + - People + '/people/{id}/extended': + get: + description: Returns people extended record + operationId: getPeopleExtended + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + - description: meta + in: query + name: meta + required: false + schema: + type: string + enum: [ translations ] + example: translations + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/PeopleExtendedRecord' + status: + type: string + type: object + '400': + description: Invalid people id + '401': + description: Unauthorized + '404': + description: People not found + tags: + - People + '/people/{id}/translations/{language}': + get: + description: Returns people translation record + operationId: getPeopleTranslation + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + - description: language + in: path + name: language + required: true + schema: + type: string + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/Translation' + status: + type: string + type: object + '400': + description: Invalid people id, invalid language. + '401': + description: Unauthorized + '404': + description: People not found + tags: + - People + /people/types: + get: + description: returns list of peopleType records + operationId: getAllPeopleTypes + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/PeopleType' + type: array + status: + type: string + type: object + tags: + - People Types + + /search: + get: + description: Our search index includes series, movies, people, and companies. Search is limited to 5k results max. + operationId: getSearchResults + parameters: + - description: The primary search string, which can include the main title for a record including all translations and aliases. + in: query + name: query + schema: + type: string + - description: Alias of the "query" parameter. Recommend using query instead as this field will eventually be deprecated. + in: query + name: q + schema: + type: string + - description: Restrict results to a specific entity type. Can be movie, series, person, or company. + in: query + name: type + schema: + type: string + - description: Restrict results to a specific year. Currently only used for series and movies. + in: query + name: year + schema: + type: number + - description: Restrict results to a specific company (original network, production company, studio, etc). As an example, "The Walking Dead" would have companies of "AMC", "AMC+", and "Disney+". + in: query + name: company + schema: + type: string + - description: Restrict results to a specific country of origin. Should contain a 3 character country code. Currently only used for series and movies. + in: query + name: country + schema: + type: string + - description: Restrict results to a specific director. Generally only used for movies. Should include the full name of the director, such as "Steven Spielberg". + in: query + name: director + schema: + type: string + - description: Restrict results to a specific primary language. Should include the 3 character language code. Currently only used for series and movies. + in: query + name: language + schema: + type: string + - description: Restrict results to a specific type of company. Should include the full name of the type of company, such as "Production Company". Only used for companies. + in: query + name: primaryType + schema: + type: string + - description: Restrict results to a specific network. Used for TV and TV movies, and functions the same as the company parameter with more specificity. + in: query + name: network + schema: + type: string + + + - description: Search for a specific remote id. Allows searching for an IMDB or EIDR id, for example. + in: query + name: remote_id + schema: + type: string + + - description: Offset results. + in: query + name: offset + schema: + type: number + - description: Limit results. + in: query + name: limit + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/SearchResult' + type: array + status: + type: string + links: + $ref: '#/components/schemas/Links' + type: object + '401': + description: Unauthorized + '400': + description: Max results overflow + tags: + - Search + /search/remoteid/{remoteId}: + get: + description: Search a series, movie, people, episode, company or season by specific remote id and returns a base record for that entity. + operationId: getSearchResultsByRemoteId + parameters: + - description: Search for a specific remote id. Allows searching for an IMDB or EIDR id, for example. + in: path + required: true + name: remoteId + schema: + type: string + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/SearchByRemoteIdResult' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - Search + + /seasons: + get: + description: returns list of seasons base records + operationId: getAllSeasons + parameters: + - description: page number + in: query + name: page + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/SeasonBaseRecord' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - Seasons + '/seasons/{id}': + get: + description: Returns season base record + operationId: getSeasonBase + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/SeasonBaseRecord' + status: + type: string + type: object + '400': + description: Invalid season id + '401': + description: Unauthorized + '404': + description: Season not found + tags: + - Seasons + '/seasons/{id}/extended': + get: + description: Returns season extended record + operationId: getSeasonExtended + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/SeasonExtendedRecord' + status: + type: string + type: object + '400': + description: Invalid seasons id + '401': + description: Unauthorized + '404': + description: Season not found + tags: + - Seasons + '/seasons/types': + get: + description: Returns season type records + operationId: getSeasonTypes + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/SeasonType' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - Seasons + '/seasons/{id}/translations/{language}': + get: + description: Returns season translation record + operationId: getSeasonTranslation + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + - description: language + in: path + name: language + required: true + schema: + type: string + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/Translation' + status: + type: string + type: object + '400': + description: Invalid season id, language not found. + '401': + description: Unauthorized + '404': + description: Season not found + tags: + - Seasons + /series: + get: + description: returns list of series base records + operationId: getAllSeries + parameters: + - description: page number + in: query + name: page + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/SeriesBaseRecord' + type: array + status: + type: string + links: + $ref: '#/components/schemas/Links' + type: object + '401': + description: Unauthorized + tags: + - Series + '/series/{id}': + get: + description: Returns series base record + operationId: getSeriesBase + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/SeriesBaseRecord' + status: + type: string + type: object + '400': + description: Invalid series id + '401': + description: Unauthorized + '404': + description: Series not found + tags: + - Series + '/series/{id}/artworks': + get: + description: Returns series artworks base on language and type.
Note: Artwork type is an id that can be found using **/artwork/types** endpoint. + operationId: getSeriesArtworks + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + - description: lang + in: query + name: lang + required: false + schema: + type: string + example: eng, spa + - description: type + in: query + name: type + required: false + schema: + type: integer + example: 1,2,3 + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/SeriesExtendedRecord' + status: + type: string + type: object + '400': + description: Invalid series id + '401': + description: Unauthorized + '404': + description: Series not found + tags: + - Series + '/series/{id}/nextAired': + get: + description: Returns series base record including the nextAired field.
Note: nextAired was included in the base record endpoint but that field will deprecated in the future so developers should use the nextAired endpoint. + operationId: getSeriesNextAired + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/SeriesBaseRecord' + status: + type: string + type: object + '400': + description: Invalid series id + '401': + description: Unauthorized + '404': + description: Series not found + tags: + - Series + '/series/{id}/extended': + get: + description: Returns series extended record + operationId: getSeriesExtended + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + - description: meta + in: query + name: meta + required: false + schema: + type: string + enum: [ translations, episodes ] + example: translations + - description: reduce the payload and returns the short version of this record without characters and artworks + in: query + name: short + required: false + schema: + type: boolean + enum: [ true, false ] + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/SeriesExtendedRecord' + status: + type: string + type: object + '400': + description: Invalid series id + '401': + description: Unauthorized + '404': + description: Series not found + tags: + - Series + '/series/{id}/episodes/{season-type}': + get: + description: Returns series episodes from the specified season type, default returns the episodes in the series default season type + operationId: getSeriesEpisodes + parameters: + - in: query + name: page + required: true + schema: + type: integer + default: 0 + - description: id + in: path + name: id + required: true + schema: + type: number + - description: season-type + in: path + name: season-type + required: true + schema: + type: string + examples: + default: + value: default + official: + value: official + dvd: + value: dvd + absolute: + value: absolute + alternate: + value: alternate + regional: + value: regional + - in: query + name: season + required: false + schema: + type: integer + default: 0 + - in: query + name: episodeNumber + required: false + schema: + type: integer + default: 0 + - description: airDate of the episode, format is yyyy-mm-dd + in: query + name: airDate + required: false + schema: + type: string + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + type: object + properties: + series: + $ref: '#/components/schemas/SeriesBaseRecord' + episodes: + type: array + items: + $ref: '#/components/schemas/EpisodeBaseRecord' + status: + type: string + type: object + '400': + description: Invalid series id, episodeNumber is not null then season must be present + '401': + description: Unauthorized + '404': + description: Series not found + tags: + - Series + '/series/{id}/episodes/{season-type}/{lang}': + get: + description: Returns series base record with episodes from the specified season type and language. Default returns the episodes in the series default season type. + operationId: getSeriesSeasonEpisodesTranslated + parameters: + - in: query + name: page + required: true + schema: + type: integer + default: 0 + - description: id + in: path + name: id + required: true + schema: + type: number + - description: season-type + in: path + name: season-type + required: true + schema: + type: string + examples: + default: + value: default + official: + value: official + dvd: + value: dvd + absolute: + value: absolute + alternate: + value: alternate + regional: + value: regional + - in: path + name: lang + required: true + schema: + type: string + + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + type: object + properties: + series: + $ref: '#/components/schemas/SeriesBaseRecord' + status: + type: string + type: object + '400': + description: Invalid series id, invalid language. + '401': + description: Unauthorized + '404': + description: Series not found + tags: + - Series + '/series/filter': + get: + description: Search series based on filter parameters + operationId: getSeriesFilter + parameters: + - description: production company + in: query + name: company + required: false + schema: + type: number + example: 1 + - description: content rating id base on a country + in: query + name: contentRating + required: false + schema: + type: number + example: 245 + - description: country of origin + in: query + name: country + required: true + schema: + type: string + example: usa + - description: Genre id. This id can be found using **/genres** endpoint. + in: query + name: genre + required: false + schema: + type: number + example: 3 + enum: [ 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36 ] + - description: original language + in: query + name: lang + required: true + schema: + type: string + example: eng + - description: sort by results + in: query + name: sort + required: false + schema: + type: string + enum: [ score,firstAired,lastAired,name ] + - description: sort type ascending or descending + in: query + name: sortType + required: false + schema: + type: string + enum: [ asc,desc ] + - description: status + in: query + name: status + required: false + schema: + type: number + enum: [ 1,2,3 ] + - description: release year + in: query + name: year + required: false + schema: + type: number + example: 2020 + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/SeriesBaseRecord' + type: array + type: object + '400': + description: Invalid format parameter. + '401': + description: Unauthorized + tags: + - Series + '/series/slug/{slug}': + get: + description: Returns series base record searched by slug + operationId: getSeriesBaseBySlug + parameters: + - description: slug + in: path + name: slug + required: true + schema: + type: string + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/SeriesBaseRecord' + status: + type: string + type: object + '400': + description: Invalid series slug + '401': + description: Unauthorized + '404': + description: Series not found + tags: + - Series + '/series/{id}/translations/{language}': + get: + description: Returns series translation record + operationId: getSeriesTranslation + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + - description: language + in: path + name: language + required: true + schema: + type: string + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/Translation' + status: + type: string + type: object + '400': + description: Invalid series id, invalid language. + '401': + description: Unauthorized + '404': + description: Series not found + tags: + - Series + /series/statuses: + get: + description: returns list of status records + operationId: getAllSeriesStatuses + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/Status' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - Series Statuses + /sources/types: + get: + description: returns list of sourceType records + operationId: getAllSourceTypes + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/SourceType' + type: array + status: + type: string + type: object + '401': + description: Unauthorized + tags: + - Source Types + /updates: + get: + description: Returns updated entities. methodInt indicates a created record (1), an updated record (2), or a deleted record (3). If a record is deleted because it was a duplicate of another record, the target record's information is provided in mergeToType and mergeToId. + operationId: updates + parameters: + - in: query + name: since + required: true + schema: + type: number + - in: query + name: type + required: false + schema: + type: string + enum: [ artwork,award_nominees,companies,episodes,lists,people,seasons,series,seriespeople,artworktypes,award_categories,awards,company_types,content_ratings,countries,entity_types,genres,languages,movies,movie_genres,movie_status,peopletypes,seasontypes,sourcetypes,tag_options,tags,translatedcharacters,translatedcompanies,translatedepisodes,translatedlists,translatedmovies,translatedpeople,translatedseasons,translatedserierk ] + example: movies + - in: query + name: action + required: false + schema: + type: string + enum: [ delete, update ] + example: movies + - description: name + in: query + name: page + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/EntityUpdate' + type: array + status: + type: string + links: + $ref: '#/components/schemas/Links' + type: object + '400': + description: Invalid since, type param. + '401': + description: Unauthorized + + tags: + - Updates + /user: + get: + description: returns user info + operationId: getUserInfo + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/UserInfo' + status: + type: string + type: object + '401': + description: Unauthorized + + tags: + - User info + /user/{id}: + get: + description: returns user info by user id + operationId: getUserInfoById + parameters: + - description: id + in: path + name: id + required: true + schema: + type: number + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/UserInfo' + status: + type: string + type: object + '401': + description: Unauthorized + + tags: + - User info + /user/favorites: + get: + description: returns user favorites + operationId: getUserFavorites + responses: + '200': + description: response + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/Favorites' + status: + type: string + type: object + '401': + description: Unauthorized + + tags: + - Favorites + post: + description: creates a new user favorite + operationId: createUserFavorites + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/FavoriteRecord' + responses: + '200': + description: Ok + '400': + description: Bad format + '401': + description: Unauthorized + + tags: + - Favorites + +servers: + - url: 'https://api4.thetvdb.com/v4' +components: + securitySchemes: + bearerAuth: # arbitrary name for the security scheme + type: http + scheme: bearer + bearerFormat: JWT + schemas: + Alias: + description: An alias model, which can be associated with a series, season, movie, person, or list. + properties: + language: + type: string + maximum: 4 + description: A 3-4 character string indicating the language of the alias, as defined in Language. + name: + type: string + maximum: 100 + description: A string containing the alias itself. + type: object + ArtworkBaseRecord: + description: base artwork record + properties: + height: + format: int64 + type: integer + x-go-name: Height + id: + type: integer + image: + type: string + x-go-name: Image + includesText: + type: boolean + language: + type: string + score: + type: number + thumbnail: + type: string + x-go-name: Thumbnail + type: + format: int64 + type: integer + x-go-name: Type + description: The artwork type corresponds to the ids from the /artwork/types endpoint. + width: + format: int64 + type: integer + x-go-name: Width + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + ArtworkExtendedRecord: + description: extended artwork record + properties: + episodeId: + type: integer + height: + format: int64 + type: integer + x-go-name: Height + id: + format: int64 + type: integer + x-go-name: ID + image: + type: string + x-go-name: Image + includesText: + type: boolean + language: + type: string + movieId: + type: integer + networkId: + type: integer + peopleId: + type: integer + score: + type: number + seasonId: + type: integer + seriesId: + type: integer + seriesPeopleId: + type: integer + status: + $ref: '#/components/schemas/ArtworkStatus' + tagOptions: + items: + $ref: '#/components/schemas/TagOption' + type: array + x-go-name: TagOptions + thumbnail: + type: string + x-go-name: Thumbnail + thumbnailHeight: + format: int64 + type: integer + x-go-name: ThumbnailHeight + thumbnailWidth: + format: int64 + type: integer + x-go-name: ThumbnailWidth + type: + format: int64 + type: integer + x-go-name: Type + description: The artwork type corresponds to the ids from the /artwork/types endpoint. + updatedAt: + format: int64 + type: integer + x-go-name: UpdatedAt + width: + format: int64 + type: integer + x-go-name: Width + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + ArtworkStatus: + description: artwork status record + properties: + id: + format: int64 + type: integer + x-go-name: ID + name: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + ArtworkType: + description: artwork type record + properties: + height: + format: int64 + type: integer + id: + format: int64 + type: integer + x-go-name: ID + imageFormat: + type: string + x-go-name: ImageFormat + name: + type: string + x-go-name: Name + recordType: + type: string + x-go-name: RecordType + slug: + type: string + x-go-name: Slug + thumbHeight: + format: int64 + type: integer + x-go-name: ThumbHeight + thumbWidth: + format: int64 + type: integer + x-go-name: ThumbWidth + width: + format: int64 + type: integer + x-go-name: Width + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + AwardBaseRecord: + description: base award record + properties: + id: + type: integer + name: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + AwardCategoryBaseRecord: + description: base award category record + properties: + allowCoNominees: + type: boolean + x-go-name: AllowCoNominees + award: + $ref: '#/components/schemas/AwardBaseRecord' + forMovies: + type: boolean + x-go-name: ForMovies + forSeries: + type: boolean + x-go-name: ForSeries + id: + format: int64 + type: integer + x-go-name: ID + name: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + AwardCategoryExtendedRecord: + description: extended award category record + properties: + allowCoNominees: + type: boolean + x-go-name: AllowCoNominees + award: + $ref: '#/components/schemas/AwardBaseRecord' + forMovies: + type: boolean + x-go-name: ForMovies + forSeries: + type: boolean + x-go-name: ForSeries + id: + format: int64 + type: integer + x-go-name: ID + name: + type: string + nominees: + items: + $ref: '#/components/schemas/AwardNomineeBaseRecord' + type: array + x-go-name: Nominees + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + AwardExtendedRecord: + description: extended award record + properties: + categories: + items: + $ref: '#/components/schemas/AwardCategoryBaseRecord' + type: array + x-go-name: Categories + id: + type: integer + name: + type: string + score: + format: int64 + type: integer + x-go-name: Score + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + AwardNomineeBaseRecord: + description: base award nominee record + properties: + character: + $ref: '#/components/schemas/Character' + details: + type: string + episode: + $ref: '#/components/schemas/EpisodeBaseRecord' + id: + format: int64 + type: integer + x-go-name: ID + isWinner: + type: boolean + x-go-name: IsWinner + movie: + $ref: '#/components/schemas/MovieBaseRecord' + series: + $ref: '#/components/schemas/SeriesBaseRecord' + year: + type: string + category: + type: string + name: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + Biography: + description: biography record + properties: + biography: + type: string + x-go-name: Biography + language: + type: string + x-go-name: Language + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + Character: + description: character record + properties: + aliases: + items: + $ref: '#/components/schemas/Alias' + type: array + x-go-name: Aliases + episode: + $ref: '#/components/schemas/RecordInfo' + episodeId: + type: integer + nullable: true + id: + format: int64 + type: integer + x-go-name: ID + image: + type: string + isFeatured: + type: boolean + x-go-name: IsFeatured + movieId: + type: integer + nullable: true + movie: + $ref: '#/components/schemas/RecordInfo' + name: + type: string + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + peopleId: + type: integer + personImgURL: + type: string + peopleType: + type: string + seriesId: + type: integer + nullable: true + series: + $ref: '#/components/schemas/RecordInfo' + sort: + format: int64 + type: integer + x-go-name: Sort + tagOptions: + items: + $ref: '#/components/schemas/TagOption' + type: array + x-go-name: TagOptions + type: + format: int64 + type: integer + x-go-name: Type + url: + type: string + x-go-name: URL + personName: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + Company: + description: A company record + properties: + activeDate: + type: string + aliases: + items: + $ref: '#/components/schemas/Alias' + type: array + x-go-name: Aliases + country: + type: string + id: + format: int64 + type: integer + x-go-name: ID + inactiveDate: + type: string + name: + type: string + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + primaryCompanyType: + format: int64 + type: integer + x-go-name: PrimaryCompanyType + nullable: true + slug: + type: string + x-go-name: Slug + parentCompany: + type: object + $ref: '#/components/schemas/ParentCompany' + tagOptions: + items: + $ref: '#/components/schemas/TagOption' + type: array + x-go-name: TagOptions + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + ParentCompany: + description: A parent company record + type: object + properties: + id: + type: integer + nullable: true + name: + type: string + relation: + type: object + $ref: '#/components/schemas/CompanyRelationShip' + CompanyRelationShip: + description: A company relationship + properties: + id: + type: integer + nullable: true + typeName: + type: string + CompanyType: + description: A company type record + type: object + properties: + companyTypeId: + type: integer + companyTypeName: + type: string + ContentRating: + description: content rating record + properties: + id: + format: int64 + type: integer + x-go-name: ID + name: + type: string + x-go-name: Name + description: + type: string + country: + type: string + contentType: + type: string + order: + type: integer + fullName: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + Country: + description: country record + properties: + id: + type: string + x-go-name: ID + name: + type: string + x-go-name: Name + shortCode: + type: string + x-go-name: ShortCode + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + Entity: + description: Entity record + properties: + movieId: + type: integer + order: + format: int64 + type: integer + x-go-name: Order + seriesId: + type: integer + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + EntityType: + description: Entity Type record + properties: + id: + type: integer + name: + type: string + x-go-name: Order + hasSpecials: + type: boolean + type: object + EntityUpdate: + description: entity update record + properties: + entityType: + type: string + x-go-name: EnitityType + methodInt: + type: integer + method: + type: string + x-go-name: Method + extraInfo: + type: string + userId: + type: integer + recordType: + type: string + recordId: + format: int64 + type: integer + x-go-name: RecordID + timeStamp: + format: int64 + type: integer + x-go-name: TimeStamp + seriesId: + description: Only present for episodes records + format: int64 + type: integer + x-go-name: RecordID + mergeToId: + format: int64 + type: integer + mergeToEntityType: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + EpisodeBaseRecord: + description: base episode record + properties: + absoluteNumber: + type: integer + aired: + type: string + airsAfterSeason: + type: integer + airsBeforeEpisode: + type: integer + airsBeforeSeason: + type: integer + finaleType: + description: season, midseason, or series + type: string + id: + format: int64 + type: integer + x-go-name: ID + image: + type: string + imageType: + type: integer + nullable: true + isMovie: + format: int64 + type: integer + x-go-name: IsMovie + lastUpdated: + type: string + linkedMovie: + type: integer + name: + type: string + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + number: + type: integer + overview: + type: string + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + runtime: + type: integer + nullable: true + seasonNumber: + type: integer + seasons: + items: + $ref: '#/components/schemas/SeasonBaseRecord' + type: array + x-go-name: Seasons + seriesId: + format: int64 + type: integer + x-go-name: SeriesID + seasonName: + type: string + year: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + EpisodeExtendedRecord: + description: extended episode record + properties: + aired: + type: string + airsAfterSeason: + type: integer + airsBeforeEpisode: + type: integer + airsBeforeSeason: + type: integer + awards: + items: + $ref: '#/components/schemas/AwardBaseRecord' + type: array + x-go-name: Awards + characters: + items: + $ref: '#/components/schemas/Character' + type: array + x-go-name: Characters + companies: + items: + $ref: '#/components/schemas/Company' + type: array + contentRatings: + items: + $ref: '#/components/schemas/ContentRating' + type: array + x-go-name: ContentRatings + finaleType: + description: season, midseason, or series + type: string + id: + format: int64 + type: integer + x-go-name: ID + image: + type: string + imageType: + type: integer + nullable: true + isMovie: + format: int64 + type: integer + x-go-name: IsMovie + lastUpdated: + type: string + linkedMovie: + type: integer + name: + type: string + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + networks: + items: + $ref: '#/components/schemas/Company' + type: array + nominations: + items: + $ref: '#/components/schemas/AwardNomineeBaseRecord' + type: array + x-go-name: Nominees + number: + type: integer + overview: + type: string + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + productionCode: + type: string + remoteIds: + items: + $ref: '#/components/schemas/RemoteID' + type: array + x-go-name: RemoteIDs + runtime: + type: integer + nullable: true + seasonNumber: + type: integer + seasons: + items: + $ref: '#/components/schemas/SeasonBaseRecord' + type: array + x-go-name: Seasons + seriesId: + format: int64 + type: integer + x-go-name: SeriesID + studios: + items: + $ref: '#/components/schemas/Company' + type: array + tagOptions: + items: + $ref: '#/components/schemas/TagOption' + type: array + x-go-name: TagOptions + trailers: + items: + $ref: '#/components/schemas/Trailer' + type: array + x-go-name: Trailers + translations: + $ref: '#/components/schemas/TranslationExtended' + year: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + Favorites: + description: User favorites record + properties: + series: + items: + type: integer + type: array + x-go-name: series + movies: + items: + type: integer + type: array + x-go-name: movies + episodes: + items: + type: integer + type: array + x-go-name: episodes + artwork: + items: + type: integer + type: array + x-go-name: artwork + people: + items: + type: integer + type: array + x-go-name: people + lists: + items: + type: integer + type: array + x-go-name: list + FavoriteRecord: + description: Favorites record + properties: + series: + type: integer + x-go-name: series + movie: + type: integer + x-go-name: movies + episode: + type: integer + x-go-name: episodes + artwork: + type: integer + x-go-name: artwork + people: + type: integer + x-go-name: people + list: + type: integer + x-go-name: list + Gender: + description: gender record + properties: + id: + format: int64 + type: integer + x-go-name: ID + name: + type: string + x-go-name: Name + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + GenreBaseRecord: + description: base genre record + properties: + id: + format: int64 + type: integer + x-go-name: ID + name: + type: string + x-go-name: Name + slug: + type: string + x-go-name: Slug + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + Language: + description: language record + properties: + id: + type: string + x-go-name: ID + name: + type: string + x-go-name: Name + nativeName: + type: string + x-go-name: NativeName + shortCode: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + ListBaseRecord: + description: base list record + properties: + aliases: + items: + $ref: '#/components/schemas/Alias' + type: array + x-go-name: Aliases + id: + format: int64 + type: integer + x-go-name: ID + image: + type: string + imageIsFallback: + type: boolean + isOfficial: + type: boolean + x-go-name: IsOfficial + name: + type: string + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + overview: + type: string + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + remoteIds: + items: + $ref: '#/components/schemas/RemoteID' + type: array + x-go-name: RemoteIDs + tags: + items: + $ref: '#/components/schemas/TagOption' + type: array + x-go-name: TagOptions + score: + type: integer + url: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + ListExtendedRecord: + description: extended list record + properties: + aliases: + items: + $ref: '#/components/schemas/Alias' + type: array + x-go-name: Aliases + entities: + items: + $ref: '#/components/schemas/Entity' + type: array + x-go-name: Entities + id: + format: int64 + type: integer + x-go-name: ID + image: + type: string + imageIsFallback: + type: boolean + isOfficial: + type: boolean + x-go-name: IsOfficial + name: + type: string + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + overview: + type: string + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + score: + format: int64 + type: integer + x-go-name: Score + url: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + MovieBaseRecord: + description: base movie record + properties: + aliases: + items: + $ref: '#/components/schemas/Alias' + type: array + x-go-name: Aliases + id: + format: int64 + type: integer + x-go-name: ID + image: + type: string + x-go-name: Image + lastUpdated: + type: string + name: + type: string + x-go-name: Name + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + score: + format: double + type: number + x-go-name: Score + slug: + type: string + x-go-name: Slug + status: + $ref: '#/components/schemas/Status' + runtime: + type: integer + nullable: true + year: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + MovieExtendedRecord: + description: extended movie record + properties: + aliases: + items: + $ref: '#/components/schemas/Alias' + type: array + x-go-name: Aliases + artworks: + items: + $ref: '#/components/schemas/ArtworkBaseRecord' + type: array + x-go-name: Artworks + audioLanguages: + items: + type: string + type: array + x-go-name: AudioLanguages + awards: + items: + $ref: '#/components/schemas/AwardBaseRecord' + type: array + x-go-name: Awards + boxOffice: + type: string + boxOfficeUS: + type: string + budget: + type: string + characters: + items: + $ref: '#/components/schemas/Character' + type: array + x-go-name: Characters + companies: + type: object + $ref: '#/components/schemas/Companies' + contentRatings: + items: + $ref: '#/components/schemas/ContentRating' + type: array + first_release: + type: object + $ref: '#/components/schemas/Release' + genres: + items: + $ref: '#/components/schemas/GenreBaseRecord' + type: array + x-go-name: Genres + id: + format: int64 + type: integer + x-go-name: ID + image: + type: string + x-go-name: Image + inspirations: + items: + $ref: '#/components/schemas/Inspiration' + type: array + x-go-name: Inspirations + lastUpdated: + type: string + lists: + items: + $ref: '#/components/schemas/ListBaseRecord' + type: array + name: + type: string + x-go-name: Name + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + originalCountry: + type: string + originalLanguage: + type: string + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + production_countries: + items: + $ref: '#/components/schemas/ProductionCountry' + type: array + x-go-name: ProductionCountries + releases: + items: + $ref: '#/components/schemas/Release' + type: array + x-go-name: Releases + remoteIds: + items: + $ref: '#/components/schemas/RemoteID' + type: array + x-go-name: RemoteIDs + runtime: + type: integer + nullable: true + score: + format: double + type: number + x-go-name: Score + slug: + type: string + x-go-name: Slug + spoken_languages: + items: + type: string + type: array + x-go-name: SpokenLanguages + status: + $ref: '#/components/schemas/Status' + studios: + items: + $ref: '#/components/schemas/StudioBaseRecord' + type: array + x-go-name: Studios + subtitleLanguages: + items: + type: string + type: array + x-go-name: SubtitleLanguages + tagOptions: + items: + $ref: '#/components/schemas/TagOption' + type: array + x-go-name: TagOptions + trailers: + items: + $ref: '#/components/schemas/Trailer' + type: array + x-go-name: Trailers + translations: + $ref: '#/components/schemas/TranslationExtended' + year: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + PeopleBaseRecord: + description: base people record + properties: + aliases: + items: + $ref: '#/components/schemas/Alias' + type: array + x-go-name: Aliases + id: + format: int64 + type: integer + x-go-name: ID + image: + type: string + lastUpdated: + type: string + name: + type: string + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + score: + format: int64 + type: integer + x-go-name: Score + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + PeopleExtendedRecord: + description: extended people record + properties: + aliases: + items: + $ref: '#/components/schemas/Alias' + type: array + x-go-name: Aliases + awards: + items: + $ref: '#/components/schemas/AwardBaseRecord' + type: array + x-go-name: Awards + biographies: + items: + $ref: '#/components/schemas/Biography' + type: array + x-go-name: Biographies + birth: + type: string + birthPlace: + type: string + characters: + items: + $ref: '#/components/schemas/Character' + type: array + x-go-name: Characters + death: + type: string + gender: + type: integer + id: + format: int64 + type: integer + x-go-name: ID + image: + type: string + lastUpdated: + type: string + name: + type: string + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + races: + items: + $ref: '#/components/schemas/Race' + type: array + x-go-name: Races + remoteIds: + items: + $ref: '#/components/schemas/RemoteID' + type: array + x-go-name: RemoteIDs + score: + format: int64 + type: integer + x-go-name: Score + slug: + type: string + tagOptions: + items: + $ref: '#/components/schemas/TagOption' + type: array + x-go-name: TagOptions + translations: + $ref: '#/components/schemas/TranslationExtended' + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + PeopleType: + description: people type record + properties: + id: + format: int64 + type: integer + x-go-name: ID + name: + type: string + x-go-name: Name + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + Race: + description: race record + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + RecordInfo: + description: base record info + properties: + image: + type: string + x-go-name: Image + name: + type: string + x-go-name: Name + year: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + Release: + description: release record + properties: + country: + type: string + date: + type: string + detail: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + RemoteID: + description: remote id record + properties: + id: + type: string + x-go-name: ID + type: + format: int64 + type: integer + x-go-name: Type + sourceName: + type: string + x-go-name: SourceName + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + SearchResult: + description: search result + properties: + aliases: + items: + type: string + type: array + companies: + items: + type: string + type: array + companyType: + type: string + country: + type: string + director: + type: string + first_air_time: + type: string + genres: + items: + type: string + type: array + id: + type: string + image_url: + type: string + name: + type: string + is_official: + type: boolean + name_translated: + type: string + network: + type: string + objectID: + type: string + officialList: + type: string + overview: + type: string + overviews: + $ref: '#/components/schemas/TranslationSimple' + overview_translated: + items: + type: string + type: array + poster: + type: string + posters: + items: + type: string + type: array + primary_language: + type: string + remote_ids: + items: + $ref: '#/components/schemas/RemoteID' + type: array + x-go-name: RemoteIDs + status: + type: string + x-go-name: Status + slug: + type: string + studios: + items: + type: string + type: array + title: + type: string + thumbnail: + type: string + translations: + $ref: '#/components/schemas/TranslationSimple' + translationsWithLang: + items: + type: string + type: array + tvdb_id: + type: string + type: + type: string + year: + type: string + type: object + SearchByRemoteIdResult: + description: search by remote reuslt is a base record for a movie, series, people, season or company search result + properties: + series: + type: object + $ref: '#/components/schemas/SeriesBaseRecord' + people: + type: object + $ref: '#/components/schemas/PeopleBaseRecord' + movie: + type: object + $ref: '#/components/schemas/MovieBaseRecord' + episode: + type: object + $ref: '#/components/schemas/EpisodeBaseRecord' + company: + type: object + $ref: '#/components/schemas/Company' + + SeasonBaseRecord: + description: season genre record + properties: + id: + type: integer + image: + type: string + imageType: + type: integer + lastUpdated: + type: string + name: + type: string + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + number: + format: int64 + type: integer + x-go-name: Number + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + companies: + type: object + $ref: '#/components/schemas/Companies' + seriesId: + format: int64 + type: integer + x-go-name: SeriesID + type: + $ref: '#/components/schemas/SeasonType' + year: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + SeasonExtendedRecord: + description: extended season record + properties: + artwork: + items: + $ref: '#/components/schemas/ArtworkBaseRecord' + type: array + x-go-name: Artwork + companies: + type: object + $ref: '#/components/schemas/Companies' + episodes: + items: + $ref: '#/components/schemas/EpisodeBaseRecord' + type: array + x-go-name: Episodes + id: + type: integer + image: + type: string + imageType: + type: integer + lastUpdated: + type: string + name: + type: string + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + number: + format: int64 + type: integer + x-go-name: Number + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + seriesId: + format: int64 + type: integer + x-go-name: SeriesID + trailers: + items: + $ref: '#/components/schemas/Trailer' + type: array + x-go-name: Trailers + type: + $ref: '#/components/schemas/SeasonType' + tagOptions: + items: + $ref: '#/components/schemas/TagOption' + type: array + x-go-name: TagOptions + translations: + items: + $ref: '#/components/schemas/Translation' + type: array + year: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + SeasonType: + description: season type record + properties: + alternateName: + type: string + x-go-name: Name + id: + format: int64 + type: integer + x-go-name: ID + name: + type: string + x-go-name: Name + type: + type: string + x-go-name: Type + + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + SeriesAirsDays: + description: A series airs day record + properties: + friday: + type: boolean + x-go-name: Friday + monday: + type: boolean + x-go-name: Monday + saturday: + type: boolean + x-go-name: Saturday + sunday: + type: boolean + x-go-name: Sunday + thursday: + type: boolean + x-go-name: Thursday + tuesday: + type: boolean + x-go-name: Tuesday + wednesday: + type: boolean + x-go-name: Wednesday + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + SeriesBaseRecord: + description: The base record for a series. All series airs time like firstAired, lastAired, nextAired, etc. are in US EST for US series, and for all non-US series, the time of the show’s country capital or most populous city. For streaming services, is the official release time. See https://support.thetvdb.com/kb/faq.php?id=29. + properties: + aliases: + items: + $ref: '#/components/schemas/Alias' + type: array + x-go-name: Aliases + averageRuntime: + type: integer + nullable: true + country: + type: string + defaultSeasonType: + format: int64 + type: integer + x-go-name: DefaultSeasonType + episodes: + items: + $ref: '#/components/schemas/EpisodeBaseRecord' + type: array + x-go-name: Episodes + firstAired: + type: string + id: + type: integer + image: + type: string + isOrderRandomized: + type: boolean + x-go-name: IsOrderRandomized + lastAired: + type: string + lastUpdated: + type: string + name: + type: string + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + nextAired: + type: string + x-go-name: NextAired + originalCountry: + type: string + originalLanguage: + type: string + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + score: + format: double + type: number + x-go-name: Score + slug: + type: string + status: + $ref: '#/components/schemas/Status' + year: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + SeriesExtendedRecord: + description: The extended record for a series. All series airs time like firstAired, lastAired, nextAired, etc. are in US EST for US series, and for all non-US series, the time of the show’s country capital or most populous city. For streaming services, is the official release time. See https://support.thetvdb.com/kb/faq.php?id=29. + properties: + abbreviation: + type: string + airsDays: + $ref: '#/components/schemas/SeriesAirsDays' + airsTime: + type: string + aliases: + items: + $ref: '#/components/schemas/Alias' + type: array + x-go-name: Aliases + artworks: + items: + $ref: '#/components/schemas/ArtworkExtendedRecord' + type: array + x-go-name: Artworks + averageRuntime: + type: integer + nullable: true + characters: + items: + $ref: '#/components/schemas/Character' + type: array + x-go-name: Characters + contentRatings: + items: + $ref: '#/components/schemas/ContentRating' + type: array + country: + type: string + defaultSeasonType: + format: int64 + type: integer + x-go-name: DefaultSeasonType + episodes: + items: + $ref: '#/components/schemas/EpisodeBaseRecord' + type: array + x-go-name: Episodes + firstAired: + type: string + lists: + items: + $ref: '#/components/schemas/ListBaseRecord' + genres: + items: + $ref: '#/components/schemas/GenreBaseRecord' + type: array + x-go-name: Genres + id: + type: integer + image: + type: string + isOrderRandomized: + type: boolean + x-go-name: IsOrderRandomized + lastAired: + type: string + lastUpdated: + type: string + name: + type: string + nameTranslations: + items: + type: string + type: array + x-go-name: NameTranslations + companies: + items: + $ref: '#/components/schemas/Company' + type: array + nextAired: + type: string + x-go-name: NextAired + originalCountry: + type: string + originalLanguage: + type: string + originalNetwork: + $ref: '#/components/schemas/Company' + overview: + type: string + latestNetwork: + $ref: '#/components/schemas/Company' + overviewTranslations: + items: + type: string + type: array + x-go-name: OverviewTranslations + remoteIds: + items: + $ref: '#/components/schemas/RemoteID' + type: array + x-go-name: RemoteIDs + score: + format: double + type: number + x-go-name: Score + seasons: + items: + $ref: '#/components/schemas/SeasonBaseRecord' + type: array + x-go-name: Seasons + seasonTypes: + items: + $ref: '#/components/schemas/SeasonType' + type: array + x-go-name: Seasons + slug: + type: string + status: + $ref: '#/components/schemas/Status' + tags: + items: + $ref: '#/components/schemas/TagOption' + type: array + x-go-name: TagOptions + trailers: + items: + $ref: '#/components/schemas/Trailer' + type: array + x-go-name: Trailers + translations: + $ref: '#/components/schemas/TranslationExtended' + year: + type: string + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + SourceType: + description: source type record + properties: + id: + format: int64 + type: integer + x-go-name: ID + name: + type: string + x-go-name: Name + postfix: + type: string + prefix: + type: string + slug: + type: string + x-go-name: Slug + sort: + format: int64 + type: integer + x-go-name: Sort + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + Status: + description: status record + properties: + id: + format: int64 + type: integer + x-go-name: ID + nullable: true + keepUpdated: + type: boolean + x-go-name: KeepUpdated + name: + type: string + x-go-name: Name + recordType: + type: string + x-go-name: RecordType + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + StudioBaseRecord: + description: studio record + properties: + id: + format: int64 + type: integer + x-go-name: ID + name: + type: string + x-go-name: Name + parentStudio: + type: integer + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + Tag: + description: tag record + properties: + allowsMultiple: + type: boolean + x-go-name: AllowsMultiple + helpText: + type: string + id: + format: int64 + type: integer + x-go-name: ID + name: + type: string + x-go-name: Name + options: + items: + $ref: '#/components/schemas/TagOption' + type: array + x-go-name: TagOptions + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + TagOption: + description: tag option record + properties: + helpText: + type: string + id: + format: int64 + type: integer + x-go-name: ID + name: + type: string + x-go-name: Name + tag: + format: int64 + type: integer + x-go-name: Tag + tagName: + type: string + x-go-name: TagName + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + Trailer: + description: trailer record + properties: + id: + format: int64 + type: integer + x-go-name: ID + language: + type: string + name: + type: string + url: + type: string + runtime: + type: integer + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + Translation: + description: translation record + properties: + aliases: + items: + type: string + type: array + isAlias: + type: boolean + isPrimary: + type: boolean + language: + type: string + x-go-name: Language + name: + type: string + overview: + type: string + tagline: + type: string + description: Only populated for movie translations. We disallow taglines without a title. + type: object + x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model + TranslationSimple: + description: translation simple record + additionalProperties: + type: string + example: # Ejemplo específico del objeto + ara: "تدور قصة المسلسل حول..." + ces: "Během letu č. 815 společnosti Oceanic..." + deu: "Im Bruchteil einer Sekunde gerät das Leben..." + type: object + TranslationExtended: + description: translation extended record + properties: + nameTranslations: + items: + $ref: '#/components/schemas/Translation' + type: array + overviewTranslations: + items: + $ref: '#/components/schemas/Translation' + type: array + alias: + items: + type: string + type: array + type: object + TagOptionEntity: + description: a entity with selected tag option + type: object + properties: + name: + type: string + tagName: + type: string + tagId: + type: integer + UserInfo: + description: User info record + type: object + properties: + id: + type: integer + language: + type: string + name: + type: string + type: + type: string + Inspiration: + description: Movie inspiration record + properties: + id: + format: int64 + type: integer + x-go-name: ID + type: + type: string + type_name: + type: string + url: + type: string + InspirationType: + description: Movie inspiration type record + properties: + id: + format: int64 + type: integer + x-go-name: ID + name: + type: string + description: + type: string + reference_name: + type: string + url: + type: string + ProductionCountry: + description: Production country record + properties: + id: + format: int64 + type: integer + x-go-name: ID + country: + type: string + name: + type: string + Companies: + description: Companies by type record + properties: + studio: + type: array + items: + $ref: '#/components/schemas/Company' + network: + type: array + items: + $ref: '#/components/schemas/Company' + production: + type: array + items: + $ref: '#/components/schemas/Company' + distributor: + type: array + items: + $ref: '#/components/schemas/Company' + special_effects: + type: array + items: + $ref: '#/components/schemas/Company' + Links: + description: Links for next, previous and current record + properties: + prev: + type: string + nullable: true + self: + type: string + nullable: true + next: + type: string + total_items: + type: integer + page_size: + type: integer From c796a49f53e8b13ad0b944699987c98c74aaffb0 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 20 Jul 2024 16:23:04 +0200 Subject: [PATCH 068/166] :sparkles: Add movie types to the tvdb client --- src/tvdb/client.py | 26 +++++++++++++++++++++----- src/tvdb/models.py | 37 ++++++++++++++++++++++++++++--------- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 61cdb31..70ac7b7 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -5,8 +5,14 @@ from yarl import URL from src.settings import TVDB_API_KEY -from src.tvdb.generated_models import SearchResult, SeriesBaseRecord -from src.tvdb.models import SearchResponse, SeriesResponse +from src.tvdb.generated_models import ( + MovieBaseRecord, + MovieExtendedRecord, + SearchResult, + SeriesBaseRecord, + SeriesExtendedRecord, +) +from src.tvdb.models import MovieResponse, SearchResponse, SeriesResponse from src.utils.log import get_logger log = get_logger(__name__) @@ -51,8 +57,14 @@ async def search(self, search_query: str, limit: int = 1) -> list[SearchResult]: """ return await self.client.search(search_query, "series", limit) + @overload + async def fetch(self, *, extended: Literal[False]) -> SeriesBaseRecord: ... + + @overload + async def fetch(self, *, extended: Literal[True]) -> SeriesExtendedRecord: ... + @override - async def fetch(self, *, extended: bool = False) -> SeriesBaseRecord: + async def fetch(self, *, extended: bool = False) -> SeriesBaseRecord | SeriesExtendedRecord: """Fetch a series by its ID. :param extended: @@ -75,15 +87,19 @@ async def search(self, search_query: str, limit: int = 1) -> list[SearchResult]: """ return await self.client.search(search_query, "movie", limit) + @overload + async def fetch(self, *, extended: Literal[False]) -> MovieBaseRecord: ... + @overload + async def fetch(self, *, extended: Literal[True]) -> MovieExtendedRecord: ... @override - async def fetch(self, *, extended: bool = False) -> SeriesBaseRecord: + async def fetch(self, *, extended: bool = False) -> MovieBaseRecord | MovieExtendedRecord: """Fetch a movie by its ID. :param extended: Whether to fetch extended information. :return: """ data = await self.client.request("GET", f"movies/{self.id}" + ("/extended" if extended else "")) - return SeriesResponse(**data).data # pyright: ignore[reportCallIssue] + return MovieResponse(**data).data # pyright: ignore[reportCallIssue] class InvalidApiKeyError(Exception): diff --git a/src/tvdb/models.py b/src/tvdb/models.py index ecb0811..2f3145e 100644 --- a/src/tvdb/models.py +++ b/src/tvdb/models.py @@ -1,34 +1,53 @@ from pydantic import BaseModel -from src.tvdb.generated_models import Links, SearchResult, SeriesBaseRecord, SeriesExtendedRecord +from src.tvdb.generated_models import ( + Links, + MovieBaseRecord, + MovieExtendedRecord, + SearchResult, + SeriesBaseRecord, + SeriesExtendedRecord, +) -class Response(BaseModel): +class _Response(BaseModel): """Model for any response of the TVDB API.""" status: str -class PaginatedResponse(Response): - """Model for the response of the search endpoint of the TVDB API.""" - +class _PaginatedResponse(_Response): links: Links -class SearchResponse(PaginatedResponse): +class SearchResponse(_PaginatedResponse): """Model for the response of the search endpoint of the TVDB API.""" data: list[SearchResult] -class SeriesResponse(Response): +class SeriesResponse(_Response): """Model for the response of the series/{id} endpoint of the TVDB API.""" data: SeriesBaseRecord -class SeriesExtendedResponse(Response): +class MovieResponse(_Response): + """Model for the response of the movies/{id} endpoint of the TVDB API.""" + + data: MovieBaseRecord + + +class SeriesExtendedResponse(_Response): """Model for the response of the series/{id}/extended endpoint of the TVDB API.""" data: SeriesExtendedRecord - episodes: list[dict[str, str]] + + +class MoviesExtendedResponse(_Response): + """Model for the response of the movies/{id}/extended endpoint of the TVDB API.""" + + data: MovieExtendedRecord + + +type SearchResults = list[SearchResult] From c7ab09c4d4e7eaca00654667bb9115bbef644050 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 20 Jul 2024 16:23:54 +0200 Subject: [PATCH 069/166] :sparkles: Add tvdb_info extension --- src/bot.py | 1 + src/exts/tvdb_info/__init__.py | 3 ++ src/exts/tvdb_info/main.py | 94 ++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 src/exts/tvdb_info/__init__.py create mode 100644 src/exts/tvdb_info/main.py diff --git a/src/bot.py b/src/bot.py index 0680614..a4851df 100644 --- a/src/bot.py +++ b/src/bot.py @@ -23,6 +23,7 @@ class Bot(discord.Bot): "src.exts.error_handler", "src.exts.sudo", "src.exts.help", + "src.exts.tvdb_info", ] def __init__(self, *args: object, http_session: aiohttp.ClientSession, **kwargs: object) -> None: diff --git a/src/exts/tvdb_info/__init__.py b/src/exts/tvdb_info/__init__.py new file mode 100644 index 0000000..0401fc2 --- /dev/null +++ b/src/exts/tvdb_info/__init__.py @@ -0,0 +1,3 @@ +from .main import setup + +__all__ = ["setup"] diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py new file mode 100644 index 0000000..edc1b1a --- /dev/null +++ b/src/exts/tvdb_info/main.py @@ -0,0 +1,94 @@ +from typing import Literal + +import aiohttp +import discord +from discord import ApplicationContext, Cog, option, slash_command + +from src.bot import Bot +from src.tvdb import TvdbClient +from src.tvdb.models import SearchResults +from src.utils.log import get_logger + +log = get_logger(__name__) + + +class InfoView(discord.ui.View): + """View for displaying information about a movie or series.""" + + def __init__(self, results: SearchResults): + super().__init__(disable_on_timeout=True) + self.results: SearchResults = results + self.dropdown = discord.ui.Select( + placeholder="Not what you're looking for? Select a different result.", + options=[ + discord.SelectOption( + label=result.name or result.title or "", + value=str(i), + description=result.overview[:100] if result.overview else None, + ) + for i, result in enumerate(self.results) + ], + ) + self.dropdown.callback = self._dropdown_callback + self.add_item(self.dropdown) + self.index = 0 + + def _get_embed(self) -> discord.Embed: + result = self.results[self.index] + embed = discord.Embed( + title=result.name or result.title, description=result.overview or "No overview available." + ) + embed.set_image(url=result.image_url) + return embed + + async def _dropdown_callback(self, interaction: discord.Interaction) -> None: + if not self.dropdown.values or not isinstance(self.dropdown.values[0], str): + log.error("Dropdown values are empty or not a string but callback was triggered.") + return + self.index = int(self.dropdown.values[0]) + if not self.message: + log.error("Message is not set but callback was triggered.") + return + await self.message.edit(embed=self._get_embed(), view=self) + await interaction.response.defer() + + async def send(self, ctx: ApplicationContext) -> None: + """Send the view.""" + await ctx.respond(embed=self._get_embed(), view=self) + + +class InfoCog(Cog): + """Cog to verify the bot is working.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @slash_command() + @option("query", input_type=str, description="The query to search for.") + @option( + "type", + input_type=str, + parameter_name="entity_type", + description="The type of entity to search for.", + choices=["movie", "series"], + ) + async def search(self, ctx: ApplicationContext, query: str, entity_type: Literal["movie", "series"]) -> None: + """Test out bot's latency.""" + async with aiohttp.ClientSession() as session: + client = TvdbClient(session) + match entity_type: + case "movie": + response = await client.movies.search(query, limit=5) + case "series": + response = await client.series.search(query, limit=5) + + if not response: + await ctx.respond("No results found.") + return + view = InfoView(response) + await view.send(ctx) + + +def setup(bot: Bot) -> None: + """Register the PingCog cog.""" + bot.add_cog(InfoCog(bot)) From 5b54257e0215526e401400effd27f2ea3595ded0 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 20 Jul 2024 16:34:19 +0200 Subject: [PATCH 070/166] :sparkles: Add default search both to /search [type] --- src/exts/tvdb_info/main.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index edc1b1a..6a876f7 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -63,7 +63,10 @@ class InfoCog(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - @slash_command() + @slash_command( + name="search", + description="Search for a movie or series.", + ) @option("query", input_type=str, description="The query to search for.") @option( "type", @@ -71,9 +74,13 @@ def __init__(self, bot: Bot) -> None: parameter_name="entity_type", description="The type of entity to search for.", choices=["movie", "series"], + required=False, ) - async def search(self, ctx: ApplicationContext, query: str, entity_type: Literal["movie", "series"]) -> None: - """Test out bot's latency.""" + async def search( + self, ctx: ApplicationContext, query: str, entity_type: Literal["movie", "series"] | None = None + ) -> None: + """Search for a movie or series.""" + await ctx.defer() async with aiohttp.ClientSession() as session: client = TvdbClient(session) match entity_type: @@ -81,6 +88,8 @@ async def search(self, ctx: ApplicationContext, query: str, entity_type: Literal response = await client.movies.search(query, limit=5) case "series": response = await client.series.search(query, limit=5) + case None: + response = await client.search(query, limit=5) if not response: await ctx.respond("No results found.") From d05cdf4d809a4784058fce7e88ca6aa42b0120bb Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 20 Jul 2024 17:15:47 +0200 Subject: [PATCH 071/166] :wrench: Add settings related to THETVDB --- src/settings.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/settings.py b/src/settings.py index 93ec726..c94c0ba 100644 --- a/src/settings.py +++ b/src/settings.py @@ -6,3 +6,8 @@ FAIL_EMOJI = "❌" SUCCESS_EMOJI = "✅" + +THETVDB_COPYRIGHT_FOOTER = ( + "Metadata provided by TheTVDB. Please consider adding missing information or subscribing at " "thetvdb.com." +) +THETVDB_LOGO = "https://www.thetvdb.com/images/attribution/logo1.png" From c8839565acdcd009409e9c6c2ad03d32605cb9f3 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 20 Jul 2024 17:17:06 +0200 Subject: [PATCH 072/166] :adesive_bandage: id is not nullable. This thing made everything nullable... Oh well... --- src/tvdb/generated_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tvdb/generated_models.py b/src/tvdb/generated_models.py index d0af6a3..309c99f 100644 --- a/src/tvdb/generated_models.py +++ b/src/tvdb/generated_models.py @@ -454,7 +454,7 @@ class SearchResult(BaseModel): director: str | None = None first_air_time: str | None = None genres: list[str] | None = None - id: str | None = None + id: str image_url: str | None = None name: str | None = None is_official: bool | None = None From eca0d4832eb6bcb360ff22d1b095538118ef8d2c Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 20 Jul 2024 17:24:53 +0200 Subject: [PATCH 073/166] :lipstick: Improve UI --- src/exts/tvdb_info/main.py | 30 +++++++++++++++++++++++++++--- src/tvdb/generated_models.py | 9 +++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 6a876f7..29c141b 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -5,12 +5,16 @@ from discord import ApplicationContext, Cog, option, slash_command from src.bot import Bot +from src.settings import THETVDB_COPYRIGHT_FOOTER, THETVDB_LOGO from src.tvdb import TvdbClient from src.tvdb.models import SearchResults from src.utils.log import get_logger log = get_logger(__name__) +MOVIE_EMOJI = "🎬" +SERIES_EMOJI = "📺" + class InfoView(discord.ui.View): """View for displaying information about a movie or series.""" @@ -35,9 +39,29 @@ def __init__(self, results: SearchResults): def _get_embed(self) -> discord.Embed: result = self.results[self.index] - embed = discord.Embed( - title=result.name or result.title, description=result.overview or "No overview available." - ) + original_title = result.name + en_title = result.translations["eng"] + original_overview = result.overview + en_overview = result.overviews["eng"] + if en_overview and en_overview != original_overview: + overview = f"{en_overview}" + elif not en_overview and original_overview: + overview = f"{original_overview}\n\n*No English overview available.*" + else: + overview = "*No overview available.*" + if original_title and original_title != en_title: + title = f"{original_title} ({en_title})" + else: + title = en_title + if result.id[0] == "m": + title = f"{MOVIE_EMOJI} {title}" + url = f"https://www.thetvdb.com/movies/{result.slug}" + else: + title = f"{SERIES_EMOJI} {title}" + url = f"https://www.thetvdb.com/series/{result.slug}" + embed = discord.Embed(title=title, color=discord.Color.blurple(), url=url) + embed.add_field(name="Overview", value=overview, inline=False) + embed.set_footer(text=THETVDB_COPYRIGHT_FOOTER, icon_url=THETVDB_LOGO) embed.set_image(url=result.image_url) return embed diff --git a/src/tvdb/generated_models.py b/src/tvdb/generated_models.py index 309c99f..9e8ea23 100644 --- a/src/tvdb/generated_models.py +++ b/src/tvdb/generated_models.py @@ -286,6 +286,11 @@ class Translation(BaseModel): class TranslationSimple(RootModel[dict[str, str] | None]): root: dict[str, str] | None = None + def __getitem__(self, item: str) -> str | None: + if self.root is None: + return None + return self.root.get(item) + class TranslationExtended(BaseModel): nameTranslations: list[Translation] | None = None @@ -463,7 +468,7 @@ class SearchResult(BaseModel): objectID: str | None = None officialList: str | None = None overview: str | None = None - overviews: TranslationSimple | None = None + overviews: TranslationSimple = TranslationSimple(None) overview_translated: list[str] | None = None poster: str | None = None posters: list[str] | None = None @@ -474,7 +479,7 @@ class SearchResult(BaseModel): studios: list[str] | None = None title: str | None = None thumbnail: str | None = None - translations: TranslationSimple | None = None + translations: TranslationSimple = TranslationSimple(None) translationsWithLang: list[str] | None = None tvdb_id: str | None = None type: str | None = None From f9b9da6401159ba91ba5d479cc8aa471f1186998 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 21 Jul 2024 14:21:43 +0200 Subject: [PATCH 074/166] :coffin: Remove swagger.yml --- src/tvdb/swagger.yml | 4264 ------------------------------------------ 1 file changed, 4264 deletions(-) delete mode 100644 src/tvdb/swagger.yml diff --git a/src/tvdb/swagger.yml b/src/tvdb/swagger.yml deleted file mode 100644 index fd40947..0000000 --- a/src/tvdb/swagger.yml +++ /dev/null @@ -1,4264 +0,0 @@ -openapi: 3.0.0 -info: - description: | - Documentation of [TheTVDB](https://thetvdb.com/) API V4. All related information is linked from our [Github repo](https://github.com/thetvdb/v4-api). You might also want to use our [Postman collection] (https://www.getpostman.com/collections/7a9397ce69ff246f74d0) - ## Authentication - 1. Use the /login endpoint and provide your API key as "apikey". If you have a user-supported key, also provide your subscriber PIN as "pin". Otherwise completely remove "pin" from your call. - 2. Executing this call will provide you with a bearer token, which is valid for 1 month. - 3. Provide your bearer token for subsequent API calls by clicking Authorize below or including in the header of all direct API calls: `Authorization: Bearer [your-token]` - - ## Notes - 1. "score" is a field across almost all entities. We generate scores for different types of entities in various ways, so no assumptions should be made about the meaning of this value. It is simply used to hint at relative popularity for sorting purposes. - title: TVDB API V4 - version: 4.7.10 -security: - - bearerAuth: [ ] -paths: - /login: - post: - summary: create an auth token. The token has one month validation length. - requestBody: - content: - application/json: - schema: - type: object - required: - - apikey - properties: - apikey: - type: string - pin: - type: string - required: true - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - properties: - token: - type: string - type: object - status: - type: string - type: object - '401': - description: invalid credentials - tags: - - Login - '/artwork/{id}': - get: - description: Returns a single artwork base record. - operationId: getArtworkBase - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/ArtworkBaseRecord' - status: - type: string - type: object - '400': - description: Invalid artwork id - '401': - description: Unauthorized - '404': - description: Artwork not found - tags: - - Artwork - - '/artwork/{id}/extended': - get: - description: Returns a single artwork extended record. - operationId: getArtworkExtended - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/ArtworkExtendedRecord' - status: - type: string - type: object - '400': - description: Invalid artwork id - '401': - description: Unauthorized - '404': - description: Artwork not found - tags: - - Artwork - - '/artwork/statuses': - get: - description: Returns list of artwork status records. - operationId: getAllArtworkStatuses - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/ArtworkStatus' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - Artwork Statuses - - '/artwork/types': - get: - description: Returns a list of artworkType records - operationId: getAllArtworkTypes - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/ArtworkType' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - Artwork Types - - /awards: - get: - description: Returns a list of award base records - operationId: getAllAwards - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/AwardBaseRecord' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - Awards - - '/awards/{id}': - get: - description: Returns a single award base record - operationId: getAward - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/AwardBaseRecord' - status: - type: string - type: object - '400': - description: Invalid awards id - '401': - description: Unauthorized - '404': - description: Awards not found - tags: - - Awards - - '/awards/{id}/extended': - get: - description: Returns a single award extended record - operationId: getAwardExtended - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/AwardExtendedRecord' - status: - type: string - type: object - '400': - description: Invalid awards id - '401': - description: Unauthorized - '404': - description: Awards not found - tags: - - Awards - - '/awards/categories/{id}': - get: - description: Returns a single award category base record - operationId: getAwardCategory - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/AwardCategoryBaseRecord' - status: - type: string - type: object - '400': - description: Invalid category id - '401': - description: Unauthorized - '404': - description: Category not found - tags: - - Award Categories - - '/awards/categories/{id}/extended': - get: - description: Returns a single award category extended record - operationId: getAwardCategoryExtended - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/AwardCategoryExtendedRecord' - status: - type: string - type: object - '400': - description: Invalid category id - '401': - description: Unauthorized - '404': - description: Category not found - tags: - - Award Categories - - '/characters/{id}': - get: - description: Returns character base record - operationId: getCharacterBase - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/Character' - status: - type: string - type: object - '400': - description: Invalid character id - '401': - description: Unauthorized - '404': - description: Character not found - tags: - - Characters - /companies: - get: - description: returns a paginated list of company records - operationId: getAllCompanies - parameters: - - description: name - in: query - name: page - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/Company' - type: array - status: - type: string - links: - $ref: '#/components/schemas/Links' - type: object - '401': - description: Unauthorized - tags: - - Companies - '/companies/types': - get: - description: returns all company type records - operationId: getCompanyTypes - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - type: array - items: - $ref: '#/components/schemas/CompanyType' - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - Companies - '/companies/{id}': - get: - description: returns a company record - operationId: getCompany - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/Company' - status: - type: string - type: object - '400': - description: Invalid company id - '401': - description: Unauthorized - '404': - description: Company not found - tags: - - Companies - /content/ratings: - get: - description: returns list content rating records - operationId: getAllContentRatings - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/ContentRating' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - Content Ratings - /countries: - get: - description: returns list of country records - operationId: getAllCountries - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/Country' - type: array - status: - type: string - type: object - tags: - - Countries - '/entities': - get: - description: returns the active entity types - operationId: getEntityTypes - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/EntityType' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - Entity Types - '/episodes': - get: - description: Returns a list of episodes base records with the basic attributes.
Note that all episodes are returned, even those that may not be included in a series' default season order. - operationId: getAllEpisodes - parameters: - - description: page number - in: query - name: page - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/EpisodeBaseRecord' - type: array - status: - type: string - links: - $ref: '#/components/schemas/Links' - type: object - '401': - description: Unauthorized - tags: - - Episodes - '/episodes/{id}': - get: - description: Returns episode base record - operationId: getEpisodeBase - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/EpisodeBaseRecord' - status: - type: string - type: object - '400': - description: Invalid episode id - '401': - description: Unauthorized - '404': - description: Episode not found - tags: - - Episodes - '/episodes/{id}/extended': - get: - description: Returns episode extended record - operationId: getEpisodeExtended - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - - description: meta - in: query - name: meta - required: false - schema: - type: string - enum: [ translations ] - example: translations - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/EpisodeExtendedRecord' - status: - type: string - type: object - '400': - description: Invalid episode id - '401': - description: Unauthorized - '404': - description: Episode not found - tags: - - Episodes - '/episodes/{id}/translations/{language}': - get: - description: Returns episode translation record - operationId: getEpisodeTranslation - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - - description: language - in: path - name: language - required: true - schema: - type: string - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/Translation' - status: - type: string - type: object - '400': - description: Invalid episode id. Invalid language. - '401': - description: Unauthorized - '404': - description: Episode not found - tags: - - Episodes - - /genders: - get: - description: returns list of gender records - operationId: getAllGenders - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/Gender' - type: array - status: - type: string - type: object - tags: - - Genders - - /genres: - get: - description: returns list of genre records - operationId: getAllGenres - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/GenreBaseRecord' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - - tags: - - Genres - - '/genres/{id}': - get: - description: Returns genre record - operationId: getGenreBase - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/GenreBaseRecord' - status: - type: string - type: object - '400': - description: Invalid genre id - '401': - description: Unauthorized - '404': - description: Genre not found - tags: - - Genres - /inspiration/types: - get: - description: returns list of inspiration types records - operationId: getAllInspirationTypes - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/InspirationType' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - InspirationTypes - /languages: - get: - description: returns list of language records - operationId: getAllLanguages - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/Language' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - Languages - /lists: - get: - description: returns list of list base records - operationId: getAllLists - parameters: - - description: page number - in: query - name: page - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/ListBaseRecord' - type: array - status: - type: string - links: - $ref: '#/components/schemas/Links' - '401': - description: Unauthorized - tags: - - Lists - - '/lists/{id}': - get: - description: returns an list base record - operationId: getList - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/ListBaseRecord' - status: - type: string - type: object - '400': - description: Invalid list id - '401': - description: Unauthorized - '404': - description: List not found - tags: - - Lists - '/lists/slug/{slug}': - get: - description: returns an list base record search by slug - operationId: getListBySlug - parameters: - - description: slug - in: path - name: slug - required: true - schema: - type: string - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/ListBaseRecord' - status: - type: string - type: object - '400': - description: Invalid list slug - '401': - description: Unauthorized - '404': - description: List not found - tags: - - Lists - '/lists/{id}/extended': - get: - description: returns a list extended record - operationId: getListExtended - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/ListExtendedRecord' - status: - type: string - type: object - '400': - description: Invalid list id - '401': - description: Unauthorized - '404': - description: Lists not found - tags: - - Lists - '/lists/{id}/translations/{language}': - get: - description: Returns list translation record - operationId: getListTranslation - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - - description: language - in: path - name: language - required: true - schema: - type: string - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/Translation' - type: array - status: - type: string - type: object - '400': - description: Invalid lists id - '401': - description: Unauthorized - '404': - description: Lists not found - tags: - - Lists - - /movies: - get: - description: returns list of movie base records - operationId: getAllMovie - parameters: - - description: page number - in: query - name: page - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/MovieBaseRecord' - type: array - status: - type: string - links: - $ref: '#/components/schemas/Links' - type: object - '401': - description: Unauthorized - tags: - - Movies - '/movies/{id}': - get: - description: Returns movie base record - operationId: getMovieBase - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/MovieBaseRecord' - status: - type: string - type: object - '400': - description: Invalid movie id - '401': - description: Unauthorized - '404': - description: Movie not found - tags: - - Movies - '/movies/{id}/extended': - get: - description: Returns movie extended record - operationId: getMovieExtended - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - - description: meta - in: query - name: meta - required: false - schema: - type: string - enum: [ translations ] - example: translations - - description: reduce the payload and returns the short version of this record without characters, artworks and trailers. - in: query - name: short - required: false - schema: - type: boolean - enum: [ true, false ] - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/MovieExtendedRecord' - status: - type: string - type: object - '400': - description: Invalid movie id - '401': - description: Unauthorized - '404': - description: Movie not found - tags: - - Movies - '/movies/filter': - get: - description: Search movies based on filter parameters - operationId: getMoviesFilter - parameters: - - description: production company - in: query - name: company - required: false - schema: - type: number - example: 1 - - description: content rating id base on a country - in: query - name: contentRating - required: false - schema: - type: number - example: 245 - - description: country of origin - in: query - name: country - required: true - schema: - type: string - example: usa - - description: genre - in: query - name: genre - required: false - schema: - type: number - example: 3 - enum: [ 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36 ] - - description: original language - in: query - name: lang - required: true - schema: - type: string - example: eng - - description: sort by results - in: query - name: sort - required: false - schema: - type: string - enum: [ score,firstAired,name ] - - description: status - in: query - name: status - required: false - schema: - type: number - enum: [ 1,2,3 ] - - description: release year - in: query - name: year - required: false - schema: - type: number - example: 2020 - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/MovieBaseRecord' - type: array - status: - type: string - type: object - '400': - description: Invalid format parameter. - '401': - description: Unauthorized - tags: - - Movies - '/movies/slug/{slug}': - get: - description: Returns movie base record search by slug - operationId: getMovieBaseBySlug - parameters: - - description: slug - in: path - name: slug - required: true - schema: - type: string - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/MovieBaseRecord' - status: - type: string - type: object - '400': - description: Invalid movie slug - '401': - description: Unauthorized - '404': - description: Movie not found - tags: - - Movies - '/movies/{id}/translations/{language}': - get: - description: Returns movie translation record - operationId: getMovieTranslation - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - - description: language - in: path - name: language - required: true - schema: - type: string - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/Translation' - status: - type: string - type: object - '400': - description: Invalid movie id, invalid language. - '401': - description: Unauthorized - '404': - description: Movie not found - tags: - - Movies - /movies/statuses: - get: - description: returns list of status records - operationId: getAllMovieStatuses - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/Status' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - Movie Statuses - '/people': - get: - description: Returns a list of people base records with the basic attributes. - operationId: getAllPeople - parameters: - - description: page number - in: query - name: page - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/PeopleBaseRecord' - type: array - status: - type: string - links: - $ref: '#/components/schemas/Links' - type: object - '401': - description: Unauthorized - tags: - - People - - '/people/{id}': - get: - description: Returns people base record - operationId: getPeopleBase - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/PeopleBaseRecord' - status: - type: string - type: object - '400': - description: Invalid people id - '401': - description: Unauthorized - '404': - description: People not found - tags: - - People - '/people/{id}/extended': - get: - description: Returns people extended record - operationId: getPeopleExtended - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - - description: meta - in: query - name: meta - required: false - schema: - type: string - enum: [ translations ] - example: translations - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/PeopleExtendedRecord' - status: - type: string - type: object - '400': - description: Invalid people id - '401': - description: Unauthorized - '404': - description: People not found - tags: - - People - '/people/{id}/translations/{language}': - get: - description: Returns people translation record - operationId: getPeopleTranslation - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - - description: language - in: path - name: language - required: true - schema: - type: string - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/Translation' - status: - type: string - type: object - '400': - description: Invalid people id, invalid language. - '401': - description: Unauthorized - '404': - description: People not found - tags: - - People - /people/types: - get: - description: returns list of peopleType records - operationId: getAllPeopleTypes - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/PeopleType' - type: array - status: - type: string - type: object - tags: - - People Types - - /search: - get: - description: Our search index includes series, movies, people, and companies. Search is limited to 5k results max. - operationId: getSearchResults - parameters: - - description: The primary search string, which can include the main title for a record including all translations and aliases. - in: query - name: query - schema: - type: string - - description: Alias of the "query" parameter. Recommend using query instead as this field will eventually be deprecated. - in: query - name: q - schema: - type: string - - description: Restrict results to a specific entity type. Can be movie, series, person, or company. - in: query - name: type - schema: - type: string - - description: Restrict results to a specific year. Currently only used for series and movies. - in: query - name: year - schema: - type: number - - description: Restrict results to a specific company (original network, production company, studio, etc). As an example, "The Walking Dead" would have companies of "AMC", "AMC+", and "Disney+". - in: query - name: company - schema: - type: string - - description: Restrict results to a specific country of origin. Should contain a 3 character country code. Currently only used for series and movies. - in: query - name: country - schema: - type: string - - description: Restrict results to a specific director. Generally only used for movies. Should include the full name of the director, such as "Steven Spielberg". - in: query - name: director - schema: - type: string - - description: Restrict results to a specific primary language. Should include the 3 character language code. Currently only used for series and movies. - in: query - name: language - schema: - type: string - - description: Restrict results to a specific type of company. Should include the full name of the type of company, such as "Production Company". Only used for companies. - in: query - name: primaryType - schema: - type: string - - description: Restrict results to a specific network. Used for TV and TV movies, and functions the same as the company parameter with more specificity. - in: query - name: network - schema: - type: string - - - - description: Search for a specific remote id. Allows searching for an IMDB or EIDR id, for example. - in: query - name: remote_id - schema: - type: string - - - description: Offset results. - in: query - name: offset - schema: - type: number - - description: Limit results. - in: query - name: limit - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/SearchResult' - type: array - status: - type: string - links: - $ref: '#/components/schemas/Links' - type: object - '401': - description: Unauthorized - '400': - description: Max results overflow - tags: - - Search - /search/remoteid/{remoteId}: - get: - description: Search a series, movie, people, episode, company or season by specific remote id and returns a base record for that entity. - operationId: getSearchResultsByRemoteId - parameters: - - description: Search for a specific remote id. Allows searching for an IMDB or EIDR id, for example. - in: path - required: true - name: remoteId - schema: - type: string - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/SearchByRemoteIdResult' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - Search - - /seasons: - get: - description: returns list of seasons base records - operationId: getAllSeasons - parameters: - - description: page number - in: query - name: page - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/SeasonBaseRecord' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - Seasons - '/seasons/{id}': - get: - description: Returns season base record - operationId: getSeasonBase - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/SeasonBaseRecord' - status: - type: string - type: object - '400': - description: Invalid season id - '401': - description: Unauthorized - '404': - description: Season not found - tags: - - Seasons - '/seasons/{id}/extended': - get: - description: Returns season extended record - operationId: getSeasonExtended - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/SeasonExtendedRecord' - status: - type: string - type: object - '400': - description: Invalid seasons id - '401': - description: Unauthorized - '404': - description: Season not found - tags: - - Seasons - '/seasons/types': - get: - description: Returns season type records - operationId: getSeasonTypes - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/SeasonType' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - Seasons - '/seasons/{id}/translations/{language}': - get: - description: Returns season translation record - operationId: getSeasonTranslation - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - - description: language - in: path - name: language - required: true - schema: - type: string - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/Translation' - status: - type: string - type: object - '400': - description: Invalid season id, language not found. - '401': - description: Unauthorized - '404': - description: Season not found - tags: - - Seasons - /series: - get: - description: returns list of series base records - operationId: getAllSeries - parameters: - - description: page number - in: query - name: page - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/SeriesBaseRecord' - type: array - status: - type: string - links: - $ref: '#/components/schemas/Links' - type: object - '401': - description: Unauthorized - tags: - - Series - '/series/{id}': - get: - description: Returns series base record - operationId: getSeriesBase - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/SeriesBaseRecord' - status: - type: string - type: object - '400': - description: Invalid series id - '401': - description: Unauthorized - '404': - description: Series not found - tags: - - Series - '/series/{id}/artworks': - get: - description: Returns series artworks base on language and type.
Note: Artwork type is an id that can be found using **/artwork/types** endpoint. - operationId: getSeriesArtworks - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - - description: lang - in: query - name: lang - required: false - schema: - type: string - example: eng, spa - - description: type - in: query - name: type - required: false - schema: - type: integer - example: 1,2,3 - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/SeriesExtendedRecord' - status: - type: string - type: object - '400': - description: Invalid series id - '401': - description: Unauthorized - '404': - description: Series not found - tags: - - Series - '/series/{id}/nextAired': - get: - description: Returns series base record including the nextAired field.
Note: nextAired was included in the base record endpoint but that field will deprecated in the future so developers should use the nextAired endpoint. - operationId: getSeriesNextAired - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/SeriesBaseRecord' - status: - type: string - type: object - '400': - description: Invalid series id - '401': - description: Unauthorized - '404': - description: Series not found - tags: - - Series - '/series/{id}/extended': - get: - description: Returns series extended record - operationId: getSeriesExtended - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - - description: meta - in: query - name: meta - required: false - schema: - type: string - enum: [ translations, episodes ] - example: translations - - description: reduce the payload and returns the short version of this record without characters and artworks - in: query - name: short - required: false - schema: - type: boolean - enum: [ true, false ] - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/SeriesExtendedRecord' - status: - type: string - type: object - '400': - description: Invalid series id - '401': - description: Unauthorized - '404': - description: Series not found - tags: - - Series - '/series/{id}/episodes/{season-type}': - get: - description: Returns series episodes from the specified season type, default returns the episodes in the series default season type - operationId: getSeriesEpisodes - parameters: - - in: query - name: page - required: true - schema: - type: integer - default: 0 - - description: id - in: path - name: id - required: true - schema: - type: number - - description: season-type - in: path - name: season-type - required: true - schema: - type: string - examples: - default: - value: default - official: - value: official - dvd: - value: dvd - absolute: - value: absolute - alternate: - value: alternate - regional: - value: regional - - in: query - name: season - required: false - schema: - type: integer - default: 0 - - in: query - name: episodeNumber - required: false - schema: - type: integer - default: 0 - - description: airDate of the episode, format is yyyy-mm-dd - in: query - name: airDate - required: false - schema: - type: string - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - type: object - properties: - series: - $ref: '#/components/schemas/SeriesBaseRecord' - episodes: - type: array - items: - $ref: '#/components/schemas/EpisodeBaseRecord' - status: - type: string - type: object - '400': - description: Invalid series id, episodeNumber is not null then season must be present - '401': - description: Unauthorized - '404': - description: Series not found - tags: - - Series - '/series/{id}/episodes/{season-type}/{lang}': - get: - description: Returns series base record with episodes from the specified season type and language. Default returns the episodes in the series default season type. - operationId: getSeriesSeasonEpisodesTranslated - parameters: - - in: query - name: page - required: true - schema: - type: integer - default: 0 - - description: id - in: path - name: id - required: true - schema: - type: number - - description: season-type - in: path - name: season-type - required: true - schema: - type: string - examples: - default: - value: default - official: - value: official - dvd: - value: dvd - absolute: - value: absolute - alternate: - value: alternate - regional: - value: regional - - in: path - name: lang - required: true - schema: - type: string - - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - type: object - properties: - series: - $ref: '#/components/schemas/SeriesBaseRecord' - status: - type: string - type: object - '400': - description: Invalid series id, invalid language. - '401': - description: Unauthorized - '404': - description: Series not found - tags: - - Series - '/series/filter': - get: - description: Search series based on filter parameters - operationId: getSeriesFilter - parameters: - - description: production company - in: query - name: company - required: false - schema: - type: number - example: 1 - - description: content rating id base on a country - in: query - name: contentRating - required: false - schema: - type: number - example: 245 - - description: country of origin - in: query - name: country - required: true - schema: - type: string - example: usa - - description: Genre id. This id can be found using **/genres** endpoint. - in: query - name: genre - required: false - schema: - type: number - example: 3 - enum: [ 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36 ] - - description: original language - in: query - name: lang - required: true - schema: - type: string - example: eng - - description: sort by results - in: query - name: sort - required: false - schema: - type: string - enum: [ score,firstAired,lastAired,name ] - - description: sort type ascending or descending - in: query - name: sortType - required: false - schema: - type: string - enum: [ asc,desc ] - - description: status - in: query - name: status - required: false - schema: - type: number - enum: [ 1,2,3 ] - - description: release year - in: query - name: year - required: false - schema: - type: number - example: 2020 - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/SeriesBaseRecord' - type: array - type: object - '400': - description: Invalid format parameter. - '401': - description: Unauthorized - tags: - - Series - '/series/slug/{slug}': - get: - description: Returns series base record searched by slug - operationId: getSeriesBaseBySlug - parameters: - - description: slug - in: path - name: slug - required: true - schema: - type: string - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/SeriesBaseRecord' - status: - type: string - type: object - '400': - description: Invalid series slug - '401': - description: Unauthorized - '404': - description: Series not found - tags: - - Series - '/series/{id}/translations/{language}': - get: - description: Returns series translation record - operationId: getSeriesTranslation - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - - description: language - in: path - name: language - required: true - schema: - type: string - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/Translation' - status: - type: string - type: object - '400': - description: Invalid series id, invalid language. - '401': - description: Unauthorized - '404': - description: Series not found - tags: - - Series - /series/statuses: - get: - description: returns list of status records - operationId: getAllSeriesStatuses - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/Status' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - Series Statuses - /sources/types: - get: - description: returns list of sourceType records - operationId: getAllSourceTypes - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/SourceType' - type: array - status: - type: string - type: object - '401': - description: Unauthorized - tags: - - Source Types - /updates: - get: - description: Returns updated entities. methodInt indicates a created record (1), an updated record (2), or a deleted record (3). If a record is deleted because it was a duplicate of another record, the target record's information is provided in mergeToType and mergeToId. - operationId: updates - parameters: - - in: query - name: since - required: true - schema: - type: number - - in: query - name: type - required: false - schema: - type: string - enum: [ artwork,award_nominees,companies,episodes,lists,people,seasons,series,seriespeople,artworktypes,award_categories,awards,company_types,content_ratings,countries,entity_types,genres,languages,movies,movie_genres,movie_status,peopletypes,seasontypes,sourcetypes,tag_options,tags,translatedcharacters,translatedcompanies,translatedepisodes,translatedlists,translatedmovies,translatedpeople,translatedseasons,translatedserierk ] - example: movies - - in: query - name: action - required: false - schema: - type: string - enum: [ delete, update ] - example: movies - - description: name - in: query - name: page - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/EntityUpdate' - type: array - status: - type: string - links: - $ref: '#/components/schemas/Links' - type: object - '400': - description: Invalid since, type param. - '401': - description: Unauthorized - - tags: - - Updates - /user: - get: - description: returns user info - operationId: getUserInfo - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/UserInfo' - status: - type: string - type: object - '401': - description: Unauthorized - - tags: - - User info - /user/{id}: - get: - description: returns user info by user id - operationId: getUserInfoById - parameters: - - description: id - in: path - name: id - required: true - schema: - type: number - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/UserInfo' - status: - type: string - type: object - '401': - description: Unauthorized - - tags: - - User info - /user/favorites: - get: - description: returns user favorites - operationId: getUserFavorites - responses: - '200': - description: response - content: - application/json: - schema: - properties: - data: - items: - $ref: '#/components/schemas/Favorites' - status: - type: string - type: object - '401': - description: Unauthorized - - tags: - - Favorites - post: - description: creates a new user favorite - operationId: createUserFavorites - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/FavoriteRecord' - responses: - '200': - description: Ok - '400': - description: Bad format - '401': - description: Unauthorized - - tags: - - Favorites - -servers: - - url: 'https://api4.thetvdb.com/v4' -components: - securitySchemes: - bearerAuth: # arbitrary name for the security scheme - type: http - scheme: bearer - bearerFormat: JWT - schemas: - Alias: - description: An alias model, which can be associated with a series, season, movie, person, or list. - properties: - language: - type: string - maximum: 4 - description: A 3-4 character string indicating the language of the alias, as defined in Language. - name: - type: string - maximum: 100 - description: A string containing the alias itself. - type: object - ArtworkBaseRecord: - description: base artwork record - properties: - height: - format: int64 - type: integer - x-go-name: Height - id: - type: integer - image: - type: string - x-go-name: Image - includesText: - type: boolean - language: - type: string - score: - type: number - thumbnail: - type: string - x-go-name: Thumbnail - type: - format: int64 - type: integer - x-go-name: Type - description: The artwork type corresponds to the ids from the /artwork/types endpoint. - width: - format: int64 - type: integer - x-go-name: Width - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - ArtworkExtendedRecord: - description: extended artwork record - properties: - episodeId: - type: integer - height: - format: int64 - type: integer - x-go-name: Height - id: - format: int64 - type: integer - x-go-name: ID - image: - type: string - x-go-name: Image - includesText: - type: boolean - language: - type: string - movieId: - type: integer - networkId: - type: integer - peopleId: - type: integer - score: - type: number - seasonId: - type: integer - seriesId: - type: integer - seriesPeopleId: - type: integer - status: - $ref: '#/components/schemas/ArtworkStatus' - tagOptions: - items: - $ref: '#/components/schemas/TagOption' - type: array - x-go-name: TagOptions - thumbnail: - type: string - x-go-name: Thumbnail - thumbnailHeight: - format: int64 - type: integer - x-go-name: ThumbnailHeight - thumbnailWidth: - format: int64 - type: integer - x-go-name: ThumbnailWidth - type: - format: int64 - type: integer - x-go-name: Type - description: The artwork type corresponds to the ids from the /artwork/types endpoint. - updatedAt: - format: int64 - type: integer - x-go-name: UpdatedAt - width: - format: int64 - type: integer - x-go-name: Width - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - ArtworkStatus: - description: artwork status record - properties: - id: - format: int64 - type: integer - x-go-name: ID - name: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - ArtworkType: - description: artwork type record - properties: - height: - format: int64 - type: integer - id: - format: int64 - type: integer - x-go-name: ID - imageFormat: - type: string - x-go-name: ImageFormat - name: - type: string - x-go-name: Name - recordType: - type: string - x-go-name: RecordType - slug: - type: string - x-go-name: Slug - thumbHeight: - format: int64 - type: integer - x-go-name: ThumbHeight - thumbWidth: - format: int64 - type: integer - x-go-name: ThumbWidth - width: - format: int64 - type: integer - x-go-name: Width - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - AwardBaseRecord: - description: base award record - properties: - id: - type: integer - name: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - AwardCategoryBaseRecord: - description: base award category record - properties: - allowCoNominees: - type: boolean - x-go-name: AllowCoNominees - award: - $ref: '#/components/schemas/AwardBaseRecord' - forMovies: - type: boolean - x-go-name: ForMovies - forSeries: - type: boolean - x-go-name: ForSeries - id: - format: int64 - type: integer - x-go-name: ID - name: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - AwardCategoryExtendedRecord: - description: extended award category record - properties: - allowCoNominees: - type: boolean - x-go-name: AllowCoNominees - award: - $ref: '#/components/schemas/AwardBaseRecord' - forMovies: - type: boolean - x-go-name: ForMovies - forSeries: - type: boolean - x-go-name: ForSeries - id: - format: int64 - type: integer - x-go-name: ID - name: - type: string - nominees: - items: - $ref: '#/components/schemas/AwardNomineeBaseRecord' - type: array - x-go-name: Nominees - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - AwardExtendedRecord: - description: extended award record - properties: - categories: - items: - $ref: '#/components/schemas/AwardCategoryBaseRecord' - type: array - x-go-name: Categories - id: - type: integer - name: - type: string - score: - format: int64 - type: integer - x-go-name: Score - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - AwardNomineeBaseRecord: - description: base award nominee record - properties: - character: - $ref: '#/components/schemas/Character' - details: - type: string - episode: - $ref: '#/components/schemas/EpisodeBaseRecord' - id: - format: int64 - type: integer - x-go-name: ID - isWinner: - type: boolean - x-go-name: IsWinner - movie: - $ref: '#/components/schemas/MovieBaseRecord' - series: - $ref: '#/components/schemas/SeriesBaseRecord' - year: - type: string - category: - type: string - name: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - Biography: - description: biography record - properties: - biography: - type: string - x-go-name: Biography - language: - type: string - x-go-name: Language - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - Character: - description: character record - properties: - aliases: - items: - $ref: '#/components/schemas/Alias' - type: array - x-go-name: Aliases - episode: - $ref: '#/components/schemas/RecordInfo' - episodeId: - type: integer - nullable: true - id: - format: int64 - type: integer - x-go-name: ID - image: - type: string - isFeatured: - type: boolean - x-go-name: IsFeatured - movieId: - type: integer - nullable: true - movie: - $ref: '#/components/schemas/RecordInfo' - name: - type: string - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - peopleId: - type: integer - personImgURL: - type: string - peopleType: - type: string - seriesId: - type: integer - nullable: true - series: - $ref: '#/components/schemas/RecordInfo' - sort: - format: int64 - type: integer - x-go-name: Sort - tagOptions: - items: - $ref: '#/components/schemas/TagOption' - type: array - x-go-name: TagOptions - type: - format: int64 - type: integer - x-go-name: Type - url: - type: string - x-go-name: URL - personName: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - Company: - description: A company record - properties: - activeDate: - type: string - aliases: - items: - $ref: '#/components/schemas/Alias' - type: array - x-go-name: Aliases - country: - type: string - id: - format: int64 - type: integer - x-go-name: ID - inactiveDate: - type: string - name: - type: string - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - primaryCompanyType: - format: int64 - type: integer - x-go-name: PrimaryCompanyType - nullable: true - slug: - type: string - x-go-name: Slug - parentCompany: - type: object - $ref: '#/components/schemas/ParentCompany' - tagOptions: - items: - $ref: '#/components/schemas/TagOption' - type: array - x-go-name: TagOptions - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - ParentCompany: - description: A parent company record - type: object - properties: - id: - type: integer - nullable: true - name: - type: string - relation: - type: object - $ref: '#/components/schemas/CompanyRelationShip' - CompanyRelationShip: - description: A company relationship - properties: - id: - type: integer - nullable: true - typeName: - type: string - CompanyType: - description: A company type record - type: object - properties: - companyTypeId: - type: integer - companyTypeName: - type: string - ContentRating: - description: content rating record - properties: - id: - format: int64 - type: integer - x-go-name: ID - name: - type: string - x-go-name: Name - description: - type: string - country: - type: string - contentType: - type: string - order: - type: integer - fullName: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - Country: - description: country record - properties: - id: - type: string - x-go-name: ID - name: - type: string - x-go-name: Name - shortCode: - type: string - x-go-name: ShortCode - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - Entity: - description: Entity record - properties: - movieId: - type: integer - order: - format: int64 - type: integer - x-go-name: Order - seriesId: - type: integer - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - EntityType: - description: Entity Type record - properties: - id: - type: integer - name: - type: string - x-go-name: Order - hasSpecials: - type: boolean - type: object - EntityUpdate: - description: entity update record - properties: - entityType: - type: string - x-go-name: EnitityType - methodInt: - type: integer - method: - type: string - x-go-name: Method - extraInfo: - type: string - userId: - type: integer - recordType: - type: string - recordId: - format: int64 - type: integer - x-go-name: RecordID - timeStamp: - format: int64 - type: integer - x-go-name: TimeStamp - seriesId: - description: Only present for episodes records - format: int64 - type: integer - x-go-name: RecordID - mergeToId: - format: int64 - type: integer - mergeToEntityType: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - EpisodeBaseRecord: - description: base episode record - properties: - absoluteNumber: - type: integer - aired: - type: string - airsAfterSeason: - type: integer - airsBeforeEpisode: - type: integer - airsBeforeSeason: - type: integer - finaleType: - description: season, midseason, or series - type: string - id: - format: int64 - type: integer - x-go-name: ID - image: - type: string - imageType: - type: integer - nullable: true - isMovie: - format: int64 - type: integer - x-go-name: IsMovie - lastUpdated: - type: string - linkedMovie: - type: integer - name: - type: string - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - number: - type: integer - overview: - type: string - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - runtime: - type: integer - nullable: true - seasonNumber: - type: integer - seasons: - items: - $ref: '#/components/schemas/SeasonBaseRecord' - type: array - x-go-name: Seasons - seriesId: - format: int64 - type: integer - x-go-name: SeriesID - seasonName: - type: string - year: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - EpisodeExtendedRecord: - description: extended episode record - properties: - aired: - type: string - airsAfterSeason: - type: integer - airsBeforeEpisode: - type: integer - airsBeforeSeason: - type: integer - awards: - items: - $ref: '#/components/schemas/AwardBaseRecord' - type: array - x-go-name: Awards - characters: - items: - $ref: '#/components/schemas/Character' - type: array - x-go-name: Characters - companies: - items: - $ref: '#/components/schemas/Company' - type: array - contentRatings: - items: - $ref: '#/components/schemas/ContentRating' - type: array - x-go-name: ContentRatings - finaleType: - description: season, midseason, or series - type: string - id: - format: int64 - type: integer - x-go-name: ID - image: - type: string - imageType: - type: integer - nullable: true - isMovie: - format: int64 - type: integer - x-go-name: IsMovie - lastUpdated: - type: string - linkedMovie: - type: integer - name: - type: string - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - networks: - items: - $ref: '#/components/schemas/Company' - type: array - nominations: - items: - $ref: '#/components/schemas/AwardNomineeBaseRecord' - type: array - x-go-name: Nominees - number: - type: integer - overview: - type: string - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - productionCode: - type: string - remoteIds: - items: - $ref: '#/components/schemas/RemoteID' - type: array - x-go-name: RemoteIDs - runtime: - type: integer - nullable: true - seasonNumber: - type: integer - seasons: - items: - $ref: '#/components/schemas/SeasonBaseRecord' - type: array - x-go-name: Seasons - seriesId: - format: int64 - type: integer - x-go-name: SeriesID - studios: - items: - $ref: '#/components/schemas/Company' - type: array - tagOptions: - items: - $ref: '#/components/schemas/TagOption' - type: array - x-go-name: TagOptions - trailers: - items: - $ref: '#/components/schemas/Trailer' - type: array - x-go-name: Trailers - translations: - $ref: '#/components/schemas/TranslationExtended' - year: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - Favorites: - description: User favorites record - properties: - series: - items: - type: integer - type: array - x-go-name: series - movies: - items: - type: integer - type: array - x-go-name: movies - episodes: - items: - type: integer - type: array - x-go-name: episodes - artwork: - items: - type: integer - type: array - x-go-name: artwork - people: - items: - type: integer - type: array - x-go-name: people - lists: - items: - type: integer - type: array - x-go-name: list - FavoriteRecord: - description: Favorites record - properties: - series: - type: integer - x-go-name: series - movie: - type: integer - x-go-name: movies - episode: - type: integer - x-go-name: episodes - artwork: - type: integer - x-go-name: artwork - people: - type: integer - x-go-name: people - list: - type: integer - x-go-name: list - Gender: - description: gender record - properties: - id: - format: int64 - type: integer - x-go-name: ID - name: - type: string - x-go-name: Name - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - GenreBaseRecord: - description: base genre record - properties: - id: - format: int64 - type: integer - x-go-name: ID - name: - type: string - x-go-name: Name - slug: - type: string - x-go-name: Slug - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - Language: - description: language record - properties: - id: - type: string - x-go-name: ID - name: - type: string - x-go-name: Name - nativeName: - type: string - x-go-name: NativeName - shortCode: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - ListBaseRecord: - description: base list record - properties: - aliases: - items: - $ref: '#/components/schemas/Alias' - type: array - x-go-name: Aliases - id: - format: int64 - type: integer - x-go-name: ID - image: - type: string - imageIsFallback: - type: boolean - isOfficial: - type: boolean - x-go-name: IsOfficial - name: - type: string - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - overview: - type: string - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - remoteIds: - items: - $ref: '#/components/schemas/RemoteID' - type: array - x-go-name: RemoteIDs - tags: - items: - $ref: '#/components/schemas/TagOption' - type: array - x-go-name: TagOptions - score: - type: integer - url: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - ListExtendedRecord: - description: extended list record - properties: - aliases: - items: - $ref: '#/components/schemas/Alias' - type: array - x-go-name: Aliases - entities: - items: - $ref: '#/components/schemas/Entity' - type: array - x-go-name: Entities - id: - format: int64 - type: integer - x-go-name: ID - image: - type: string - imageIsFallback: - type: boolean - isOfficial: - type: boolean - x-go-name: IsOfficial - name: - type: string - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - overview: - type: string - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - score: - format: int64 - type: integer - x-go-name: Score - url: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - MovieBaseRecord: - description: base movie record - properties: - aliases: - items: - $ref: '#/components/schemas/Alias' - type: array - x-go-name: Aliases - id: - format: int64 - type: integer - x-go-name: ID - image: - type: string - x-go-name: Image - lastUpdated: - type: string - name: - type: string - x-go-name: Name - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - score: - format: double - type: number - x-go-name: Score - slug: - type: string - x-go-name: Slug - status: - $ref: '#/components/schemas/Status' - runtime: - type: integer - nullable: true - year: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - MovieExtendedRecord: - description: extended movie record - properties: - aliases: - items: - $ref: '#/components/schemas/Alias' - type: array - x-go-name: Aliases - artworks: - items: - $ref: '#/components/schemas/ArtworkBaseRecord' - type: array - x-go-name: Artworks - audioLanguages: - items: - type: string - type: array - x-go-name: AudioLanguages - awards: - items: - $ref: '#/components/schemas/AwardBaseRecord' - type: array - x-go-name: Awards - boxOffice: - type: string - boxOfficeUS: - type: string - budget: - type: string - characters: - items: - $ref: '#/components/schemas/Character' - type: array - x-go-name: Characters - companies: - type: object - $ref: '#/components/schemas/Companies' - contentRatings: - items: - $ref: '#/components/schemas/ContentRating' - type: array - first_release: - type: object - $ref: '#/components/schemas/Release' - genres: - items: - $ref: '#/components/schemas/GenreBaseRecord' - type: array - x-go-name: Genres - id: - format: int64 - type: integer - x-go-name: ID - image: - type: string - x-go-name: Image - inspirations: - items: - $ref: '#/components/schemas/Inspiration' - type: array - x-go-name: Inspirations - lastUpdated: - type: string - lists: - items: - $ref: '#/components/schemas/ListBaseRecord' - type: array - name: - type: string - x-go-name: Name - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - originalCountry: - type: string - originalLanguage: - type: string - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - production_countries: - items: - $ref: '#/components/schemas/ProductionCountry' - type: array - x-go-name: ProductionCountries - releases: - items: - $ref: '#/components/schemas/Release' - type: array - x-go-name: Releases - remoteIds: - items: - $ref: '#/components/schemas/RemoteID' - type: array - x-go-name: RemoteIDs - runtime: - type: integer - nullable: true - score: - format: double - type: number - x-go-name: Score - slug: - type: string - x-go-name: Slug - spoken_languages: - items: - type: string - type: array - x-go-name: SpokenLanguages - status: - $ref: '#/components/schemas/Status' - studios: - items: - $ref: '#/components/schemas/StudioBaseRecord' - type: array - x-go-name: Studios - subtitleLanguages: - items: - type: string - type: array - x-go-name: SubtitleLanguages - tagOptions: - items: - $ref: '#/components/schemas/TagOption' - type: array - x-go-name: TagOptions - trailers: - items: - $ref: '#/components/schemas/Trailer' - type: array - x-go-name: Trailers - translations: - $ref: '#/components/schemas/TranslationExtended' - year: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - PeopleBaseRecord: - description: base people record - properties: - aliases: - items: - $ref: '#/components/schemas/Alias' - type: array - x-go-name: Aliases - id: - format: int64 - type: integer - x-go-name: ID - image: - type: string - lastUpdated: - type: string - name: - type: string - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - score: - format: int64 - type: integer - x-go-name: Score - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - PeopleExtendedRecord: - description: extended people record - properties: - aliases: - items: - $ref: '#/components/schemas/Alias' - type: array - x-go-name: Aliases - awards: - items: - $ref: '#/components/schemas/AwardBaseRecord' - type: array - x-go-name: Awards - biographies: - items: - $ref: '#/components/schemas/Biography' - type: array - x-go-name: Biographies - birth: - type: string - birthPlace: - type: string - characters: - items: - $ref: '#/components/schemas/Character' - type: array - x-go-name: Characters - death: - type: string - gender: - type: integer - id: - format: int64 - type: integer - x-go-name: ID - image: - type: string - lastUpdated: - type: string - name: - type: string - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - races: - items: - $ref: '#/components/schemas/Race' - type: array - x-go-name: Races - remoteIds: - items: - $ref: '#/components/schemas/RemoteID' - type: array - x-go-name: RemoteIDs - score: - format: int64 - type: integer - x-go-name: Score - slug: - type: string - tagOptions: - items: - $ref: '#/components/schemas/TagOption' - type: array - x-go-name: TagOptions - translations: - $ref: '#/components/schemas/TranslationExtended' - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - PeopleType: - description: people type record - properties: - id: - format: int64 - type: integer - x-go-name: ID - name: - type: string - x-go-name: Name - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - Race: - description: race record - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - RecordInfo: - description: base record info - properties: - image: - type: string - x-go-name: Image - name: - type: string - x-go-name: Name - year: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - Release: - description: release record - properties: - country: - type: string - date: - type: string - detail: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - RemoteID: - description: remote id record - properties: - id: - type: string - x-go-name: ID - type: - format: int64 - type: integer - x-go-name: Type - sourceName: - type: string - x-go-name: SourceName - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - SearchResult: - description: search result - properties: - aliases: - items: - type: string - type: array - companies: - items: - type: string - type: array - companyType: - type: string - country: - type: string - director: - type: string - first_air_time: - type: string - genres: - items: - type: string - type: array - id: - type: string - image_url: - type: string - name: - type: string - is_official: - type: boolean - name_translated: - type: string - network: - type: string - objectID: - type: string - officialList: - type: string - overview: - type: string - overviews: - $ref: '#/components/schemas/TranslationSimple' - overview_translated: - items: - type: string - type: array - poster: - type: string - posters: - items: - type: string - type: array - primary_language: - type: string - remote_ids: - items: - $ref: '#/components/schemas/RemoteID' - type: array - x-go-name: RemoteIDs - status: - type: string - x-go-name: Status - slug: - type: string - studios: - items: - type: string - type: array - title: - type: string - thumbnail: - type: string - translations: - $ref: '#/components/schemas/TranslationSimple' - translationsWithLang: - items: - type: string - type: array - tvdb_id: - type: string - type: - type: string - year: - type: string - type: object - SearchByRemoteIdResult: - description: search by remote reuslt is a base record for a movie, series, people, season or company search result - properties: - series: - type: object - $ref: '#/components/schemas/SeriesBaseRecord' - people: - type: object - $ref: '#/components/schemas/PeopleBaseRecord' - movie: - type: object - $ref: '#/components/schemas/MovieBaseRecord' - episode: - type: object - $ref: '#/components/schemas/EpisodeBaseRecord' - company: - type: object - $ref: '#/components/schemas/Company' - - SeasonBaseRecord: - description: season genre record - properties: - id: - type: integer - image: - type: string - imageType: - type: integer - lastUpdated: - type: string - name: - type: string - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - number: - format: int64 - type: integer - x-go-name: Number - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - companies: - type: object - $ref: '#/components/schemas/Companies' - seriesId: - format: int64 - type: integer - x-go-name: SeriesID - type: - $ref: '#/components/schemas/SeasonType' - year: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - SeasonExtendedRecord: - description: extended season record - properties: - artwork: - items: - $ref: '#/components/schemas/ArtworkBaseRecord' - type: array - x-go-name: Artwork - companies: - type: object - $ref: '#/components/schemas/Companies' - episodes: - items: - $ref: '#/components/schemas/EpisodeBaseRecord' - type: array - x-go-name: Episodes - id: - type: integer - image: - type: string - imageType: - type: integer - lastUpdated: - type: string - name: - type: string - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - number: - format: int64 - type: integer - x-go-name: Number - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - seriesId: - format: int64 - type: integer - x-go-name: SeriesID - trailers: - items: - $ref: '#/components/schemas/Trailer' - type: array - x-go-name: Trailers - type: - $ref: '#/components/schemas/SeasonType' - tagOptions: - items: - $ref: '#/components/schemas/TagOption' - type: array - x-go-name: TagOptions - translations: - items: - $ref: '#/components/schemas/Translation' - type: array - year: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - SeasonType: - description: season type record - properties: - alternateName: - type: string - x-go-name: Name - id: - format: int64 - type: integer - x-go-name: ID - name: - type: string - x-go-name: Name - type: - type: string - x-go-name: Type - - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - SeriesAirsDays: - description: A series airs day record - properties: - friday: - type: boolean - x-go-name: Friday - monday: - type: boolean - x-go-name: Monday - saturday: - type: boolean - x-go-name: Saturday - sunday: - type: boolean - x-go-name: Sunday - thursday: - type: boolean - x-go-name: Thursday - tuesday: - type: boolean - x-go-name: Tuesday - wednesday: - type: boolean - x-go-name: Wednesday - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - SeriesBaseRecord: - description: The base record for a series. All series airs time like firstAired, lastAired, nextAired, etc. are in US EST for US series, and for all non-US series, the time of the show’s country capital or most populous city. For streaming services, is the official release time. See https://support.thetvdb.com/kb/faq.php?id=29. - properties: - aliases: - items: - $ref: '#/components/schemas/Alias' - type: array - x-go-name: Aliases - averageRuntime: - type: integer - nullable: true - country: - type: string - defaultSeasonType: - format: int64 - type: integer - x-go-name: DefaultSeasonType - episodes: - items: - $ref: '#/components/schemas/EpisodeBaseRecord' - type: array - x-go-name: Episodes - firstAired: - type: string - id: - type: integer - image: - type: string - isOrderRandomized: - type: boolean - x-go-name: IsOrderRandomized - lastAired: - type: string - lastUpdated: - type: string - name: - type: string - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - nextAired: - type: string - x-go-name: NextAired - originalCountry: - type: string - originalLanguage: - type: string - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - score: - format: double - type: number - x-go-name: Score - slug: - type: string - status: - $ref: '#/components/schemas/Status' - year: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - SeriesExtendedRecord: - description: The extended record for a series. All series airs time like firstAired, lastAired, nextAired, etc. are in US EST for US series, and for all non-US series, the time of the show’s country capital or most populous city. For streaming services, is the official release time. See https://support.thetvdb.com/kb/faq.php?id=29. - properties: - abbreviation: - type: string - airsDays: - $ref: '#/components/schemas/SeriesAirsDays' - airsTime: - type: string - aliases: - items: - $ref: '#/components/schemas/Alias' - type: array - x-go-name: Aliases - artworks: - items: - $ref: '#/components/schemas/ArtworkExtendedRecord' - type: array - x-go-name: Artworks - averageRuntime: - type: integer - nullable: true - characters: - items: - $ref: '#/components/schemas/Character' - type: array - x-go-name: Characters - contentRatings: - items: - $ref: '#/components/schemas/ContentRating' - type: array - country: - type: string - defaultSeasonType: - format: int64 - type: integer - x-go-name: DefaultSeasonType - episodes: - items: - $ref: '#/components/schemas/EpisodeBaseRecord' - type: array - x-go-name: Episodes - firstAired: - type: string - lists: - items: - $ref: '#/components/schemas/ListBaseRecord' - genres: - items: - $ref: '#/components/schemas/GenreBaseRecord' - type: array - x-go-name: Genres - id: - type: integer - image: - type: string - isOrderRandomized: - type: boolean - x-go-name: IsOrderRandomized - lastAired: - type: string - lastUpdated: - type: string - name: - type: string - nameTranslations: - items: - type: string - type: array - x-go-name: NameTranslations - companies: - items: - $ref: '#/components/schemas/Company' - type: array - nextAired: - type: string - x-go-name: NextAired - originalCountry: - type: string - originalLanguage: - type: string - originalNetwork: - $ref: '#/components/schemas/Company' - overview: - type: string - latestNetwork: - $ref: '#/components/schemas/Company' - overviewTranslations: - items: - type: string - type: array - x-go-name: OverviewTranslations - remoteIds: - items: - $ref: '#/components/schemas/RemoteID' - type: array - x-go-name: RemoteIDs - score: - format: double - type: number - x-go-name: Score - seasons: - items: - $ref: '#/components/schemas/SeasonBaseRecord' - type: array - x-go-name: Seasons - seasonTypes: - items: - $ref: '#/components/schemas/SeasonType' - type: array - x-go-name: Seasons - slug: - type: string - status: - $ref: '#/components/schemas/Status' - tags: - items: - $ref: '#/components/schemas/TagOption' - type: array - x-go-name: TagOptions - trailers: - items: - $ref: '#/components/schemas/Trailer' - type: array - x-go-name: Trailers - translations: - $ref: '#/components/schemas/TranslationExtended' - year: - type: string - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - SourceType: - description: source type record - properties: - id: - format: int64 - type: integer - x-go-name: ID - name: - type: string - x-go-name: Name - postfix: - type: string - prefix: - type: string - slug: - type: string - x-go-name: Slug - sort: - format: int64 - type: integer - x-go-name: Sort - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - Status: - description: status record - properties: - id: - format: int64 - type: integer - x-go-name: ID - nullable: true - keepUpdated: - type: boolean - x-go-name: KeepUpdated - name: - type: string - x-go-name: Name - recordType: - type: string - x-go-name: RecordType - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - StudioBaseRecord: - description: studio record - properties: - id: - format: int64 - type: integer - x-go-name: ID - name: - type: string - x-go-name: Name - parentStudio: - type: integer - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - Tag: - description: tag record - properties: - allowsMultiple: - type: boolean - x-go-name: AllowsMultiple - helpText: - type: string - id: - format: int64 - type: integer - x-go-name: ID - name: - type: string - x-go-name: Name - options: - items: - $ref: '#/components/schemas/TagOption' - type: array - x-go-name: TagOptions - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - TagOption: - description: tag option record - properties: - helpText: - type: string - id: - format: int64 - type: integer - x-go-name: ID - name: - type: string - x-go-name: Name - tag: - format: int64 - type: integer - x-go-name: Tag - tagName: - type: string - x-go-name: TagName - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - Trailer: - description: trailer record - properties: - id: - format: int64 - type: integer - x-go-name: ID - language: - type: string - name: - type: string - url: - type: string - runtime: - type: integer - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - Translation: - description: translation record - properties: - aliases: - items: - type: string - type: array - isAlias: - type: boolean - isPrimary: - type: boolean - language: - type: string - x-go-name: Language - name: - type: string - overview: - type: string - tagline: - type: string - description: Only populated for movie translations. We disallow taglines without a title. - type: object - x-go-package: github.com/whip-networks/tvdb-api-v4-core/tvdb-api-v4-core/pkg/model - TranslationSimple: - description: translation simple record - additionalProperties: - type: string - example: # Ejemplo específico del objeto - ara: "تدور قصة المسلسل حول..." - ces: "Během letu č. 815 společnosti Oceanic..." - deu: "Im Bruchteil einer Sekunde gerät das Leben..." - type: object - TranslationExtended: - description: translation extended record - properties: - nameTranslations: - items: - $ref: '#/components/schemas/Translation' - type: array - overviewTranslations: - items: - $ref: '#/components/schemas/Translation' - type: array - alias: - items: - type: string - type: array - type: object - TagOptionEntity: - description: a entity with selected tag option - type: object - properties: - name: - type: string - tagName: - type: string - tagId: - type: integer - UserInfo: - description: User info record - type: object - properties: - id: - type: integer - language: - type: string - name: - type: string - type: - type: string - Inspiration: - description: Movie inspiration record - properties: - id: - format: int64 - type: integer - x-go-name: ID - type: - type: string - type_name: - type: string - url: - type: string - InspirationType: - description: Movie inspiration type record - properties: - id: - format: int64 - type: integer - x-go-name: ID - name: - type: string - description: - type: string - reference_name: - type: string - url: - type: string - ProductionCountry: - description: Production country record - properties: - id: - format: int64 - type: integer - x-go-name: ID - country: - type: string - name: - type: string - Companies: - description: Companies by type record - properties: - studio: - type: array - items: - $ref: '#/components/schemas/Company' - network: - type: array - items: - $ref: '#/components/schemas/Company' - production: - type: array - items: - $ref: '#/components/schemas/Company' - distributor: - type: array - items: - $ref: '#/components/schemas/Company' - special_effects: - type: array - items: - $ref: '#/components/schemas/Company' - Links: - description: Links for next, previous and current record - properties: - prev: - type: string - nullable: true - self: - type: string - nullable: true - next: - type: string - total_items: - type: integer - page_size: - type: integer From 24b127aed96a975f52f351172c5f784a8990ceb1 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 21 Jul 2024 14:26:22 +0200 Subject: [PATCH 075/166] :sparkles: Add generate-tvdb-models script --- poetry.lock | 101 +++++++++++++++++++++++++++++++++- pyproject.toml | 20 ++++++- tools/generate_tvdb_models.py | 36 ++++++++++++ 3 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 tools/generate_tvdb_models.py diff --git a/poetry.lock b/poetry.lock index deb3728..fa3505d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -120,6 +120,26 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "anyio" +version = "4.4.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + [[package]] name = "argcomplete" version = "3.4.0" @@ -211,6 +231,17 @@ d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "certifi" +version = "2024.7.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -343,6 +374,7 @@ files = [ argcomplete = ">=1.10,<4.0" black = ">=19.10b0" genson = ">=1.2.1,<2.0" +httpx = {version = "*", optional = true, markers = "extra == \"http\""} inflect = ">=4.1.0,<6.0" isort = ">=4.3.21,<6.0" jinja2 = ">=2.10.1,<4.0" @@ -515,6 +547,62 @@ files = [ {file = "genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37"}, ] +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] + +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + [[package]] name = "humanfriendly" version = "10.0" @@ -1195,6 +1283,17 @@ files = [ {file = "ruff-0.3.7.tar.gz", hash = "sha256:d5c1aebee5162c2226784800ae031f660c350e7a3402c4d1f8ea4e97e232e3ba"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -1332,4 +1431,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "28ca88a0d5c02aee82e5c6dbc9fab9eace69ce0edecd230f735790a76588e995" +content-hash = "47f02cdaaf0e859a61387b320a3796f1b7a5755be0165cd52be6459cd2ca74ec" diff --git a/pyproject.toml b/pyproject.toml index 9e5625c..c5d53e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,25 @@ pytest-cov = "^5.0.0" [tool.poetry.group.dev.dependencies] -datamodel-code-generator = "^0.25.8" +datamodel-code-generator = {extras = ["http"], version = "^0.25.8"} + + +[tool.datamodel-codegen] +field-constraints = true +snake-case-field = true +target-python-version = "3.12" +use-default-kwarg = true +use-exact-imports = true +use-field-description = true +use-union-operator = true +reuse-model = true +output-model-type = "pydantic_v2.BaseModel" +custom-file-header = "# ruff: noqa: D101 # Allow missing docstrings" +field-include-all-keys = true +strict-nullable = true + +[tool.poetry.scripts] +generate-tvdb-models = "tools.generate_tvdb_models:main" [build-system] requires = ["poetry-core"] diff --git a/tools/generate_tvdb_models.py b/tools/generate_tvdb_models.py new file mode 100644 index 0000000..540983c --- /dev/null +++ b/tools/generate_tvdb_models.py @@ -0,0 +1,36 @@ +import subprocess +import sys + + +def _run_datamodel_codegen() -> None: + command = [ + "poetry", + "run", + "datamodel-codegen", + "--url", + "https://thetvdb.github.io/v4-api/swagger.yml", + "--input-file-type", + "openapi", + "--output", + r"./src/tvdb/generated_models.py", + "--output-model-type", + "pydantic_v2.BaseModel", + ] + try: + subprocess.run(command, check=True) # noqa: S603 + print("Code generation completed successfully.") # noqa: T201 + except subprocess.CalledProcessError as e: + print(f"Error occurred while running datamodel-codegen: {e}", file=sys.stderr) # noqa: T201 + sys.exit(1) + + +def main() -> None: + """The main entry point for the script. + + :return: + """ + _run_datamodel_codegen() + + +if __name__ == "__main__": + main() From 59f19c81e98e230ddf85e7e18a29068d7312c4bd Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 21 Jul 2024 14:40:31 +0200 Subject: [PATCH 076/166] :sparkles: Improve tvdb --- src/exts/tvdb_info/main.py | 32 +- src/tvdb/__init__.py | 4 +- src/tvdb/client.py | 165 ++++-- src/tvdb/generated_models.py | 955 +++++++++++++++++++++-------------- src/tvdb/models.py | 2 +- tools/__init__.py | 0 6 files changed, 707 insertions(+), 451 deletions(-) create mode 100644 tools/__init__.py diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 29c141b..b347282 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -6,8 +6,7 @@ from src.bot import Bot from src.settings import THETVDB_COPYRIGHT_FOOTER, THETVDB_LOGO -from src.tvdb import TvdbClient -from src.tvdb.models import SearchResults +from src.tvdb import Movie, Series, TvdbClient from src.utils.log import get_logger log = get_logger(__name__) @@ -19,14 +18,14 @@ class InfoView(discord.ui.View): """View for displaying information about a movie or series.""" - def __init__(self, results: SearchResults): + def __init__(self, results: list[Movie | Series]): super().__init__(disable_on_timeout=True) - self.results: SearchResults = results + self.results = results self.dropdown = discord.ui.Select( placeholder="Not what you're looking for? Select a different result.", options=[ discord.SelectOption( - label=result.name or result.title or "", + label=result.bilingual_name or "", value=str(i), description=result.overview[:100] if result.overview else None, ) @@ -39,21 +38,14 @@ def __init__(self, results: SearchResults): def _get_embed(self) -> discord.Embed: result = self.results[self.index] - original_title = result.name - en_title = result.translations["eng"] - original_overview = result.overview - en_overview = result.overviews["eng"] - if en_overview and en_overview != original_overview: - overview = f"{en_overview}" - elif not en_overview and original_overview: - overview = f"{original_overview}\n\n*No English overview available.*" + if result.overview_eng: + overview = f"{result.overview_eng}" + elif not result.overview_eng and result.overview: + overview = f"{result.overview}\n\n*No English overview available.*" else: overview = "*No overview available.*" - if original_title and original_title != en_title: - title = f"{original_title} ({en_title})" - else: - title = en_title - if result.id[0] == "m": + title = result.bilingual_name + if result.entity_type == "Movie": title = f"{MOVIE_EMOJI} {title}" url = f"https://www.thetvdb.com/movies/{result.slug}" else: @@ -109,9 +101,9 @@ async def search( client = TvdbClient(session) match entity_type: case "movie": - response = await client.movies.search(query, limit=5) + response = await client.search(query, limit=5, entity_type="movie") case "series": - response = await client.series.search(query, limit=5) + response = await client.search(query, limit=5, entity_type="series") case None: response = await client.search(query, limit=5) diff --git a/src/tvdb/__init__.py b/src/tvdb/__init__.py index 2d6d393..be757af 100644 --- a/src/tvdb/__init__.py +++ b/src/tvdb/__init__.py @@ -1,6 +1,8 @@ -from .client import InvalidApiKeyError, TvdbClient +from .client import InvalidApiKeyError, Movie, Series, TvdbClient __all__ = [ "TvdbClient", "InvalidApiKeyError", + "Movie", + "Series", ] diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 70ac7b7..b44c033 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Any, ClassVar, Literal, overload, override +from typing import ClassVar, Literal, overload, override import aiohttp from yarl import URL @@ -12,94 +12,149 @@ SeriesBaseRecord, SeriesExtendedRecord, ) -from src.tvdb.models import MovieResponse, SearchResponse, SeriesResponse +from src.tvdb.models import ( + MovieExtendedResponse, + MovieResponse, + SearchResponse, + SeriesExtendedResponse, + SeriesResponse, +) from src.utils.log import get_logger log = get_logger(__name__) type JSON_DATA = dict[str, JSON_DATA] | list[JSON_DATA] | str | int | float | bool | None # noice +type SeriesRecord = SeriesBaseRecord | SeriesExtendedRecord +type MovieRecord = MovieBaseRecord | MovieExtendedRecord +type AnyRecord = SeriesRecord | MovieRecord + + +def parse_media_id(media_id: int | str) -> int: + """Parse the media ID from a string.""" + return int(str(media_id).removeprefix("movie-").removeprefix("series-")) + class _Media(ABC): - def __init__(self, client: "TvdbClient"): + def __init__(self, client: "TvdbClient", data: SeriesRecord | MovieRecord | SearchResult): + self.data = data self.client = client - self._id: int | None = None + self.name: str | None = self.data.name + self.overview: str | None = None + # if the class name is "Movie" or "Series" + self.entity_type: Literal["Movie", "Series"] = self.__class__.__name__ # pyright: ignore [reportAttributeAccessIssue] + if hasattr(self.data, "overview"): + self.overview = self.data.overview # pyright: ignore [reportAttributeAccessIssue] + self.slug: str | None = None + if hasattr(self.data, "slug"): + self.slug = self.data.slug + if hasattr(self.data, "id"): + self.id = self.data.id + + self.name_eng: str | None = None + self.overview_eng: str | None = None + + self.image_url: URL | None = None + if isinstance(self.data, SearchResult) and self.data.image_url: + self.image_url = URL(self.data.image_url) + # that not isn't needed but is there for clarity and for pyright + elif not isinstance(self.data, SearchResult) and self.data.image: + self.image_url = URL(self.data.image) + + if isinstance(self.data, SearchResult): + if self.data.translations and self.data.translations.root: + self.name_eng = self.data.translations.root.get("eng") + if self.data.overviews and self.data.overviews.root: + self.overview_eng = self.data.overviews.root.get("eng") + else: + if self.data.aliases: + self.name_eng = next(alias for alias in self.data.aliases if alias.language == "eng").name + if isinstance(self.data, (SeriesExtendedRecord, MovieExtendedRecord)) and self.data.translations: + if self.data.translations.name_translations: + self.name_eng = next( + translation.name + for translation in self.data.translations.name_translations + if translation.language == "eng" + ) + if self.data.translations.overview_translations: + self.overview_eng = next( + translation.overview + for translation in self.data.translations.overview_translations + if translation.language == "eng" + ) + + @property + def bilingual_name(self) -> str | None: + if self.name == self.name_eng: + return self.name + return f"{self.name} ({self.name_eng})" @property def id(self) -> int | str | None: return self._id @id.setter - def id(self, value: int | str) -> None: - self._id = int(str(value).split("-")[1]) - - def __call__(self, media_id: str) -> "_Media": - self.id = media_id - return self - - @abstractmethod - async def search(self, search_query: str, limit: int = 1) -> list[Any]: ... + def id(self, value: int | str | None) -> None: + if value: + self._id = int(str(value).split("-")[1]) + else: + self._id = None + @classmethod @abstractmethod - async def fetch(self, *, extended: bool = False) -> Any: ... - - -class Series(_Media): - """Class to interact with the TVDB API for series.""" + async def fetch(cls, media_id: int | str, *, client: "TvdbClient", extended: bool = False) -> "_Media": ... - @override - async def search(self, search_query: str, limit: int = 1) -> list[SearchResult]: - """Search for a series in the TVDB database. - :param search_query: - :param limit: - :return: - """ - return await self.client.search(search_query, "series", limit) +class Movie(_Media): + """Class to interact with the TVDB API for movies.""" @overload - async def fetch(self, *, extended: Literal[False]) -> SeriesBaseRecord: ... - + @classmethod + async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: Literal[False]) -> "Movie": ... @overload - async def fetch(self, *, extended: Literal[True]) -> SeriesExtendedRecord: ... + @classmethod + async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: Literal[True]) -> "Movie": ... @override - async def fetch(self, *, extended: bool = False) -> SeriesBaseRecord | SeriesExtendedRecord: - """Fetch a series by its ID. + @classmethod + async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: bool = False) -> "Movie": + """Fetch a movie by its ID. - :param extended: + :param media_id: The ID of the movie. + :param client: The TVDB client to use. + :param extended: Whether to fetch extended information. :return: """ - data = await self.client.request("GET", f"series/{self.id}" + ("/extended" if extended else "")) - return SeriesResponse(**data).data # pyright: ignore[reportCallIssue] - + media_id = parse_media_id(media_id) + response = await client.request("GET", f"movies/{media_id}" + ("/extended" if extended else "")) + response = MovieResponse(**response) if not extended else MovieExtendedResponse(**response) # pyright: ignore[reportCallIssue] + return cls(client, response.data) -class Movies(_Media): - """Class to interact with the TVDB API for movies.""" - @override - async def search(self, search_query: str, limit: int = 1) -> list[SearchResult]: - """Search for a movie in the TVDB database. - - :param search_query: - :param limit: - :return: list[SearchResult] - """ - return await self.client.search(search_query, "movie", limit) +class Series(_Media): + """Class to interact with the TVDB API for series.""" @overload - async def fetch(self, *, extended: Literal[False]) -> MovieBaseRecord: ... + @classmethod + async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: Literal[False]) -> "Series": ... @overload - async def fetch(self, *, extended: Literal[True]) -> MovieExtendedRecord: ... + @classmethod + async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: Literal[True]) -> "Series": ... + @override - async def fetch(self, *, extended: bool = False) -> MovieBaseRecord | MovieExtendedRecord: - """Fetch a movie by its ID. + @classmethod + async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: bool = False) -> "Series": + """Fetch a series by its ID. + :param media_id: The ID of the series. + :param client: The TVDB client to use. :param extended: Whether to fetch extended information. :return: """ - data = await self.client.request("GET", f"movies/{self.id}" + ("/extended" if extended else "")) - return MovieResponse(**data).data # pyright: ignore[reportCallIssue] + media_id = parse_media_id(media_id) + response = await client.request("GET", f"series/{media_id}" + ("/extended" if extended else "")) + response = SeriesResponse(**response) if not extended else SeriesExtendedResponse(**response) # pyright: ignore[reportCallIssue] + return cls(client, response.data) class InvalidApiKeyError(Exception): @@ -119,8 +174,6 @@ class TvdbClient: def __init__(self, http_session: aiohttp.ClientSession): self.http_session = http_session self.auth_token = None - self.series = Series(self) - self.movies = Movies(self) @overload async def request( @@ -165,14 +218,14 @@ async def request( async def search( self, search_query: str, entity_type: Literal["series", "movie", "all"] = "all", limit: int = 1 - ) -> list[SearchResult]: + ) -> list[Movie | Series]: """Search for a series or movie in the TVDB database.""" query: dict[str, str] = {"query": search_query, "limit": str(limit)} if entity_type != "all": query["type"] = entity_type data = await self.request("GET", "search", query=query) response = SearchResponse(**data) # pyright: ignore[reportCallIssue] - return response.data + return [Movie(self, result) if result.id[0] == "m" else Series(self, result) for result in response.data] async def _login(self) -> None: """Obtain the auth token from the TVDB API. diff --git a/src/tvdb/generated_models.py b/src/tvdb/generated_models.py index 9e8ea23..3f88704 100644 --- a/src/tvdb/generated_models.py +++ b/src/tvdb/generated_models.py @@ -1,51 +1,59 @@ -# generated by datamodel-codegen: -# filename: swagger.yml -# timestamp: 2024-07-20T12:48:32+00:00 -# ruff: noqa: N815, ERA001, D101 # allow camelCase, disable check for commented out code, and allow undocumented class docstring +# ruff: noqa: D101 # Allow missing docstrings + from __future__ import annotations from pydantic import BaseModel, Field, RootModel class Alias(BaseModel): - language: str | None = Field( - None, - description="A 3-4 character string indicating the language of the alias, as defined in Language.", - max_length=4, - ) - name: str | None = Field(None, description="A string containing the alias itself.", max_length=100) + language: str = Field(le=4) + """ + A 3-4 character string indicating the language of the alias, as defined in Language. + """ + name: str = Field(le=100) + """ + A string containing the alias itself. + """ class ArtworkBaseRecord(BaseModel): - height: int | None = None + height: int | None = Field(default=None, json_schema_extra={"x-go-name": "Height"}) id: int | None = None - image: str | None = None - includesText: bool | None = None + image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) + includes_text: bool | None = Field(default=None, alias="includesText") language: str | None = None score: float | None = None - thumbnail: str | None = None - type: int | None = Field( - None, - description="The artwork type corresponds to the ids from the /artwork/types endpoint.", - ) - width: int | None = None + thumbnail: str | None = Field(default=None, json_schema_extra={"x-go-name": "Thumbnail"}) + type: int | None = Field(default=None, json_schema_extra={"x-go-name": "Type"}) + """ + The artwork type corresponds to the ids from the /artwork/types endpoint. + """ + width: int | None = Field(default=None, json_schema_extra={"x-go-name": "Width"}) class ArtworkStatus(BaseModel): - id: int | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) name: str | None = None class ArtworkType(BaseModel): height: int | None = None - id: int | None = None - imageFormat: str | None = None - name: str | None = None - recordType: str | None = None - slug: str | None = None - thumbHeight: int | None = None - thumbWidth: int | None = None - width: int | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + image_format: str | None = Field( + default=None, + alias="imageFormat", + json_schema_extra={"x-go-name": "ImageFormat"}, + ) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + record_type: str | None = Field(default=None, alias="recordType", json_schema_extra={"x-go-name": "RecordType"}) + slug: str | None = Field(default=None, json_schema_extra={"x-go-name": "Slug"}) + thumb_height: int | None = Field( + default=None, + alias="thumbHeight", + json_schema_extra={"x-go-name": "ThumbHeight"}, + ) + thumb_width: int | None = Field(default=None, alias="thumbWidth", json_schema_extra={"x-go-name": "ThumbWidth"}) + width: int | None = Field(default=None, json_schema_extra={"x-go-name": "Width"}) class AwardBaseRecord(BaseModel): @@ -54,143 +62,167 @@ class AwardBaseRecord(BaseModel): class AwardCategoryBaseRecord(BaseModel): - allowCoNominees: bool | None = None + allow_co_nominees: bool | None = Field( + default=None, + alias="allowCoNominees", + json_schema_extra={"x-go-name": "AllowCoNominees"}, + ) award: AwardBaseRecord | None = None - forMovies: bool | None = None - forSeries: bool | None = None - id: int | None = None + for_movies: bool | None = Field(default=None, alias="forMovies", json_schema_extra={"x-go-name": "ForMovies"}) + for_series: bool | None = Field(default=None, alias="forSeries", json_schema_extra={"x-go-name": "ForSeries"}) + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) name: str | None = None class AwardExtendedRecord(BaseModel): - categories: list[AwardCategoryBaseRecord] | None = None + categories: list[AwardCategoryBaseRecord] | None = Field( + default=None, json_schema_extra={"x-go-name": "Categories"} + ) id: int | None = None name: str | None = None - score: int | None = None + score: int | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) class Biography(BaseModel): - biography: str | None = None - language: str | None = None + biography: str | None = Field(default=None, json_schema_extra={"x-go-name": "Biography"}) + language: str | None = Field(default=None, json_schema_extra={"x-go-name": "Language"}) class CompanyRelationShip(BaseModel): id: int | None = None - typeName: str | None = None + type_name: str | None = Field(default=None, alias="typeName") class CompanyType(BaseModel): - companyTypeId: int | None = None - companyTypeName: str | None = None + company_type_id: int | None = Field(default=None, alias="companyTypeId") + company_type_name: str | None = Field(default=None, alias="companyTypeName") class ContentRating(BaseModel): - id: int | None = None - name: str | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) description: str | None = None country: str | None = None - contentType: str | None = None + content_type: str | None = Field(default=None, alias="contentType") order: int | None = None - fullName: str | None = None + full_name: str | None = Field(default=None, alias="fullName") class Country(BaseModel): - id: str | None = None - name: str | None = None - shortCode: str | None = None + id: str | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + short_code: str | None = Field(default=None, alias="shortCode", json_schema_extra={"x-go-name": "ShortCode"}) class Entity(BaseModel): - movieId: int | None = None - order: int | None = None - seriesId: int | None = None + movie_id: int | None = Field(default=None, alias="movieId") + order: int | None = Field(default=None, json_schema_extra={"x-go-name": "Order"}) + series_id: int | None = Field(default=None, alias="seriesId") class EntityType(BaseModel): id: int | None = None - name: str | None = None - hasSpecials: bool | None = None + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Order"}) + has_specials: bool | None = Field(default=None, alias="hasSpecials") class EntityUpdate(BaseModel): - entityType: str | None = None - methodInt: int | None = None - method: str | None = None - extraInfo: str | None = None - userId: int | None = None - recordType: str | None = None - recordId: int | None = None - timeStamp: int | None = None - seriesId: int | None = Field(None, description="Only present for episodes records") - mergeToId: int | None = None - mergeToEntityType: str | None = None + entity_type: str | None = Field(default=None, alias="entityType", json_schema_extra={"x-go-name": "EnitityType"}) + method_int: int | None = Field(default=None, alias="methodInt") + method: str | None = Field(default=None, json_schema_extra={"x-go-name": "Method"}) + extra_info: str | None = Field(default=None, alias="extraInfo") + user_id: int | None = Field(default=None, alias="userId") + record_type: str | None = Field(default=None, alias="recordType") + record_id: int | None = Field(default=None, alias="recordId", json_schema_extra={"x-go-name": "RecordID"}) + time_stamp: int | None = Field(default=None, alias="timeStamp", json_schema_extra={"x-go-name": "TimeStamp"}) + series_id: int | None = Field(default=None, alias="seriesId", json_schema_extra={"x-go-name": "RecordID"}) + """ + Only present for episodes records + """ + merge_to_id: int | None = Field(default=None, alias="mergeToId") + merge_to_entity_type: str | None = Field(default=None, alias="mergeToEntityType") class Favorites(BaseModel): - series: list[int] | None = None - movies: list[int] | None = None - episodes: list[int] | None = None - artwork: list[int] | None = None - people: list[int] | None = None - lists: list[int] | None = None + series: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "series"}) + movies: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "movies"}) + episodes: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "episodes"}) + artwork: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "artwork"}) + people: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "people"}) + lists: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "list"}) class FavoriteRecord(BaseModel): - series: int | None = None - movie: int | None = None - episode: int | None = None - artwork: int | None = None - people: int | None = None - list: int | None = None + series: int | None = Field(default=None, json_schema_extra={"x-go-name": "series"}) + movie: int | None = Field(default=None, json_schema_extra={"x-go-name": "movies"}) + episode: int | None = Field(default=None, json_schema_extra={"x-go-name": "episodes"}) + artwork: int | None = Field(default=None, json_schema_extra={"x-go-name": "artwork"}) + people: int | None = Field(default=None, json_schema_extra={"x-go-name": "people"}) + list: int | None = Field(default=None, json_schema_extra={"x-go-name": "list"}) class Gender(BaseModel): - id: int | None = None - name: str | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) class GenreBaseRecord(BaseModel): - id: int | None = None - name: str | None = None - slug: str | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + slug: str | None = Field(default=None, json_schema_extra={"x-go-name": "Slug"}) class Language(BaseModel): - id: str | None = None - name: str | None = None - nativeName: str | None = None - shortCode: str | None = None + id: str | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + native_name: str | None = Field(default=None, alias="nativeName", json_schema_extra={"x-go-name": "NativeName"}) + short_code: str | None = Field(default=None, alias="shortCode") class ListExtendedRecord(BaseModel): - aliases: list[Alias] | None = None - entities: list[Entity] | None = None - id: int | None = None + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) + entities: list[Entity] | None = Field(default=None, json_schema_extra={"x-go-name": "Entities"}) + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image: str | None = None - imageIsFallback: bool | None = None - isOfficial: bool | None = None + image_is_fallback: bool | None = Field(default=None, alias="imageIsFallback") + is_official: bool | None = Field(default=None, alias="isOfficial", json_schema_extra={"x-go-name": "IsOfficial"}) name: str | None = None - nameTranslations: list[str] | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) overview: str | None = None - overviewTranslations: list[str] | None = None - score: int | None = None + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + score: int | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) url: str | None = None class PeopleBaseRecord(BaseModel): - aliases: list[Alias] | None = None - id: int | None = None + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image: str | None = None - lastUpdated: str | None = None + last_updated: str | None = Field(default=None, alias="lastUpdated") name: str | None = None - nameTranslations: list[str] | None = None - overviewTranslations: list[str] | None = None - score: int | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + score: int | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) -class PeopleType(BaseModel): - id: int | None = None - name: str | None = None +class PeopleType(Gender): + pass class Race(BaseModel): @@ -198,8 +230,8 @@ class Race(BaseModel): class RecordInfo(BaseModel): - image: str | None = None - name: str | None = None + image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) year: str | None = None @@ -210,60 +242,64 @@ class Release(BaseModel): class RemoteID(BaseModel): - id: str | None = None - type: int | None = None - sourceName: str | None = None + id: str | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + type: int | None = Field(default=None, json_schema_extra={"x-go-name": "Type"}) + source_name: str | None = Field(default=None, alias="sourceName", json_schema_extra={"x-go-name": "SourceName"}) class SeasonType(BaseModel): - alternateName: str | None = None - id: int | None = None - name: str | None = None - type: str | None = None + alternate_name: str | None = Field(default=None, alias="alternateName", json_schema_extra={"x-go-name": "Name"}) + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + type: str | None = Field(default=None, json_schema_extra={"x-go-name": "Type"}) class SeriesAirsDays(BaseModel): - friday: bool | None = None - monday: bool | None = None - saturday: bool | None = None - sunday: bool | None = None - thursday: bool | None = None - tuesday: bool | None = None - wednesday: bool | None = None + friday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Friday"}) + monday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Monday"}) + saturday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Saturday"}) + sunday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Sunday"}) + thursday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Thursday"}) + tuesday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Tuesday"}) + wednesday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Wednesday"}) class SourceType(BaseModel): - id: int | None = None - name: str | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) postfix: str | None = None prefix: str | None = None - slug: str | None = None - sort: int | None = None + slug: str | None = Field(default=None, json_schema_extra={"x-go-name": "Slug"}) + sort: int | None = Field(default=None, json_schema_extra={"x-go-name": "Sort"}) class Status(BaseModel): - id: int | None = None - keepUpdated: bool | None = None - name: str | None = None - recordType: str | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + keep_updated: bool | None = Field( + default=None, + alias="keepUpdated", + json_schema_extra={"x-go-name": "KeepUpdated"}, + ) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + record_type: str | None = Field(default=None, alias="recordType", json_schema_extra={"x-go-name": "RecordType"}) class StudioBaseRecord(BaseModel): - id: int | None = None - name: str | None = None - parentStudio: int | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + parent_studio: int | None = Field(default=None, alias="parentStudio") class TagOption(BaseModel): - helpText: str | None = None - id: int | None = None - name: str | None = None - tag: int | None = None - tagName: str | None = None + help_text: str | None = Field(default=None, alias="helpText") + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + tag: int | None = Field(default=None, json_schema_extra={"x-go-name": "Tag"}) + tag_name: str | None = Field(default=None, alias="tagName", json_schema_extra={"x-go-name": "TagName"}) class Trailer(BaseModel): - id: int | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) language: str | None = None name: str | None = None url: str | None = None @@ -272,36 +308,31 @@ class Trailer(BaseModel): class Translation(BaseModel): aliases: list[str] | None = None - isAlias: bool | None = None - isPrimary: bool | None = None - language: str | None = None + is_alias: bool | None = Field(default=None, alias="isAlias") + is_primary: bool | None = Field(default=None, alias="isPrimary") + language: str | None = Field(default=None, json_schema_extra={"x-go-name": "Language"}) name: str | None = None overview: str | None = None - tagline: str | None = Field( - None, - description="Only populated for movie translations. We disallow taglines without a title.", - ) + tagline: str | None = None + """ + Only populated for movie translations. We disallow taglines without a title. + """ class TranslationSimple(RootModel[dict[str, str] | None]): root: dict[str, str] | None = None - def __getitem__(self, item: str) -> str | None: - if self.root is None: - return None - return self.root.get(item) - class TranslationExtended(BaseModel): - nameTranslations: list[Translation] | None = None - overviewTranslations: list[Translation] | None = None + name_translations: list[Translation] | None = Field(default=None, alias="nameTranslations") + overview_translations: list[Translation] | None = Field(default=None, alias="overviewTranslations") alias: list[str] | None = None class TagOptionEntity(BaseModel): name: str | None = None - tagName: str | None = None - tagId: int | None = None + tag_name: str | None = Field(default=None, alias="tagName") + tag_id: int | None = Field(default=None, alias="tagId") class UserInfo(BaseModel): @@ -312,14 +343,14 @@ class UserInfo(BaseModel): class Inspiration(BaseModel): - id: int | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) type: str | None = None type_name: str | None = None url: str | None = None class InspirationType(BaseModel): - id: int | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) name: str | None = None description: str | None = None reference_name: str | None = None @@ -327,7 +358,7 @@ class InspirationType(BaseModel): class ProductionCountry(BaseModel): - id: int | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) country: str | None = None name: str | None = None @@ -341,54 +372,74 @@ class Links(BaseModel): class ArtworkExtendedRecord(BaseModel): - episodeId: int | None = None - height: int | None = None - id: int | None = None - image: str | None = None - includesText: bool | None = None + episode_id: int | None = Field(default=None, alias="episodeId") + height: int | None = Field(default=None, json_schema_extra={"x-go-name": "Height"}) + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) + includes_text: bool | None = Field(default=None, alias="includesText") language: str | None = None - movieId: int | None = None - networkId: int | None = None - peopleId: int | None = None + movie_id: int | None = Field(default=None, alias="movieId") + network_id: int | None = Field(default=None, alias="networkId") + people_id: int | None = Field(default=None, alias="peopleId") score: float | None = None - seasonId: int | None = None - seriesId: int | None = None - seriesPeopleId: int | None = None + season_id: int | None = Field(default=None, alias="seasonId") + series_id: int | None = Field(default=None, alias="seriesId") + series_people_id: int | None = Field(default=None, alias="seriesPeopleId") status: ArtworkStatus | None = None - tagOptions: list[TagOption] | None = None - thumbnail: str | None = None - thumbnailHeight: int | None = None - thumbnailWidth: int | None = None - type: int | None = Field( - None, - description="The artwork type corresponds to the ids from the /artwork/types endpoint.", + tag_options: list[TagOption] | None = Field( + default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + ) + thumbnail: str | None = Field(default=None, json_schema_extra={"x-go-name": "Thumbnail"}) + thumbnail_height: int | None = Field( + default=None, + alias="thumbnailHeight", + json_schema_extra={"x-go-name": "ThumbnailHeight"}, ) - updatedAt: int | None = None - width: int | None = None + thumbnail_width: int | None = Field( + default=None, + alias="thumbnailWidth", + json_schema_extra={"x-go-name": "ThumbnailWidth"}, + ) + type: int | None = Field(default=None, json_schema_extra={"x-go-name": "Type"}) + """ + The artwork type corresponds to the ids from the /artwork/types endpoint. + """ + updated_at: int | None = Field(default=None, alias="updatedAt", json_schema_extra={"x-go-name": "UpdatedAt"}) + width: int | None = Field(default=None, json_schema_extra={"x-go-name": "Width"}) class Character(BaseModel): - aliases: list[Alias] | None = None + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) episode: RecordInfo | None = None - episodeId: int | None = None - id: int | None = None + episode_id: int | None = Field(default=None, alias="episodeId") + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image: str | None = None - isFeatured: bool | None = None - movieId: int | None = None + is_featured: bool | None = Field(default=None, alias="isFeatured", json_schema_extra={"x-go-name": "IsFeatured"}) + movie_id: int | None = Field(default=None, alias="movieId") movie: RecordInfo | None = None name: str | None = None - nameTranslations: list[str] | None = None - overviewTranslations: list[str] | None = None - peopleId: int | None = None - personImgURL: str | None = None - peopleType: str | None = None - seriesId: int | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + people_id: int | None = Field(default=None, alias="peopleId") + person_img_url: str | None = Field(default=None, alias="personImgURL") + people_type: str | None = Field(default=None, alias="peopleType") + series_id: int | None = Field(default=None, alias="seriesId") series: RecordInfo | None = None - sort: int | None = None - tagOptions: list[TagOption] | None = None - type: int | None = None - url: str | None = None - personName: str | None = None + sort: int | None = Field(default=None, json_schema_extra={"x-go-name": "Sort"}) + tag_options: list[TagOption] | None = Field( + default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + ) + type: int | None = Field(default=None, json_schema_extra={"x-go-name": "Type"}) + url: str | None = Field(default=None, json_schema_extra={"x-go-name": "URL"}) + person_name: str | None = Field(default=None, alias="personName") class ParentCompany(BaseModel): @@ -398,63 +449,93 @@ class ParentCompany(BaseModel): class ListBaseRecord(BaseModel): - aliases: list[Alias] | None = None - id: int | None = None + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image: str | None = None - imageIsFallback: bool | None = None - isOfficial: bool | None = None + image_is_fallback: bool | None = Field(default=None, alias="imageIsFallback") + is_official: bool | None = Field(default=None, alias="isOfficial", json_schema_extra={"x-go-name": "IsOfficial"}) name: str | None = None - nameTranslations: list[str] | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) overview: str | None = None - overviewTranslations: list[str] | None = None - remoteIds: list[RemoteID] | None = None - tags: list[TagOption] | None = None + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + remote_ids: list[RemoteID] | None = Field( + default=None, alias="remoteIds", json_schema_extra={"x-go-name": "RemoteIDs"} + ) + tags: list[TagOption] | None = Field(default=None, json_schema_extra={"x-go-name": "TagOptions"}) score: int | None = None url: str | None = None class MovieBaseRecord(BaseModel): - aliases: list[Alias] | None = None - id: int | None = None - image: str | None = None - lastUpdated: str | None = None - name: str | None = None - nameTranslations: list[str] | None = None - overviewTranslations: list[str] | None = None - score: float | None = None - slug: str | None = None + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) + last_updated: str | None = Field(default=None, alias="lastUpdated") + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + score: float | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) + slug: str | None = Field(default=None, json_schema_extra={"x-go-name": "Slug"}) status: Status | None = None runtime: int | None = None year: str | None = None class PeopleExtendedRecord(BaseModel): - aliases: list[Alias] | None = None - awards: list[AwardBaseRecord] | None = None - biographies: list[Biography] | None = None + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) + awards: list[AwardBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Awards"}) + biographies: list[Biography] | None = Field(default=None, json_schema_extra={"x-go-name": "Biographies"}) birth: str | None = None - birthPlace: str | None = None - characters: list[Character] | None = None + birth_place: str | None = Field(default=None, alias="birthPlace") + characters: list[Character] | None = Field(default=None, json_schema_extra={"x-go-name": "Characters"}) death: str | None = None gender: int | None = None - id: int | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image: str | None = None - lastUpdated: str | None = None + last_updated: str | None = Field(default=None, alias="lastUpdated") name: str | None = None - nameTranslations: list[str] | None = None - overviewTranslations: list[str] | None = None - races: list[Race] | None = None - remoteIds: list[RemoteID] | None = None - score: int | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + races: list[Race] | None = Field(default=None, json_schema_extra={"x-go-name": "Races"}) + remote_ids: list[RemoteID] | None = Field( + default=None, alias="remoteIds", json_schema_extra={"x-go-name": "RemoteIDs"} + ) + score: int | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) slug: str | None = None - tagOptions: list[TagOption] | None = None + tag_options: list[TagOption] | None = Field( + default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + ) translations: TranslationExtended | None = None class SearchResult(BaseModel): aliases: list[str] | None = None companies: list[str] | None = None - companyType: str | None = None + company_type: str | None = Field(default=None, alias="companyType") country: str | None = None director: str | None = None first_air_time: str | None = None @@ -465,48 +546,66 @@ class SearchResult(BaseModel): is_official: bool | None = None name_translated: str | None = None network: str | None = None - objectID: str | None = None - officialList: str | None = None + object_id: str | None = Field(default=None, alias="objectID") + official_list: str | None = Field(default=None, alias="officialList") overview: str | None = None - overviews: TranslationSimple = TranslationSimple(None) + overviews: TranslationSimple | None = None overview_translated: list[str] | None = None poster: str | None = None posters: list[str] | None = None primary_language: str | None = None - remote_ids: list[RemoteID] | None = None - status: str | None = None + remote_ids: list[RemoteID] | None = Field(default=None, json_schema_extra={"x-go-name": "RemoteIDs"}) + status: str | None = Field(default=None, json_schema_extra={"x-go-name": "Status"}) slug: str | None = None studios: list[str] | None = None title: str | None = None thumbnail: str | None = None - translations: TranslationSimple = TranslationSimple(None) - translationsWithLang: list[str] | None = None + translations: TranslationSimple | None = None + translations_with_lang: list[str] | None = Field(default=None, alias="translationsWithLang") tvdb_id: str | None = None type: str | None = None year: str | None = None class Tag(BaseModel): - allowsMultiple: bool | None = None - helpText: str | None = None - id: int | None = None - name: str | None = None - options: list[TagOption] | None = None + allows_multiple: bool | None = Field( + default=None, + alias="allowsMultiple", + json_schema_extra={"x-go-name": "AllowsMultiple"}, + ) + help_text: str | None = Field(default=None, alias="helpText") + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + options: list[TagOption] | None = Field(default=None, json_schema_extra={"x-go-name": "TagOptions"}) class Company(BaseModel): - activeDate: str | None = None - aliases: list[Alias] | None = None + active_date: str | None = Field(default=None, alias="activeDate") + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) country: str | None = None - id: int | None = None - inactiveDate: str | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + inactive_date: str | None = Field(default=None, alias="inactiveDate") name: str | None = None - nameTranslations: list[str] | None = None - overviewTranslations: list[str] | None = None - primaryCompanyType: int | None = None - slug: str | None = None - parentCompany: ParentCompany | None = None - tagOptions: list[TagOption] | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + primary_company_type: int | None = Field( + default=None, + alias="primaryCompanyType", + json_schema_extra={"x-go-name": "PrimaryCompanyType"}, + ) + slug: str | None = Field(default=None, json_schema_extra={"x-go-name": "Slug"}) + parent_company: ParentCompany | None = Field(default=None, alias="parentCompany") + tag_options: list[TagOption] | None = Field( + default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + ) class Companies(BaseModel): @@ -518,40 +617,62 @@ class Companies(BaseModel): class MovieExtendedRecord(BaseModel): - aliases: list[Alias] | None = None - artworks: list[ArtworkBaseRecord] | None = None - audioLanguages: list[str] | None = None - awards: list[AwardBaseRecord] | None = None - boxOffice: str | None = None - boxOfficeUS: str | None = None + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) + artworks: list[ArtworkBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Artworks"}) + audio_languages: list[str] | None = Field( + default=None, + alias="audioLanguages", + json_schema_extra={"x-go-name": "AudioLanguages"}, + ) + awards: list[AwardBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Awards"}) + box_office: str | None = Field(default=None, alias="boxOffice") + box_office_us: str | None = Field(default=None, alias="boxOfficeUS") budget: str | None = None - characters: list[Character] | None = None + characters: list[Character] | None = Field(default=None, json_schema_extra={"x-go-name": "Characters"}) companies: Companies | None = None - contentRatings: list[ContentRating] | None = None + content_ratings: list[ContentRating] | None = Field(default=None, alias="contentRatings") first_release: Release | None = None - genres: list[GenreBaseRecord] | None = None - id: int | None = None - image: str | None = None - inspirations: list[Inspiration] | None = None - lastUpdated: str | None = None + genres: list[GenreBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Genres"}) + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) + inspirations: list[Inspiration] | None = Field(default=None, json_schema_extra={"x-go-name": "Inspirations"}) + last_updated: str | None = Field(default=None, alias="lastUpdated") lists: list[ListBaseRecord] | None = None - name: str | None = None - nameTranslations: list[str] | None = None - originalCountry: str | None = None - originalLanguage: str | None = None - overviewTranslations: list[str] | None = None - production_countries: list[ProductionCountry] | None = None - releases: list[Release] | None = None - remoteIds: list[RemoteID] | None = None + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) + original_country: str | None = Field(default=None, alias="originalCountry") + original_language: str | None = Field(default=None, alias="originalLanguage") + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + production_countries: list[ProductionCountry] | None = Field( + default=None, json_schema_extra={"x-go-name": "ProductionCountries"} + ) + releases: list[Release] | None = Field(default=None, json_schema_extra={"x-go-name": "Releases"}) + remote_ids: list[RemoteID] | None = Field( + default=None, alias="remoteIds", json_schema_extra={"x-go-name": "RemoteIDs"} + ) runtime: int | None = None - score: float | None = None - slug: str | None = None - spoken_languages: list[str] | None = None + score: float | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) + slug: str | None = Field(default=None, json_schema_extra={"x-go-name": "Slug"}) + spoken_languages: list[str] | None = Field(default=None, json_schema_extra={"x-go-name": "SpokenLanguages"}) status: Status | None = None - studios: list[StudioBaseRecord] | None = None - subtitleLanguages: list[str] | None = None - tagOptions: list[TagOption] | None = None - trailers: list[Trailer] | None = None + studios: list[StudioBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Studios"}) + subtitle_languages: list[str] | None = Field( + default=None, + alias="subtitleLanguages", + json_schema_extra={"x-go-name": "SubtitleLanguages"}, + ) + tag_options: list[TagOption] | None = Field( + default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + ) + trailers: list[Trailer] | None = Field(default=None, json_schema_extra={"x-go-name": "Trailers"}) translations: TranslationExtended | None = None year: str | None = None @@ -559,83 +680,128 @@ class MovieExtendedRecord(BaseModel): class SeasonBaseRecord(BaseModel): id: int | None = None image: str | None = None - imageType: int | None = None - lastUpdated: str | None = None + image_type: int | None = Field(default=None, alias="imageType") + last_updated: str | None = Field(default=None, alias="lastUpdated") name: str | None = None - nameTranslations: list[str] | None = None - number: int | None = None - overviewTranslations: list[str] | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) + number: int | None = Field(default=None, json_schema_extra={"x-go-name": "Number"}) + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) companies: Companies | None = None - seriesId: int | None = None + series_id: int | None = Field(default=None, alias="seriesId", json_schema_extra={"x-go-name": "SeriesID"}) type: SeasonType | None = None year: str | None = None class EpisodeBaseRecord(BaseModel): - absoluteNumber: int | None = None + absolute_number: int | None = Field(default=None, alias="absoluteNumber") aired: str | None = None - airsAfterSeason: int | None = None - airsBeforeEpisode: int | None = None - airsBeforeSeason: int | None = None - finaleType: str | None = Field(None, description="season, midseason, or series") - id: int | None = None + airs_after_season: int | None = Field(default=None, alias="airsAfterSeason") + airs_before_episode: int | None = Field(default=None, alias="airsBeforeEpisode") + airs_before_season: int | None = Field(default=None, alias="airsBeforeSeason") + finale_type: str | None = Field(default=None, alias="finaleType") + """ + season, midseason, or series + """ + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image: str | None = None - imageType: int | None = None - isMovie: int | None = None - lastUpdated: str | None = None - linkedMovie: int | None = None - name: str | None = None - nameTranslations: list[str] | None = None + image_type: int | None = Field(default=None, alias="imageType") + is_movie: int | None = Field(default=None, alias="isMovie", json_schema_extra={"x-go-name": "IsMovie"}) + last_updated: str | None = Field(default=None, alias="lastUpdated") + linked_movie: int | None = Field(default=None, alias="linkedMovie") + name: str | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) number: int | None = None overview: str | None = None - overviewTranslations: list[str] | None = None + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) runtime: int | None = None - seasonNumber: int | None = None - seasons: list[SeasonBaseRecord] | None = None - seriesId: int | None = None - seasonName: str | None = None + season_number: int | None = Field(default=None, alias="seasonNumber") + seasons: list[SeasonBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Seasons"}) + series_id: int | None = Field(default=None, alias="seriesId", json_schema_extra={"x-go-name": "SeriesID"}) + season_name: str | None = Field(default=None, alias="seasonName") year: str | None = None class SeasonExtendedRecord(BaseModel): - artwork: list[ArtworkBaseRecord] | None = None + artwork: list[ArtworkBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Artwork"}) companies: Companies | None = None - episodes: list[EpisodeBaseRecord] | None = None + episodes: list[EpisodeBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Episodes"}) id: int | None = None image: str | None = None - imageType: int | None = None - lastUpdated: str | None = None + image_type: int | None = Field(default=None, alias="imageType") + last_updated: str | None = Field(default=None, alias="lastUpdated") name: str | None = None - nameTranslations: list[str] | None = None - number: int | None = None - overviewTranslations: list[str] | None = None - seriesId: int | None = None - trailers: list[Trailer] | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) + number: int | None = Field(default=None, json_schema_extra={"x-go-name": "Number"}) + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + series_id: int | None = Field(default=None, alias="seriesId", json_schema_extra={"x-go-name": "SeriesID"}) + trailers: list[Trailer] | None = Field(default=None, json_schema_extra={"x-go-name": "Trailers"}) type: SeasonType | None = None - tagOptions: list[TagOption] | None = None + tag_options: list[TagOption] | None = Field( + default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + ) translations: list[Translation] | None = None year: str | None = None class SeriesBaseRecord(BaseModel): - aliases: list[Alias] | None = None - averageRuntime: int | None = None + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) + average_runtime: int | None = Field(default=None, alias="averageRuntime") country: str | None = None - defaultSeasonType: int | None = None - episodes: list[EpisodeBaseRecord] | None = None - firstAired: str | None = None + default_season_type: int | None = Field( + default=None, + alias="defaultSeasonType", + json_schema_extra={"x-go-name": "DefaultSeasonType"}, + ) + episodes: list[EpisodeBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Episodes"}) + first_aired: str | None = Field(default=None, alias="firstAired") id: int | None = None image: str | None = None - isOrderRandomized: bool | None = None - lastAired: str | None = None - lastUpdated: str | None = None + is_order_randomized: bool | None = Field( + default=None, + alias="isOrderRandomized", + json_schema_extra={"x-go-name": "IsOrderRandomized"}, + ) + last_aired: str | None = Field(default=None, alias="lastAired") + last_updated: str | None = Field(default=None, alias="lastUpdated") name: str | None = None - nameTranslations: list[str] | None = None - nextAired: str | None = None - originalCountry: str | None = None - originalLanguage: str | None = None - overviewTranslations: list[str] | None = None - score: float | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) + next_aired: str | None = Field(default=None, alias="nextAired", json_schema_extra={"x-go-name": "NextAired"}) + original_country: str | None = Field(default=None, alias="originalCountry") + original_language: str | None = Field(default=None, alias="originalLanguage") + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + score: float | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) slug: str | None = None status: Status | None = None year: str | None = None @@ -643,42 +809,62 @@ class SeriesBaseRecord(BaseModel): class SeriesExtendedRecord(BaseModel): abbreviation: str | None = None - airsDays: SeriesAirsDays | None = None - airsTime: str | None = None - aliases: list[Alias] | None = None - artworks: list[ArtworkExtendedRecord] | None = None - averageRuntime: int | None = None - characters: list[Character] | None = None - contentRatings: list[ContentRating] | None = None + airs_days: SeriesAirsDays | None = Field(default=None, alias="airsDays") + airs_time: str | None = Field(default=None, alias="airsTime") + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) + artworks: list[ArtworkExtendedRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Artworks"}) + average_runtime: int | None = Field(default=None, alias="averageRuntime") + characters: list[Character] | None = Field(default=None, json_schema_extra={"x-go-name": "Characters"}) + content_ratings: list[ContentRating] | None = Field(default=None, alias="contentRatings") country: str | None = None - defaultSeasonType: int | None = None - episodes: list[EpisodeBaseRecord] | None = None - firstAired: str | None = None + default_season_type: int | None = Field( + default=None, + alias="defaultSeasonType", + json_schema_extra={"x-go-name": "DefaultSeasonType"}, + ) + episodes: list[EpisodeBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Episodes"}) + first_aired: str | None = Field(default=None, alias="firstAired") lists: list[ListBaseRecord] | None = None - genres: list[GenreBaseRecord] | None = None + genres: list[GenreBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Genres"}) id: int | None = None image: str | None = None - isOrderRandomized: bool | None = None - lastAired: str | None = None - lastUpdated: str | None = None + is_order_randomized: bool | None = Field( + default=None, + alias="isOrderRandomized", + json_schema_extra={"x-go-name": "IsOrderRandomized"}, + ) + last_aired: str | None = Field(default=None, alias="lastAired") + last_updated: str | None = Field(default=None, alias="lastUpdated") name: str | None = None - nameTranslations: list[str] | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) companies: list[Company] | None = None - nextAired: str | None = None - originalCountry: str | None = None - originalLanguage: str | None = None - originalNetwork: Company | None = None + next_aired: str | None = Field(default=None, alias="nextAired", json_schema_extra={"x-go-name": "NextAired"}) + original_country: str | None = Field(default=None, alias="originalCountry") + original_language: str | None = Field(default=None, alias="originalLanguage") + original_network: Company | None = Field(default=None, alias="originalNetwork") overview: str | None = None - latestNetwork: Company | None = None - overviewTranslations: list[str] | None = None - remoteIds: list[RemoteID] | None = None - score: float | None = None - seasons: list[SeasonBaseRecord] | None = None - seasonTypes: list[SeasonType] | None = None + latest_network: Company | None = Field(default=None, alias="latestNetwork") + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + remote_ids: list[RemoteID] | None = Field( + default=None, alias="remoteIds", json_schema_extra={"x-go-name": "RemoteIDs"} + ) + score: float | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) + seasons: list[SeasonBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Seasons"}) + season_types: list[SeasonType] | None = Field( + default=None, alias="seasonTypes", json_schema_extra={"x-go-name": "Seasons"} + ) slug: str | None = None status: Status | None = None - tags: list[TagOption] | None = None - trailers: list[Trailer] | None = None + tags: list[TagOption] | None = Field(default=None, json_schema_extra={"x-go-name": "TagOptions"}) + trailers: list[Trailer] | None = Field(default=None, json_schema_extra={"x-go-name": "Trailers"}) translations: TranslationExtended | None = None year: str | None = None @@ -687,8 +873,8 @@ class AwardNomineeBaseRecord(BaseModel): character: Character | None = None details: str | None = None episode: EpisodeBaseRecord | None = None - id: int | None = None - isWinner: bool | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + is_winner: bool | None = Field(default=None, alias="isWinner", json_schema_extra={"x-go-name": "IsWinner"}) movie: MovieBaseRecord | None = None series: SeriesBaseRecord | None = None year: str | None = None @@ -698,36 +884,55 @@ class AwardNomineeBaseRecord(BaseModel): class EpisodeExtendedRecord(BaseModel): aired: str | None = None - airsAfterSeason: int | None = None - airsBeforeEpisode: int | None = None - airsBeforeSeason: int | None = None - awards: list[AwardBaseRecord] | None = None - characters: list[Character] | None = None + airs_after_season: int | None = Field(default=None, alias="airsAfterSeason") + airs_before_episode: int | None = Field(default=None, alias="airsBeforeEpisode") + airs_before_season: int | None = Field(default=None, alias="airsBeforeSeason") + awards: list[AwardBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Awards"}) + characters: list[Character] | None = Field(default=None, json_schema_extra={"x-go-name": "Characters"}) companies: list[Company] | None = None - contentRatings: list[ContentRating] | None = None - finaleType: str | None = Field(None, description="season, midseason, or series") - id: int | None = None + content_ratings: list[ContentRating] | None = Field( + default=None, + alias="contentRatings", + json_schema_extra={"x-go-name": "ContentRatings"}, + ) + finale_type: str | None = Field(default=None, alias="finaleType") + """ + season, midseason, or series + """ + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image: str | None = None - imageType: int | None = None - isMovie: int | None = None - lastUpdated: str | None = None - linkedMovie: int | None = None - name: str | None = None - nameTranslations: list[str] | None = None + image_type: int | None = Field(default=None, alias="imageType") + is_movie: int | None = Field(default=None, alias="isMovie", json_schema_extra={"x-go-name": "IsMovie"}) + last_updated: str | None = Field(default=None, alias="lastUpdated") + linked_movie: int | None = Field(default=None, alias="linkedMovie") + name: str | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) networks: list[Company] | None = None - nominations: list[AwardNomineeBaseRecord] | None = None + nominations: list[AwardNomineeBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Nominees"}) number: int | None = None overview: str | None = None - overviewTranslations: list[str] | None = None - productionCode: str | None = None - remoteIds: list[RemoteID] | None = None + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + production_code: str | None = Field(default=None, alias="productionCode") + remote_ids: list[RemoteID] | None = Field( + default=None, alias="remoteIds", json_schema_extra={"x-go-name": "RemoteIDs"} + ) runtime: int | None = None - seasonNumber: int | None = None - seasons: list[SeasonBaseRecord] | None = None - seriesId: int | None = None + season_number: int | None = Field(default=None, alias="seasonNumber") + seasons: list[SeasonBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Seasons"}) + series_id: int | None = Field(default=None, alias="seriesId", json_schema_extra={"x-go-name": "SeriesID"}) studios: list[Company] | None = None - tagOptions: list[TagOption] | None = None - trailers: list[Trailer] | None = None + tag_options: list[TagOption] | None = Field( + default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + ) + trailers: list[Trailer] | None = Field(default=None, json_schema_extra={"x-go-name": "Trailers"}) translations: TranslationExtended | None = None year: str | None = None @@ -741,10 +946,14 @@ class SearchByRemoteIdResult(BaseModel): class AwardCategoryExtendedRecord(BaseModel): - allowCoNominees: bool | None = None + allow_co_nominees: bool | None = Field( + default=None, + alias="allowCoNominees", + json_schema_extra={"x-go-name": "AllowCoNominees"}, + ) award: AwardBaseRecord | None = None - forMovies: bool | None = None - forSeries: bool | None = None - id: int | None = None + for_movies: bool | None = Field(default=None, alias="forMovies", json_schema_extra={"x-go-name": "ForMovies"}) + for_series: bool | None = Field(default=None, alias="forSeries", json_schema_extra={"x-go-name": "ForSeries"}) + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) name: str | None = None - nominees: list[AwardNomineeBaseRecord] | None = None + nominees: list[AwardNomineeBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Nominees"}) diff --git a/src/tvdb/models.py b/src/tvdb/models.py index 2f3145e..4f422fa 100644 --- a/src/tvdb/models.py +++ b/src/tvdb/models.py @@ -44,7 +44,7 @@ class SeriesExtendedResponse(_Response): data: SeriesExtendedRecord -class MoviesExtendedResponse(_Response): +class MovieExtendedResponse(_Response): """Model for the response of the movies/{id}/extended endpoint of the TVDB API.""" data: MovieExtendedRecord diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 From 37ddfc46d0be76ff18aa9db8ee76244b2842a315 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 21 Jul 2024 14:53:32 +0200 Subject: [PATCH 077/166] :wastebasket: Clean up code --- src/exts/tvdb_info/main.py | 11 +++-------- src/tvdb/client.py | 9 +++------ src/tvdb/generated_models.py | 8 ++++---- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index b347282..d3b3be8 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -59,12 +59,10 @@ def _get_embed(self) -> discord.Embed: async def _dropdown_callback(self, interaction: discord.Interaction) -> None: if not self.dropdown.values or not isinstance(self.dropdown.values[0], str): - log.error("Dropdown values are empty or not a string but callback was triggered.") - return + raise ValueError("Dropdown values are empty or not a string but callback was triggered.") self.index = int(self.dropdown.values[0]) if not self.message: - log.error("Message is not set but callback was triggered.") - return + raise ValueError("Message is not set but callback was triggered.") await self.message.edit(embed=self._get_embed(), view=self) await interaction.response.defer() @@ -79,10 +77,7 @@ class InfoCog(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - @slash_command( - name="search", - description="Search for a movie or series.", - ) + @slash_command() @option("query", input_type=str, description="The query to search for.") @option( "type", diff --git a/src/tvdb/client.py b/src/tvdb/client.py index b44c033..07260ec 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -90,15 +90,12 @@ def bilingual_name(self) -> str | None: return f"{self.name} ({self.name_eng})" @property - def id(self) -> int | str | None: + def id(self) -> int: return self._id @id.setter - def id(self, value: int | str | None) -> None: - if value: - self._id = int(str(value).split("-")[1]) - else: - self._id = None + def id(self, value: int | str) -> None: # pyright: ignore[reportPropertyTypeMismatch] + self._id = parse_media_id(value) @classmethod @abstractmethod diff --git a/src/tvdb/generated_models.py b/src/tvdb/generated_models.py index 3f88704..fdebb6d 100644 --- a/src/tvdb/generated_models.py +++ b/src/tvdb/generated_models.py @@ -476,7 +476,7 @@ class ListBaseRecord(BaseModel): class MovieBaseRecord(BaseModel): aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + id: int = Field(json_schema_extra={"x-go-name": "ID"}) image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) last_updated: str | None = Field(default=None, alias="lastUpdated") name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) @@ -633,7 +633,7 @@ class MovieExtendedRecord(BaseModel): content_ratings: list[ContentRating] | None = Field(default=None, alias="contentRatings") first_release: Release | None = None genres: list[GenreBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Genres"}) - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + id: int = Field(json_schema_extra={"x-go-name": "ID"}) image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) inspirations: list[Inspiration] | None = Field(default=None, json_schema_extra={"x-go-name": "Inspirations"}) last_updated: str | None = Field(default=None, alias="lastUpdated") @@ -778,7 +778,7 @@ class SeriesBaseRecord(BaseModel): ) episodes: list[EpisodeBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Episodes"}) first_aired: str | None = Field(default=None, alias="firstAired") - id: int | None = None + id: int image: str | None = None is_order_randomized: bool | None = Field( default=None, @@ -826,7 +826,7 @@ class SeriesExtendedRecord(BaseModel): first_aired: str | None = Field(default=None, alias="firstAired") lists: list[ListBaseRecord] | None = None genres: list[GenreBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Genres"}) - id: int | None = None + id: int image: str | None = None is_order_randomized: bool | None = Field( default=None, From 79d4400176632e6e9fce25ba711f544b4314a3ed Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 21 Jul 2024 15:18:25 +0200 Subject: [PATCH 078/166] Right Co-authored-by: ItsDrike --- src/tvdb/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 07260ec..70a6d65 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -57,7 +57,6 @@ def __init__(self, client: "TvdbClient", data: SeriesRecord | MovieRecord | Sear self.image_url: URL | None = None if isinstance(self.data, SearchResult) and self.data.image_url: self.image_url = URL(self.data.image_url) - # that not isn't needed but is there for clarity and for pyright elif not isinstance(self.data, SearchResult) and self.data.image: self.image_url = URL(self.data.image) From 9533061c1174673dd637f30f7cc41ff228b11402 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 21 Jul 2024 15:43:29 +0200 Subject: [PATCH 079/166] :recycle: Fix stuff --- src/tvdb/client.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 70a6d65..390bb60 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import ClassVar, Literal, overload, override +from typing import ClassVar, Literal, final, overload, override import aiohttp from yarl import URL @@ -96,21 +96,15 @@ def id(self) -> int: def id(self, value: int | str) -> None: # pyright: ignore[reportPropertyTypeMismatch] self._id = parse_media_id(value) - @classmethod @abstractmethod + @classmethod async def fetch(cls, media_id: int | str, *, client: "TvdbClient", extended: bool = False) -> "_Media": ... +@final class Movie(_Media): """Class to interact with the TVDB API for movies.""" - @overload - @classmethod - async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: Literal[False]) -> "Movie": ... - @overload - @classmethod - async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: Literal[True]) -> "Movie": ... - @override @classmethod async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: bool = False) -> "Movie": @@ -127,16 +121,10 @@ async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: boo return cls(client, response.data) +@final class Series(_Media): """Class to interact with the TVDB API for series.""" - @overload - @classmethod - async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: Literal[False]) -> "Series": ... - @overload - @classmethod - async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: Literal[True]) -> "Series": ... - @override @classmethod async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: bool = False) -> "Series": From 4d6caaed665c89ac0cbbbcfca2f141f6eca156c7 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 21 Jul 2024 16:13:48 +0200 Subject: [PATCH 080/166] Make requested changes --- src/tvdb/client.py | 3 +-- src/tvdb/models.py | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 390bb60..7d20d6c 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -48,8 +48,7 @@ def __init__(self, client: "TvdbClient", data: SeriesRecord | MovieRecord | Sear self.slug: str | None = None if hasattr(self.data, "slug"): self.slug = self.data.slug - if hasattr(self.data, "id"): - self.id = self.data.id + self.id = self.data.id self.name_eng: str | None = None self.overview_eng: str | None = None diff --git a/src/tvdb/models.py b/src/tvdb/models.py index 4f422fa..e2d1d37 100644 --- a/src/tvdb/models.py +++ b/src/tvdb/models.py @@ -48,6 +48,3 @@ class MovieExtendedResponse(_Response): """Model for the response of the movies/{id}/extended endpoint of the TVDB API.""" data: MovieExtendedRecord - - -type SearchResults = list[SearchResult] From 17f1c0582a4f4bb39eec29ee887e5dd8e47ec60b Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 21 Jul 2024 16:34:20 +0200 Subject: [PATCH 081/166] :ambulance: Oops - inverted position of decorators --- src/tvdb/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 7d20d6c..5752a15 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -95,8 +95,8 @@ def id(self) -> int: def id(self, value: int | str) -> None: # pyright: ignore[reportPropertyTypeMismatch] self._id = parse_media_id(value) - @abstractmethod @classmethod + @abstractmethod async def fetch(cls, media_id: int | str, *, client: "TvdbClient", extended: bool = False) -> "_Media": ... From 6d787c4a6d57a33b82c59feb1f953f40d08b2eb2 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 16:36:31 +0200 Subject: [PATCH 082/166] Fix grammar and add markdown alert blocks Co-authored-by: Paillat --- README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d9137d9..3e95c40 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ The project also supports [docker](https://www.docker.com/) installation, which anywhere, without installing all of the dependencies manually. This is a lot more convenient way to run the bot, if you just want to run it and you don't wish to do any actual development. -To use docker, you can check out the images that are automatically built after each update to the `main` branch on +To use docker, you can check out the images that are automatically built after each update to the `main` branch at [ghcr](https://github.com/itsdrike/code-jam-2024/pkgs/container/code-jam-2024). You can also use [`docker compose`](https://docs.docker.com/compose/) with the [`docker-compose.yaml`](./docker-compose.yaml) file, which will pull this image from ghcr. To run the container using this file, you can use the following command: @@ -38,8 +38,11 @@ pull this image from ghcr. To run the container using this file, you can use the docker compose up ``` +> [!TIP] +> To run the container in the background, add the `-d` flag to the command. + If you want to build the image locally (to include some other changes that aren't yet in the main branch, maybe during -development or to customize something when deploying), you can also use the +development or to customize something when deploying), you can also use [`docker-compose.local.yaml`](./docker-compose.local.yaml), which defines an image building step from our [`Dockerfile`](./Dockerfile). To run this local version of docker-compose, you can use the following command: @@ -47,8 +50,9 @@ development or to customize something when deploying), you can also use the docker compose -f ./docker-compose.local.yaml up ``` -Note that you will still need to create a `.env` file with all of the configuration variables (see [the configuring -section](#configuring-the-bot) +> [!IMPORTANT] +> Note that you will still need to create a `.env` file with all of the configuration variables (see [the configuring +> section](#configuring-the-bot) ## Configuring the bot From ae28455ade0c2f77fbe89bdff25747b746f5ebcf Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 17:59:18 +0200 Subject: [PATCH 083/166] Add sqlalchemy --- .gitignore | 3 + README.md | 20 +++-- poetry.lock | 178 +++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 + src/__main__.py | 33 ++++++- src/bot.py | 10 ++- src/db_tables/__init__.py | 0 src/settings.py | 5 ++ src/utils/database.py | 66 ++++++++++++++ 9 files changed, 305 insertions(+), 12 deletions(-) create mode 100644 src/db_tables/__init__.py create mode 100644 src/utils/database.py diff --git a/.gitignore b/.gitignore index 6d08a49..d95e651 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# SQLite database +database.db + # Files generated by the interpreter __pycache__/ *.py[cod] diff --git a/README.md b/README.md index ca8fcad..b623084 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,19 @@ The bot is configured using environment variables. You can either create a `.env there, or you can set / export them manually. Using the `.env` file is generally a better idea and will likely be more convenient. -| Variable name | Type | Description | -| -------------------- | ------ | --------------------------------------------------------------------------------------------------- | -| `BOT_TOKEN` | string | Bot token of the discord application (see: [this guide][bot-token-guide] if you don't have one yet) | -| `TVDB_API_KEY` | string | API key for TVDB (see [this page][tvdb-api-page] if you don't have one yet) | -| `DEBUG` | bool | If `1`, debug logs will be enabled, if `0` only info logs and above will be shown | -| `LOG_FILE` | path | If set, also write the logs into given file, otherwise, only print them | -| `TRACE_LEVEL_FILTER` | custom | Configuration for trace level logging, see: [trace logs config section](#trace-logs-config) | + + +| Variable name | Type | Default | Description | +| ---------------------- | ------ | ------------- | ------------------------------------------------------------------------------------------------------------------ | +| `BOT_TOKEN` | string | N/A | Bot token of the discord application (see: [this guide][bot-token-guide] if you don't have one yet) | +| `TVDB_API_KEY` | string | N/A | API key for TVDB (see [this page][tvdb-api-page] if you don't have one yet) | +| `SQLITE_DATABASE_FILE` | path | ./database.db | Path to sqlite database file, can be relative to project root (if the file doesn't yet exists, it will be created) | +| `ECHO_SQL` | bool | 0 | If `1`, print out every SQL command that SQLAlchemy library runs internally (can be useful when debugging) | +| `DEBUG` | bool | 0 | If `1`, debug logs will be enabled, if `0` only info logs and above will be shown | +| `LOG_FILE` | path | N/A | If set, also write the logs into given file, otherwise, only print them | +| `TRACE_LEVEL_FILTER` | custom | N/A | Configuration for trace level logging, see: [trace logs config section](#trace-logs-config) | [bot-token-guide]: https://guide.pycord.dev/getting-started/creating-your-first-bot#creating-the-bot-application [tvdb-api-page]: https://www.thetvdb.com/api-information diff --git a/poetry.lock b/poetry.lock index fa3505d..ac12cb6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -109,6 +109,24 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "aiosqlite" +version = "0.20.0" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6"}, + {file = "aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7"}, +] + +[package.dependencies] +typing_extensions = ">=4.0" + +[package.extras] +dev = ["attribution (==1.7.0)", "black (==24.2.0)", "coverage[toml] (==7.4.1)", "flake8 (==7.0.0)", "flake8-bugbear (==24.2.6)", "flit (==3.9.0)", "mypy (==1.8.0)", "ufmt (==2.3.0)", "usort (==1.0.8.post1)"] +docs = ["sphinx (==7.2.6)", "sphinx-mdinclude (==0.5.3)"] + [[package]] name = "annotated-types" version = "0.7.0" @@ -547,6 +565,77 @@ files = [ {file = "genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37"}, ] +[[package]] +name = "greenlet" +version = "3.0.3" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.7" +files = [ + {file = "greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405"}, + {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f"}, + {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb"}, + {file = "greenlet-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9"}, + {file = "greenlet-3.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22"}, + {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3"}, + {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d"}, + {file = "greenlet-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728"}, + {file = "greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf"}, + {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305"}, + {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6"}, + {file = "greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2"}, + {file = "greenlet-3.0.3-cp37-cp37m-macosx_11_0_universal2.whl", hash = "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41"}, + {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7"}, + {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6"}, + {file = "greenlet-3.0.3-cp37-cp37m-win32.whl", hash = "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d"}, + {file = "greenlet-3.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67"}, + {file = "greenlet-3.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4"}, + {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5"}, + {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da"}, + {file = "greenlet-3.0.3-cp38-cp38-win32.whl", hash = "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3"}, + {file = "greenlet-3.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf"}, + {file = "greenlet-3.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b"}, + {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6"}, + {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113"}, + {file = "greenlet-3.0.3-cp39-cp39-win32.whl", hash = "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e"}, + {file = "greenlet-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067"}, + {file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil"] + [[package]] name = "h11" version = "0.14.0" @@ -1294,6 +1383,93 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "sqlalchemy" +version = "2.0.31" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "SQLAlchemy-2.0.31-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f2a213c1b699d3f5768a7272de720387ae0122f1becf0901ed6eaa1abd1baf6c"}, + {file = "SQLAlchemy-2.0.31-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9fea3d0884e82d1e33226935dac990b967bef21315cbcc894605db3441347443"}, + {file = "SQLAlchemy-2.0.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ad7f221d8a69d32d197e5968d798217a4feebe30144986af71ada8c548e9fa"}, + {file = "SQLAlchemy-2.0.31-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2bee229715b6366f86a95d497c347c22ddffa2c7c96143b59a2aa5cc9eebbc"}, + {file = "SQLAlchemy-2.0.31-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cd5b94d4819c0c89280b7c6109c7b788a576084bf0a480ae17c227b0bc41e109"}, + {file = "SQLAlchemy-2.0.31-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:750900a471d39a7eeba57580b11983030517a1f512c2cb287d5ad0fcf3aebd58"}, + {file = "SQLAlchemy-2.0.31-cp310-cp310-win32.whl", hash = "sha256:7bd112be780928c7f493c1a192cd8c5fc2a2a7b52b790bc5a84203fb4381c6be"}, + {file = "SQLAlchemy-2.0.31-cp310-cp310-win_amd64.whl", hash = "sha256:5a48ac4d359f058474fadc2115f78a5cdac9988d4f99eae44917f36aa1476327"}, + {file = "SQLAlchemy-2.0.31-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f68470edd70c3ac3b6cd5c2a22a8daf18415203ca1b036aaeb9b0fb6f54e8298"}, + {file = "SQLAlchemy-2.0.31-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e2c38c2a4c5c634fe6c3c58a789712719fa1bf9b9d6ff5ebfce9a9e5b89c1ca"}, + {file = "SQLAlchemy-2.0.31-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd15026f77420eb2b324dcb93551ad9c5f22fab2c150c286ef1dc1160f110203"}, + {file = "SQLAlchemy-2.0.31-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2196208432deebdfe3b22185d46b08f00ac9d7b01284e168c212919891289396"}, + {file = "SQLAlchemy-2.0.31-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:352b2770097f41bff6029b280c0e03b217c2dcaddc40726f8f53ed58d8a85da4"}, + {file = "SQLAlchemy-2.0.31-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:56d51ae825d20d604583f82c9527d285e9e6d14f9a5516463d9705dab20c3740"}, + {file = "SQLAlchemy-2.0.31-cp311-cp311-win32.whl", hash = "sha256:6e2622844551945db81c26a02f27d94145b561f9d4b0c39ce7bfd2fda5776dac"}, + {file = "SQLAlchemy-2.0.31-cp311-cp311-win_amd64.whl", hash = "sha256:ccaf1b0c90435b6e430f5dd30a5aede4764942a695552eb3a4ab74ed63c5b8d3"}, + {file = "SQLAlchemy-2.0.31-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3b74570d99126992d4b0f91fb87c586a574a5872651185de8297c6f90055ae42"}, + {file = "SQLAlchemy-2.0.31-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f77c4f042ad493cb8595e2f503c7a4fe44cd7bd59c7582fd6d78d7e7b8ec52c"}, + {file = "SQLAlchemy-2.0.31-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd1591329333daf94467e699e11015d9c944f44c94d2091f4ac493ced0119449"}, + {file = "SQLAlchemy-2.0.31-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74afabeeff415e35525bf7a4ecdab015f00e06456166a2eba7590e49f8db940e"}, + {file = "SQLAlchemy-2.0.31-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b9c01990d9015df2c6f818aa8f4297d42ee71c9502026bb074e713d496e26b67"}, + {file = "SQLAlchemy-2.0.31-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:66f63278db425838b3c2b1c596654b31939427016ba030e951b292e32b99553e"}, + {file = "SQLAlchemy-2.0.31-cp312-cp312-win32.whl", hash = "sha256:0b0f658414ee4e4b8cbcd4a9bb0fd743c5eeb81fc858ca517217a8013d282c96"}, + {file = "SQLAlchemy-2.0.31-cp312-cp312-win_amd64.whl", hash = "sha256:fa4b1af3e619b5b0b435e333f3967612db06351217c58bfb50cee5f003db2a5a"}, + {file = "SQLAlchemy-2.0.31-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f43e93057cf52a227eda401251c72b6fbe4756f35fa6bfebb5d73b86881e59b0"}, + {file = "SQLAlchemy-2.0.31-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d337bf94052856d1b330d5fcad44582a30c532a2463776e1651bd3294ee7e58b"}, + {file = "SQLAlchemy-2.0.31-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c06fb43a51ccdff3b4006aafee9fcf15f63f23c580675f7734245ceb6b6a9e05"}, + {file = "SQLAlchemy-2.0.31-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:b6e22630e89f0e8c12332b2b4c282cb01cf4da0d26795b7eae16702a608e7ca1"}, + {file = "SQLAlchemy-2.0.31-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:79a40771363c5e9f3a77f0e28b3302801db08040928146e6808b5b7a40749c88"}, + {file = "SQLAlchemy-2.0.31-cp37-cp37m-win32.whl", hash = "sha256:501ff052229cb79dd4c49c402f6cb03b5a40ae4771efc8bb2bfac9f6c3d3508f"}, + {file = "SQLAlchemy-2.0.31-cp37-cp37m-win_amd64.whl", hash = "sha256:597fec37c382a5442ffd471f66ce12d07d91b281fd474289356b1a0041bdf31d"}, + {file = "SQLAlchemy-2.0.31-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:dc6d69f8829712a4fd799d2ac8d79bdeff651c2301b081fd5d3fe697bd5b4ab9"}, + {file = "SQLAlchemy-2.0.31-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:23b9fbb2f5dd9e630db70fbe47d963c7779e9c81830869bd7d137c2dc1ad05fb"}, + {file = "SQLAlchemy-2.0.31-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21c97efcbb9f255d5c12a96ae14da873233597dfd00a3a0c4ce5b3e5e79704"}, + {file = "SQLAlchemy-2.0.31-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26a6a9837589c42b16693cf7bf836f5d42218f44d198f9343dd71d3164ceeeac"}, + {file = "SQLAlchemy-2.0.31-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc251477eae03c20fae8db9c1c23ea2ebc47331bcd73927cdcaecd02af98d3c3"}, + {file = "SQLAlchemy-2.0.31-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2fd17e3bb8058359fa61248c52c7b09a97cf3c820e54207a50af529876451808"}, + {file = "SQLAlchemy-2.0.31-cp38-cp38-win32.whl", hash = "sha256:c76c81c52e1e08f12f4b6a07af2b96b9b15ea67ccdd40ae17019f1c373faa227"}, + {file = "SQLAlchemy-2.0.31-cp38-cp38-win_amd64.whl", hash = "sha256:4b600e9a212ed59355813becbcf282cfda5c93678e15c25a0ef896b354423238"}, + {file = "SQLAlchemy-2.0.31-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b6cf796d9fcc9b37011d3f9936189b3c8074a02a4ed0c0fbbc126772c31a6d4"}, + {file = "SQLAlchemy-2.0.31-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:78fe11dbe37d92667c2c6e74379f75746dc947ee505555a0197cfba9a6d4f1a4"}, + {file = "SQLAlchemy-2.0.31-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fc47dc6185a83c8100b37acda27658fe4dbd33b7d5e7324111f6521008ab4fe"}, + {file = "SQLAlchemy-2.0.31-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a41514c1a779e2aa9a19f67aaadeb5cbddf0b2b508843fcd7bafdf4c6864005"}, + {file = "SQLAlchemy-2.0.31-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:afb6dde6c11ea4525318e279cd93c8734b795ac8bb5dda0eedd9ebaca7fa23f1"}, + {file = "SQLAlchemy-2.0.31-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3f9faef422cfbb8fd53716cd14ba95e2ef655400235c3dfad1b5f467ba179c8c"}, + {file = "SQLAlchemy-2.0.31-cp39-cp39-win32.whl", hash = "sha256:fc6b14e8602f59c6ba893980bea96571dd0ed83d8ebb9c4479d9ed5425d562e9"}, + {file = "SQLAlchemy-2.0.31-cp39-cp39-win_amd64.whl", hash = "sha256:3cb8a66b167b033ec72c3812ffc8441d4e9f5f78f5e31e54dcd4c90a4ca5bebc"}, + {file = "SQLAlchemy-2.0.31-py3-none-any.whl", hash = "sha256:69f3e3c08867a8e4856e92d7afb618b95cdee18e0bc1647b77599722c9a28911"}, + {file = "SQLAlchemy-2.0.31.tar.gz", hash = "sha256:b607489dd4a54de56984a0c7656247504bd5523d9d0ba799aef59d4add009484"}, +] + +[package.dependencies] +greenlet = {version = "!=0.4.17", optional = true, markers = "python_version < \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\") or extra == \"asyncio\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] +aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] +aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -1431,4 +1607,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "47f02cdaaf0e859a61387b320a3796f1b7a5755be0165cd52be6459cd2ca74ec" +content-hash = "a94d99de883e7a7fbaa16e0ebe8e8367c34d914868de715ecbcee60bf669fa4d" diff --git a/pyproject.toml b/pyproject.toml index c5d53e0..37125ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,8 @@ py-cord = "^2.6.0" python-decouple = "^3.8" coloredlogs = "^15.0.1" pydantic = "^2.8.2" +sqlalchemy = { version = "^2.0.31", extras = ["asyncio"] } +aiosqlite = "^0.20.0" [tool.poetry.group.lint.dependencies] ruff = "^0.3.2" diff --git a/src/__main__.py b/src/__main__.py index 09d0a58..82afbd3 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -4,12 +4,37 @@ import discord from src.bot import Bot -from src.settings import BOT_TOKEN +from src.settings import BOT_TOKEN, SQLITE_DATABASE_FILE +from src.utils.database import Base, engine, get_db_session, load_db_models from src.utils.log import get_logger log = get_logger(__name__) +async def _init_database(*, retries: int = 5, retry_time: float = 3) -> None: + """Try to connect to the database, keep trying if we fail. + + :param retries: Number of re-try attempts, in case db connection fails. + :param retry_time: Time to wait between re-try attempts. + """ + load_db_models() + + # NOTE: The retry logic here isn't that useful with sqlite databases, but it's here + # in case we switch to a different database in the future. + for _ in range(retries): + log.debug(f"Connecting to the database: {SQLITE_DATABASE_FILE}") + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + except ConnectionRefusedError as exc: + log.exception(f"Database connection failed, retrying in {retry_time} seconds.", exc_info=exc) + await asyncio.sleep(retry_time) + else: + break + + log.debug("Database connection established") + + async def main() -> None: """Main entrypoint of the application. @@ -18,8 +43,10 @@ async def main() -> None: intents = discord.Intents().default() intents.message_content = True - async with aiohttp.ClientSession() as http_session: - bot = Bot(intents=intents, http_session=http_session) + await _init_database() + + async with aiohttp.ClientSession() as http_session, get_db_session() as db_session: + bot = Bot(intents=intents, http_session=http_session, db_session=db_session) bot.load_all_extensions() log.info("Starting the bot...") diff --git a/src/bot.py b/src/bot.py index a4851df..1200e68 100644 --- a/src/bot.py +++ b/src/bot.py @@ -4,6 +4,7 @@ import aiohttp import discord +from sqlalchemy.ext.asyncio import AsyncSession from src.utils.log import get_logger @@ -26,10 +27,17 @@ class Bot(discord.Bot): "src.exts.tvdb_info", ] - def __init__(self, *args: object, http_session: aiohttp.ClientSession, **kwargs: object) -> None: + def __init__( + self, + *args: object, + http_session: aiohttp.ClientSession, + db_session: AsyncSession, + **kwargs: object, + ) -> None: """Initialize the bot instance, containing various state variables.""" super().__init__(*args, **kwargs) self.http_session = http_session + self.db_session = db_session self.event(self.on_ready) diff --git a/src/db_tables/__init__.py b/src/db_tables/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/settings.py b/src/settings.py index c94c0ba..af17539 100644 --- a/src/settings.py +++ b/src/settings.py @@ -1,9 +1,14 @@ +from pathlib import Path + from src.utils.config import get_config GITHUB_REPO = "https://github.com/ItsDrike/code-jam-2024" BOT_TOKEN = get_config("BOT_TOKEN") TVDB_API_KEY = get_config("TVDB_API_KEY") +SQLITE_DATABASE_FILE = get_config("SQLITE_DATABASE_FILE", cast=Path, default=Path("./database.db")) +ECHO_SQL = get_config("ECHO_SQL", cast=bool, default=False) + FAIL_EMOJI = "❌" SUCCESS_EMOJI = "✅" diff --git a/src/utils/database.py b/src/utils/database.py new file mode 100644 index 0000000..e794e7f --- /dev/null +++ b/src/utils/database.py @@ -0,0 +1,66 @@ +import importlib +import pkgutil +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import NoReturn + +from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from src.settings import ECHO_SQL, SQLITE_DATABASE_FILE +from src.utils.log import get_logger + +log = get_logger(__name__) + +__all__ = ["engine", "Base", "load_db_models", "get_db_session"] + +TABLES_PACKAGE_PATH = "src.db_tables" + + +engine = create_async_engine( + f"sqlite+aiosqlite:///{SQLITE_DATABASE_FILE.absolute()}", + echo=ECHO_SQL, +) + +SessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +class Base(AsyncAttrs, DeclarativeBase): + """SQLAlchemy base class for registering ORM models. + + Note: Before calling ``Base.metadata.create_all``, all models that inherit + from this class must already be loaded (imported), so that this metaclass + can know about all of the models. See :func:`load_models`. + """ + + +def load_db_models() -> None: + """Import all models (all files/modules containing the models). + + This step is required before calling ``Base.metadata.create_all``, as all models + need to first be imported, so that they get registered into the :class:`Base` class. + """ + + def on_error(name: str) -> NoReturn: + """Handle an error encountered while walking packages.""" + raise ImportError(name=name) + + def ignore_module(module: pkgutil.ModuleInfo) -> bool: + """Return whether the module with name `name` should be ignored.""" + return any(name.startswith("_") for name in module.name.split(".")) + + log.debug(f"Loading database modules from {TABLES_PACKAGE_PATH}") + db_module = importlib.import_module(TABLES_PACKAGE_PATH) + for module_info in pkgutil.walk_packages(db_module.__path__, f"{db_module.__name__}.", onerror=on_error): + if ignore_module(module_info): + continue + + log.debug(f"Loading database module: {module_info.name}") + importlib.import_module(module_info.name) + + +@asynccontextmanager +async def get_db_session() -> AsyncIterator[AsyncSession]: + """Obtain a database session.""" + async with SessionLocal() as session: + yield session From 67d88a47f5321788fac5b9eb642ea5ee344b4023 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 18:07:10 +0200 Subject: [PATCH 084/166] Add README to db_tables/ --- src/db_tables/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/db_tables/README.md diff --git a/src/db_tables/README.md b/src/db_tables/README.md new file mode 100644 index 0000000..0f6400a --- /dev/null +++ b/src/db_tables/README.md @@ -0,0 +1,12 @@ + + +# Welcome to `db_tables` directory + +This directory defines all of our database tables. To do so, we're using [`SQLAlchemy`](https://docs.sqlalchemy.org) +ORM. That means our database tables are defined as python classes, that follow certain special syntax to achieve this. + +All of these tables must inherit from the `Base` class, that can be imported from `src.utils.database` module. + +There is no need to register newly created classes / files anywhere, as all files in this directory (except those +starting with `_`) will be automatically imported and picked up by SQLAlchemy. From 479fd9522f5d6a3fa267240f2b2d69ae1af09a9f Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 18:26:51 +0200 Subject: [PATCH 085/166] Add alembic --- alembic-migrations/env.py | 93 +++++++++++++++++++++++ alembic-migrations/script.py.mako | 25 +++++++ alembic.ini | 118 ++++++++++++++++++++++++++++++ poetry.lock | 40 +++++++++- pyproject.toml | 20 +++++ src/utils/database.py | 6 +- 6 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 alembic-migrations/env.py create mode 100644 alembic-migrations/script.py.mako create mode 100644 alembic.ini diff --git a/alembic-migrations/env.py b/alembic-migrations/env.py new file mode 100644 index 0000000..8362e5c --- /dev/null +++ b/alembic-migrations/env.py @@ -0,0 +1,93 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import AsyncEngine + +from src.utils.database import Base, SQLALCHEMY_URL, load_db_models + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +load_db_models() +target_metadata = Base.metadata + +config.set_main_option("sqlalchemy.url", SQLALCHEMY_URL) + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + configuration = config.get_section(config.config_ini_section) + if configuration is None: + raise RuntimeError("Config ini section doesn't exists (should never happen)") + + connectable = AsyncEngine( + engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + future=True, + ) + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/alembic-migrations/script.py.mako b/alembic-migrations/script.py.mako new file mode 100644 index 0000000..d5f4a9a --- /dev/null +++ b/alembic-migrations/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +${imports if imports else ""} +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..e6bfdae --- /dev/null +++ b/alembic.ini @@ -0,0 +1,118 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = alembic-migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic-migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic-migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# Connection string to the database to perform migrations on +# **This is a placeholder, we obtain the real value dynamically from the environment** +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/poetry.lock b/poetry.lock index ac12cb6..8ea4c33 100644 --- a/poetry.lock +++ b/poetry.lock @@ -127,6 +127,25 @@ typing_extensions = ">=4.0" dev = ["attribution (==1.7.0)", "black (==24.2.0)", "coverage[toml] (==7.4.1)", "flake8 (==7.0.0)", "flake8-bugbear (==24.2.6)", "flit (==3.9.0)", "mypy (==1.8.0)", "ufmt (==2.3.0)", "usort (==1.0.8.post1)"] docs = ["sphinx (==7.2.6)", "sphinx-mdinclude (==0.5.3)"] +[[package]] +name = "alembic" +version = "1.13.2" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.8" +files = [ + {file = "alembic-1.13.2-py3-none-any.whl", hash = "sha256:6b8733129a6224a9a711e17c99b08462dbf7cc9670ba8f2e2ae9af860ceb1953"}, + {file = "alembic-1.13.2.tar.gz", hash = "sha256:1ff0ae32975f4fd96028c39ed9bb3c867fe3af956bd7bb37343b54c9fe7445ef"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" +typing-extensions = ">=4" + +[package.extras] +tz = ["backports.zoneinfo"] + [[package]] name = "annotated-types" version = "0.7.0" @@ -788,6 +807,25 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "mako" +version = "1.3.5" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Mako-1.3.5-py3-none-any.whl", hash = "sha256:260f1dbc3a519453a9c856dedfe4beb4e50bd5a26d96386cb6c80856556bb91a"}, + {file = "Mako-1.3.5.tar.gz", hash = "sha256:48dbc20568c1d276a2698b36d968fa76161bf127194907ea6fc594fa81f943bc"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + [[package]] name = "markupsafe" version = "2.1.5" @@ -1607,4 +1645,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "a94d99de883e7a7fbaa16e0ebe8e8367c34d914868de715ecbcee60bf669fa4d" +content-hash = "436577e4e557a7f3d5739fa0ee023753d94f04a8a01443632d53f4d88ff7ff06" diff --git a/pyproject.toml b/pyproject.toml index 37125ca..7b6a8b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ coloredlogs = "^15.0.1" pydantic = "^2.8.2" sqlalchemy = { version = "^2.0.31", extras = ["asyncio"] } aiosqlite = "^0.20.0" +alembic = "^1.13.2" [tool.poetry.group.lint.dependencies] ruff = "^0.3.2" @@ -165,6 +166,19 @@ max-statements = 75 ".github/scripts/**.py" = [ "INP001", # Implicit namespace package ] +"alembic-migrations/env.py" = [ + "INP001", # Implicit namespace package + "ERA001", # Found commented out code +] +"alembic-migrations/versions/*" = [ + "INP001", # Implicit namespace package + "W291", # Trailing whitespace + "D103", # Missing docstring in public function + "D400", # First line should end with a period + "D415", # First line should end with a period, question mark, or exclamation point + "F401", # Unused import + "Q000", # Single quotes found but double quotes preferred +] [tool.ruff.format] line-ending = "lf" @@ -204,6 +218,12 @@ reportUnknownMemberType = false reportUnknownParameterType = false reportUnknownLambdaType = false +executionEnvironments = [ + { root = "src" }, + { root = "tests" }, + { root = "alembic-migrations/versions", reportUnusedImport = false }, +] + [tool.pytest.ini_options] minversion = "6.0" asyncio_mode = "auto" diff --git a/src/utils/database.py b/src/utils/database.py index e794e7f..1e987e4 100644 --- a/src/utils/database.py +++ b/src/utils/database.py @@ -14,13 +14,11 @@ __all__ = ["engine", "Base", "load_db_models", "get_db_session"] +SQLALCHEMY_URL = f"sqlite+aiosqlite:///{SQLITE_DATABASE_FILE.absolute()}" TABLES_PACKAGE_PATH = "src.db_tables" -engine = create_async_engine( - f"sqlite+aiosqlite:///{SQLITE_DATABASE_FILE.absolute()}", - echo=ECHO_SQL, -) +engine = create_async_engine(SQLALCHEMY_URL, echo=ECHO_SQL) SessionLocal = async_sessionmaker(engine, expire_on_commit=False) From 35975b999405a7b721e18fa749005ea0a52572cd Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 18:54:33 +0200 Subject: [PATCH 086/166] Add initial migration --- ..._21_1854-c55da3c62644_initial_migration.py | 29 +++++++++++++++++++ pyproject.toml | 1 + 2 files changed, 30 insertions(+) create mode 100644 alembic-migrations/versions/2024_07_21_1854-c55da3c62644_initial_migration.py diff --git a/alembic-migrations/versions/2024_07_21_1854-c55da3c62644_initial_migration.py b/alembic-migrations/versions/2024_07_21_1854-c55da3c62644_initial_migration.py new file mode 100644 index 0000000..2facb6a --- /dev/null +++ b/alembic-migrations/versions/2024_07_21_1854-c55da3c62644_initial_migration.py @@ -0,0 +1,29 @@ +"""Initial migration + +Revision ID: c55da3c62644 +Revises: +Create Date: 2024-07-21 18:54:26.159716 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'c55da3c62644' +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index 7b6a8b0..ee704e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -182,6 +182,7 @@ max-statements = 75 [tool.ruff.format] line-ending = "lf" +exclude = ["alembic-migrations/versions/*.py"] [tool.basedpyright] pythonPlatform = "All" From 8c97bf284951ffb1335b368873749d0bd3c7c99c Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 18:59:49 +0200 Subject: [PATCH 087/166] Run ruff as post-hook for alembic autogen migrations --- ..._21_1854-c55da3c62644_initial_migration.py | 7 ++---- alembic.ini | 22 +++++++++---------- pyproject.toml | 10 --------- 3 files changed, 13 insertions(+), 26 deletions(-) diff --git a/alembic-migrations/versions/2024_07_21_1854-c55da3c62644_initial_migration.py b/alembic-migrations/versions/2024_07_21_1854-c55da3c62644_initial_migration.py index 2facb6a..0ee2d3f 100644 --- a/alembic-migrations/versions/2024_07_21_1854-c55da3c62644_initial_migration.py +++ b/alembic-migrations/versions/2024_07_21_1854-c55da3c62644_initial_migration.py @@ -1,17 +1,14 @@ """Initial migration Revision ID: c55da3c62644 -Revises: +Revises: Create Date: 2024-07-21 18:54:26.159716 """ from collections.abc import Sequence -import sqlalchemy as sa -from alembic import op - # revision identifiers, used by Alembic. -revision: str = 'c55da3c62644' +revision: str = "c55da3c62644" down_revision: str | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None diff --git a/alembic.ini b/alembic.ini index e6bfdae..87142fa 100644 --- a/alembic.ini +++ b/alembic.ini @@ -69,18 +69,18 @@ sqlalchemy.url = driver://user:pass@localhost/dbname # post_write_hooks defines scripts or Python functions that are run # on newly generated revision scripts. See the documentation for further # detail and examples +# https://alembic.sqlalchemy.org/en/latest/autogenerate.html#basic-post-processor-configuration + +hooks = ruff-autofix, ruff-format + +ruff-autofix.type = exec +ruff-autofix.executable = %(here)s/.venv/bin/ruff +ruff-autofix.options = check --fix REVISION_SCRIPT_FILENAME + +ruff-format.type = exec +ruff-format.executable = %(here)s/.venv/bin/ruff +ruff-format.options = format REVISION_SCRIPT_FILENAME -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -# lint with attempts to fix using "ruff" - use the exec runner, execute a binary -# hooks = ruff -# ruff.type = exec -# ruff.executable = %(here)s/.venv/bin/ruff -# ruff.options = --fix REVISION_SCRIPT_FILENAME # Logging configuration [loggers] diff --git a/pyproject.toml b/pyproject.toml index ee704e8..8f6c0d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -172,17 +172,13 @@ max-statements = 75 ] "alembic-migrations/versions/*" = [ "INP001", # Implicit namespace package - "W291", # Trailing whitespace "D103", # Missing docstring in public function "D400", # First line should end with a period "D415", # First line should end with a period, question mark, or exclamation point - "F401", # Unused import - "Q000", # Single quotes found but double quotes preferred ] [tool.ruff.format] line-ending = "lf" -exclude = ["alembic-migrations/versions/*.py"] [tool.basedpyright] pythonPlatform = "All" @@ -219,12 +215,6 @@ reportUnknownMemberType = false reportUnknownParameterType = false reportUnknownLambdaType = false -executionEnvironments = [ - { root = "src" }, - { root = "tests" }, - { root = "alembic-migrations/versions", reportUnusedImport = false }, -] - [tool.pytest.ini_options] minversion = "6.0" asyncio_mode = "auto" From 93080a6791f5b402c1e1c17745e249ab6ce7c4fd Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 19:26:09 +0200 Subject: [PATCH 088/166] Add readme to alembic-migrations/ --- alembic-migrations/README.md | 71 ++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 alembic-migrations/README.md diff --git a/alembic-migrations/README.md b/alembic-migrations/README.md new file mode 100644 index 0000000..fc37f2d --- /dev/null +++ b/alembic-migrations/README.md @@ -0,0 +1,71 @@ + + +# Welcome to `alembic-migrations` directory + +This directory contains all of our database migrations. + +## What are database migrations? + +In case you aren't familiar, a database migration is essentially just a set of SQL instructions that should be +performed, to get your database into the state that we expect it to be in. + +The thing is, as software changes, the requirements for the database structure change alongside with it and that means +that anyone who would like to update this application to a newer version will also need to find a way to get their +database up to date with any changes that were made. + +If people had to do this manually, it would mean going through diffs and checking what exactly changed in the relevant +files, then using some tool where they can run SQL commands, figuring out what commands to even run to properly get +everything up to date without losing any existing information and finally actually running them. + +Clearly, this isn't ideal, especially for people who just want to use this bot as it's being updated and don't know +much about SQL and databases. For that reason, we decided to instead keep the instructions for these migrations in +individual version files, which people can simply execute to get their database up to date (or even to downgrade it, +in case someone needs to test out an older version of the bot). + +## How to use these migrations? + +We're using [`alembic`](https://alembic.sqlalchemy.org/en/latest/index.html), which is a tool that makes generating and +applying migrations very easy. If you just wish to get your application up-to-date, all you need to do is run: + +```bash +alembic upgrade head +``` + +This will run all of the migrations one by one, from the last previously executed migrations (if you haven't run the +command before, it will simply run each one). Alembic will then keep track of the revision id (basically the specific +migration file) that was applied and store that id into your database (Alembic will create it's own database table for +this). That way, alembic will always know what version is your current database at. + +> [!TIP] +> You can also run `alembic check`, to see if there are any pending migrations that you haven't yet applied. + +## How to create migrations? (for developers) + +If you're adding a new database table, deleting it, or just changing it somehow, you will want to create a new +migration file for it. Thankfully, alembic makes this very easy too. All you need to do is run: + +```bash +alembic revision --autogenerate -m "Some message (e.g.: Added users table)" +``` + +In most cases, this will be all that you need to do, alembic will automatically up any changes you made in the python +code, recognize what to do to get the existing database up to date with them and create the migration file with +instructions for it. + +That said, alembic has it's limitations and in some cases, the automatic generation doesn't work, or doesn't do what +we'd like it to do. For example, if you rename a table, alembic can't understand that this was a rename, rather than a +deletion of one table and a creation of another. This is a problem, because instead of simply renaming while keeping +the old existing data, alembic will generate instructions that would lead to losing those old data. + +In these cases, you will need to do some extra work and edit the migration files yourself. In case auto-generation +fails completely, you can run the same command without that `--autogenerate` flag, which will generate an empty +migration file, that you'll need to fill out. + +That said, in vast majority of cases, you will not need to write your migrations manually. For more info on when you +might need to, check [the documentation][alembic-autogeneration-autodetection]. + +> [!IMPORTANT] +> After generating a migration, don't forget to also apply it yourself (`alembic upgrade head`). + +[alembic-autogeneration-autodetection]: https://guide.pycord.dev/getting-started/creating-your-first-bot#creating-the-bot-application From cb3d8afbccee154ef633ce290352a303860c9c8d Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 20:08:45 +0200 Subject: [PATCH 089/166] Use our logging instead of default logging for alembic --- alembic-migrations/env.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/alembic-migrations/env.py b/alembic-migrations/env.py index 8362e5c..fa19769 100644 --- a/alembic-migrations/env.py +++ b/alembic-migrations/env.py @@ -1,5 +1,4 @@ import asyncio -from logging.config import fileConfig from alembic import context from sqlalchemy import engine_from_config, pool @@ -7,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine from src.utils.database import Base, SQLALCHEMY_URL, load_db_models +from src.utils.log import setup_logging # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -14,8 +14,7 @@ # Interpret the config file for Python logging. # This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) +setup_logging() # add your model's MetaData object here # for 'autogenerate' support From 7654a6280ffcf9acaa40d2c28575cadaa66cdb18 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 20:44:27 +0200 Subject: [PATCH 090/166] Improve env.py file for alembic --- alembic-migrations/env.py | 63 +++++++++++++++++---------------------- pyproject.toml | 1 - 2 files changed, 27 insertions(+), 37 deletions(-) diff --git a/alembic-migrations/env.py b/alembic-migrations/env.py index fa19769..188bf3c 100644 --- a/alembic-migrations/env.py +++ b/alembic-migrations/env.py @@ -1,45 +1,28 @@ import asyncio from alembic import context -from sqlalchemy import engine_from_config, pool +from sqlalchemy import MetaData, engine_from_config, pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import AsyncEngine from src.utils.database import Base, SQLALCHEMY_URL, load_db_models -from src.utils.log import setup_logging +from src.utils.log import get_logger -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -setup_logging() - -# add your model's MetaData object here -# for 'autogenerate' support -load_db_models() -target_metadata = Base.metadata +# Obtain a custom logger instance +# This will also set up logging with our custom configuration +log = get_logger(__name__) -config.set_main_option("sqlalchemy.url", SQLALCHEMY_URL) - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. +# This is the Alembic Config object, which provides access to the values within the .ini file in use. +config = context.config -def run_migrations_offline() -> None: +def run_migrations_offline(target_metadata: MetaData) -> None: """Run migrations in 'offline' mode. - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. + This configures the context with just a URL and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation we don't even need a DBAPI to be available. + Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( @@ -53,12 +36,10 @@ def run_migrations_offline() -> None: context.run_migrations() -async def run_migrations_online() -> None: +async def run_migrations_online(target_metadata: MetaData) -> None: """Run migrations in 'online' mode. - In this scenario we need to create an Engine - and associate a connection with the context. - + In this scenario we need to create an Engine and associate a connection with the context. """ def do_run_migrations(connection: Connection) -> None: @@ -86,7 +67,17 @@ def do_run_migrations(connection: Connection) -> None: await connectable.dispose() -if context.is_offline_mode(): - run_migrations_offline() -else: - asyncio.run(run_migrations_online()) +def main() -> None: + """Main entry point function.""" + config.set_main_option("sqlalchemy.url", SQLALCHEMY_URL) + load_db_models() + + target_metadata = Base.metadata + + if context.is_offline_mode(): + run_migrations_offline(target_metadata) + else: + asyncio.run(run_migrations_online(target_metadata)) + + +main() diff --git a/pyproject.toml b/pyproject.toml index 8f6c0d2..a0c58fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -168,7 +168,6 @@ max-statements = 75 ] "alembic-migrations/env.py" = [ "INP001", # Implicit namespace package - "ERA001", # Found commented out code ] "alembic-migrations/versions/*" = [ "INP001", # Implicit namespace package From 5d2f741a4ed0a5b486692cddcddff25c7b428ccf Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 21:25:20 +0200 Subject: [PATCH 091/166] Automatically run alembic migrations when needed --- alembic-migrations/README.md | 10 ++++--- src/__main__.py | 7 +++-- src/utils/database.py | 57 ++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/alembic-migrations/README.md b/alembic-migrations/README.md index fc37f2d..f8a29b9 100644 --- a/alembic-migrations/README.md +++ b/alembic-migrations/README.md @@ -26,7 +26,12 @@ in case someone needs to test out an older version of the bot). ## How to use these migrations? We're using [`alembic`](https://alembic.sqlalchemy.org/en/latest/index.html), which is a tool that makes generating and -applying migrations very easy. If you just wish to get your application up-to-date, all you need to do is run: +applying migrations very easy. Additionally, we have some custom logic in place, to make sure that all migrations that +weren't yet applied will automatically be ran once the application is started, so you don't actually need to do +anything to apply them. + +That said, if you would want to apply the migrations manually, without having to start the bot first, you can do so +with the command below: ```bash alembic upgrade head @@ -65,7 +70,4 @@ migration file, that you'll need to fill out. That said, in vast majority of cases, you will not need to write your migrations manually. For more info on when you might need to, check [the documentation][alembic-autogeneration-autodetection]. -> [!IMPORTANT] -> After generating a migration, don't forget to also apply it yourself (`alembic upgrade head`). - [alembic-autogeneration-autodetection]: https://guide.pycord.dev/getting-started/creating-your-first-bot#creating-the-bot-application diff --git a/src/__main__.py b/src/__main__.py index 82afbd3..9af2558 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -5,7 +5,7 @@ from src.bot import Bot from src.settings import BOT_TOKEN, SQLITE_DATABASE_FILE -from src.utils.database import Base, engine, get_db_session, load_db_models +from src.utils.database import apply_db_migrations, engine, get_db_session, load_db_models from src.utils.log import get_logger log = get_logger(__name__) @@ -24,12 +24,13 @@ async def _init_database(*, retries: int = 5, retry_time: float = 3) -> None: for _ in range(retries): log.debug(f"Connecting to the database: {SQLITE_DATABASE_FILE}") try: - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + conn = engine.connect() except ConnectionRefusedError as exc: log.exception(f"Database connection failed, retrying in {retry_time} seconds.", exc_info=exc) await asyncio.sleep(retry_time) else: + async with conn, conn.begin(): + await conn.run_sync(apply_db_migrations) break log.debug("Database connection established") diff --git a/src/utils/database.py b/src/utils/database.py index 1e987e4..6a0815d 100644 --- a/src/utils/database.py +++ b/src/utils/database.py @@ -4,6 +4,11 @@ from contextlib import asynccontextmanager from typing import NoReturn +import alembic.config +from alembic.runtime.environment import EnvironmentContext +from alembic.runtime.migration import MigrationContext, RevisionStep +from alembic.script import ScriptDirectory +from sqlalchemy import Connection from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase @@ -57,6 +62,58 @@ def ignore_module(module: pkgutil.ModuleInfo) -> bool: importlib.import_module(module_info.name) +def apply_db_migrations(db_conn: Connection) -> None: + """Apply alembic database migrations. + + This method will first check if the database is empty (no applied alembic revisions), + in which case, it use SQLAlchemy to create all tables and then stamp the database for alembic. + + If the database is not empty, it will apply all necessary migrations, bringing the database + up to date with the latest revision. + """ + # Create a standalone minimal config, that doesn't use alembic.ini + # (we don't want to load env.py, since they do a lot of things we don't want + # like setting up logging in a different way, ...) + alembic_cfg = alembic.config.Config() + alembic_cfg.set_main_option("script_location", "alembic-migrations") + alembic_cfg.set_main_option("sqlalchemy.url", SQLALCHEMY_URL) + + script = ScriptDirectory.from_config(alembic_cfg) + + def retrieve_migrations(rev: str, context: MigrationContext) -> list[RevisionStep]: + """Retrieve all remaining migrations to be applied to get to "head". + + The returned migrations will be the migrations that will get applied when upgrading. + """ + migrations = script._upgrade_revs("head", rev) # pyright: ignore[reportPrivateUsage] + + if len(migrations) > 0: + log.info(f"Applying {len(migrations)} database migrations") + else: + log.debug("No database migrations to apply, database is up to date") + + return migrations + + env_context = EnvironmentContext(alembic_cfg, script) + env_context.configure(connection=db_conn, target_metadata=Base.metadata, fn=retrieve_migrations) + context = env_context.get_context() + + current_rev = context.get_current_revision() + + # If there is no current revision, this is a brand new database + # instead of going through the migrations, we can instead use metadata.create_all + # to create all tables and then stamp the database with the head revision. + if current_rev is None: + log.info("Performing initial database setup (creating tables)") + Base.metadata.create_all(db_conn) + context.stamp(script, "head") + return + + log.debug("Checking for database migrations") + with context.begin_transaction(): + context.run_migrations() + + @asynccontextmanager async def get_db_session() -> AsyncIterator[AsyncSession]: """Obtain a database session.""" From 7d89a62cf39b92e48591c2d9a93690593bcc73d1 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 21:25:49 +0200 Subject: [PATCH 092/166] Suppress alembic and aiosqlite debug logs --- src/utils/log.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/log.py b/src/utils/log.py index 8c14ca1..ac15b1d 100644 --- a/src/utils/log.py +++ b/src/utils/log.py @@ -192,5 +192,7 @@ def _setup_external_log_levels(root_log: LoggerClass) -> None: set explicitly here, avoiding unneeded spammy logs. """ get_logger("asyncio").setLevel(logging.INFO) + get_logger("aiosqlite").setLevel(logging.INFO) + get_logger("alembic.runtime.migration").setLevel(logging.WARNING) get_logger("parso").setLevel(logging.WARNING) # For usage in IPython From 74938784aee66395f8f06928828427ef9d231557 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 21:37:24 +0200 Subject: [PATCH 093/166] Explain alembic stamping --- alembic-migrations/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/alembic-migrations/README.md b/alembic-migrations/README.md index f8a29b9..fc351ae 100644 --- a/alembic-migrations/README.md +++ b/alembic-migrations/README.md @@ -71,3 +71,15 @@ That said, in vast majority of cases, you will not need to write your migrations might need to, check [the documentation][alembic-autogeneration-autodetection]. [alembic-autogeneration-autodetection]: https://guide.pycord.dev/getting-started/creating-your-first-bot#creating-the-bot-application + +### Stamping + +In case you've made modifications to your database already (perhaps by manually running some SQL commands to test out a +manually written migration), you might want to skip applying a migration and instead just tell Alembic that the +database is already up to date with the latest revision. + +Thankfully, alembic makes this really simple, all you need to do is run: + +```bash +alembic stamp head +``` From 0dec86cf68d53a3b63f8692f423dd031914f05b1 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 21:42:44 +0200 Subject: [PATCH 094/166] Improve alembic readme --- alembic-migrations/README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/alembic-migrations/README.md b/alembic-migrations/README.md index fc351ae..c92e334 100644 --- a/alembic-migrations/README.md +++ b/alembic-migrations/README.md @@ -48,15 +48,23 @@ this). That way, alembic will always know what version is your current database ## How to create migrations? (for developers) If you're adding a new database table, deleting it, or just changing it somehow, you will want to create a new -migration file for it. Thankfully, alembic makes this very easy too. All you need to do is run: +migration file for it. Thankfully, alembic makes this very easy. All you need to do is run: ```bash alembic revision --autogenerate -m "Some message (e.g.: Added users table)" ``` -In most cases, this will be all that you need to do, alembic will automatically up any changes you made in the python -code, recognize what to do to get the existing database up to date with them and create the migration file with -instructions for it. +Alembic will actually load the python classes that represent all of the tables and compare that with what you currently +have in the database, automatically generating all of the instructions that need to be ran in a new migration script. +This script will be stored in `alembic-migrations/versions/` directory. + +Note that after you did this, you will want to apply the migrations. You can do that by simply running the bot for a +while, to let the custom logic we have in place run alembic migrations for you, or you can run them manually with +`alembic upgrade head`. + +### Manual migrations + +In most cases, running the command to auto-generate the migration will be all that you need to do. That said, alembic has it's limitations and in some cases, the automatic generation doesn't work, or doesn't do what we'd like it to do. For example, if you rename a table, alembic can't understand that this was a rename, rather than a From ddddcb39b82781b51bef9c90b3f71bd7143718ca Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 22:22:37 +0200 Subject: [PATCH 095/166] Add example orm database classes --- src/db_tables/README.md | 73 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/db_tables/README.md b/src/db_tables/README.md index 0f6400a..39a071a 100644 --- a/src/db_tables/README.md +++ b/src/db_tables/README.md @@ -10,3 +10,76 @@ All of these tables must inherit from the `Base` class, that can be imported fro There is no need to register newly created classes / files anywhere, as all files in this directory (except those starting with `_`) will be automatically imported and picked up by SQLAlchemy. + +## Example database class + +Imagine an application that schedules appointmnets for users: + +`user.py`: + +```python +import enum +from typing import TYPE_CHECKING +from sqlalchemy import Mapped, mapped_column, relationship + +from src.utils.database import Base + + +# Prevent circular imports for relationships +if TYPE_CHECKING: + from src.db_tables.appointment import Appointment + + +class Role(enum.IntEnum): + """Enumeration of all possible user roles.""" + + USER = 0 + ADMIN = 1 + + +class User(Base): + """User database table.""" + + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True, index=True, nullable=False) + email: Mapped[str] = mapped_column(unique=True, nullable=False) + name: Mapped[str] = mapped_column(nullable=False) + surname: Mapped[str] = mapped_column(nullable=False) + user_role: Mapped[Role] = mapped_column(nullable=False, default=Role.USER) + + appointments: Mapped[list["Appointment"]] = relationship( + lazy="selectin", + back_populates="user", + cascade="all, delete-orphan", + ) +``` + +`appointment.py`: + +```python +from datetime import date, time +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.utils.database import Base + +# Prevent circular imports for relationships +if TYPE_CHECKING: + from src.db_tables.user import User + + +class Appointment(Base): + """Appointment database table.""" + + __tablename__ = "appointment" + + id: Mapped[int] = mapped_column(primary_key=True, index=True, nullable=False) + booked_date: Mapped[date] = mapped_column(nullable=False) + booked_time: Mapped[time] = mapped_column(nullable=False) + user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), nullable=False) + + user: Mapped["User"] = relationship(lazy="selectin", back_populates="appointments") +``` From af3838e2e29ac1534b8cd53e7d66eb312f078772 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 21 Jul 2024 21:51:40 +0200 Subject: [PATCH 096/166] Suppress some spammy py-cord logs --- src/utils/log.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/log.py b/src/utils/log.py index ac15b1d..1372a59 100644 --- a/src/utils/log.py +++ b/src/utils/log.py @@ -192,6 +192,8 @@ def _setup_external_log_levels(root_log: LoggerClass) -> None: set explicitly here, avoiding unneeded spammy logs. """ get_logger("asyncio").setLevel(logging.INFO) + get_logger("discord.http").setLevel(logging.INFO) + get_logger("discord.gateway").setLevel(logging.WARNING) get_logger("aiosqlite").setLevel(logging.INFO) get_logger("alembic.runtime.migration").setLevel(logging.WARNING) From 3aeab4457cfc3298198c3ae0da586bd00bdf1ffd Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 21 Jul 2024 22:08:04 +0200 Subject: [PATCH 097/166] :sparkles: Improve he way pydantic models are generated and a couple bugfixes --- src/exts/tvdb_info/main.py | 42 +- src/tvdb/client.py | 35 +- src/tvdb/generated_models.py | 1471 +++++++++++++++++++++++---------- src/tvdb/models.py | 50 -- tools/generate_tvdb_models.py | 73 +- 5 files changed, 1127 insertions(+), 544 deletions(-) delete mode 100644 src/tvdb/models.py diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index d3b3be8..c46fc92 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -18,7 +18,7 @@ class InfoView(discord.ui.View): """View for displaying information about a movie or series.""" - def __init__(self, results: list[Movie | Series]): + def __init__(self, results: list[Movie | Series] | list[Movie] | list[Series]) -> None: super().__init__(disable_on_timeout=True) self.results = results self.dropdown = discord.ui.Select( @@ -87,24 +87,40 @@ def __init__(self, bot: Bot) -> None: choices=["movie", "series"], required=False, ) + @option("by_id", input_type=bool, description="Search by tvdb ID.", required=False) async def search( - self, ctx: ApplicationContext, query: str, entity_type: Literal["movie", "series"] | None = None + self, + ctx: ApplicationContext, + query: str, + entity_type: Literal["movie", "series"] | None = None, + by_id: bool = False, # noqa: FBT001, FBT002 ) -> None: """Search for a movie or series.""" await ctx.defer() async with aiohttp.ClientSession() as session: client = TvdbClient(session) - match entity_type: - case "movie": - response = await client.search(query, limit=5, entity_type="movie") - case "series": - response = await client.search(query, limit=5, entity_type="series") - case None: - response = await client.search(query, limit=5) - - if not response: - await ctx.respond("No results found.") - return + if by_id: + match entity_type: + case "movie": + response = [await Movie.fetch(int(query), client, extended=True)] + case "series": + response = [await Series.fetch(int(query), client, extended=True)] + case None: + await ctx.respond( + "You must specify a type (movie or series) when searching by ID.", ephemeral=True + ) + return + else: + match entity_type: + case "movie": + response = await client.search(query, limit=5, entity_type="movie") + case "series": + response = await client.search(query, limit=5, entity_type="series") + case None: + response = await client.search(query, limit=5) + if not response: + await ctx.respond("No results found.") + return view = InfoView(response) await view.send(ctx) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 5752a15..5e86856 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -8,16 +8,14 @@ from src.tvdb.generated_models import ( MovieBaseRecord, MovieExtendedRecord, + MoviesIdExtendedGetResponse, + MoviesIdGetResponse, + SearchGetResponse, SearchResult, SeriesBaseRecord, SeriesExtendedRecord, -) -from src.tvdb.models import ( - MovieExtendedResponse, - MovieResponse, - SearchResponse, - SeriesExtendedResponse, - SeriesResponse, + SeriesIdExtendedGetResponse, + SeriesIdGetResponse, ) from src.utils.log import get_logger @@ -36,7 +34,9 @@ def parse_media_id(media_id: int | str) -> int: class _Media(ABC): - def __init__(self, client: "TvdbClient", data: SeriesRecord | MovieRecord | SearchResult): + def __init__(self, client: "TvdbClient", data: AnyRecord | SearchResult | None): + if data is None: + raise ValueError("Data can't be None but is allowed to because of the broken pydantic generated models.") self.data = data self.client = client self.name: str | None = self.data.name @@ -116,7 +116,7 @@ async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: boo """ media_id = parse_media_id(media_id) response = await client.request("GET", f"movies/{media_id}" + ("/extended" if extended else "")) - response = MovieResponse(**response) if not extended else MovieExtendedResponse(**response) # pyright: ignore[reportCallIssue] + response = MoviesIdGetResponse(**response) if not extended else MoviesIdExtendedGetResponse(**response) # pyright: ignore[reportCallIssue] return cls(client, response.data) @@ -136,7 +136,7 @@ async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: boo """ media_id = parse_media_id(media_id) response = await client.request("GET", f"series/{media_id}" + ("/extended" if extended else "")) - response = SeriesResponse(**response) if not extended else SeriesExtendedResponse(**response) # pyright: ignore[reportCallIssue] + response = SeriesIdGetResponse(**response) if not extended else SeriesIdExtendedGetResponse(**response) # pyright: ignore[reportCallIssue] return cls(client, response.data) @@ -207,8 +207,19 @@ async def search( if entity_type != "all": query["type"] = entity_type data = await self.request("GET", "search", query=query) - response = SearchResponse(**data) # pyright: ignore[reportCallIssue] - return [Movie(self, result) if result.id[0] == "m" else Series(self, result) for result in response.data] + response = SearchGetResponse(**data) # pyright: ignore[reportCallIssue] + if not response.data: + raise ValueError("This should not happen.") + returnable: list[Movie | Series] = [] + for result in response.data: + match result.type: + case "movie": + returnable.append(Movie(self, result)) + case "series": + returnable.append(Series(self, result)) + case _: + pass + return returnable async def _login(self) -> None: """Obtain the auth token from the TVDB API. diff --git a/src/tvdb/generated_models.py b/src/tvdb/generated_models.py index fdebb6d..678b47f 100644 --- a/src/tvdb/generated_models.py +++ b/src/tvdb/generated_models.py @@ -1,22 +1,35 @@ -# ruff: noqa: D101 # Allow missing docstrings +# ruff: noqa: D101, ERA001, E501 +# generated by datamodel-codegen: +# filename: swagger.yml +# timestamp: 2024-07-21T19:34:06+00:00 +# version: 0.25.8 from __future__ import annotations +from enum import Enum + from pydantic import BaseModel, Field, RootModel +class Action(Enum): + delete = "delete" + update = "update" + + class Alias(BaseModel): - language: str = Field(le=4) - """ - A 3-4 character string indicating the language of the alias, as defined in Language. - """ - name: str = Field(le=100) - """ - A string containing the alias itself. - """ + """An alias model, which can be associated with a series, season, movie, person, or list.""" + + language: str | None = Field( + default=None, + description="A 3-4 character string indicating the language of the alias, as defined in Language.", + le=4, + ) + name: str | None = Field(default=None, description="A string containing the alias itself.", le=100) class ArtworkBaseRecord(BaseModel): + """base artwork record.""" + height: int | None = Field(default=None, json_schema_extra={"x-go-name": "Height"}) id: int | None = None image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) @@ -24,19 +37,79 @@ class ArtworkBaseRecord(BaseModel): language: str | None = None score: float | None = None thumbnail: str | None = Field(default=None, json_schema_extra={"x-go-name": "Thumbnail"}) - type: int | None = Field(default=None, json_schema_extra={"x-go-name": "Type"}) - """ - The artwork type corresponds to the ids from the /artwork/types endpoint. - """ + type: int | None = Field( + default=None, + description="The artwork type corresponds to the ids from the /artwork/types endpoint.", + json_schema_extra={"x-go-name": "Type"}, + ) + width: int | None = Field(default=None, json_schema_extra={"x-go-name": "Width"}) + + +class ArtworkExtendedRecord(BaseModel): + """extended artwork record.""" + + episode_id: int | None = Field(default=None, alias="episodeId") + height: int | None = Field(default=None, json_schema_extra={"x-go-name": "Height"}) + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) + includes_text: bool | None = Field(default=None, alias="includesText") + language: str | None = None + movie_id: int | None = Field(default=None, alias="movieId") + network_id: int | None = Field(default=None, alias="networkId") + people_id: int | None = Field(default=None, alias="peopleId") + score: float | None = None + season_id: int | None = Field(default=None, alias="seasonId") + series_id: int | None = Field(default=None, alias="seriesId") + series_people_id: int | None = Field(default=None, alias="seriesPeopleId") + status: ArtworkStatus | None = None + tag_options: list[TagOption] | None = Field( + default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + ) + thumbnail: str | None = Field(default=None, json_schema_extra={"x-go-name": "Thumbnail"}) + thumbnail_height: int | None = Field( + default=None, + alias="thumbnailHeight", + json_schema_extra={"x-go-name": "ThumbnailHeight"}, + ) + thumbnail_width: int | None = Field( + default=None, + alias="thumbnailWidth", + json_schema_extra={"x-go-name": "ThumbnailWidth"}, + ) + type: int | None = Field( + default=None, + description="The artwork type corresponds to the ids from the /artwork/types endpoint.", + json_schema_extra={"x-go-name": "Type"}, + ) + updated_at: int | None = Field(default=None, alias="updatedAt", json_schema_extra={"x-go-name": "UpdatedAt"}) width: int | None = Field(default=None, json_schema_extra={"x-go-name": "Width"}) +class ArtworkIdExtendedGetResponse(BaseModel): + data: ArtworkExtendedRecord | None = None + status: str | None = None + + +class ArtworkIdGetResponse(BaseModel): + data: ArtworkBaseRecord | None = None + status: str | None = None + + class ArtworkStatus(BaseModel): + """artwork status record.""" + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) name: str | None = None +class ArtworkStatusesGetResponse(BaseModel): + data: list[ArtworkStatus] | None = None + status: str | None = None + + class ArtworkType(BaseModel): + """artwork type record.""" + height: int | None = None id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image_format: str | None = Field( @@ -56,12 +129,36 @@ class ArtworkType(BaseModel): width: int | None = Field(default=None, json_schema_extra={"x-go-name": "Width"}) +class ArtworkTypesGetResponse(BaseModel): + data: list[ArtworkType] | None = None + status: str | None = None + + class AwardBaseRecord(BaseModel): + """base award record.""" + id: int | None = None name: str | None = None class AwardCategoryBaseRecord(BaseModel): + """base award category record.""" + + allow_co_nominees: bool | None = Field( + default=None, + alias="allowCoNominees", + json_schema_extra={"x-go-name": "AllowCoNominees"}, + ) + award: AwardBaseRecord | None = None + for_movies: bool | None = Field(default=None, alias="forMovies", json_schema_extra={"x-go-name": "ForMovies"}) + for_series: bool | None = Field(default=None, alias="forSeries", json_schema_extra={"x-go-name": "ForSeries"}) + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = None + + +class AwardCategoryExtendedRecord(BaseModel): + """extended award category record.""" + allow_co_nominees: bool | None = Field( default=None, alias="allowCoNominees", @@ -72,9 +169,12 @@ class AwardCategoryBaseRecord(BaseModel): for_series: bool | None = Field(default=None, alias="forSeries", json_schema_extra={"x-go-name": "ForSeries"}) id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) name: str | None = None + nominees: list[AwardNomineeBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Nominees"}) class AwardExtendedRecord(BaseModel): + """extended award record.""" + categories: list[AwardCategoryBaseRecord] | None = Field( default=None, json_schema_extra={"x-go-name": "Categories"} ) @@ -83,22 +183,168 @@ class AwardExtendedRecord(BaseModel): score: int | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) +class AwardNomineeBaseRecord(BaseModel): + """base award nominee record.""" + + character: Character | None = None + details: str | None = None + episode: EpisodeBaseRecord | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + is_winner: bool | None = Field(default=None, alias="isWinner", json_schema_extra={"x-go-name": "IsWinner"}) + movie: MovieBaseRecord | None = None + series: SeriesBaseRecord | None = None + year: str | None = None + category: str | None = None + name: str | None = None + + +class AwardsCategoriesIdExtendedGetResponse(BaseModel): + data: AwardCategoryExtendedRecord | None = None + status: str | None = None + + +class AwardsCategoriesIdGetResponse(BaseModel): + data: AwardCategoryBaseRecord | None = None + status: str | None = None + + +class AwardsGetResponse(BaseModel): + data: list[AwardBaseRecord] | None = None + status: str | None = None + + +class AwardsIdExtendedGetResponse(BaseModel): + data: AwardExtendedRecord | None = None + status: str | None = None + + +class AwardsIdGetResponse(BaseModel): + data: AwardBaseRecord | None = None + status: str | None = None + + class Biography(BaseModel): + """biography record.""" + biography: str | None = Field(default=None, json_schema_extra={"x-go-name": "Biography"}) language: str | None = Field(default=None, json_schema_extra={"x-go-name": "Language"}) +class Character(BaseModel): + """character record.""" + + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) + episode: RecordInfo | None = None + episode_id: int | None = Field(default=None, alias="episodeId") + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + image: str | None = None + is_featured: bool | None = Field(default=None, alias="isFeatured", json_schema_extra={"x-go-name": "IsFeatured"}) + movie_id: int | None = Field(default=None, alias="movieId") + movie: RecordInfo | None = None + name: str | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + people_id: int | None = Field(default=None, alias="peopleId") + person_img_url: str | None = Field(default=None, alias="personImgURL") + people_type: str | None = Field(default=None, alias="peopleType") + series_id: int | None = Field(default=None, alias="seriesId") + series: RecordInfo | None = None + sort: int | None = Field(default=None, json_schema_extra={"x-go-name": "Sort"}) + tag_options: list[TagOption] | None = Field( + default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + ) + type: int | None = Field(default=None, json_schema_extra={"x-go-name": "Type"}) + url: str | None = Field(default=None, json_schema_extra={"x-go-name": "URL"}) + person_name: str | None = Field(default=None, alias="personName") + + +class CharactersIdGetResponse(BaseModel): + data: Character | None = None + status: str | None = None + + +class Companies(BaseModel): + """Companies by type record.""" + + studio: list[Company] | None = None + network: list[Company] | None = None + production: list[Company] | None = None + distributor: list[Company] | None = None + special_effects: list[Company] | None = None + + +class CompaniesGetResponse(BaseModel): + data: list[Company] | None = None + status: str | None = None + links: Links | None = None + + +class CompaniesIdGetResponse(BaseModel): + data: Company | None = None + status: str | None = None + + +class CompaniesTypesGetResponse(BaseModel): + data: list[CompanyType] | None = None + status: str | None = None + + +class Company(BaseModel): + """A company record.""" + + active_date: str | None = Field(default=None, alias="activeDate") + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) + country: str | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + inactive_date: str | None = Field(default=None, alias="inactiveDate") + name: str | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + primary_company_type: int | None = Field( + default=None, + alias="primaryCompanyType", + json_schema_extra={"x-go-name": "PrimaryCompanyType"}, + ) + slug: str | None = Field(default=None, json_schema_extra={"x-go-name": "Slug"}) + parent_company: ParentCompany | None = Field(default=None, alias="parentCompany") + tag_options: list[TagOption] | None = Field( + default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + ) + + class CompanyRelationShip(BaseModel): + """A company relationship.""" + id: int | None = None type_name: str | None = Field(default=None, alias="typeName") class CompanyType(BaseModel): + """A company type record.""" + company_type_id: int | None = Field(default=None, alias="companyTypeId") company_type_name: str | None = Field(default=None, alias="companyTypeName") class ContentRating(BaseModel): + """content rating record.""" + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) description: str | None = None @@ -108,25 +354,61 @@ class ContentRating(BaseModel): full_name: str | None = Field(default=None, alias="fullName") +class ContentRatingsGetResponse(BaseModel): + data: list[ContentRating] | None = None + status: str | None = None + + +class CountriesGetResponse(BaseModel): + data: list[Country] | None = None + status: str | None = None + + class Country(BaseModel): + """country record.""" + id: str | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) short_code: str | None = Field(default=None, alias="shortCode", json_schema_extra={"x-go-name": "ShortCode"}) +class Data(BaseModel): + token: str | None = None + + +class Data1(BaseModel): + series: SeriesBaseRecord | None = None + episodes: list[EpisodeBaseRecord] | None = None + + +class Data2(BaseModel): + series: SeriesBaseRecord | None = None + + +class EntitiesGetResponse(BaseModel): + data: list[EntityType] | None = None + status: str | None = None + + class Entity(BaseModel): + """Entity record.""" + movie_id: int | None = Field(default=None, alias="movieId") order: int | None = Field(default=None, json_schema_extra={"x-go-name": "Order"}) series_id: int | None = Field(default=None, alias="seriesId") class EntityType(BaseModel): + """Entity Type record.""" + id: int | None = None name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Order"}) has_specials: bool | None = Field(default=None, alias="hasSpecials") class EntityUpdate(BaseModel): + """entity update record.""" + entity_type: str | None = Field(default=None, alias="entityType", json_schema_extra={"x-go-name": "EnitityType"}) method_int: int | None = Field(default=None, alias="methodInt") method: str | None = Field(default=None, json_schema_extra={"x-go-name": "Method"}) @@ -135,214 +417,220 @@ class EntityUpdate(BaseModel): record_type: str | None = Field(default=None, alias="recordType") record_id: int | None = Field(default=None, alias="recordId", json_schema_extra={"x-go-name": "RecordID"}) time_stamp: int | None = Field(default=None, alias="timeStamp", json_schema_extra={"x-go-name": "TimeStamp"}) - series_id: int | None = Field(default=None, alias="seriesId", json_schema_extra={"x-go-name": "RecordID"}) - """ - Only present for episodes records - """ + series_id: int | None = Field( + default=None, + alias="seriesId", + description="Only present for episodes records", + json_schema_extra={"x-go-name": "RecordID"}, + ) merge_to_id: int | None = Field(default=None, alias="mergeToId") merge_to_entity_type: str | None = Field(default=None, alias="mergeToEntityType") -class Favorites(BaseModel): - series: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "series"}) - movies: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "movies"}) - episodes: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "episodes"}) - artwork: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "artwork"}) - people: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "people"}) - lists: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "list"}) - - -class FavoriteRecord(BaseModel): - series: int | None = Field(default=None, json_schema_extra={"x-go-name": "series"}) - movie: int | None = Field(default=None, json_schema_extra={"x-go-name": "movies"}) - episode: int | None = Field(default=None, json_schema_extra={"x-go-name": "episodes"}) - artwork: int | None = Field(default=None, json_schema_extra={"x-go-name": "artwork"}) - people: int | None = Field(default=None, json_schema_extra={"x-go-name": "people"}) - list: int | None = Field(default=None, json_schema_extra={"x-go-name": "list"}) - - -class Gender(BaseModel): - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) - - -class GenreBaseRecord(BaseModel): - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) - slug: str | None = Field(default=None, json_schema_extra={"x-go-name": "Slug"}) - - -class Language(BaseModel): - id: str | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) - native_name: str | None = Field(default=None, alias="nativeName", json_schema_extra={"x-go-name": "NativeName"}) - short_code: str | None = Field(default=None, alias="shortCode") - +class EpisodeBaseRecord(BaseModel): + """base episode record.""" -class ListExtendedRecord(BaseModel): - aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) - entities: list[Entity] | None = Field(default=None, json_schema_extra={"x-go-name": "Entities"}) + absolute_number: int | None = Field(default=None, alias="absoluteNumber") + aired: str | None = None + airs_after_season: int | None = Field(default=None, alias="airsAfterSeason") + airs_before_episode: int | None = Field(default=None, alias="airsBeforeEpisode") + airs_before_season: int | None = Field(default=None, alias="airsBeforeSeason") + finale_type: str | None = Field(default=None, alias="finaleType", description="season, midseason, or series") id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image: str | None = None - image_is_fallback: bool | None = Field(default=None, alias="imageIsFallback") - is_official: bool | None = Field(default=None, alias="isOfficial", json_schema_extra={"x-go-name": "IsOfficial"}) + image_type: int | None = Field(default=None, alias="imageType") + is_movie: int | None = Field(default=None, alias="isMovie", json_schema_extra={"x-go-name": "IsMovie"}) + last_updated: str | None = Field(default=None, alias="lastUpdated") + linked_movie: int | None = Field(default=None, alias="linkedMovie") name: str | None = None name_translations: list[str] | None = Field( default=None, alias="nameTranslations", json_schema_extra={"x-go-name": "NameTranslations"}, ) + number: int | None = None overview: str | None = None overview_translations: list[str] | None = Field( default=None, alias="overviewTranslations", json_schema_extra={"x-go-name": "OverviewTranslations"}, ) - score: int | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) - url: str | None = None + runtime: int | None = None + season_number: int | None = Field(default=None, alias="seasonNumber") + seasons: list[SeasonBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Seasons"}) + series_id: int | None = Field(default=None, alias="seriesId", json_schema_extra={"x-go-name": "SeriesID"}) + season_name: str | None = Field(default=None, alias="seasonName") + year: str | None = None -class PeopleBaseRecord(BaseModel): - aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) +class EpisodeExtendedRecord(BaseModel): + """extended episode record.""" + + aired: str | None = None + airs_after_season: int | None = Field(default=None, alias="airsAfterSeason") + airs_before_episode: int | None = Field(default=None, alias="airsBeforeEpisode") + airs_before_season: int | None = Field(default=None, alias="airsBeforeSeason") + awards: list[AwardBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Awards"}) + characters: list[Character] | None = Field(default=None, json_schema_extra={"x-go-name": "Characters"}) + companies: list[Company] | None = None + content_ratings: list[ContentRating] | None = Field( + default=None, + alias="contentRatings", + json_schema_extra={"x-go-name": "ContentRatings"}, + ) + finale_type: str | None = Field(default=None, alias="finaleType", description="season, midseason, or series") id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image: str | None = None + image_type: int | None = Field(default=None, alias="imageType") + is_movie: int | None = Field(default=None, alias="isMovie", json_schema_extra={"x-go-name": "IsMovie"}) last_updated: str | None = Field(default=None, alias="lastUpdated") + linked_movie: int | None = Field(default=None, alias="linkedMovie") name: str | None = None name_translations: list[str] | None = Field( default=None, alias="nameTranslations", json_schema_extra={"x-go-name": "NameTranslations"}, ) + networks: list[Company] | None = None + nominations: list[AwardNomineeBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Nominees"}) + number: int | None = None + overview: str | None = None overview_translations: list[str] | None = Field( default=None, alias="overviewTranslations", json_schema_extra={"x-go-name": "OverviewTranslations"}, ) - score: int | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) - + production_code: str | None = Field(default=None, alias="productionCode") + remote_ids: list[RemoteID] | None = Field( + default=None, alias="remoteIds", json_schema_extra={"x-go-name": "RemoteIDs"} + ) + runtime: int | None = None + season_number: int | None = Field(default=None, alias="seasonNumber") + seasons: list[SeasonBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Seasons"}) + series_id: int | None = Field(default=None, alias="seriesId", json_schema_extra={"x-go-name": "SeriesID"}) + studios: list[Company] | None = None + tag_options: list[TagOption] | None = Field( + default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + ) + trailers: list[Trailer] | None = Field(default=None, json_schema_extra={"x-go-name": "Trailers"}) + translations: TranslationExtended | None = None + year: str | None = None -class PeopleType(Gender): - pass +class EpisodesGetResponse(BaseModel): + data: list[EpisodeBaseRecord] | None = None + status: str | None = None + links: Links | None = None -class Race(BaseModel): - pass +class EpisodesIdExtendedGetResponse(BaseModel): + data: EpisodeExtendedRecord | None = None + status: str | None = None -class RecordInfo(BaseModel): - image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) - name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) - year: str | None = None +class EpisodesIdGetResponse(BaseModel): + data: EpisodeBaseRecord | None = None + status: str | None = None -class Release(BaseModel): - country: str | None = None - date: str | None = None - detail: str | None = None +class EpisodesIdTranslationsLanguageGetResponse(BaseModel): + data: Translation | None = None + status: str | None = None -class RemoteID(BaseModel): - id: str | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - type: int | None = Field(default=None, json_schema_extra={"x-go-name": "Type"}) - source_name: str | None = Field(default=None, alias="sourceName", json_schema_extra={"x-go-name": "SourceName"}) +class FavoriteRecord(BaseModel): + """Favorites record.""" -class SeasonType(BaseModel): - alternate_name: str | None = Field(default=None, alias="alternateName", json_schema_extra={"x-go-name": "Name"}) - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) - type: str | None = Field(default=None, json_schema_extra={"x-go-name": "Type"}) + series: int | None = Field(default=None, json_schema_extra={"x-go-name": "series"}) + movie: int | None = Field(default=None, json_schema_extra={"x-go-name": "movies"}) + episode: int | None = Field(default=None, json_schema_extra={"x-go-name": "episodes"}) + artwork: int | None = Field(default=None, json_schema_extra={"x-go-name": "artwork"}) + people: int | None = Field(default=None, json_schema_extra={"x-go-name": "people"}) + list: int | None = Field(default=None, json_schema_extra={"x-go-name": "list"}) -class SeriesAirsDays(BaseModel): - friday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Friday"}) - monday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Monday"}) - saturday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Saturday"}) - sunday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Sunday"}) - thursday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Thursday"}) - tuesday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Tuesday"}) - wednesday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Wednesday"}) +class Favorites(BaseModel): + """User favorites record.""" + series: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "series"}) + movies: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "movies"}) + episodes: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "episodes"}) + artwork: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "artwork"}) + people: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "people"}) + lists: list[int] | None = Field(default=None, json_schema_extra={"x-go-name": "list"}) -class SourceType(BaseModel): - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) - postfix: str | None = None - prefix: str | None = None - slug: str | None = Field(default=None, json_schema_extra={"x-go-name": "Slug"}) - sort: int | None = Field(default=None, json_schema_extra={"x-go-name": "Sort"}) +class Gender(BaseModel): + """gender record.""" -class Status(BaseModel): id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - keep_updated: bool | None = Field( - default=None, - alias="keepUpdated", - json_schema_extra={"x-go-name": "KeepUpdated"}, - ) name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) - record_type: str | None = Field(default=None, alias="recordType", json_schema_extra={"x-go-name": "RecordType"}) -class StudioBaseRecord(BaseModel): - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) - parent_studio: int | None = Field(default=None, alias="parentStudio") +class GendersGetResponse(BaseModel): + data: list[Gender] | None = None + status: str | None = None + + +class Genre(Enum): + number_1 = 1 + number_2 = 2 + number_3 = 3 + number_4 = 4 + number_5 = 5 + number_6 = 6 + number_7 = 7 + number_8 = 8 + number_9 = 9 + number_10 = 10 + number_11 = 11 + number_12 = 12 + number_13 = 13 + number_14 = 14 + number_15 = 15 + number_16 = 16 + number_17 = 17 + number_18 = 18 + number_19 = 19 + number_21 = 21 + number_22 = 22 + number_23 = 23 + number_24 = 24 + number_25 = 25 + number_26 = 26 + number_27 = 27 + number_28 = 28 + number_29 = 29 + number_30 = 30 + number_31 = 31 + number_32 = 32 + number_33 = 33 + number_34 = 34 + number_35 = 35 + number_36 = 36 -class TagOption(BaseModel): - help_text: str | None = Field(default=None, alias="helpText") - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) - tag: int | None = Field(default=None, json_schema_extra={"x-go-name": "Tag"}) - tag_name: str | None = Field(default=None, alias="tagName", json_schema_extra={"x-go-name": "TagName"}) - +class GenreBaseRecord(BaseModel): + """base genre record.""" -class Trailer(BaseModel): id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - language: str | None = None - name: str | None = None - url: str | None = None - runtime: int | None = None - - -class Translation(BaseModel): - aliases: list[str] | None = None - is_alias: bool | None = Field(default=None, alias="isAlias") - is_primary: bool | None = Field(default=None, alias="isPrimary") - language: str | None = Field(default=None, json_schema_extra={"x-go-name": "Language"}) - name: str | None = None - overview: str | None = None - tagline: str | None = None - """ - Only populated for movie translations. We disallow taglines without a title. - """ - - -class TranslationSimple(RootModel[dict[str, str] | None]): - root: dict[str, str] | None = None - - -class TranslationExtended(BaseModel): - name_translations: list[Translation] | None = Field(default=None, alias="nameTranslations") - overview_translations: list[Translation] | None = Field(default=None, alias="overviewTranslations") - alias: list[str] | None = None + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + slug: str | None = Field(default=None, json_schema_extra={"x-go-name": "Slug"}) -class TagOptionEntity(BaseModel): - name: str | None = None - tag_name: str | None = Field(default=None, alias="tagName") - tag_id: int | None = Field(default=None, alias="tagId") +class GenresGetResponse(BaseModel): + data: list[GenreBaseRecord] | None = None + status: str | None = None -class UserInfo(BaseModel): - id: int | None = None - language: str | None = None - name: str | None = None - type: str | None = None +class GenresIdGetResponse(BaseModel): + data: GenreBaseRecord | None = None + status: str | None = None class Inspiration(BaseModel): + """Movie inspiration record.""" + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) type: str | None = None type_name: str | None = None @@ -350,6 +638,8 @@ class Inspiration(BaseModel): class InspirationType(BaseModel): + """Movie inspiration type record.""" + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) name: str | None = None description: str | None = None @@ -357,13 +647,28 @@ class InspirationType(BaseModel): url: str | None = None -class ProductionCountry(BaseModel): - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - country: str | None = None - name: str | None = None +class InspirationTypesGetResponse(BaseModel): + data: list[InspirationType] | None = None + status: str | None = None + + +class Language(BaseModel): + """language record.""" + + id: str | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + native_name: str | None = Field(default=None, alias="nativeName", json_schema_extra={"x-go-name": "NativeName"}) + short_code: str | None = Field(default=None, alias="shortCode") + + +class LanguagesGetResponse(BaseModel): + data: list[Language] | None = None + status: str | None = None class Links(BaseModel): + """Links for next, previous and current record.""" + prev: str | None = None self: str | None = None next: str | None = None @@ -371,85 +676,39 @@ class Links(BaseModel): page_size: int | None = None -class ArtworkExtendedRecord(BaseModel): - episode_id: int | None = Field(default=None, alias="episodeId") - height: int | None = Field(default=None, json_schema_extra={"x-go-name": "Height"}) - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) - includes_text: bool | None = Field(default=None, alias="includesText") - language: str | None = None - movie_id: int | None = Field(default=None, alias="movieId") - network_id: int | None = Field(default=None, alias="networkId") - people_id: int | None = Field(default=None, alias="peopleId") - score: float | None = None - season_id: int | None = Field(default=None, alias="seasonId") - series_id: int | None = Field(default=None, alias="seriesId") - series_people_id: int | None = Field(default=None, alias="seriesPeopleId") - status: ArtworkStatus | None = None - tag_options: list[TagOption] | None = Field( - default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} - ) - thumbnail: str | None = Field(default=None, json_schema_extra={"x-go-name": "Thumbnail"}) - thumbnail_height: int | None = Field( - default=None, - alias="thumbnailHeight", - json_schema_extra={"x-go-name": "ThumbnailHeight"}, - ) - thumbnail_width: int | None = Field( - default=None, - alias="thumbnailWidth", - json_schema_extra={"x-go-name": "ThumbnailWidth"}, - ) - type: int | None = Field(default=None, json_schema_extra={"x-go-name": "Type"}) - """ - The artwork type corresponds to the ids from the /artwork/types endpoint. - """ - updated_at: int | None = Field(default=None, alias="updatedAt", json_schema_extra={"x-go-name": "UpdatedAt"}) - width: int | None = Field(default=None, json_schema_extra={"x-go-name": "Width"}) - +class ListBaseRecord(BaseModel): + """base list record.""" -class Character(BaseModel): aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) - episode: RecordInfo | None = None - episode_id: int | None = Field(default=None, alias="episodeId") id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image: str | None = None - is_featured: bool | None = Field(default=None, alias="isFeatured", json_schema_extra={"x-go-name": "IsFeatured"}) - movie_id: int | None = Field(default=None, alias="movieId") - movie: RecordInfo | None = None + image_is_fallback: bool | None = Field(default=None, alias="imageIsFallback") + is_official: bool | None = Field(default=None, alias="isOfficial", json_schema_extra={"x-go-name": "IsOfficial"}) name: str | None = None name_translations: list[str] | None = Field( default=None, alias="nameTranslations", json_schema_extra={"x-go-name": "NameTranslations"}, ) + overview: str | None = None overview_translations: list[str] | None = Field( default=None, alias="overviewTranslations", json_schema_extra={"x-go-name": "OverviewTranslations"}, ) - people_id: int | None = Field(default=None, alias="peopleId") - person_img_url: str | None = Field(default=None, alias="personImgURL") - people_type: str | None = Field(default=None, alias="peopleType") - series_id: int | None = Field(default=None, alias="seriesId") - series: RecordInfo | None = None - sort: int | None = Field(default=None, json_schema_extra={"x-go-name": "Sort"}) - tag_options: list[TagOption] | None = Field( - default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + remote_ids: list[RemoteID] | None = Field( + default=None, alias="remoteIds", json_schema_extra={"x-go-name": "RemoteIDs"} ) - type: int | None = Field(default=None, json_schema_extra={"x-go-name": "Type"}) - url: str | None = Field(default=None, json_schema_extra={"x-go-name": "URL"}) - person_name: str | None = Field(default=None, alias="personName") - + tags: list[TagOption] | None = Field(default=None, json_schema_extra={"x-go-name": "TagOptions"}) + score: int | None = None + url: str | None = None -class ParentCompany(BaseModel): - id: int | None = None - name: str | None = None - relation: CompanyRelationShip | None = None +class ListExtendedRecord(BaseModel): + """extended list record.""" -class ListBaseRecord(BaseModel): aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) + entities: list[Entity] | None = Field(default=None, json_schema_extra={"x-go-name": "Entities"}) id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image: str | None = None image_is_fallback: bool | None = Field(default=None, alias="imageIsFallback") @@ -466,126 +725,62 @@ class ListBaseRecord(BaseModel): alias="overviewTranslations", json_schema_extra={"x-go-name": "OverviewTranslations"}, ) - remote_ids: list[RemoteID] | None = Field( - default=None, alias="remoteIds", json_schema_extra={"x-go-name": "RemoteIDs"} - ) - tags: list[TagOption] | None = Field(default=None, json_schema_extra={"x-go-name": "TagOptions"}) - score: int | None = None + score: int | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) url: str | None = None -class MovieBaseRecord(BaseModel): - aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) - id: int = Field(json_schema_extra={"x-go-name": "ID"}) - image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) - last_updated: str | None = Field(default=None, alias="lastUpdated") - name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) - name_translations: list[str] | None = Field( - default=None, - alias="nameTranslations", - json_schema_extra={"x-go-name": "NameTranslations"}, - ) - overview_translations: list[str] | None = Field( - default=None, - alias="overviewTranslations", - json_schema_extra={"x-go-name": "OverviewTranslations"}, - ) - score: float | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) - slug: str | None = Field(default=None, json_schema_extra={"x-go-name": "Slug"}) - status: Status | None = None - runtime: int | None = None - year: str | None = None +class ListsGetResponse(BaseModel): + data: list[ListBaseRecord] | None = None + status: str | None = None + links: Links | None = None -class PeopleExtendedRecord(BaseModel): - aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) - awards: list[AwardBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Awards"}) - biographies: list[Biography] | None = Field(default=None, json_schema_extra={"x-go-name": "Biographies"}) - birth: str | None = None - birth_place: str | None = Field(default=None, alias="birthPlace") - characters: list[Character] | None = Field(default=None, json_schema_extra={"x-go-name": "Characters"}) - death: str | None = None - gender: int | None = None - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - image: str | None = None - last_updated: str | None = Field(default=None, alias="lastUpdated") - name: str | None = None - name_translations: list[str] | None = Field( - default=None, - alias="nameTranslations", - json_schema_extra={"x-go-name": "NameTranslations"}, - ) - overview_translations: list[str] | None = Field( - default=None, - alias="overviewTranslations", - json_schema_extra={"x-go-name": "OverviewTranslations"}, - ) - races: list[Race] | None = Field(default=None, json_schema_extra={"x-go-name": "Races"}) - remote_ids: list[RemoteID] | None = Field( - default=None, alias="remoteIds", json_schema_extra={"x-go-name": "RemoteIDs"} - ) - score: int | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) - slug: str | None = None - tag_options: list[TagOption] | None = Field( - default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} - ) - translations: TranslationExtended | None = None +class ListsIdExtendedGetResponse(BaseModel): + data: ListExtendedRecord | None = None + status: str | None = None -class SearchResult(BaseModel): - aliases: list[str] | None = None - companies: list[str] | None = None - company_type: str | None = Field(default=None, alias="companyType") - country: str | None = None - director: str | None = None - first_air_time: str | None = None - genres: list[str] | None = None - id: str - image_url: str | None = None - name: str | None = None - is_official: bool | None = None - name_translated: str | None = None - network: str | None = None - object_id: str | None = Field(default=None, alias="objectID") - official_list: str | None = Field(default=None, alias="officialList") - overview: str | None = None - overviews: TranslationSimple | None = None - overview_translated: list[str] | None = None - poster: str | None = None - posters: list[str] | None = None - primary_language: str | None = None - remote_ids: list[RemoteID] | None = Field(default=None, json_schema_extra={"x-go-name": "RemoteIDs"}) - status: str | None = Field(default=None, json_schema_extra={"x-go-name": "Status"}) - slug: str | None = None - studios: list[str] | None = None - title: str | None = None - thumbnail: str | None = None - translations: TranslationSimple | None = None - translations_with_lang: list[str] | None = Field(default=None, alias="translationsWithLang") - tvdb_id: str | None = None - type: str | None = None - year: str | None = None +class ListsIdGetResponse(BaseModel): + data: ListBaseRecord | None = None + status: str | None = None -class Tag(BaseModel): - allows_multiple: bool | None = Field( - default=None, - alias="allowsMultiple", - json_schema_extra={"x-go-name": "AllowsMultiple"}, - ) - help_text: str | None = Field(default=None, alias="helpText") - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) - options: list[TagOption] | None = Field(default=None, json_schema_extra={"x-go-name": "TagOptions"}) +class ListsIdTranslationsLanguageGetResponse(BaseModel): + data: list[Translation] | None = None + status: str | None = None -class Company(BaseModel): - active_date: str | None = Field(default=None, alias="activeDate") +class ListsSlugSlugGetResponse(ListsIdGetResponse): + pass + + +class LoginPostRequest(BaseModel): + apikey: str + pin: str | None = None + + +class LoginPostResponse(BaseModel): + data: Data | None = None + status: str | None = None + + +class Meta(Enum): + translations = "translations" + + +class Meta3(Enum): + translations = "translations" + episodes = "episodes" + + +class MovieBaseRecord(BaseModel): + """base movie record.""" + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) - country: str | None = None - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - inactive_date: str | None = Field(default=None, alias="inactiveDate") - name: str | None = None + id: int = Field(json_schema_extra={"x-go-name": "ID"}) + image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) + last_updated: str | None = Field(default=None, alias="lastUpdated") + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) name_translations: list[str] | None = Field( default=None, alias="nameTranslations", @@ -596,27 +791,16 @@ class Company(BaseModel): alias="overviewTranslations", json_schema_extra={"x-go-name": "OverviewTranslations"}, ) - primary_company_type: int | None = Field( - default=None, - alias="primaryCompanyType", - json_schema_extra={"x-go-name": "PrimaryCompanyType"}, - ) + score: float | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) slug: str | None = Field(default=None, json_schema_extra={"x-go-name": "Slug"}) - parent_company: ParentCompany | None = Field(default=None, alias="parentCompany") - tag_options: list[TagOption] | None = Field( - default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} - ) - - -class Companies(BaseModel): - studio: list[Company] | None = None - network: list[Company] | None = None - production: list[Company] | None = None - distributor: list[Company] | None = None - special_effects: list[Company] | None = None + status: Status | None = None + runtime: int | None = None + year: str | None = None class MovieExtendedRecord(BaseModel): + """extended movie record.""" + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) artworks: list[ArtworkBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Artworks"}) audio_languages: list[str] | None = Field( @@ -633,7 +817,7 @@ class MovieExtendedRecord(BaseModel): content_ratings: list[ContentRating] | None = Field(default=None, alias="contentRatings") first_release: Release | None = None genres: list[GenreBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Genres"}) - id: int = Field(json_schema_extra={"x-go-name": "ID"}) + id: int = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) inspirations: list[Inspiration] | None = Field(default=None, json_schema_extra={"x-go-name": "Inspirations"}) last_updated: str | None = Field(default=None, alias="lastUpdated") @@ -677,10 +861,54 @@ class MovieExtendedRecord(BaseModel): year: str | None = None -class SeasonBaseRecord(BaseModel): +class MoviesFilterGetResponse(BaseModel): + data: list[MovieBaseRecord] | None = None + status: str | None = None + + +class MoviesGetResponse(BaseModel): + data: list[MovieBaseRecord] | None = None + status: str | None = None + links: Links | None = None + + +class MoviesIdExtendedGetResponse(BaseModel): + data: MovieExtendedRecord | None = None + status: str | None = None + + +class MoviesIdGetResponse(BaseModel): + data: MovieBaseRecord | None = None + status: str | None = None + + +class MoviesIdTranslationsLanguageGetResponse(EpisodesIdTranslationsLanguageGetResponse): + pass + + +class MoviesSlugSlugGetResponse(MoviesIdGetResponse): + pass + + +class MoviesStatusesGetResponse(BaseModel): + data: list[Status] | None = None + status: str | None = None + + +class ParentCompany(BaseModel): + """A parent company record.""" + id: int | None = None + name: str | None = None + relation: CompanyRelationShip | None = None + + +class PeopleBaseRecord(BaseModel): + """base people record.""" + + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) image: str | None = None - image_type: int | None = Field(default=None, alias="imageType") last_updated: str | None = Field(default=None, alias="lastUpdated") name: str | None = None name_translations: list[str] | None = Field( @@ -688,60 +916,209 @@ class SeasonBaseRecord(BaseModel): alias="nameTranslations", json_schema_extra={"x-go-name": "NameTranslations"}, ) - number: int | None = Field(default=None, json_schema_extra={"x-go-name": "Number"}) overview_translations: list[str] | None = Field( default=None, alias="overviewTranslations", json_schema_extra={"x-go-name": "OverviewTranslations"}, ) - companies: Companies | None = None - series_id: int | None = Field(default=None, alias="seriesId", json_schema_extra={"x-go-name": "SeriesID"}) - type: SeasonType | None = None + score: int | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) + + +class PeopleExtendedRecord(BaseModel): + """extended people record.""" + + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) + awards: list[AwardBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Awards"}) + biographies: list[Biography] | None = Field(default=None, json_schema_extra={"x-go-name": "Biographies"}) + birth: str | None = None + birth_place: str | None = Field(default=None, alias="birthPlace") + characters: list[Character] | None = Field(default=None, json_schema_extra={"x-go-name": "Characters"}) + death: str | None = None + gender: int | None = None + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + image: str | None = None + last_updated: str | None = Field(default=None, alias="lastUpdated") + name: str | None = None + name_translations: list[str] | None = Field( + default=None, + alias="nameTranslations", + json_schema_extra={"x-go-name": "NameTranslations"}, + ) + overview_translations: list[str] | None = Field( + default=None, + alias="overviewTranslations", + json_schema_extra={"x-go-name": "OverviewTranslations"}, + ) + races: list[Race] | None = Field(default=None, json_schema_extra={"x-go-name": "Races"}) + remote_ids: list[RemoteID] | None = Field( + default=None, alias="remoteIds", json_schema_extra={"x-go-name": "RemoteIDs"} + ) + score: int | None = Field(default=None, json_schema_extra={"x-go-name": "Score"}) + slug: str | None = None + tag_options: list[TagOption] | None = Field( + default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + ) + translations: TranslationExtended | None = None + + +class PeopleGetResponse(BaseModel): + data: list[PeopleBaseRecord] | None = None + status: str | None = None + links: Links | None = None + + +class PeopleIdExtendedGetResponse(BaseModel): + data: PeopleExtendedRecord | None = None + status: str | None = None + + +class PeopleIdGetResponse(BaseModel): + data: PeopleBaseRecord | None = None + status: str | None = None + + +class PeopleIdTranslationsLanguageGetResponse(EpisodesIdTranslationsLanguageGetResponse): + pass + + +class PeopleType(BaseModel): + """people type record.""" + + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + + +class PeopleTypesGetResponse(BaseModel): + data: list[PeopleType] | None = None + status: str | None = None + + +class ProductionCountry(BaseModel): + """Production country record.""" + + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + country: str | None = None + name: str | None = None + + +class Race(BaseModel): + """race record.""" + + +class RecordInfo(BaseModel): + """base record info.""" + + image: str | None = Field(default=None, json_schema_extra={"x-go-name": "Image"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + year: str | None = None + + +class Release(BaseModel): + """release record.""" + + country: str | None = None + date: str | None = None + detail: str | None = None + + +class RemoteID(BaseModel): + """remote id record.""" + + id: str | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + type: int | None = Field(default=None, json_schema_extra={"x-go-name": "Type"}) + source_name: str | None = Field(default=None, alias="sourceName", json_schema_extra={"x-go-name": "SourceName"}) + + +class SearchByRemoteIdResult(BaseModel): + """search by remote reuslt is a base record for a movie, series, people, season or company search result.""" + + series: SeriesBaseRecord | None = None + people: PeopleBaseRecord | None = None + movie: MovieBaseRecord | None = None + episode: EpisodeBaseRecord | None = None + company: Company | None = None + + +class SearchGetResponse(BaseModel): + data: list[SearchResult] | None = None + status: str | None = None + links: Links | None = None + + +class SearchRemoteidRemoteIdGetResponse(BaseModel): + data: list[SearchByRemoteIdResult] | None = None + status: str | None = None + + +class SearchResult(BaseModel): + """search result.""" + + aliases: list[str] | None = None + companies: list[str] | None = None + company_type: str | None = Field(default=None, alias="companyType") + country: str | None = None + director: str | None = None + first_air_time: str | None = None + genres: list[str] | None = None + id: str + image_url: str | None = None + name: str | None = None + is_official: bool | None = None + name_translated: str | None = None + network: str | None = None + object_id: str = Field(alias="objectID") + official_list: str | None = Field(default=None, alias="officialList") + overview: str | None = None + overviews: TranslationSimple | None = None + overview_translated: list[str] | None = None + poster: str | None = None + posters: list[str] | None = None + primary_language: str | None = None + remote_ids: list[RemoteID] | None = Field(default=None, json_schema_extra={"x-go-name": "RemoteIDs"}) + status: str | None = Field(default=None, json_schema_extra={"x-go-name": "Status"}) + slug: str | None = None + studios: list[str] | None = None + title: str | None = None + thumbnail: str | None = None + translations: TranslationSimple | None = None + translations_with_lang: list[str] | None = Field(default=None, alias="translationsWithLang") + tvdb_id: str | None = None + type: str | None = None year: str | None = None -class EpisodeBaseRecord(BaseModel): - absolute_number: int | None = Field(default=None, alias="absoluteNumber") - aired: str | None = None - airs_after_season: int | None = Field(default=None, alias="airsAfterSeason") - airs_before_episode: int | None = Field(default=None, alias="airsBeforeEpisode") - airs_before_season: int | None = Field(default=None, alias="airsBeforeSeason") - finale_type: str | None = Field(default=None, alias="finaleType") - """ - season, midseason, or series - """ - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) +class SeasonBaseRecord(BaseModel): + """season genre record.""" + + id: int image: str | None = None image_type: int | None = Field(default=None, alias="imageType") - is_movie: int | None = Field(default=None, alias="isMovie", json_schema_extra={"x-go-name": "IsMovie"}) last_updated: str | None = Field(default=None, alias="lastUpdated") - linked_movie: int | None = Field(default=None, alias="linkedMovie") name: str | None = None name_translations: list[str] | None = Field( default=None, alias="nameTranslations", json_schema_extra={"x-go-name": "NameTranslations"}, ) - number: int | None = None - overview: str | None = None + number: int | None = Field(default=None, json_schema_extra={"x-go-name": "Number"}) overview_translations: list[str] | None = Field( default=None, alias="overviewTranslations", json_schema_extra={"x-go-name": "OverviewTranslations"}, ) - runtime: int | None = None - season_number: int | None = Field(default=None, alias="seasonNumber") - seasons: list[SeasonBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Seasons"}) + companies: Companies | None = None series_id: int | None = Field(default=None, alias="seriesId", json_schema_extra={"x-go-name": "SeriesID"}) - season_name: str | None = Field(default=None, alias="seasonName") + type: SeasonType | None = None year: str | None = None class SeasonExtendedRecord(BaseModel): + """extended season record.""" + artwork: list[ArtworkBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Artwork"}) companies: Companies | None = None episodes: list[EpisodeBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Episodes"}) - id: int | None = None + id: int image: str | None = None image_type: int | None = Field(default=None, alias="imageType") last_updated: str | None = Field(default=None, alias="lastUpdated") @@ -767,7 +1144,54 @@ class SeasonExtendedRecord(BaseModel): year: str | None = None +class SeasonType(BaseModel): + """season type record.""" + + alternate_name: str | None = Field(default=None, alias="alternateName", json_schema_extra={"x-go-name": "Name"}) + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + type: str | None = Field(default=None, json_schema_extra={"x-go-name": "Type"}) + + +class SeasonsGetResponse(BaseModel): + data: list[SeasonBaseRecord] | None = None + status: str | None = None + + +class SeasonsIdExtendedGetResponse(BaseModel): + data: SeasonExtendedRecord | None = None + status: str | None = None + + +class SeasonsIdGetResponse(BaseModel): + data: SeasonBaseRecord | None = None + status: str | None = None + + +class SeasonsIdTranslationsLanguageGetResponse(EpisodesIdTranslationsLanguageGetResponse): + pass + + +class SeasonsTypesGetResponse(BaseModel): + data: list[SeasonType] | None = None + status: str | None = None + + +class SeriesAirsDays(BaseModel): + """A series airs day record.""" + + friday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Friday"}) + monday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Monday"}) + saturday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Saturday"}) + sunday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Sunday"}) + thursday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Thursday"}) + tuesday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Tuesday"}) + wednesday: bool | None = Field(default=None, json_schema_extra={"x-go-name": "Wednesday"}) + + class SeriesBaseRecord(BaseModel): + """The base record for a series. All series airs time like firstAired, lastAired, nextAired, etc. are in US EST for US series, and for all non-US series, the time of the show's country capital or most populous city. For streaming services, is the official release time. See https://support.thetvdb.com/kb/faq.php?id=29.""" + aliases: list[Alias] | None = Field(default=None, json_schema_extra={"x-go-name": "Aliases"}) average_runtime: int | None = Field(default=None, alias="averageRuntime") country: str | None = None @@ -808,6 +1232,8 @@ class SeriesBaseRecord(BaseModel): class SeriesExtendedRecord(BaseModel): + """The extended record for a series. All series airs time like firstAired, lastAired, nextAired, etc. are in US EST for US series, and for all non-US series, the time of the show's country capital or most populous city. For streaming services, is the official release time. See https://support.thetvdb.com/kb/faq.php?id=29.""" + abbreviation: str | None = None airs_days: SeriesAirsDays | None = Field(default=None, alias="airsDays") airs_time: str | None = Field(default=None, alias="airsTime") @@ -869,91 +1295,254 @@ class SeriesExtendedRecord(BaseModel): year: str | None = None -class AwardNomineeBaseRecord(BaseModel): - character: Character | None = None - details: str | None = None - episode: EpisodeBaseRecord | None = None +class SeriesFilterGetResponse(BaseModel): + data: list[SeriesBaseRecord] | None = None + + +class SeriesGetResponse(BaseModel): + data: list[SeriesBaseRecord] | None = None + status: str | None = None + links: Links | None = None + + +class SeriesIdArtworksGetResponse(BaseModel): + data: SeriesExtendedRecord | None = None + status: str | None = None + + +class SeriesIdEpisodesSeasonTypeGetResponse(BaseModel): + data: Data1 | None = None + status: str | None = None + + +class SeriesIdEpisodesSeasonTypeLangGetResponse(BaseModel): + data: Data2 | None = None + status: str | None = None + + +class SeriesIdExtendedGetResponse(SeriesIdArtworksGetResponse): + pass + + +class SeriesIdGetResponse(BaseModel): + data: SeriesBaseRecord | None = None + status: str | None = None + + +class SeriesIdNextAiredGetResponse(SeriesIdGetResponse): + pass + + +class SeriesIdTranslationsLanguageGetResponse(EpisodesIdTranslationsLanguageGetResponse): + pass + + +class SeriesSlugSlugGetResponse(SeriesIdGetResponse): + pass + + +class SeriesStatusesGetResponse(MoviesStatusesGetResponse): + pass + + +class Short(Enum): + boolean_true = True + boolean_false = False + + +class Sort(Enum): + score = "score" + first_aired = "firstAired" + name_ = "name" + + +class Sort1(Enum): + score = "score" + first_aired = "firstAired" + last_aired = "lastAired" + name_ = "name" + + +class SortType(Enum): + asc = "asc" + desc = "desc" + + +class SourceType(BaseModel): + """source type record.""" + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - is_winner: bool | None = Field(default=None, alias="isWinner", json_schema_extra={"x-go-name": "IsWinner"}) - movie: MovieBaseRecord | None = None - series: SeriesBaseRecord | None = None - year: str | None = None - category: str | None = None - name: str | None = None + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + postfix: str | None = None + prefix: str | None = None + slug: str | None = Field(default=None, json_schema_extra={"x-go-name": "Slug"}) + sort: int | None = Field(default=None, json_schema_extra={"x-go-name": "Sort"}) -class EpisodeExtendedRecord(BaseModel): - aired: str | None = None - airs_after_season: int | None = Field(default=None, alias="airsAfterSeason") - airs_before_episode: int | None = Field(default=None, alias="airsBeforeEpisode") - airs_before_season: int | None = Field(default=None, alias="airsBeforeSeason") - awards: list[AwardBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Awards"}) - characters: list[Character] | None = Field(default=None, json_schema_extra={"x-go-name": "Characters"}) - companies: list[Company] | None = None - content_ratings: list[ContentRating] | None = Field( +class SourcesTypesGetResponse(BaseModel): + data: list[SourceType] | None = None + status: str | None = None + + +class Status(BaseModel): + """status record.""" + + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + keep_updated: bool | None = Field( default=None, - alias="contentRatings", - json_schema_extra={"x-go-name": "ContentRatings"}, + alias="keepUpdated", + json_schema_extra={"x-go-name": "KeepUpdated"}, ) - finale_type: str | None = Field(default=None, alias="finaleType") - """ - season, midseason, or series - """ + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + record_type: str | None = Field(default=None, alias="recordType", json_schema_extra={"x-go-name": "RecordType"}) + + +class Status1(Enum): + number_1 = 1 + number_2 = 2 + number_3 = 3 + + +class StudioBaseRecord(BaseModel): + """studio record.""" + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) - image: str | None = None - image_type: int | None = Field(default=None, alias="imageType") - is_movie: int | None = Field(default=None, alias="isMovie", json_schema_extra={"x-go-name": "IsMovie"}) - last_updated: str | None = Field(default=None, alias="lastUpdated") - linked_movie: int | None = Field(default=None, alias="linkedMovie") - name: str | None = None - name_translations: list[str] | None = Field( + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + parent_studio: int | None = Field(default=None, alias="parentStudio") + + +class Tag(BaseModel): + """tag record.""" + + allows_multiple: bool | None = Field( default=None, - alias="nameTranslations", - json_schema_extra={"x-go-name": "NameTranslations"}, + alias="allowsMultiple", + json_schema_extra={"x-go-name": "AllowsMultiple"}, ) - networks: list[Company] | None = None - nominations: list[AwardNomineeBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Nominees"}) - number: int | None = None + help_text: str | None = Field(default=None, alias="helpText") + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + options: list[TagOption] | None = Field(default=None, json_schema_extra={"x-go-name": "TagOptions"}) + + +class TagOption(BaseModel): + """tag option record.""" + + help_text: str | None = Field(default=None, alias="helpText") + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + name: str | None = Field(default=None, json_schema_extra={"x-go-name": "Name"}) + tag: int | None = Field(default=None, json_schema_extra={"x-go-name": "Tag"}) + tag_name: str | None = Field(default=None, alias="tagName", json_schema_extra={"x-go-name": "TagName"}) + + +class TagOptionEntity(BaseModel): + """a entity with selected tag option.""" + + name: str | None = None + tag_name: str | None = Field(default=None, alias="tagName") + tag_id: int | None = Field(default=None, alias="tagId") + + +class Trailer(BaseModel): + """trailer record.""" + + id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + language: str | None = None + name: str | None = None + url: str | None = None + runtime: int | None = None + + +class Translation(BaseModel): + """translation record.""" + + aliases: list[str] | None = None + is_alias: bool | None = Field(default=None, alias="isAlias") + is_primary: bool | None = Field(default=None, alias="isPrimary") + language: str | None = Field(default=None, json_schema_extra={"x-go-name": "Language"}) + name: str | None = None overview: str | None = None - overview_translations: list[str] | None = Field( + tagline: str | None = Field( default=None, - alias="overviewTranslations", - json_schema_extra={"x-go-name": "OverviewTranslations"}, - ) - production_code: str | None = Field(default=None, alias="productionCode") - remote_ids: list[RemoteID] | None = Field( - default=None, alias="remoteIds", json_schema_extra={"x-go-name": "RemoteIDs"} - ) - runtime: int | None = None - season_number: int | None = Field(default=None, alias="seasonNumber") - seasons: list[SeasonBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Seasons"}) - series_id: int | None = Field(default=None, alias="seriesId", json_schema_extra={"x-go-name": "SeriesID"}) - studios: list[Company] | None = None - tag_options: list[TagOption] | None = Field( - default=None, alias="tagOptions", json_schema_extra={"x-go-name": "TagOptions"} + description="Only populated for movie translations. We disallow taglines without a title.", ) - trailers: list[Trailer] | None = Field(default=None, json_schema_extra={"x-go-name": "Trailers"}) - translations: TranslationExtended | None = None - year: str | None = None -class SearchByRemoteIdResult(BaseModel): - series: SeriesBaseRecord | None = None - people: PeopleBaseRecord | None = None - movie: MovieBaseRecord | None = None - episode: EpisodeBaseRecord | None = None - company: Company | None = None +class TranslationExtended(BaseModel): + """translation extended record.""" + name_translations: list[Translation] | None = Field(default=None, alias="nameTranslations") + overview_translations: list[Translation] | None = Field(default=None, alias="overviewTranslations") + alias: list[str] | None = None -class AwardCategoryExtendedRecord(BaseModel): - allow_co_nominees: bool | None = Field( - default=None, - alias="allowCoNominees", - json_schema_extra={"x-go-name": "AllowCoNominees"}, - ) - award: AwardBaseRecord | None = None - for_movies: bool | None = Field(default=None, alias="forMovies", json_schema_extra={"x-go-name": "ForMovies"}) - for_series: bool | None = Field(default=None, alias="forSeries", json_schema_extra={"x-go-name": "ForSeries"}) - id: int | None = Field(default=None, json_schema_extra={"x-go-name": "ID"}) + +class TranslationSimple(RootModel[dict[str, str] | None]): + """translation simple record.""" + + root: dict[str, str] | None = None + + +class Type(Enum): + artwork = "artwork" + award_nominees = "award_nominees" + companies = "companies" + episodes = "episodes" + lists = "lists" + people = "people" + seasons = "seasons" + series = "series" + seriespeople = "seriespeople" + artworktypes = "artworktypes" + award_categories = "award_categories" + awards = "awards" + company_types = "company_types" + content_ratings = "content_ratings" + countries = "countries" + entity_types = "entity_types" + genres = "genres" + languages = "languages" + movies = "movies" + movie_genres = "movie_genres" + movie_status = "movie_status" + peopletypes = "peopletypes" + seasontypes = "seasontypes" + sourcetypes = "sourcetypes" + tag_options = "tag_options" + tags = "tags" + translatedcharacters = "translatedcharacters" + translatedcompanies = "translatedcompanies" + translatedepisodes = "translatedepisodes" + translatedlists = "translatedlists" + translatedmovies = "translatedmovies" + translatedpeople = "translatedpeople" + translatedseasons = "translatedseasons" + translatedserierk = "translatedserierk" + + +class UpdatesGetResponse(BaseModel): + data: list[EntityUpdate] | None = None + status: str | None = None + links: Links | None = None + + +class UserFavoritesGetResponse(BaseModel): + data: list[Favorites] | None = None + status: str | None = None + + +class UserGetResponse(BaseModel): + data: list[UserInfo] | None = None + status: str | None = None + + +class UserIdGetResponse(UserGetResponse): + pass + + +class UserInfo(BaseModel): + """User info record.""" + + id: int | None = None + language: str | None = None name: str | None = None - nominees: list[AwardNomineeBaseRecord] | None = Field(default=None, json_schema_extra={"x-go-name": "Nominees"}) + type: str | None = None diff --git a/src/tvdb/models.py b/src/tvdb/models.py deleted file mode 100644 index e2d1d37..0000000 --- a/src/tvdb/models.py +++ /dev/null @@ -1,50 +0,0 @@ -from pydantic import BaseModel - -from src.tvdb.generated_models import ( - Links, - MovieBaseRecord, - MovieExtendedRecord, - SearchResult, - SeriesBaseRecord, - SeriesExtendedRecord, -) - - -class _Response(BaseModel): - """Model for any response of the TVDB API.""" - - status: str - - -class _PaginatedResponse(_Response): - links: Links - - -class SearchResponse(_PaginatedResponse): - """Model for the response of the search endpoint of the TVDB API.""" - - data: list[SearchResult] - - -class SeriesResponse(_Response): - """Model for the response of the series/{id} endpoint of the TVDB API.""" - - data: SeriesBaseRecord - - -class MovieResponse(_Response): - """Model for the response of the movies/{id} endpoint of the TVDB API.""" - - data: MovieBaseRecord - - -class SeriesExtendedResponse(_Response): - """Model for the response of the series/{id}/extended endpoint of the TVDB API.""" - - data: SeriesExtendedRecord - - -class MovieExtendedResponse(_Response): - """Model for the response of the movies/{id}/extended endpoint of the TVDB API.""" - - data: MovieExtendedRecord diff --git a/tools/generate_tvdb_models.py b/tools/generate_tvdb_models.py index 540983c..69bf35e 100644 --- a/tools/generate_tvdb_models.py +++ b/tools/generate_tvdb_models.py @@ -1,35 +1,52 @@ import subprocess -import sys - - -def _run_datamodel_codegen() -> None: - command = [ - "poetry", - "run", - "datamodel-codegen", - "--url", - "https://thetvdb.github.io/v4-api/swagger.yml", - "--input-file-type", - "openapi", - "--output", - r"./src/tvdb/generated_models.py", - "--output-model-type", - "pydantic_v2.BaseModel", - ] - try: - subprocess.run(command, check=True) # noqa: S603 - print("Code generation completed successfully.") # noqa: T201 - except subprocess.CalledProcessError as e: - print(f"Error occurred while running datamodel-codegen: {e}", file=sys.stderr) # noqa: T201 - sys.exit(1) +from pathlib import Path +from urllib.parse import ParseResult, urlparse +from datamodel_code_generator import DataModelType, InputFileType, OpenAPIScope, PythonVersion, generate + +HEADER: str = """# ruff: noqa: D101, ERA001, E501 +""" + + +def _generate_models() -> Path: + output = Path("./src/tvdb/generated_models.py") + url: ParseResult = urlparse("https://thetvdb.github.io/v4-api/swagger.yml") + generate( + url, + input_file_type=InputFileType.OpenAPI, + input_filename="swagger.yml", + output=output, + output_model_type=DataModelType.PydanticV2BaseModel, + field_constraints=True, + snake_case_field=True, + target_python_version=PythonVersion.PY_312, + use_default_kwarg=True, + use_union_operator=True, + reuse_model=True, + field_include_all_keys=True, + strict_nullable=True, + use_schema_description=True, + keep_model_order=True, + enable_version_header=True, + openapi_scopes=[OpenAPIScope.Schemas, OpenAPIScope.Paths], + ) + with output.open("r") as f: + contents = f.read() + contents = contents.replace("’", "'") # noqa: RUF001 + with output.open("w") as f: + f.write(HEADER + contents) + f.truncate() + return output -def main() -> None: - """The main entry point for the script. - :return: - """ - _run_datamodel_codegen() +def _run_ruff(file_path: Path) -> None: + subprocess.run(["poetry", "run", "ruff", "check", "--fix", "--unsafe-fixes", str(file_path)], check=True) # noqa: S603, S607 + + +def main() -> None: + """The main entry point for the script.""" + generated_file = _generate_models() + _run_ruff(generated_file) if __name__ == "__main__": From 2af0d6cd7df6b5811f12eed8c2ab7c5fd6f7fd33 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 21 Jul 2024 23:25:11 +0200 Subject: [PATCH 098/166] :sparkles: Improve tvdb client and example extension --- src/exts/tvdb_info/main.py | 36 ++++++------- src/tvdb/__init__.py | 10 ++-- src/tvdb/client.py | 98 +++++++++++++++++++++++++----------- src/tvdb/errors.py | 18 +++++++ src/tvdb/generated_models.py | 4 +- 5 files changed, 110 insertions(+), 56 deletions(-) create mode 100644 src/tvdb/errors.py diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index c46fc92..2ae2b43 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from typing import Literal import aiohttp @@ -6,7 +7,7 @@ from src.bot import Bot from src.settings import THETVDB_COPYRIGHT_FOOTER, THETVDB_LOGO -from src.tvdb import Movie, Series, TvdbClient +from src.tvdb import FetchMeta, Movie, Series, TvdbClient from src.utils.log import get_logger log = get_logger(__name__) @@ -18,22 +19,23 @@ class InfoView(discord.ui.View): """View for displaying information about a movie or series.""" - def __init__(self, results: list[Movie | Series] | list[Movie] | list[Series]) -> None: + def __init__(self, results: Sequence[Movie | Series]) -> None: super().__init__(disable_on_timeout=True) self.results = results - self.dropdown = discord.ui.Select( - placeholder="Not what you're looking for? Select a different result.", - options=[ - discord.SelectOption( - label=result.bilingual_name or "", - value=str(i), - description=result.overview[:100] if result.overview else None, - ) - for i, result in enumerate(self.results) - ], - ) - self.dropdown.callback = self._dropdown_callback - self.add_item(self.dropdown) + if len(self.results) > 1: + self.dropdown = discord.ui.Select( + placeholder="Not what you're looking for? Select a different result.", + options=[ + discord.SelectOption( + label=result.bilingual_name or "", + value=str(i), + description=result.overview[:100] if result.overview else None, + ) + for i, result in enumerate(self.results) + ], + ) + self.dropdown.callback = self._dropdown_callback + self.add_item(self.dropdown) self.index = 0 def _get_embed(self) -> discord.Embed: @@ -102,9 +104,9 @@ async def search( if by_id: match entity_type: case "movie": - response = [await Movie.fetch(int(query), client, extended=True)] + response = [await Movie.fetch(int(query), client, extended=True, meta=FetchMeta.TRANSLATIONS)] case "series": - response = [await Series.fetch(int(query), client, extended=True)] + response = [await Series.fetch(int(query), client, extended=True, meta=FetchMeta.TRANSLATIONS)] case None: await ctx.respond( "You must specify a type (movie or series) when searching by ID.", ephemeral=True diff --git a/src/tvdb/__init__.py b/src/tvdb/__init__.py index be757af..cb92c16 100644 --- a/src/tvdb/__init__.py +++ b/src/tvdb/__init__.py @@ -1,8 +1,4 @@ -from .client import InvalidApiKeyError, Movie, Series, TvdbClient +from .client import FetchMeta, Movie, Series, TvdbClient +from .errors import InvalidApiKeyError -__all__ = [ - "TvdbClient", - "InvalidApiKeyError", - "Movie", - "Series", -] +__all__ = ["TvdbClient", "InvalidApiKeyError", "Movie", "Series", "FetchMeta"] diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 5e86856..49a2f63 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod -from typing import ClassVar, Literal, final, overload, override +from enum import Enum +from typing import ClassVar, Literal, Self, final, overload, override import aiohttp from yarl import URL @@ -19,6 +20,8 @@ ) from src.utils.log import get_logger +from .errors import BadCallError, InvalidApiKeyError + log = get_logger(__name__) type JSON_DATA = dict[str, JSON_DATA] | list[JSON_DATA] | str | int | float | bool | None # noice @@ -28,12 +31,24 @@ type AnyRecord = SeriesRecord | MovieRecord +class FetchMeta(Enum): + """When calling fetch with extended=True, is used if wanting to fetch translations or episodes as well.""" + + TRANSLATIONS = "translations" + EPISODES = "episodes" + + def parse_media_id(media_id: int | str) -> int: """Parse the media ID from a string.""" return int(str(media_id).removeprefix("movie-").removeprefix("series-")) class _Media(ABC): + ENDPOINT: ClassVar[str] + + ResponseType: ClassVar[type[MoviesIdGetResponse | SeriesIdGetResponse]] + ExtendedResponseType: ClassVar[type[MoviesIdExtendedGetResponse | SeriesIdExtendedGetResponse]] + def __init__(self, client: "TvdbClient", data: AnyRecord | SearchResult | None): if data is None: raise ValueError("Data can't be None but is allowed to because of the broken pydantic generated models.") @@ -97,56 +112,79 @@ def id(self, value: int | str) -> None: # pyright: ignore[reportPropertyTypeMis @classmethod @abstractmethod - async def fetch(cls, media_id: int | str, *, client: "TvdbClient", extended: bool = False) -> "_Media": ... + def supports_meta(cls, meta: FetchMeta) -> bool: + """Check if the class supports a specific meta.""" + ... - -@final -class Movie(_Media): - """Class to interact with the TVDB API for movies.""" - - @override @classmethod - async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: bool = False) -> "Movie": + async def fetch( + cls: type[Self], + media_id: int | str, + client: "TvdbClient", + *, + extended: bool = False, + short: bool = True, + meta: FetchMeta | None = None, + ) -> Self: """Fetch a movie by its ID. :param media_id: The ID of the movie. :param client: The TVDB client to use. :param extended: Whether to fetch extended information. + :param short: Whether to omit characters and artworks from the response. Requires extended=True to work. + :param meta: The meta to fetch. Requires extended=True to work. :return: """ media_id = parse_media_id(media_id) - response = await client.request("GET", f"movies/{media_id}" + ("/extended" if extended else "")) - response = MoviesIdGetResponse(**response) if not extended else MoviesIdExtendedGetResponse(**response) # pyright: ignore[reportCallIssue] + query: dict[str, str] = {} + if extended: + if meta: + query["meta"] = meta.value + if short: + query["short"] = "true" + else: + query["short"] = "false" + elif meta: + raise BadCallError("Meta can only be used with extended=True.") + response = await client.request( + "GET", + f"{cls.ENDPOINT}/{media_id}" + ("/extended" if extended else ""), + query=query if query else None, + ) + response = cls.ResponseType(**response) if not extended else cls.ExtendedResponseType(**response) # pyright: ignore[reportCallIssue] return cls(client, response.data) @final -class Series(_Media): - """Class to interact with the TVDB API for series.""" +class Movie(_Media): + """Class to interact with the TVDB API for movies.""" + + ENDPOINT: ClassVar[str] = "movies" + + ResponseType = MoviesIdGetResponse + ExtendedResponseType = MoviesIdExtendedGetResponse @override @classmethod - async def fetch(cls, media_id: int | str, client: "TvdbClient", *, extended: bool = False) -> "Series": - """Fetch a series by its ID. + async def supports_meta(cls, meta: FetchMeta) -> bool: + """Check if the class supports a specific meta.""" + return meta == FetchMeta.TRANSLATIONS - :param media_id: The ID of the series. - :param client: The TVDB client to use. - :param extended: Whether to fetch extended information. - :return: - """ - media_id = parse_media_id(media_id) - response = await client.request("GET", f"series/{media_id}" + ("/extended" if extended else "")) - response = SeriesIdGetResponse(**response) if not extended else SeriesIdExtendedGetResponse(**response) # pyright: ignore[reportCallIssue] - return cls(client, response.data) +@final +class Series(_Media): + """Class to interact with the TVDB API for series.""" -class InvalidApiKeyError(Exception): - """Exception raised when the TVDB API key used was invalid.""" + ENDPOINT: ClassVar[str] = "series" - def __init__(self, response: aiohttp.ClientResponse, response_txt: str): - self.response = response - self.response_txt = response_txt - super().__init__("Invalid TVDB API key.") + ResponseType = SeriesIdGetResponse + ExtendedResponseType = SeriesIdExtendedGetResponse + + @override + @classmethod + async def supports_meta(cls, meta: FetchMeta) -> bool: + """Check if the class supports a specific meta.""" + return meta in {FetchMeta.TRANSLATIONS, FetchMeta.EPISODES} class TvdbClient: diff --git a/src/tvdb/errors.py b/src/tvdb/errors.py new file mode 100644 index 0000000..632987e --- /dev/null +++ b/src/tvdb/errors.py @@ -0,0 +1,18 @@ +import aiohttp + + +class TVDBError(Exception): + """The base exception for all TVDB errors.""" + + +class BadCallError(TVDBError): + """Exception raised when the meta value is incompatible with the class.""" + + +class InvalidApiKeyError(TVDBError): + """Exception raised when the TVDB API key used was invalid.""" + + def __init__(self, response: aiohttp.ClientResponse, response_txt: str): + self.response = response + self.response_txt = response_txt + super().__init__("Invalid TVDB API key.") diff --git a/src/tvdb/generated_models.py b/src/tvdb/generated_models.py index 678b47f..70e1988 100644 --- a/src/tvdb/generated_models.py +++ b/src/tvdb/generated_models.py @@ -22,9 +22,9 @@ class Alias(BaseModel): language: str | None = Field( default=None, description="A 3-4 character string indicating the language of the alias, as defined in Language.", - le=4, + max_length=4, ) - name: str | None = Field(default=None, description="A string containing the alias itself.", le=100) + name: str | None = Field(default=None, description="A string containing the alias itself.", max_length=100) class ArtworkBaseRecord(BaseModel): From 6e9058aa1103ea040a71b0dfc076cbbd8f172979 Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 22 Jul 2024 00:10:30 +0200 Subject: [PATCH 099/166] Update docstring of FetchMeta Co-authored-by: ItsDrike --- src/tvdb/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 49a2f63..4085ab2 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -32,7 +32,7 @@ class FetchMeta(Enum): - """When calling fetch with extended=True, is used if wanting to fetch translations or episodes as well.""" + """When calling fetch with extended=True, this is used if we want to fetch translations or episodes as well.""" TRANSLATIONS = "translations" EPISODES = "episodes" From a37fbd9d743596b78bf7e044d2e513630c6a9046 Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 22 Jul 2024 00:11:01 +0200 Subject: [PATCH 100/166] Update src/tvdb/client.py Co-authored-by: ItsDrike --- src/tvdb/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 4085ab2..fdc29de 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -168,7 +168,7 @@ class Movie(_Media): @classmethod async def supports_meta(cls, meta: FetchMeta) -> bool: """Check if the class supports a specific meta.""" - return meta == FetchMeta.TRANSLATIONS + return meta is FetchMeta.TRANSLATIONS @final From 1344223b639050f735ffdbda4547abf02e89c15f Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 22 Jul 2024 00:07:00 +0200 Subject: [PATCH 101/166] :adhesive_bandage: Fix id format in /search --- src/exts/tvdb_info/main.py | 33 +++++++++++++++++++++++---------- src/tvdb/client.py | 9 +++++++-- src/tvdb/errors.py | 4 ++++ 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 2ae2b43..369e830 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -8,6 +8,7 @@ from src.bot import Bot from src.settings import THETVDB_COPYRIGHT_FOOTER, THETVDB_LOGO from src.tvdb import FetchMeta, Movie, Series, TvdbClient +from src.tvdb.errors import InvalidIdError from src.utils.log import get_logger log = get_logger(__name__) @@ -102,16 +103,28 @@ async def search( async with aiohttp.ClientSession() as session: client = TvdbClient(session) if by_id: - match entity_type: - case "movie": - response = [await Movie.fetch(int(query), client, extended=True, meta=FetchMeta.TRANSLATIONS)] - case "series": - response = [await Series.fetch(int(query), client, extended=True, meta=FetchMeta.TRANSLATIONS)] - case None: - await ctx.respond( - "You must specify a type (movie or series) when searching by ID.", ephemeral=True - ) - return + if query.startswith("movie-"): + entity_type = "movie" + query = query[6:] + elif query.startswith("series-"): + entity_type = "series" + query = query[7:] + try: + match entity_type: + case "movie": + response = [await Movie.fetch(query, client, extended=True, meta=FetchMeta.TRANSLATIONS)] + case "series": + response = [await Series.fetch(query, client, extended=True, meta=FetchMeta.TRANSLATIONS)] + case None: + await ctx.respond( + "You must specify a type (movie or series) when searching by ID.", ephemeral=True + ) + return + except InvalidIdError: + await ctx.respond( + 'Invalid ID. Id must be an integer, or "movie-" / "series-" followed by an integer.' + ) + return else: match entity_type: case "movie": diff --git a/src/tvdb/client.py b/src/tvdb/client.py index fdc29de..35a427c 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -20,7 +20,7 @@ ) from src.utils.log import get_logger -from .errors import BadCallError, InvalidApiKeyError +from .errors import BadCallError, InvalidApiKeyError, InvalidIdError log = get_logger(__name__) @@ -40,7 +40,12 @@ class FetchMeta(Enum): def parse_media_id(media_id: int | str) -> int: """Parse the media ID from a string.""" - return int(str(media_id).removeprefix("movie-").removeprefix("series-")) + try: + media_id = int(str(media_id).removeprefix("movie-").removeprefix("series-")) + except ValueError: + raise InvalidIdError("Invalid media ID.") + else: + return media_id class _Media(ABC): diff --git a/src/tvdb/errors.py b/src/tvdb/errors.py index 632987e..7eebdea 100644 --- a/src/tvdb/errors.py +++ b/src/tvdb/errors.py @@ -9,6 +9,10 @@ class BadCallError(TVDBError): """Exception raised when the meta value is incompatible with the class.""" +class InvalidIdError(TVDBError): + """Exception raised when the ID provided is invalid.""" + + class InvalidApiKeyError(TVDBError): """Exception raised when the TVDB API key used was invalid.""" From 5eda0c069ccd3708c5ae0d93596ced4f8647394f Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 22 Jul 2024 00:14:55 +0200 Subject: [PATCH 102/166] Update src/tvdb/client.py Co-authored-by: ItsDrike --- src/tvdb/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 35a427c..af9c2cb 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -123,7 +123,7 @@ def supports_meta(cls, meta: FetchMeta) -> bool: @classmethod async def fetch( - cls: type[Self], + cls, media_id: int | str, client: "TvdbClient", *, From 19834a08e1fc82e7f42d27006f3d3a059fbbddca Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 22 Jul 2024 00:17:20 +0200 Subject: [PATCH 103/166] :adhesive_bandage: Change the way TVDBClient.search works --- src/exts/tvdb_info/main.py | 8 +------- src/tvdb/client.py | 4 ++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 369e830..008645c 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -126,13 +126,7 @@ async def search( ) return else: - match entity_type: - case "movie": - response = await client.search(query, limit=5, entity_type="movie") - case "series": - response = await client.search(query, limit=5, entity_type="series") - case None: - response = await client.search(query, limit=5) + response = await client.search(query, limit=5, entity_type=entity_type) if not response: await ctx.respond("No results found.") return diff --git a/src/tvdb/client.py b/src/tvdb/client.py index af9c2cb..85d3542 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -243,11 +243,11 @@ async def request( return await response.json() async def search( - self, search_query: str, entity_type: Literal["series", "movie", "all"] = "all", limit: int = 1 + self, search_query: str, entity_type: Literal["series", "movie", None] = None, limit: int = 1 ) -> list[Movie | Series]: """Search for a series or movie in the TVDB database.""" query: dict[str, str] = {"query": search_query, "limit": str(limit)} - if entity_type != "all": + if entity_type: query["type"] = entity_type data = await self.request("GET", "search", query=query) response = SearchGetResponse(**data) # pyright: ignore[reportCallIssue] From 705bbb1c1699732b69d7b1d2e3f527240099f3b0 Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 22 Jul 2024 00:33:48 +0200 Subject: [PATCH 104/166] :adhesive_bandage: Use overloads on fetch --- src/exts/tvdb_info/main.py | 3 ++- src/tvdb/client.py | 28 +++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 008645c..434b67e 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -122,7 +122,8 @@ async def search( return except InvalidIdError: await ctx.respond( - 'Invalid ID. Id must be an integer, or "movie-" / "series-" followed by an integer.' + 'Invalid ID. Id must be an integer, or "movie-" / "series-" followed by an integer.', + ephemeral=True, ) return else: diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 85d3542..df908f8 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -121,6 +121,30 @@ def supports_meta(cls, meta: FetchMeta) -> bool: """Check if the class supports a specific meta.""" ... + @classmethod + @overload + async def fetch( + cls, + media_id: int | str, + client: "TvdbClient", + *, + extended: Literal[False], + short: Literal[False] | None = None, + meta: None = None, + ) -> Self: ... + + @classmethod + @overload + async def fetch( + cls, + media_id: int | str, + client: "TvdbClient", + *, + extended: Literal[True], + short: bool | None = None, + meta: FetchMeta | None = None, + ) -> Self: ... + @classmethod async def fetch( cls, @@ -128,7 +152,7 @@ async def fetch( client: "TvdbClient", *, extended: bool = False, - short: bool = True, + short: bool | None = None, meta: FetchMeta | None = None, ) -> Self: """Fetch a movie by its ID. @@ -151,6 +175,8 @@ async def fetch( query["short"] = "false" elif meta: raise BadCallError("Meta can only be used with extended=True.") + elif short: + raise BadCallError("Short can only be enabled with extended=True.") response = await client.request( "GET", f"{cls.ENDPOINT}/{media_id}" + ("/extended" if extended else ""), From 60a78af6405a1c771e28b6358578f3abcf4a7f60 Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 22 Jul 2024 00:35:45 +0200 Subject: [PATCH 105/166] :adhesive_bandage: Make bool kwarg --- src/exts/tvdb_info/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 434b67e..12db327 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -94,9 +94,10 @@ def __init__(self, bot: Bot) -> None: async def search( self, ctx: ApplicationContext, + *, query: str, entity_type: Literal["movie", "series"] | None = None, - by_id: bool = False, # noqa: FBT001, FBT002 + by_id: bool = False, ) -> None: """Search for a movie or series.""" await ctx.defer() From 32efa7c01cedf9ba347ebe6f61e27ad7917a3ddb Mon Sep 17 00:00:00 2001 From: Paillat Date: Mon, 22 Jul 2024 01:16:13 +0200 Subject: [PATCH 106/166] :adhesive_bandage: Handle no results correctly --- src/tvdb/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index df908f8..57fbc2d 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -277,9 +277,9 @@ async def search( query["type"] = entity_type data = await self.request("GET", "search", query=query) response = SearchGetResponse(**data) # pyright: ignore[reportCallIssue] - if not response.data: - raise ValueError("This should not happen.") returnable: list[Movie | Series] = [] + if not response.data: + return returnable for result in response.data: match result.type: case "movie": From 3780a3a7d474ac14bcf4a100de36b0c37a0cc167 Mon Sep 17 00:00:00 2001 From: Ashtik Mahapatra Date: Tue, 23 Jul 2024 19:39:12 +0530 Subject: [PATCH 107/166] Added support for sub-groups in slash commands. --- src/exts/help/help.py | 43 ++++++++++++++++++++++++++++--------------- src/settings.py | 2 ++ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/exts/help/help.py b/src/exts/help/help.py index fa631b3..e03a362 100644 --- a/src/exts/help/help.py +++ b/src/exts/help/help.py @@ -1,9 +1,20 @@ +from typing import Any + import discord -from discord import ApplicationContext, CheckFailure, Cog, SlashCommand, SlashCommandGroup, slash_command +from discord import ( + ApplicationCommand, + ApplicationContext, + CheckFailure, + Cog, + SlashCommand, + SlashCommandGroup, + slash_command, +) from discord.ext.commands import CheckFailure as CommandCheckFailure from discord.ext.pages import Page, Paginator from src.bot import Bot +from src.settings import COMMAND_EMOJI, GROUP_EMOJI from src.utils import mention_command from src.utils.cat_api import get_cat_image_url from src.utils.log import get_logger @@ -20,30 +31,32 @@ def __init__(self, bot: Bot) -> None: @slash_command() async def help(self, ctx: ApplicationContext) -> None: """Shows help for all available commands.""" - cat_image_url = await get_cat_image_url(self.bot.http_session) + cat_image_url: str = await get_cat_image_url(self.bot.http_session) fields: list[tuple[str, str]] = [] - for command in self.bot.commands: + async def gather_all_commands(command: ApplicationCommand[Any, ..., Any], depth: int = 0) -> None: try: can_run = await command.can_run(ctx) except (CheckFailure, CommandCheckFailure): can_run = False if not can_run: - continue + return + full_command_name: str = f"{mention_command(command)}".strip() if isinstance(command, SlashCommand): - fields.append((mention_command(command), command.description)) - if isinstance(command, SlashCommandGroup): - value = ( - command.description - + "\n\n" - + "\n".join( - f"{mention_command(subcommand)}: {subcommand.description}" - for subcommand in command.subcommands - ) + fields.append( + (f'{COMMAND_EMOJI} {full_command_name} {"sub-" * (depth-1)}command', f"{command.description}") ) - fields.append((f"{mention_command(command)} group", value)) + elif isinstance(command, SlashCommandGroup): + fields.append((f'{GROUP_EMOJI}`{command}` {"sub-" * depth}group', f"{command.description}")) + for subcommand in command.subcommands: + await gather_all_commands(subcommand, depth + 1) + else: + log.error(f"Got unexpected command type: {command.__class__.__name__}, {command!r}") + + for command in self.bot.commands: + await gather_all_commands(command) - new_embed = lambda url: discord.Embed(title="help command").set_thumbnail(url=url) + new_embed = lambda url: discord.Embed(title="Help command").set_thumbnail(url=url) embeds: list[discord.Embed] = [new_embed(cat_image_url)] for name, value in fields: diff --git a/src/settings.py b/src/settings.py index af17539..96611f3 100644 --- a/src/settings.py +++ b/src/settings.py @@ -11,6 +11,8 @@ FAIL_EMOJI = "❌" SUCCESS_EMOJI = "✅" +GROUP_EMOJI = get_config("GROUP_EMOJI", default=":file_folder:") +COMMAND_EMOJI = get_config("COMMAND_EMOJI", default=":arrow_forward:") THETVDB_COPYRIGHT_FOOTER = ( "Metadata provided by TheTVDB. Please consider adding missing information or subscribing at " "thetvdb.com." From 998c796bf2fbca1a56c1671c538635c8431166e3 Mon Sep 17 00:00:00 2001 From: Paillat Date: Tue, 23 Jul 2024 17:14:14 +0200 Subject: [PATCH 108/166] :sparkles: Add imlementation placeholders --- src/exts/tvdb_info/main.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 12db327..476729d 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -37,6 +37,33 @@ def __init__(self, results: Sequence[Movie | Series]) -> None: ) self.dropdown.callback = self._dropdown_callback self.add_item(self.dropdown) + self.add_item( + discord.ui.Button( + style=discord.ButtonStyle.success, + label="Mark as watched", + emoji="✅", + disabled=True, + row=1, + ) + ) + self.add_item( + discord.ui.Button( + style=discord.ButtonStyle.primary, + label="Favorite", + emoji="⭐", + disabled=True, + row=1, + ) + ) + self.add_item( + discord.ui.Button( + style=discord.ButtonStyle.danger, + label="View episodes", + emoji="📺", + disabled=True, + row=1, + ) + ) self.index = 0 def _get_embed(self) -> discord.Embed: From 52a5366670c590e85bd3da1f7923202f316ece12 Mon Sep 17 00:00:00 2001 From: Paillat Date: Tue, 23 Jul 2024 17:19:57 +0200 Subject: [PATCH 109/166] :adhesive_bandage: Fix tvdb iterator (#46) Co-authored-by: ItsDrike --- src/tvdb/client.py | 7 ++++--- src/utils/iterators.py | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 src/utils/iterators.py diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 57fbc2d..fedca37 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -18,6 +18,7 @@ SeriesIdExtendedGetResponse, SeriesIdGetResponse, ) +from src.utils.iterators import get_first from src.utils.log import get_logger from .errors import BadCallError, InvalidApiKeyError, InvalidIdError @@ -86,16 +87,16 @@ def __init__(self, client: "TvdbClient", data: AnyRecord | SearchResult | None): self.overview_eng = self.data.overviews.root.get("eng") else: if self.data.aliases: - self.name_eng = next(alias for alias in self.data.aliases if alias.language == "eng").name + self.name_eng = get_first(alias.name for alias in self.data.aliases if alias.language == "eng") if isinstance(self.data, (SeriesExtendedRecord, MovieExtendedRecord)) and self.data.translations: if self.data.translations.name_translations: - self.name_eng = next( + self.name_eng = get_first( translation.name for translation in self.data.translations.name_translations if translation.language == "eng" ) if self.data.translations.overview_translations: - self.overview_eng = next( + self.overview_eng = get_first( translation.overview for translation in self.data.translations.overview_translations if translation.language == "eng" diff --git a/src/utils/iterators.py b/src/utils/iterators.py new file mode 100644 index 0000000..e2719fc --- /dev/null +++ b/src/utils/iterators.py @@ -0,0 +1,9 @@ +from collections.abc import Iterator + + +def get_first[T, V](it: Iterator[T], default: V = None) -> T | V: + """Get the first item from an iterable, or `default` if it's empty.""" + try: + return next(it) + except StopIteration: + return default From b92ae418dab14c9a5bc17bda775c0f3f2989df07 Mon Sep 17 00:00:00 2001 From: Benjiguy Date: Wed, 24 Jul 2024 18:02:25 +0300 Subject: [PATCH 110/166] add cache with aiocached --- poetry.lock | 48 +++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + src/exts/tvdb_info/main.py | 2 +- src/tvdb/client.py | 41 +++++++++++++++++++++++++------- 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8ea4c33..fcda70b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,25 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "aiocache" +version = "0.12.2" +description = "multi backend asyncio cache" +optional = false +python-versions = "*" +files = [ + {file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"}, + {file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"}, +] + +[package.dependencies] +aiomcache = {version = ">=0.5.2", optional = true, markers = "extra == \"memcached\""} +redis = {version = ">=4.2.0", optional = true, markers = "extra == \"redis\""} + +[package.extras] +memcached = ["aiomcache (>=0.5.2)"] +msgpack = ["msgpack (>=0.5.5)"] +redis = ["redis (>=4.2.0)"] + [[package]] name = "aiohttp" version = "3.9.5" @@ -95,6 +115,17 @@ yarl = ">=1.0,<2.0" [package.extras] speedups = ["Brotli", "aiodns", "brotlicffi"] +[[package]] +name = "aiomcache" +version = "0.8.2" +description = "Minimal pure python memcached client" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiomcache-0.8.2-py3-none-any.whl", hash = "sha256:9d78d6b6e74e775df18b350b1cddfa96bd2f0a44d49ad27fa87759a3469cef5e"}, + {file = "aiomcache-0.8.2.tar.gz", hash = "sha256:43b220d7f499a32a71871c4f457116eb23460fa216e69c1d32b81e3209e51359"}, +] + [[package]] name = "aiosignal" version = "1.3.1" @@ -1384,6 +1415,21 @@ files = [ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] +[[package]] +name = "redis" +version = "5.0.7" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.7" +files = [ + {file = "redis-5.0.7-py3-none-any.whl", hash = "sha256:0e479e24da960c690be5d9b96d21f7b918a98c0cf49af3b6fafaa0753f93a0db"}, + {file = "redis-5.0.7.tar.gz", hash = "sha256:8f611490b93c8109b50adc317b31bfd84fff31def3475b92e7e80bf39f48175b"}, +] + +[package.extras] +hiredis = ["hiredis (>=1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + [[package]] name = "ruff" version = "0.3.7" @@ -1645,4 +1691,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "436577e4e557a7f3d5739fa0ee023753d94f04a8a01443632d53f4d88ff7ff06" +content-hash = "29da624decbb73ca07e87b8449b602ba20f01b9f868c09a064bf0ad4b0ab6325" diff --git a/pyproject.toml b/pyproject.toml index a0c58fd..754b127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ pydantic = "^2.8.2" sqlalchemy = { version = "^2.0.31", extras = ["asyncio"] } aiosqlite = "^0.20.0" alembic = "^1.13.2" +aiocache = {extras = ["memcached", "redis"], version = "^0.12.2"} [tool.poetry.group.lint.dependencies] ruff = "^0.3.2" diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 476729d..33147e0 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -28,7 +28,7 @@ def __init__(self, results: Sequence[Movie | Series]) -> None: placeholder="Not what you're looking for? Select a different result.", options=[ discord.SelectOption( - label=result.bilingual_name or "", + label=(result.bilingual_name or "")[:100], value=str(i), description=result.overview[:100] if result.overview else None, ) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index fedca37..5003d12 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -3,6 +3,7 @@ from typing import ClassVar, Literal, Self, final, overload, override import aiohttp +from aiocache import SimpleMemoryCache from yarl import URL from src.settings import TVDB_API_KEY @@ -49,6 +50,9 @@ def parse_media_id(media_id: int | str) -> int: return media_id +cache = SimpleMemoryCache() + + class _Media(ABC): ENDPOINT: ClassVar[str] @@ -166,6 +170,12 @@ async def fetch( :return: """ media_id = parse_media_id(media_id) + cache_key: str = f"{media_id}" + if extended: + cache_key += f"_{bool(short)}" + if meta: + cache_key += f"_{meta.value}" + response = await cache.get(cache_key) query: dict[str, str] = {} if extended: if meta: @@ -178,12 +188,18 @@ async def fetch( raise BadCallError("Meta can only be used with extended=True.") elif short: raise BadCallError("Short can only be enabled with extended=True.") - response = await client.request( - "GET", - f"{cls.ENDPOINT}/{media_id}" + ("/extended" if extended else ""), - query=query if query else None, - ) + if not response: + response = await client.request( + "GET", + f"{cls.ENDPOINT}/{media_id}" + ("/extended" if extended else ""), + query=query if query else None, + ) + await cache.set(key=cache_key, value=response) + log.trace(f"Stored into cache: {cache_key}") + else: + log.trace(f"Loaded from cache: {cache_key}") response = cls.ResponseType(**response) if not extended else cls.ExtendedResponseType(**response) # pyright: ignore[reportCallIssue] + return cls(client, response.data) @@ -273,10 +289,17 @@ async def search( self, search_query: str, entity_type: Literal["series", "movie", None] = None, limit: int = 1 ) -> list[Movie | Series]: """Search for a series or movie in the TVDB database.""" - query: dict[str, str] = {"query": search_query, "limit": str(limit)} - if entity_type: - query["type"] = entity_type - data = await self.request("GET", "search", query=query) + cache_key: str = f"{search_query}_{entity_type}_{limit}" + data = await cache.get(cache_key) + if not data: + query: dict[str, str] = {"query": search_query, "limit": str(limit)} + if entity_type: + query["type"] = entity_type + data = await self.request("GET", "search", query=query) + await cache.set(key=cache_key, value=data) + log.trace(f"Stored into cache: {cache_key}") + else: + log.trace(f"Loaded from cache: {cache_key}") response = SearchGetResponse(**data) # pyright: ignore[reportCallIssue] returnable: list[Movie | Series] = [] if not response.data: From 147bc788c58ece164216096fe2c3a103443cfbf6 Mon Sep 17 00:00:00 2001 From: Benjiguy Date: Wed, 24 Jul 2024 20:26:10 +0300 Subject: [PATCH 111/166] changed copyright holders --- LICENSE.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE.txt b/LICENSE.txt index 5a04926..dcab151 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright 2021 Python Discord +Copyright 2021 Python Discord, ItsDrike, Benji, Paillat-dev, v33010, Ash8121 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: From 13d1ef9889795ce0c66caa4b53d152bcac73d26e Mon Sep 17 00:00:00 2001 From: Benjiguy Date: Wed, 24 Jul 2024 22:37:39 +0300 Subject: [PATCH 112/166] changed copyright --- LICENSE.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE.txt b/LICENSE.txt index dcab151..e16fc78 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright 2021 Python Discord, ItsDrike, Benji, Paillat-dev, v33010, Ash8121 +Copyright 2024 ItsDrike , Benji , Paillat-dev , v33010 , Ash8121 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: From 27867e9a6ffbe880bd966d5d01e9d6bc4f278bf3 Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 25 Jul 2024 15:55:34 +0200 Subject: [PATCH 113/166] Add 1h ttl to cached items and make cache a bot attribute --- src/__main__.py | 5 ++++- src/bot.py | 3 +++ src/exts/tvdb_info/main.py | 2 +- src/tvdb/client.py | 16 +++++++--------- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/__main__.py b/src/__main__.py index 9af2558..21b03e7 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -2,6 +2,7 @@ import aiohttp import discord +from aiocache import SimpleMemoryCache from src.bot import Bot from src.settings import BOT_TOKEN, SQLITE_DATABASE_FILE @@ -46,8 +47,10 @@ async def main() -> None: await _init_database() + cache = SimpleMemoryCache() + async with aiohttp.ClientSession() as http_session, get_db_session() as db_session: - bot = Bot(intents=intents, http_session=http_session, db_session=db_session) + bot = Bot(intents=intents, http_session=http_session, db_session=db_session, cache=cache) bot.load_all_extensions() log.info("Starting the bot...") diff --git a/src/bot.py b/src/bot.py index 1200e68..58c1813 100644 --- a/src/bot.py +++ b/src/bot.py @@ -4,6 +4,7 @@ import aiohttp import discord +from aiocache import BaseCache from sqlalchemy.ext.asyncio import AsyncSession from src.utils.log import get_logger @@ -32,12 +33,14 @@ def __init__( *args: object, http_session: aiohttp.ClientSession, db_session: AsyncSession, + cache: BaseCache, **kwargs: object, ) -> None: """Initialize the bot instance, containing various state variables.""" super().__init__(*args, **kwargs) self.http_session = http_session self.db_session = db_session + self.cache = cache self.event(self.on_ready) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 33147e0..0655c98 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -129,7 +129,7 @@ async def search( """Search for a movie or series.""" await ctx.defer() async with aiohttp.ClientSession() as session: - client = TvdbClient(session) + client = TvdbClient(session, self.bot.cache) if by_id: if query.startswith("movie-"): entity_type = "movie" diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 5003d12..932e554 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -3,7 +3,7 @@ from typing import ClassVar, Literal, Self, final, overload, override import aiohttp -from aiocache import SimpleMemoryCache +from aiocache import BaseCache from yarl import URL from src.settings import TVDB_API_KEY @@ -50,9 +50,6 @@ def parse_media_id(media_id: int | str) -> int: return media_id -cache = SimpleMemoryCache() - - class _Media(ABC): ENDPOINT: ClassVar[str] @@ -175,7 +172,7 @@ async def fetch( cache_key += f"_{bool(short)}" if meta: cache_key += f"_{meta.value}" - response = await cache.get(cache_key) + response = await client.cache.get(cache_key, namespace=f"tvdb_{cls.ENDPOINT}") query: dict[str, str] = {} if extended: if meta: @@ -194,7 +191,7 @@ async def fetch( f"{cls.ENDPOINT}/{media_id}" + ("/extended" if extended else ""), query=query if query else None, ) - await cache.set(key=cache_key, value=response) + await client.cache.set(key=cache_key, value=response, ttl=60 * 60, namespace=f"tvdb_{cls.ENDPOINT}") log.trace(f"Stored into cache: {cache_key}") else: log.trace(f"Loaded from cache: {cache_key}") @@ -240,9 +237,10 @@ class TvdbClient: BASE_URL: ClassVar[URL] = URL("https://api4.thetvdb.com/v4/") - def __init__(self, http_session: aiohttp.ClientSession): + def __init__(self, http_session: aiohttp.ClientSession, cache: BaseCache): self.http_session = http_session self.auth_token = None + self.cache = cache @overload async def request( @@ -290,13 +288,13 @@ async def search( ) -> list[Movie | Series]: """Search for a series or movie in the TVDB database.""" cache_key: str = f"{search_query}_{entity_type}_{limit}" - data = await cache.get(cache_key) + data = await self.cache.get(cache_key, namespace="tvdb_search") if not data: query: dict[str, str] = {"query": search_query, "limit": str(limit)} if entity_type: query["type"] = entity_type data = await self.request("GET", "search", query=query) - await cache.set(key=cache_key, value=data) + await self.cache.set(key=cache_key, value=data, ttl=60 * 60, namespace="tvdb_search") log.trace(f"Stored into cache: {cache_key}") else: log.trace(f"Loaded from cache: {cache_key}") From 043c69f24e05bd2620e184e69765d8a52f6e18dd Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Wed, 24 Jul 2024 19:02:20 +0200 Subject: [PATCH 114/166] Add base implementation for rate-limiting --- src/exts/error_handler/error_handler.py | 53 +++++++-- src/utils/ratelimit.py | 136 ++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 src/utils/ratelimit.py diff --git a/src/exts/error_handler/error_handler.py b/src/exts/error_handler/error_handler.py index 6c3a6e5..809b191 100644 --- a/src/exts/error_handler/error_handler.py +++ b/src/exts/error_handler/error_handler.py @@ -1,12 +1,13 @@ import textwrap from typing import cast -from discord import Any, ApplicationContext, Cog, Colour, Embed, errors +from discord import Any, ApplicationContext, Cog, Colour, Embed, EmbedField, EmbedFooter, errors from discord.ext.commands import errors as commands_errors from src.bot import Bot from src.settings import FAIL_EMOJI, GITHUB_REPO from src.utils.log import get_logger +from src.utils.ratelimit import RateLimitExceededError log = get_logger(__name__) @@ -23,12 +24,20 @@ async def send_error_embed( *, title: str | None = None, description: str | None = None, + fields: list[EmbedField] | None = None, + footer: EmbedFooter | None = None, ) -> None: """Send an embed regarding the unhandled exception that occurred.""" if title is None and description is None: raise ValueError("You need to provide either a title or a description.") - embed = Embed(title=title, description=description, color=Colour.red()) + embed = Embed( + title=title, + description=description, + color=Colour.red(), + fields=fields, + footer=footer, + ) await ctx.respond(f"Sorry, {ctx.author.mention}", embed=embed) async def send_unhandled_embed(self, ctx: ApplicationContext, exc: BaseException) -> None: @@ -128,6 +137,38 @@ async def _handle_check_failure( await self.send_unhandled_embed(ctx, exc) + async def _handle_command_invoke_error( + self, + ctx: ApplicationContext, + exc: errors.ApplicationCommandInvokeError, + ) -> None: + original_exception = exc.__cause__ + + if original_exception is None: + await self.send_unhandled_embed(ctx, exc) + log.exception("Got ApplicationCommandInvokeError without a cause.", exc_info=exc) + return + + if isinstance(original_exception, RateLimitExceededError): + msg = original_exception.msg or "Hit a rate-limit, please try again later." + time_remaining = f"Expected reset: " + footer = None + if original_exception.updates_when_exceeded: + footer = EmbedFooter( + text="Spamming the command will only increase the time you have to wait.", + ) + await self.send_error_embed( + ctx, + title="Rate limit exceeded", + description=f"{FAIL_EMOJI} {msg}", + fields=[EmbedField(name="", value=time_remaining)], + footer=footer, + ) + return + + await self.send_unhandled_embed(ctx, original_exception) + log.exception("Unhandled exception occurred.", exc_info=original_exception) + @Cog.listener() async def on_application_command_error(self, ctx: ApplicationContext, exc: errors.DiscordException) -> None: """Handle exceptions that have occurred while running some command.""" @@ -136,12 +177,8 @@ async def on_application_command_error(self, ctx: ApplicationContext, exc: error return if isinstance(exc, errors.ApplicationCommandInvokeError): - original_exception = exc.__cause__ - - if original_exception is not None: - await self.send_unhandled_embed(ctx, original_exception) - log.exception("Unhandled exception occurred.", exc_info=original_exception) - return + await self._handle_command_invoke_error(ctx, exc) + return await self.send_unhandled_embed(ctx, exc) diff --git a/src/utils/ratelimit.py b/src/utils/ratelimit.py new file mode 100644 index 0000000..c36b0d3 --- /dev/null +++ b/src/utils/ratelimit.py @@ -0,0 +1,136 @@ +import time +from typing import cast + +from aiocache import BaseCache + +from src.utils.log import get_logger + +log = get_logger(__name__) + + +class RateLimitExceededError(Exception): + """Exception raised when a rate limit was exceeded.""" + + def __init__( # noqa: PLR0913 + self, + msg: str | None, + *, + key: str, + limit: int, + period: float, + closest_expiration: float, + updates_when_exceeded: bool, + ) -> None: + """Initialize the rate limit exceeded error. + + :param msg: + Custom error message to include in the exception. + + This exception should also be shown to the user if the exception makes its way + to the error handler. If this is not provided, a generic message will be used. + :param key: Cache key that was rate-limit. + :param period: The period of time in seconds, in which the limit is enforced. + :param closest_expiration: The unix time-stamp of the closest expiration of the rate limit. + :param updates_when_exceeded: Does the rate limit get updated even if it was exceeded. + """ + self.msg = msg + self.key = key + self.limit = limit + self.period = period + self.closest_expiration = closest_expiration + self.updates_when_exceeded = updates_when_exceeded + + err_msg = f"Rate limit exceeded for key '{key}' ({limit}/{period}s)" + if msg: + err_msg += f": {msg}" + super().__init__(err_msg) + + +async def rate_limit( + cache: BaseCache, + key: str, + *, + limit: int, + period: float, + update_when_exceeded: bool = False, + err_msg: str | None = None, +) -> None: + """Log a new request for given key, enforcing the rate limit. + + The cache keys are name-spaced under 'rate-limit' to avoid conflicts with other cache keys. + + The rate-limiting uses a sliding window approach, where each request has its own expiration time + (i.e. a request is allowed if it is within the last `period` seconds). The requests are stored + as time-stamps under given key in the cache. + + :param cache: The cache instance used to keep track of the rate limits. + :param key: The key to use for this rate-limit. + :param limit: The number of requests allowed in the period. + :param period: The period of time in seconds, in which the limit is enforced. + :param update_when_exceeded: + Log a new rate-limit request time even if the limit was exceeded. + + This can be useful to disincentivize users from spamming requests, as they would + otherwise still receive the response eventually. With this behavior, they will + actually need to wait and not spam requests. + + By default, this behavior is disabled, mainly for global / internal rate limits. + :param err_msg: + Custom error message to include in the `RateLimitExceededError` exception. + + This message will be caught by the error handler and sent to the user, instead + of using a more generic message. + :raises RateLimitExceededError: If the rate limit was exceeded. + """ + current_timestamp = time.time() + + # No existing requests + if not await cache.exists(key, namespace="rate-limit"): + log.trace(f"No existing rate-limit requests for key {key!r}, adding the first one") + await cache.set(key, (current_timestamp,), ttl=period, namespace="rate-limit") + return + + # Get the existing requests + cache_time_stamps = cast(tuple[float, ...], await cache.get(key, namespace="rate-limit")) + log.trace(f"Fetched {len(cache_time_stamps)} existing requests for key {key!r}") + + # Expire requests older than the period + remaining_time_stamps = list(cache_time_stamps) + for time_stamp in cache_time_stamps: + if (current_timestamp - time_stamp) > period: + remaining_time_stamps.remove(time_stamp) + + # Also remove the oldest requests, keeping only up to limit + # This is just to avoid the list growing for no reason. + # As an advantage, it also makes it easier to find the closest expiration time. + remaining_time_stamps = remaining_time_stamps[-limit:] + + log.trace(f"Found {len(remaining_time_stamps)} non-expired existing requests for key {key!r}") + + # Add the new request, along with the existing non-expired ones, resetting the key + # Only do this if the rate limit wasn't exceeded, or if updating on exceeded requests is enabled + if len(remaining_time_stamps) < limit or update_when_exceeded: + log.trace("Updating rate limit with the new request") + new_timestamps: tuple[float, ...] = (*remaining_time_stamps, current_timestamp) + await cache.set(key, new_timestamps, ttl=period, namespace="rate-limit") + + # Check if the limit was exceeded + if len(remaining_time_stamps) >= limit: + # If update on exceeded requests are enabled, add the current timestamp to the list + # and trim to limit requests, allowing us to obtain the proper closest timestamp + if update_when_exceeded: + remaining_time_stamps.append(current_timestamp) + remaining_time_stamps = remaining_time_stamps[-limit:] + + closest_expiration = min(remaining_time_stamps) + period + + log.debug(f"Rate limit exceeded on key: {key!r}") + log.trace(f"Exceeded rate limit details: {limit}/{period}s, {remaining_time_stamps=!r}, {closest_expiration=}") + raise RateLimitExceededError( + err_msg, + key=key, + limit=limit, + period=period, + closest_expiration=closest_expiration, + updates_when_exceeded=update_when_exceeded, + ) From 0f2bdc19ce19c8544fef53c47339009639f221e7 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 25 Jul 2024 17:20:11 +0200 Subject: [PATCH 115/166] Use a single tvdb client across the cog --- src/exts/tvdb_info/main.py | 67 ++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 0655c98..c5e6bdd 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -1,7 +1,6 @@ from collections.abc import Sequence from typing import Literal -import aiohttp import discord from discord import ApplicationContext, Cog, option, slash_command @@ -106,6 +105,7 @@ class InfoCog(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot + self.tvdb_client = TvdbClient(self.bot.http_session, self.bot.cache) @slash_command() @option("query", input_type=str, description="The query to search for.") @@ -128,37 +128,40 @@ async def search( ) -> None: """Search for a movie or series.""" await ctx.defer() - async with aiohttp.ClientSession() as session: - client = TvdbClient(session, self.bot.cache) - if by_id: - if query.startswith("movie-"): - entity_type = "movie" - query = query[6:] - elif query.startswith("series-"): - entity_type = "series" - query = query[7:] - try: - match entity_type: - case "movie": - response = [await Movie.fetch(query, client, extended=True, meta=FetchMeta.TRANSLATIONS)] - case "series": - response = [await Series.fetch(query, client, extended=True, meta=FetchMeta.TRANSLATIONS)] - case None: - await ctx.respond( - "You must specify a type (movie or series) when searching by ID.", ephemeral=True - ) - return - except InvalidIdError: - await ctx.respond( - 'Invalid ID. Id must be an integer, or "movie-" / "series-" followed by an integer.', - ephemeral=True, - ) - return - else: - response = await client.search(query, limit=5, entity_type=entity_type) - if not response: - await ctx.respond("No results found.") - return + + if by_id: + if query.startswith("movie-"): + entity_type = "movie" + query = query[6:] + elif query.startswith("series-"): + entity_type = "series" + query = query[7:] + try: + match entity_type: + case "movie": + response = [ + await Movie.fetch(query, self.tvdb_client, extended=True, meta=FetchMeta.TRANSLATIONS) + ] + case "series": + response = [ + await Series.fetch(query, self.tvdb_client, extended=True, meta=FetchMeta.TRANSLATIONS) + ] + case None: + await ctx.respond( + "You must specify a type (movie or series) when searching by ID.", ephemeral=True + ) + return + except InvalidIdError: + await ctx.respond( + 'Invalid ID. Id must be an integer, or "movie-" / "series-" followed by an integer.', + ephemeral=True, + ) + return + else: + response = await self.tvdb_client.search(query, limit=5, entity_type=entity_type) + if not response: + await ctx.respond("No results found.") + return view = InfoView(response) await view.send(ctx) From 2ac8d273c6a6fc162730a87cedd3fdbfbb80d217 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Wed, 24 Jul 2024 19:12:47 +0200 Subject: [PATCH 116/166] Apply tvdb client wide rate-limit --- README.md | 20 +++++++++++--------- src/settings.py | 10 ++++++++++ src/tvdb/client.py | 13 ++++++++++++- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b623084..9d817c2 100644 --- a/README.md +++ b/README.md @@ -64,15 +64,17 @@ convenient. TODO: Separate these to variables necessary to run the bot, and those only relevant during development. --> -| Variable name | Type | Default | Description | -| ---------------------- | ------ | ------------- | ------------------------------------------------------------------------------------------------------------------ | -| `BOT_TOKEN` | string | N/A | Bot token of the discord application (see: [this guide][bot-token-guide] if you don't have one yet) | -| `TVDB_API_KEY` | string | N/A | API key for TVDB (see [this page][tvdb-api-page] if you don't have one yet) | -| `SQLITE_DATABASE_FILE` | path | ./database.db | Path to sqlite database file, can be relative to project root (if the file doesn't yet exists, it will be created) | -| `ECHO_SQL` | bool | 0 | If `1`, print out every SQL command that SQLAlchemy library runs internally (can be useful when debugging) | -| `DEBUG` | bool | 0 | If `1`, debug logs will be enabled, if `0` only info logs and above will be shown | -| `LOG_FILE` | path | N/A | If set, also write the logs into given file, otherwise, only print them | -| `TRACE_LEVEL_FILTER` | custom | N/A | Configuration for trace level logging, see: [trace logs config section](#trace-logs-config) | +| Variable name | Type | Default | Description | +| -------------------------- | ------ | ------------- | ------------------------------------------------------------------------------------------------------------------- | +| `BOT_TOKEN` | string | N/A | Bot token of the discord application (see: [this guide][bot-token-guide] if you don't have one yet) | +| `TVDB_API_KEY` | string | N/A | API key for TVDB (see [this page][tvdb-api-page] if you don't have one yet) | +| `TVDB_RATE_LIMIT_REQUESTS` | int | 5 | Amount of requests that the bot is allowed to make to the TVDB API within `TVDB_RATE_LIMIT_PERIOD` | +| `TVDB_RATE_LIMIT_PERIOD` | float | 5 | Period of time in seconds, within which the bot can make up to `TVDB_RATE_LIMIT_REQUESTS` requests to the TVDB API. | +| `SQLITE_DATABASE_FILE` | path | ./database.db | Path to sqlite database file, can be relative to project root (if the file doesn't yet exists, it will be created) | +| `ECHO_SQL` | bool | 0 | If `1`, print out every SQL command that SQLAlchemy library runs internally (can be useful when debugging) | +| `DEBUG` | bool | 0 | If `1`, debug logs will be enabled, if `0` only info logs and above will be shown | +| `LOG_FILE` | path | N/A | If set, also write the logs into given file, otherwise, only print them | +| `TRACE_LEVEL_FILTER` | custom | N/A | Configuration for trace level logging, see: [trace logs config section](#trace-logs-config) | [bot-token-guide]: https://guide.pycord.dev/getting-started/creating-your-first-bot#creating-the-bot-application [tvdb-api-page]: https://www.thetvdb.com/api-information diff --git a/src/settings.py b/src/settings.py index 96611f3..0e4248c 100644 --- a/src/settings.py +++ b/src/settings.py @@ -18,3 +18,13 @@ "Metadata provided by TheTVDB. Please consider adding missing information or subscribing at " "thetvdb.com." ) THETVDB_LOGO = "https://www.thetvdb.com/images/attribution/logo1.png" + +# The default rate-limit might be a bit too small for production-ready bots that live +# on multiple guilds. But it's good enough for our demonstration purposes and it's +# still actually quite hard to hit this rate-limit on a single guild, unless multiple +# people actually try to make many requests after each other.. +# +# Note that tvdb doesn't actually have rate-limits (or at least they aren't documented), +# but we should still be careful not to spam the API too much and be on the safe side. +TVDB_RATE_LIMIT_REQUESTS = get_config("TVDB_RATE_LIMIT_REQUESTS", cast=int, default=5) +TVDB_RATE_LIMIT_PERIOD = get_config("TVDB_RATE_LIMIT_PERIOD", cast=float, default=5) # seconds diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 932e554..0146fe0 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -6,7 +6,7 @@ from aiocache import BaseCache from yarl import URL -from src.settings import TVDB_API_KEY +from src.settings import TVDB_API_KEY, TVDB_RATE_LIMIT_PERIOD, TVDB_RATE_LIMIT_REQUESTS from src.tvdb.generated_models import ( MovieBaseRecord, MovieExtendedRecord, @@ -21,6 +21,7 @@ ) from src.utils.iterators import get_first from src.utils.log import get_logger +from src.utils.ratelimit import rate_limit from .errors import BadCallError, InvalidApiKeyError, InvalidIdError @@ -266,6 +267,16 @@ async def request( """Make an authorized request to the TVDB API.""" log.trace(f"Making TVDB {method} request to {endpoint}") + # TODO: It would be better to instead use a queue to handle rate-limits + # and block until the next request can be made. + await rate_limit( + self.cache, + "tvdb", + limit=TVDB_RATE_LIMIT_REQUESTS, + period=TVDB_RATE_LIMIT_PERIOD, + err_msg="Bot wide rate-limit for TheTVDB API was exceeded.", + ) + if self.auth_token is None: log.trace("No auth token found, requesting initial login.") await self._login() From 3b61f4c620f8d3e4fdb8e9378c14b8e3a067b6eb Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 25 Jul 2024 14:46:35 +0200 Subject: [PATCH 117/166] Add rate limit decorator function --- src/utils/ratelimit.py | 75 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/utils/ratelimit.py b/src/utils/ratelimit.py index c36b0d3..e737791 100644 --- a/src/utils/ratelimit.py +++ b/src/utils/ratelimit.py @@ -1,8 +1,12 @@ import time -from typing import cast +from collections.abc import Awaitable, Callable +from functools import wraps +from typing import Concatenate, cast from aiocache import BaseCache +from discord import ApplicationContext, Cog +from src.bot import Bot from src.utils.log import get_logger log = get_logger(__name__) @@ -134,3 +138,72 @@ async def rate_limit( closest_expiration=closest_expiration, updates_when_exceeded=update_when_exceeded, ) + + +type CogCommandFunction[T: Cog, **P] = Callable[Concatenate[T, ApplicationContext, P], Awaitable[None]] +type TransformFunction[T, R] = Callable[[T, ApplicationContext], R] + + +def rate_limited[T: Cog, **P]( + key: str | Callable[[T, ApplicationContext], str], + *, + limit: int | TransformFunction[T, int], + period: float | TransformFunction[T, float], + update_when_exceeded: bool | TransformFunction[T, bool] = False, + err_msg: str | None | TransformFunction[T, str | None] = None, +) -> Callable[[CogCommandFunction[T, P]], CogCommandFunction[T, P]]: + """Apply rate limits to given cog command function. + + The decorated function must be a slash command function that belongs to a cog class + (as an instance method). Make sure to apply this decorator before the ``slash_command`` + decorator. + + This uses the :func:`rate_limit` function internally to enforce the rate limits. + See its description for more info. + + All of the parameters can be set directly, or they can be callables, which will get called + with self (the cog instance) and ctx, using the return value as the value of that parameter. + These parameters will then all be forwarded to the ``rate_limit`` function. + + .. note:: + Py-cord does provide a built-in way to rate-limit commands through "cooldown" structures. + These work similarly to our custom implementation, but bucketing isn't as flexible and + doesn't work globally across the whole application. + + Using this decorator is therefore preferred, even for simple rate limits, for consistency. + """ + + def inner(func: CogCommandFunction[T, P]) -> CogCommandFunction[T, P]: + @wraps(func) + async def wrapper(self: T, ctx: ApplicationContext, *args: P.args, **kwargs: P.kwargs) -> None: + bot = ctx.bot + if not isinstance(bot, Bot): + raise TypeError( + "The bot instance must be of our custom Bot type (src.bot.Bot), " + f"found: {bot.__class__.__qualname__}" + ) + + cache = bot.cache + + # Call transformer functions, if used + key_ = key(self, ctx) if isinstance(key, Callable) else key + limit_ = limit(self, ctx) if isinstance(limit, Callable) else limit + period_ = period(self, ctx) if isinstance(period, Callable) else period + update_when_exceeded_ = ( + update_when_exceeded(self, ctx) if isinstance(update_when_exceeded, Callable) else update_when_exceeded + ) + err_msg_ = err_msg(self, ctx) if isinstance(err_msg, Callable) else err_msg + + await rate_limit( + cache, + key_, + limit=limit_, + period=period_, + update_when_exceeded=update_when_exceeded_, + err_msg=err_msg_, + ) + return await func(self, ctx, *args, **kwargs) + + return wrapper + + return inner From bb3f7e70ceb12af964feeed693b5d86612aac9d0 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 25 Jul 2024 15:50:39 +0200 Subject: [PATCH 118/166] Add prefix_key bool arg to the rate limit decorator --- src/utils/ratelimit.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/utils/ratelimit.py b/src/utils/ratelimit.py index e737791..624aaf7 100644 --- a/src/utils/ratelimit.py +++ b/src/utils/ratelimit.py @@ -151,6 +151,7 @@ def rate_limited[T: Cog, **P]( period: float | TransformFunction[T, float], update_when_exceeded: bool | TransformFunction[T, bool] = False, err_msg: str | None | TransformFunction[T, str | None] = None, + prefix_key: bool = False, ) -> Callable[[CogCommandFunction[T, P]], CogCommandFunction[T, P]]: """Apply rate limits to given cog command function. @@ -161,9 +162,11 @@ def rate_limited[T: Cog, **P]( This uses the :func:`rate_limit` function internally to enforce the rate limits. See its description for more info. - All of the parameters can be set directly, or they can be callables, which will get called - with self (the cog instance) and ctx, using the return value as the value of that parameter. - These parameters will then all be forwarded to the ``rate_limit`` function. + All of the parameters (except `prefix_key`) can be set directly, or they can be callables, + which will get called with self (the cog instance) and ctx, using the return value as the + value of that parameter. These parameters will then all be forwarded to the ``rate_limit`` function. + + :param prefix_key: Whether to prefix the key with the hash of the slash command function object. .. note:: Py-cord does provide a built-in way to rate-limit commands through "cooldown" structures. @@ -194,6 +197,9 @@ async def wrapper(self: T, ctx: ApplicationContext, *args: P.args, **kwargs: P.k ) err_msg_ = err_msg(self, ctx) if isinstance(err_msg, Callable) else err_msg + if prefix_key: + key_ = f"{hash(func)}-{key_}" + await rate_limit( cache, key_, From 8e4805bd2612c5c19114fc0948b60ea5f4d86cdc Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 25 Jul 2024 17:25:13 +0200 Subject: [PATCH 119/166] Ignore debug logs from aiocache --- src/utils/log.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/log.py b/src/utils/log.py index 1372a59..6ed3180 100644 --- a/src/utils/log.py +++ b/src/utils/log.py @@ -196,5 +196,6 @@ def _setup_external_log_levels(root_log: LoggerClass) -> None: get_logger("discord.gateway").setLevel(logging.WARNING) get_logger("aiosqlite").setLevel(logging.INFO) get_logger("alembic.runtime.migration").setLevel(logging.WARNING) + get_logger("aiocache.base").setLevel(logging.INFO) get_logger("parso").setLevel(logging.WARNING) # For usage in IPython From 617e2d7460c5e2dac7540f158d9f9e8c9b502f7e Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 25 Jul 2024 18:12:53 +0200 Subject: [PATCH 120/166] Enforce per-user rate limits on /search --- src/exts/tvdb_info/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index c5e6bdd..0a90890 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -9,6 +9,7 @@ from src.tvdb import FetchMeta, Movie, Series, TvdbClient from src.tvdb.errors import InvalidIdError from src.utils.log import get_logger +from src.utils.ratelimit import rate_limited log = get_logger(__name__) @@ -118,6 +119,7 @@ def __init__(self, bot: Bot) -> None: required=False, ) @option("by_id", input_type=bool, description="Search by tvdb ID.", required=False) + @rate_limited(key=lambda self, ctx: f"{ctx.user}", limit=2, period=8, update_when_exceeded=True, prefix_key=True) async def search( self, ctx: ApplicationContext, From 7843e45b202b75fde5aced80918246a8776ec18d Mon Sep 17 00:00:00 2001 From: Paillat Date: Wed, 24 Jul 2024 21:10:51 +0200 Subject: [PATCH 121/166] :card_file_box: Add basic models --- ...7_25_1814-8fc4b07d9adc_add_basic_models.py | 73 +++++++++++++ pyproject.toml | 4 + src/db_tables/media.py | 38 +++++++ src/db_tables/user.py | 19 ++++ src/db_tables/user_list.py | 102 ++++++++++++++++++ 5 files changed, 236 insertions(+) create mode 100644 alembic-migrations/versions/2024_07_25_1814-8fc4b07d9adc_add_basic_models.py create mode 100644 src/db_tables/media.py create mode 100644 src/db_tables/user.py create mode 100644 src/db_tables/user_list.py diff --git a/alembic-migrations/versions/2024_07_25_1814-8fc4b07d9adc_add_basic_models.py b/alembic-migrations/versions/2024_07_25_1814-8fc4b07d9adc_add_basic_models.py new file mode 100644 index 0000000..c8071b4 --- /dev/null +++ b/alembic-migrations/versions/2024_07_25_1814-8fc4b07d9adc_add_basic_models.py @@ -0,0 +1,73 @@ +"""Add basic models + +Revision ID: 8fc4b07d9adc +Revises: c55da3c62644 +Create Date: 2024-07-25 18:14:19.322905 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "8fc4b07d9adc" +down_revision: str | None = "c55da3c62644" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table("movies", sa.Column("tvdb_id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("tvdb_id")) + op.create_table("shows", sa.Column("tvdb_id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("tvdb_id")) + op.create_table( + "users", sa.Column("discord_id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("discord_id") + ) + op.create_table( + "episodes", + sa.Column("tvdb_id", sa.Integer(), nullable=False), + sa.Column("show_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["show_id"], + ["shows.tvdb_id"], + ), + sa.PrimaryKeyConstraint("tvdb_id"), + ) + op.create_table( + "user_lists", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("item_kind", sa.Enum("SHOW", "MOVIE", "EPISODE", "MEDIA", "ANY", name="itemkind"), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.discord_id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "name", name="unique_user_list_name"), + ) + op.create_index("ix_user_lists_user_id_name", "user_lists", ["user_id", "name"], unique=True) + op.create_table( + "user_list_items", + sa.Column("list_id", sa.Integer(), nullable=False), + sa.Column("tvdb_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["list_id"], + ["user_lists.id"], + ), + sa.PrimaryKeyConstraint("list_id", "tvdb_id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("user_list_items") + op.drop_index("ix_user_lists_user_id_name", table_name="user_lists") + op.drop_table("user_lists") + op.drop_table("episodes") + op.drop_table("users") + op.drop_table("shows") + op.drop_table("movies") + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index a0c58fd..bcbd5d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -214,6 +214,10 @@ reportUnknownMemberType = false reportUnknownParameterType = false reportUnknownLambdaType = false +executionEnvironments = [ + { root = "src/db_tables", reportImportCycles = false }, +] + [tool.pytest.ini_options] minversion = "6.0" asyncio_mode = "auto" diff --git a/src/db_tables/media.py b/src/db_tables/media.py new file mode 100644 index 0000000..d8d0fee --- /dev/null +++ b/src/db_tables/media.py @@ -0,0 +1,38 @@ +"""This file houses all tvdb media related database tables. + +Some of these tables only have one column (`tvdb_id`), which may seem like a mistake, but is intentional. +That's because this provides better type safety and allows us to define proper foreign key relationships that +refer to these tables instead of duplicating that data. +It also may become useful if at any point we would +want to store something extra that's global to each movie / show / episode. +""" + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column + +from src.utils.database import Base + + +class Movie(Base): + """Table to store movies.""" + + __tablename__ = "movies" + + tvdb_id: Mapped[int] = mapped_column(primary_key=True) + + +class Series(Base): + """Table to store series.""" + + __tablename__ = "series" + + tvdb_id: Mapped[int] = mapped_column(primary_key=True) + + +class Episode(Base): + """Table to store episodes of series.""" + + __tablename__ = "episodes" + + tvdb_id: Mapped[int] = mapped_column(primary_key=True) + series_id: Mapped[int] = mapped_column(ForeignKey("series.tvdb_id")) diff --git a/src/db_tables/user.py b/src/db_tables/user.py new file mode 100644 index 0000000..e5c2a84 --- /dev/null +++ b/src/db_tables/user.py @@ -0,0 +1,19 @@ +from typing import TYPE_CHECKING + +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.utils.database import Base + +# Prevent circular imports for relationships +if TYPE_CHECKING: + from src.db_tables.user_list import UserList + + +class User(Base): + """Table to store users.""" + + __tablename__ = "users" + + discord_id: Mapped[int] = mapped_column(primary_key=True) + + lists: Mapped[list["UserList"]] = relationship("UserList", back_populates="user") diff --git a/src/db_tables/user_list.py b/src/db_tables/user_list.py new file mode 100644 index 0000000..c0d6c4f --- /dev/null +++ b/src/db_tables/user_list.py @@ -0,0 +1,102 @@ +from enum import Enum +from typing import ClassVar, TYPE_CHECKING + +from sqlalchemy import ForeignKey, Index, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.utils.database import Base + +# Prevent circular imports for relationships +if TYPE_CHECKING: + from src.db_tables.media import Episode, Movie, Series + from src.db_tables.user import User + + +class ItemKind(Enum): + """Enum to represent the kind of item in a user list.""" + + SERIES = "series" + MOVIE = "movie" + EPISODE = "episode" + MEDIA = "media" # either series or movie + ANY = "any" + + +class UserList(Base): + """Table to store user lists. + + This provides a dynamic way to store various lists of media for the user, such as favorites, to watch, + already watched, ... all tracked in the same table, instead of having to define tables for each such + structure. + """ + + __tablename__ = "user_lists" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.discord_id"), nullable=False) + name: Mapped[str] = mapped_column(nullable=False) + item_kind: Mapped[ItemKind] = mapped_column(nullable=False) + + user: Mapped["User"] = relationship("User", back_populates="lists") + items: Mapped[list["UserListItem"]] = relationship("UserListItem", back_populates="user_list") + + __table_args__ = ( + UniqueConstraint("user_id", "name", name="unique_user_list_name"), + Index( + "ix_user_lists_user_id_name", + "user_id", + "name", + unique=True, + ), + ) + + +class UserListItem(Base): + """Base class for items in a user list.""" + + __tablename__ = "user_list_items" + list_id: Mapped[int] = mapped_column(ForeignKey("user_lists.id"), primary_key=True) + tvdb_id: Mapped[int] = mapped_column(primary_key=True) + + user_list: Mapped["UserList"] = relationship("UserList", back_populates="items") + + __mapper_args__: ClassVar = {"polymorphic_on": tvdb_id, "polymorphic_identity": "base"} + + +class UserListItemSeries(UserListItem): + """Represents a reference to a series in a user list.""" + + __mapper_args__: ClassVar = { + "polymorphic_identity": "series", + } + + tvdb_id: Mapped[int] = mapped_column( + ForeignKey("series.tvdb_id"), nullable=False, use_existing_column=True, primary_key=True + ) + series: Mapped["Series"] = relationship("Series") + + +class UserListItemMovie(UserListItem): + """Represents a reference to a movie in a user list.""" + + __mapper_args__: ClassVar = { + "polymorphic_identity": "movie", + } + + tvdb_id: Mapped[int] = mapped_column( + ForeignKey("movies.tvdb_id"), nullable=False, use_existing_column=True, primary_key=True + ) + movie: Mapped["Movie"] = relationship("Movie") + + +class UserListItemEpisode(UserListItem): + """Represents a reference to an episode in a user list.""" + + __mapper_args__: ClassVar = { + "polymorphic_identity": "episode", + } + + tvdb_id: Mapped[int] = mapped_column( + ForeignKey("episodes.tvdb_id"), nullable=False, use_existing_column=True, primary_key=True + ) + episode: Mapped["Episode"] = relationship("Episode") From 53525089d82cb23fdcb0f67cea3c06a721e09cc0 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 25 Jul 2024 22:46:59 +0200 Subject: [PATCH 122/166] Ignore discord.webhook.async_ debug logs --- src/utils/log.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/log.py b/src/utils/log.py index 6ed3180..0d7b370 100644 --- a/src/utils/log.py +++ b/src/utils/log.py @@ -194,6 +194,7 @@ def _setup_external_log_levels(root_log: LoggerClass) -> None: get_logger("asyncio").setLevel(logging.INFO) get_logger("discord.http").setLevel(logging.INFO) get_logger("discord.gateway").setLevel(logging.WARNING) + get_logger("discord.webhook.async_").setLevel(logging.INFO) get_logger("aiosqlite").setLevel(logging.INFO) get_logger("alembic.runtime.migration").setLevel(logging.WARNING) get_logger("aiocache.base").setLevel(logging.INFO) From b8f142c39dd0b3d48df34ad5b6674e7017bd164d Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 25 Jul 2024 23:45:17 +0200 Subject: [PATCH 123/166] Install operations proxy before running auto-migrations --- src/utils/database.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/database.py b/src/utils/database.py index 6a0815d..3c7dab0 100644 --- a/src/utils/database.py +++ b/src/utils/database.py @@ -5,6 +5,7 @@ from typing import NoReturn import alembic.config +from alembic.operations import Operations from alembic.runtime.environment import EnvironmentContext from alembic.runtime.migration import MigrationContext, RevisionStep from alembic.script import ScriptDirectory @@ -110,7 +111,7 @@ def retrieve_migrations(rev: str, context: MigrationContext) -> list[RevisionSte return log.debug("Checking for database migrations") - with context.begin_transaction(): + with Operations.context(context) as _op, context.begin_transaction(): context.run_migrations() From 54e1481fdf41be9151545b52dc168c9829ef148d Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Fri, 26 Jul 2024 08:23:21 +0000 Subject: [PATCH 124/166] Enable alembic logs for CLI (#63) --- alembic-migrations/env.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/alembic-migrations/env.py b/alembic-migrations/env.py index 188bf3c..17bdec3 100644 --- a/alembic-migrations/env.py +++ b/alembic-migrations/env.py @@ -12,6 +12,12 @@ # This will also set up logging with our custom configuration log = get_logger(__name__) +# Override the logging level of the alembic migration logs +# we set this to WARNING in the project to avoid log spam on auto-migrations +# however when alembic is ran manually, we want to see these logs, so set it +# back to the same level as the root log (INFO or DEBUG) +get_logger("alembic.runtime.migration").setLevel(get_logger().getEffectiveLevel()) + # This is the Alembic Config object, which provides access to the values within the .ini file in use. config = context.config From cb59f81289c504d3b2130ecf4e371b903ddab615 Mon Sep 17 00:00:00 2001 From: Ashtik Mahapatra Date: Thu, 25 Jul 2024 18:39:00 +0200 Subject: [PATCH 125/166] :sparkles: episodes and seasons support --- src/tvdb/client.py | 117 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 4 deletions(-) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 0146fe0..01a4db5 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -7,15 +7,22 @@ from yarl import URL from src.settings import TVDB_API_KEY, TVDB_RATE_LIMIT_PERIOD, TVDB_RATE_LIMIT_REQUESTS +from src.tvdb.errors import BadCallError, InvalidApiKeyError, InvalidIdError from src.tvdb.generated_models import ( + EpisodeBaseRecord, + EpisodeExtendedRecord, + EpisodesIdExtendedGetResponse, + EpisodesIdGetResponse, MovieBaseRecord, MovieExtendedRecord, MoviesIdExtendedGetResponse, MoviesIdGetResponse, SearchGetResponse, SearchResult, + SeasonBaseRecord, SeriesBaseRecord, SeriesExtendedRecord, + SeriesIdEpisodesSeasonTypeGetResponse, SeriesIdExtendedGetResponse, SeriesIdGetResponse, ) @@ -23,8 +30,6 @@ from src.utils.log import get_logger from src.utils.ratelimit import rate_limit -from .errors import BadCallError, InvalidApiKeyError, InvalidIdError - log = get_logger(__name__) type JSON_DATA = dict[str, JSON_DATA] | list[JSON_DATA] | str | int | float | bool | None # noice @@ -44,7 +49,7 @@ class FetchMeta(Enum): def parse_media_id(media_id: int | str) -> int: """Parse the media ID from a string.""" try: - media_id = int(str(media_id).removeprefix("movie-").removeprefix("series-")) + media_id = int(str(media_id).removeprefix("movie-").removeprefix("series-").removeprefix("episode-")) except ValueError: raise InvalidIdError("Invalid media ID.") else: @@ -60,8 +65,12 @@ class _Media(ABC): def __init__(self, client: "TvdbClient", data: AnyRecord | SearchResult | None): if data is None: raise ValueError("Data can't be None but is allowed to because of the broken pydantic generated models.") - self.data = data self.client = client + self.set_attributes(data) + + def set_attributes(self, data: AnyRecord | SearchResult) -> None: + """Setting attributes.""" + self.data = data self.name: str | None = self.data.name self.overview: str | None = None # if the class name is "Movie" or "Series" @@ -206,6 +215,7 @@ class Movie(_Media): """Class to interact with the TVDB API for movies.""" ENDPOINT: ClassVar[str] = "movies" + data: SearchResult | MovieBaseRecord | MovieExtendedRecord ResponseType = MoviesIdGetResponse ExtendedResponseType = MoviesIdExtendedGetResponse @@ -222,16 +232,115 @@ class Series(_Media): """Class to interact with the TVDB API for series.""" ENDPOINT: ClassVar[str] = "series" + data: SearchResult | SeriesBaseRecord | SeriesExtendedRecord ResponseType = SeriesIdGetResponse ExtendedResponseType = SeriesIdExtendedGetResponse + def __init__(self, client: "TvdbClient", data: AnyRecord | SearchResult | None): + super().__init__(client, data) + + @override + def set_attributes(self, data: SearchResult | SeriesBaseRecord | SeriesExtendedRecord) -> None: + super().set_attributes(data) + self.episodes: list[Episode] | None = None + self.seasons: list[SeasonBaseRecord] | None = None + if isinstance(self.data, SeriesExtendedRecord): + self.seasons = self.data.seasons + @override @classmethod async def supports_meta(cls, meta: FetchMeta) -> bool: """Check if the class supports a specific meta.""" return meta in {FetchMeta.TRANSLATIONS, FetchMeta.EPISODES} + async def fetch_episodes(self, season_type: str = "official") -> None: + """Fetch episodes for the series based on the season type.""" + cache_key: str = f"{self.id}_{season_type}" + endpoint = f"series/{self.id}/episodes/{season_type}" + response = await self.client.cache.get(cache_key, namespace="tvdb_episodes") + if not response: + response = await self.client.request("GET", endpoint) + await self.client.cache.set(cache_key, value=response, namespace="tvdb_episodes", ttl=60 * 60) + log.trace(f"Stored into cache: {cache_key}") + else: + log.trace(f"Loaded from cache: {cache_key}") + + # Assuming 'episodes' field contains the list of episodes + response = SeriesIdEpisodesSeasonTypeGetResponse(**response) # pyright: ignore[reportCallIssue] + + if response.data and response.data.episodes: + self.episodes = [Episode(episode, client=self.client) for episode in response.data.episodes] + + async def ensure_seasons_and_episodes(self) -> None: + """Ensure that reponse contains seasons.""" + if not isinstance(self.data, SeriesExtendedRecord): + series = await self.fetch( + media_id=self.id, client=self.client, extended=True, short=True, meta=FetchMeta.EPISODES + ) + self.set_attributes(series.data) + + +class Episode: + """Represents an episode from Tvdb.""" + + def __init__(self, data: EpisodeBaseRecord | EpisodeExtendedRecord, client: "TvdbClient") -> None: + self.data = data + self.id: int | None = self.data.id + self.image: str | None = self.data.image + self.name: str | None = self.data.name + self.overview: str | None = self.data.overview + self.season_number: int | None = self.data.season_number + self.eng_name: str | None = None + self.eng_overview: str | None = None + self.series_id: int | None = self.data.series_id + self.client = client + + if isinstance(self.data, EpisodeExtendedRecord): + if self.data.translations and self.data.translations.name_translations: + self.eng_name = get_first( + translation.name + for translation in self.data.translations.name_translations + if translation.language == "eng" + ) + + if self.data.translations and self.data.translations.overview_translations: + self.eng_overview = get_first( + translation.overview + for translation in self.data.translations.overview_translations + if translation.language == "eng" + ) + + @classmethod + async def fetch(cls, media_id: str | int, *, client: "TvdbClient", extended: bool = True) -> "Episode": + """Fetch episode.""" + endpoint = f"/episodes/{parse_media_id(media_id)}" + query: dict[str, str] | None = None + + if extended: + endpoint += "/extended" + query = {"meta": "translations"} + response = await client.request("GET", endpoint=endpoint, query=query) + response = EpisodesIdGetResponse(**response) if not extended else EpisodesIdExtendedGetResponse(**response) # pyright: ignore[reportCallIssue] + + if not response.data: + raise ValueError("No data found for Episode") + return cls(response.data, client=client) + + async def fetch_series( + self, *, extended: bool = False, short: bool | None = None, meta: FetchMeta | None = None + ) -> Series: + """Fetching series.""" + if not self.series_id: + raise ValueError("Series Id cannot be None.") + return await Series.fetch( # pyright: ignore[reportCallIssue] + client=self.client, + media_id=self.series_id, + extended=extended, # pyright: ignore[reportArgumentType] + short=short, + meta=meta, + ) + class TvdbClient: """Class to interact with the TVDB API.""" From e7a3bfe65cc650c093793efbc433ba826f8e793e Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 26 Jul 2024 17:50:56 +0200 Subject: [PATCH 126/166] :adhesive_bandage: Add `UserListItemKind` --- ..._1755-526fbc726d80_fix_id_is_not_unique.py | 42 +++++++++++++++++++ src/db_tables/user_list.py | 15 +++++-- 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 alembic-migrations/versions/2024_07_26_1755-526fbc726d80_fix_id_is_not_unique.py diff --git a/alembic-migrations/versions/2024_07_26_1755-526fbc726d80_fix_id_is_not_unique.py b/alembic-migrations/versions/2024_07_26_1755-526fbc726d80_fix_id_is_not_unique.py new file mode 100644 index 0000000..b1b4c91 --- /dev/null +++ b/alembic-migrations/versions/2024_07_26_1755-526fbc726d80_fix_id_is_not_unique.py @@ -0,0 +1,42 @@ +"""Fix id is not unique + +Revision ID: 526fbc726d80 +Revises: 8fc4b07d9adc +Create Date: 2024-07-26 17:55:32.326226 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "526fbc726d80" +down_revision: str | None = "8fc4b07d9adc" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table("series", sa.Column("tvdb_id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("tvdb_id")) + op.drop_table("shows") + op.add_column("episodes", sa.Column("series_id", sa.Integer(), nullable=False)) + op.create_foreign_key(None, "episodes", "series", ["series_id"], ["tvdb_id"]) + op.drop_column("episodes", "show_id") + op.add_column( + "user_list_items", + sa.Column("kind", sa.Enum("SERIES", "MOVIE", "EPISODE", name="userlistitemkind"), nullable=False), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user_list_items", "kind") + op.add_column("episodes", sa.Column("show_id", sa.INTEGER(), nullable=False)) + op.create_foreign_key(None, "episodes", "shows", ["show_id"], ["tvdb_id"]) + op.drop_column("episodes", "series_id") + op.create_table("shows", sa.Column("tvdb_id", sa.INTEGER(), nullable=False), sa.PrimaryKeyConstraint("tvdb_id")) + op.drop_table("series") + # ### end Alembic commands ### diff --git a/src/db_tables/user_list.py b/src/db_tables/user_list.py index c0d6c4f..e4c8f2f 100644 --- a/src/db_tables/user_list.py +++ b/src/db_tables/user_list.py @@ -12,8 +12,8 @@ from src.db_tables.user import User -class ItemKind(Enum): - """Enum to represent the kind of item in a user list.""" +class UserListKind(Enum): + """Enum to represent the kind of items that are stored in a user list.""" SERIES = "series" MOVIE = "movie" @@ -22,6 +22,14 @@ class ItemKind(Enum): ANY = "any" +class UserListItemKind(Enum): + """Enum to represent the kind of item in a user list.""" + + SERIES = 0 + MOVIE = 1 + EPISODE = 2 + + class UserList(Base): """Table to store user lists. @@ -35,7 +43,7 @@ class UserList(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.discord_id"), nullable=False) name: Mapped[str] = mapped_column(nullable=False) - item_kind: Mapped[ItemKind] = mapped_column(nullable=False) + item_kind: Mapped[UserListKind] = mapped_column(nullable=False) user: Mapped["User"] = relationship("User", back_populates="lists") items: Mapped[list["UserListItem"]] = relationship("UserListItem", back_populates="user_list") @@ -59,6 +67,7 @@ class UserListItem(Base): tvdb_id: Mapped[int] = mapped_column(primary_key=True) user_list: Mapped["UserList"] = relationship("UserList", back_populates="items") + kind: Mapped[UserListItemKind] = mapped_column(nullable=False, primary_key=True) __mapper_args__: ClassVar = {"polymorphic_on": tvdb_id, "polymorphic_identity": "base"} From f73e725179e4c1ae12e809cc19f8c64b0c7f5bd4 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 27 Jul 2024 00:39:12 +0200 Subject: [PATCH 127/166] :sparkles: Add user interface for marking as fav/watched, and make the database work --- ..._21_1854-c55da3c62644_initial_migration.py | 26 - ...7_25_1814-8fc4b07d9adc_add_basic_models.py | 73 --- ..._1755-526fbc726d80_fix_id_is_not_unique.py | 42 -- src/db_adapters/__init__.py | 22 + src/db_adapters/lists.py | 80 +++ src/db_adapters/media.py | 22 + src/db_adapters/user.py | 64 +++ src/db_tables/user_list.py | 67 +-- src/exts/tvdb_info/main.py | 96 +--- src/exts/tvdb_info/ui.py | 512 ++++++++++++++++++ src/tvdb/client.py | 8 + src/utils/tvdb.py | 12 + 12 files changed, 754 insertions(+), 270 deletions(-) delete mode 100644 alembic-migrations/versions/2024_07_21_1854-c55da3c62644_initial_migration.py delete mode 100644 alembic-migrations/versions/2024_07_25_1814-8fc4b07d9adc_add_basic_models.py delete mode 100644 alembic-migrations/versions/2024_07_26_1755-526fbc726d80_fix_id_is_not_unique.py create mode 100644 src/db_adapters/__init__.py create mode 100644 src/db_adapters/lists.py create mode 100644 src/db_adapters/media.py create mode 100644 src/db_adapters/user.py create mode 100644 src/exts/tvdb_info/ui.py create mode 100644 src/utils/tvdb.py diff --git a/alembic-migrations/versions/2024_07_21_1854-c55da3c62644_initial_migration.py b/alembic-migrations/versions/2024_07_21_1854-c55da3c62644_initial_migration.py deleted file mode 100644 index 0ee2d3f..0000000 --- a/alembic-migrations/versions/2024_07_21_1854-c55da3c62644_initial_migration.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Initial migration - -Revision ID: c55da3c62644 -Revises: -Create Date: 2024-07-21 18:54:26.159716 -""" - -from collections.abc import Sequence - -# revision identifiers, used by Alembic. -revision: str = "c55da3c62644" -down_revision: str | None = None -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### diff --git a/alembic-migrations/versions/2024_07_25_1814-8fc4b07d9adc_add_basic_models.py b/alembic-migrations/versions/2024_07_25_1814-8fc4b07d9adc_add_basic_models.py deleted file mode 100644 index c8071b4..0000000 --- a/alembic-migrations/versions/2024_07_25_1814-8fc4b07d9adc_add_basic_models.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Add basic models - -Revision ID: 8fc4b07d9adc -Revises: c55da3c62644 -Create Date: 2024-07-25 18:14:19.322905 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "8fc4b07d9adc" -down_revision: str | None = "c55da3c62644" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table("movies", sa.Column("tvdb_id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("tvdb_id")) - op.create_table("shows", sa.Column("tvdb_id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("tvdb_id")) - op.create_table( - "users", sa.Column("discord_id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("discord_id") - ) - op.create_table( - "episodes", - sa.Column("tvdb_id", sa.Integer(), nullable=False), - sa.Column("show_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["show_id"], - ["shows.tvdb_id"], - ), - sa.PrimaryKeyConstraint("tvdb_id"), - ) - op.create_table( - "user_lists", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("item_kind", sa.Enum("SHOW", "MOVIE", "EPISODE", "MEDIA", "ANY", name="itemkind"), nullable=False), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.discord_id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("user_id", "name", name="unique_user_list_name"), - ) - op.create_index("ix_user_lists_user_id_name", "user_lists", ["user_id", "name"], unique=True) - op.create_table( - "user_list_items", - sa.Column("list_id", sa.Integer(), nullable=False), - sa.Column("tvdb_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["list_id"], - ["user_lists.id"], - ), - sa.PrimaryKeyConstraint("list_id", "tvdb_id"), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("user_list_items") - op.drop_index("ix_user_lists_user_id_name", table_name="user_lists") - op.drop_table("user_lists") - op.drop_table("episodes") - op.drop_table("users") - op.drop_table("shows") - op.drop_table("movies") - # ### end Alembic commands ### diff --git a/alembic-migrations/versions/2024_07_26_1755-526fbc726d80_fix_id_is_not_unique.py b/alembic-migrations/versions/2024_07_26_1755-526fbc726d80_fix_id_is_not_unique.py deleted file mode 100644 index b1b4c91..0000000 --- a/alembic-migrations/versions/2024_07_26_1755-526fbc726d80_fix_id_is_not_unique.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Fix id is not unique - -Revision ID: 526fbc726d80 -Revises: 8fc4b07d9adc -Create Date: 2024-07-26 17:55:32.326226 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "526fbc726d80" -down_revision: str | None = "8fc4b07d9adc" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table("series", sa.Column("tvdb_id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("tvdb_id")) - op.drop_table("shows") - op.add_column("episodes", sa.Column("series_id", sa.Integer(), nullable=False)) - op.create_foreign_key(None, "episodes", "series", ["series_id"], ["tvdb_id"]) - op.drop_column("episodes", "show_id") - op.add_column( - "user_list_items", - sa.Column("kind", sa.Enum("SERIES", "MOVIE", "EPISODE", name="userlistitemkind"), nullable=False), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column("user_list_items", "kind") - op.add_column("episodes", sa.Column("show_id", sa.INTEGER(), nullable=False)) - op.create_foreign_key(None, "episodes", "shows", ["show_id"], ["tvdb_id"]) - op.drop_column("episodes", "series_id") - op.create_table("shows", sa.Column("tvdb_id", sa.INTEGER(), nullable=False), sa.PrimaryKeyConstraint("tvdb_id")) - op.drop_table("series") - # ### end Alembic commands ### diff --git a/src/db_adapters/__init__.py b/src/db_adapters/__init__.py new file mode 100644 index 0000000..cdc97be --- /dev/null +++ b/src/db_adapters/__init__.py @@ -0,0 +1,22 @@ +from .lists import ( + get_list_item, + list_get_item, + list_put_item, + list_put_item_safe, + list_remove_item, + refresh_list_items, +) +from .user import user_create_list, user_get, user_get_list_safe, user_get_safe + +__all__ = [ + "user_get", + "user_create_list", + "list_put_item", + "list_put_item_safe", + "list_get_item", + "user_get_safe", + "user_get_list_safe", + "list_remove_item", + "refresh_list_items", + "get_list_item", +] diff --git a/src/db_adapters/lists.py b/src/db_adapters/lists.py new file mode 100644 index 0000000..b494b6f --- /dev/null +++ b/src/db_adapters/lists.py @@ -0,0 +1,80 @@ +from typing import Literal, overload + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db_adapters.media import ensure_media +from src.db_tables.user_list import UserList, UserListItem, UserListItemKind + + +@overload +async def list_put_item( + session: AsyncSession, + user_list: UserList, + tvdb_id: int, + kind: Literal[UserListItemKind.MOVIE, UserListItemKind.SERIES], +) -> UserListItem: ... + + +@overload +async def list_put_item( + session: AsyncSession, + user_list: UserList, + tvdb_id: int, + kind: Literal[UserListItemKind.EPISODE], + series_id: int, +) -> UserListItem: ... + + +async def list_put_item( + session: AsyncSession, user_list: UserList, tvdb_id: int, kind: UserListItemKind, series_id: int | None = None +) -> UserListItem: + """Add an item to a user list, raises an error if the item is already present.""" + await ensure_media(session, tvdb_id, kind, series_id=series_id) + item = UserListItem(list_id=user_list.id, tvdb_id=tvdb_id, kind=kind) + session.add(item) + await session.commit() + return item + + +async def list_get_item( + session: AsyncSession, user_list: UserList, tvdb_id: int, kind: UserListItemKind +) -> UserListItem | None: + """Get an item from a user list.""" + return await session.get(UserListItem, (user_list.id, tvdb_id, kind)) + + +async def list_remove_item(session: AsyncSession, user_list: UserList, item: UserListItem) -> None: + """Remove an item from a user list.""" + await session.delete(item) + await session.commit() + await session.refresh(user_list, ["items"]) + + +async def list_put_item_safe( + session: AsyncSession, user_list: UserList, tvdb_id: int, kind: UserListItemKind +) -> UserListItem: + """Add an item to a user list, or return the existing item if it is already present.""" + await ensure_media(session, tvdb_id, kind) + item = await list_get_item(session, user_list, tvdb_id, kind) + if item: + return item + + item = UserListItem(list_id=user_list.id, tvdb_id=tvdb_id, kind=kind) + session.add(item) + await session.commit() + return item + + +async def refresh_list_items(session: AsyncSession, user_list: UserList) -> None: + """Refresh the items in a user list.""" + await session.refresh(user_list, ["items"]) + + +async def get_list_item( + session: AsyncSession, + user_list: UserList, + tvdb_id: int, + kind: UserListItemKind, +) -> UserListItem | None: + """Get a user list.""" + return await session.get(UserListItem, (user_list.id, tvdb_id, kind)) diff --git a/src/db_adapters/media.py b/src/db_adapters/media.py new file mode 100644 index 0000000..e7709bc --- /dev/null +++ b/src/db_adapters/media.py @@ -0,0 +1,22 @@ +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db_tables.media import Episode, Movie, Series +from src.db_tables.user_list import UserListItemKind + + +async def ensure_media(session: AsyncSession, tvdb_id: int, kind: UserListItemKind, **kwargs: Any) -> None: + """Ensure that a tvdb media item is present in its respective table.""" + match kind: + case UserListItemKind.MOVIE: + cls = Movie + case UserListItemKind.SERIES: + cls = Series + case UserListItemKind.EPISODE: + cls = Episode + media = await session.get(cls, tvdb_id) + if media is None: + media = cls(tvdb_id=tvdb_id, **kwargs) + session.add(media) + await session.commit() diff --git a/src/db_adapters/user.py b/src/db_adapters/user.py new file mode 100644 index 0000000..a14029d --- /dev/null +++ b/src/db_adapters/user.py @@ -0,0 +1,64 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db_tables.user import User +from src.db_tables.user_list import UserList, UserListKind + + +async def user_get(session: AsyncSession, discord_id: int) -> User | None: + """Get a user by their Discord ID.""" + return await session.get(User, discord_id) + + +async def user_get_safe(session: AsyncSession, discord_id: int) -> User: + """Get a user by their Discord ID, creating them if they don't exist.""" + user = await user_get(session, discord_id) + if user is None: + user = User(discord_id=discord_id) + session.add(user) + await session.commit() + + return user + + +async def user_create_list(session: AsyncSession, user: User, name: str, item_kind: UserListKind) -> UserList: + """Create a new list for a user. + + :raises ValueError: If a list with the same name already exists for the user. + """ + if await session.get(UserList, (user.discord_id, name)) is not None: + raise ValueError(f"List with name {name} already exists for user {user.discord_id}.") + user_list = UserList(user_id=user.discord_id, name=name, item_kind=item_kind) + session.add(user_list) + await session.commit() + await session.refresh(user, ["lists"]) + + return user_list + + +async def user_get_list(session: AsyncSession, user: User, name: str) -> UserList | None: + """Get a user's list by name.""" + # use where clause on user.id and name + user_list = await session.execute( + select(UserList) + .where( + UserList.user_id == user.discord_id, + ) + .where(UserList.name == name) + ) + return user_list.scalars().first() + + +async def user_get_list_safe( + session: AsyncSession, user: User, name: str, kind: UserListKind = UserListKind.MEDIA +) -> UserList: + """Get a user's list by name, creating it if it doesn't exist. + + :param kind: The kind of list to create if it doesn't exist. + :return: The user list. + """ + user_list = await user_get_list(session, user, name) + if user_list is None: + user_list = await user_create_list(session, user, name, kind) + + return user_list diff --git a/src/db_tables/user_list.py b/src/db_tables/user_list.py index e4c8f2f..6c95004 100644 --- a/src/db_tables/user_list.py +++ b/src/db_tables/user_list.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import ClassVar, TYPE_CHECKING +from typing import TYPE_CHECKING from sqlalchemy import ForeignKey, Index, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -65,47 +65,36 @@ class UserListItem(Base): __tablename__ = "user_list_items" list_id: Mapped[int] = mapped_column(ForeignKey("user_lists.id"), primary_key=True) tvdb_id: Mapped[int] = mapped_column(primary_key=True) - - user_list: Mapped["UserList"] = relationship("UserList", back_populates="items") kind: Mapped[UserListItemKind] = mapped_column(nullable=False, primary_key=True) - __mapper_args__: ClassVar = {"polymorphic_on": tvdb_id, "polymorphic_identity": "base"} - - -class UserListItemSeries(UserListItem): - """Represents a reference to a series in a user list.""" - - __mapper_args__: ClassVar = { - "polymorphic_identity": "series", - } + user_list: Mapped["UserList"] = relationship("UserList", back_populates="items") - tvdb_id: Mapped[int] = mapped_column( - ForeignKey("series.tvdb_id"), nullable=False, use_existing_column=True, primary_key=True + # 'tvdb_id' can reference Series, Movie, or Episode tables, determined by 'media_type' + # viewonly=True is used to prevent SQLAlchemy from managing complex relationships, + # as we only need them for querying + series: Mapped["Series"] = relationship( + "Series", + foreign_keys=[tvdb_id], + primaryjoin="and_(UserListItem.tvdb_id == Series.tvdb_id, UserListItem.kind == 'series')", + uselist=False, + viewonly=True, ) - series: Mapped["Series"] = relationship("Series") - - -class UserListItemMovie(UserListItem): - """Represents a reference to a movie in a user list.""" - - __mapper_args__: ClassVar = { - "polymorphic_identity": "movie", - } - - tvdb_id: Mapped[int] = mapped_column( - ForeignKey("movies.tvdb_id"), nullable=False, use_existing_column=True, primary_key=True + movie: Mapped["Movie"] = relationship( + "Movie", + foreign_keys=[tvdb_id], + primaryjoin="and_(UserListItem.tvdb_id == Movie.tvdb_id, UserListItem.kind == 'movie')", + uselist=False, + viewonly=True, ) - movie: Mapped["Movie"] = relationship("Movie") - - -class UserListItemEpisode(UserListItem): - """Represents a reference to an episode in a user list.""" - - __mapper_args__: ClassVar = { - "polymorphic_identity": "episode", - } - - tvdb_id: Mapped[int] = mapped_column( - ForeignKey("episodes.tvdb_id"), nullable=False, use_existing_column=True, primary_key=True + episode: Mapped["Episode"] = relationship( + "Episode", + foreign_keys=[tvdb_id], + primaryjoin="and_(UserListItem.tvdb_id == Episode.tvdb_id, UserListItem.kind == 'episode')", + uselist=False, + viewonly=True, ) - episode: Mapped["Episode"] = relationship("Episode") + + @property + def media(self) -> "Series | Movie | Episode": + """Return the media item associated with this user list item.""" + return self.series or self.movie or self.episode diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 0a90890..7c02e8a 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -1,108 +1,23 @@ -from collections.abc import Sequence from typing import Literal -import discord from discord import ApplicationContext, Cog, option, slash_command from src.bot import Bot -from src.settings import THETVDB_COPYRIGHT_FOOTER, THETVDB_LOGO from src.tvdb import FetchMeta, Movie, Series, TvdbClient from src.tvdb.errors import InvalidIdError from src.utils.log import get_logger from src.utils.ratelimit import rate_limited +from .ui import InfoView + log = get_logger(__name__) MOVIE_EMOJI = "🎬" SERIES_EMOJI = "📺" -class InfoView(discord.ui.View): - """View for displaying information about a movie or series.""" - - def __init__(self, results: Sequence[Movie | Series]) -> None: - super().__init__(disable_on_timeout=True) - self.results = results - if len(self.results) > 1: - self.dropdown = discord.ui.Select( - placeholder="Not what you're looking for? Select a different result.", - options=[ - discord.SelectOption( - label=(result.bilingual_name or "")[:100], - value=str(i), - description=result.overview[:100] if result.overview else None, - ) - for i, result in enumerate(self.results) - ], - ) - self.dropdown.callback = self._dropdown_callback - self.add_item(self.dropdown) - self.add_item( - discord.ui.Button( - style=discord.ButtonStyle.success, - label="Mark as watched", - emoji="✅", - disabled=True, - row=1, - ) - ) - self.add_item( - discord.ui.Button( - style=discord.ButtonStyle.primary, - label="Favorite", - emoji="⭐", - disabled=True, - row=1, - ) - ) - self.add_item( - discord.ui.Button( - style=discord.ButtonStyle.danger, - label="View episodes", - emoji="📺", - disabled=True, - row=1, - ) - ) - self.index = 0 - - def _get_embed(self) -> discord.Embed: - result = self.results[self.index] - if result.overview_eng: - overview = f"{result.overview_eng}" - elif not result.overview_eng and result.overview: - overview = f"{result.overview}\n\n*No English overview available.*" - else: - overview = "*No overview available.*" - title = result.bilingual_name - if result.entity_type == "Movie": - title = f"{MOVIE_EMOJI} {title}" - url = f"https://www.thetvdb.com/movies/{result.slug}" - else: - title = f"{SERIES_EMOJI} {title}" - url = f"https://www.thetvdb.com/series/{result.slug}" - embed = discord.Embed(title=title, color=discord.Color.blurple(), url=url) - embed.add_field(name="Overview", value=overview, inline=False) - embed.set_footer(text=THETVDB_COPYRIGHT_FOOTER, icon_url=THETVDB_LOGO) - embed.set_image(url=result.image_url) - return embed - - async def _dropdown_callback(self, interaction: discord.Interaction) -> None: - if not self.dropdown.values or not isinstance(self.dropdown.values[0], str): - raise ValueError("Dropdown values are empty or not a string but callback was triggered.") - self.index = int(self.dropdown.values[0]) - if not self.message: - raise ValueError("Message is not set but callback was triggered.") - await self.message.edit(embed=self._get_embed(), view=self) - await interaction.response.defer() - - async def send(self, ctx: ApplicationContext) -> None: - """Send the view.""" - await ctx.respond(embed=self._get_embed(), view=self) - - class InfoCog(Cog): - """Cog to verify the bot is working.""" + """Cog to show information about a movie or a series.""" def __init__(self, bot: Bot) -> None: self.bot = bot @@ -164,8 +79,9 @@ async def search( if not response: await ctx.respond("No results found.") return - view = InfoView(response) - await view.send(ctx) + + view = InfoView(self.bot, ctx.user.id, response) + await view.send(ctx.interaction) def setup(bot: Bot) -> None: diff --git a/src/exts/tvdb_info/ui.py b/src/exts/tvdb_info/ui.py new file mode 100644 index 0000000..bc18b7e --- /dev/null +++ b/src/exts/tvdb_info/ui.py @@ -0,0 +1,512 @@ +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import NamedTuple, TYPE_CHECKING, TypedDict, override + +import discord + +from src.bot import Bot +from src.db_adapters import ( + get_list_item, + list_put_item, + list_remove_item, + refresh_list_items, + user_get_list_safe, + user_get_safe, +) +from src.db_tables.user_list import UserList, UserListItem, UserListItemKind +from src.settings import THETVDB_COPYRIGHT_FOOTER, THETVDB_LOGO +from src.tvdb import Movie, Series +from src.utils.log import get_logger +from src.utils.tvdb import by_season + +if TYPE_CHECKING: + from src.db_tables.user import User + from src.tvdb.client import Episode + +log = get_logger(__name__) + +MOVIE_EMOJI = "🎬" +SERIES_EMOJI = "📺" + + +class ReactiveButtonState(TypedDict): + """Type definition for a reactive button state.""" + + label: str + style: discord.ButtonStyle + emoji: str + + +# A reactive button is a tuple of the button, the item in the list, the state when active, and the state when inactive. +# It allows us to programmatically change the button's appearance based on the state of the item in the list. + + +class ReactiveButton(NamedTuple): + """A tuple of the button, the item in the list, the state when active, and the state when inactive.""" + + button: discord.ui.Button # pyright: ignore[reportMissingTypeArgument] + item: UserListItem | None + active_state: ReactiveButtonState + inactive_state: ReactiveButtonState + + +class _ReactiveView(discord.ui.View, ABC): + _reactive_buttons_store: list[ReactiveButton] | None = None + bot: Bot + + async def _update_states(self) -> None: + for button, item, active_state, inactive_state in await self._reactive_buttons: + if item: + button.style = inactive_state["style"] + button.label = inactive_state["label"] + button.emoji = inactive_state["emoji"] + else: + button.style = active_state["style"] + button.label = active_state["label"] + button.emoji = active_state["emoji"] + + async def _refresh(self) -> None: + await self._update_states() + if not self.message: + raise ValueError("Message is not set but refresh was called.") + await self.message.edit(embed=self._get_embed(), view=self) + + @abstractmethod + def _get_embed(self) -> discord.Embed: + """Get the embed for the view.""" + + @abstractmethod + async def send(self, interaction: discord.Interaction) -> None: + """Send the view.""" + + @property + @abstractmethod + async def _reactive_buttons(self) -> list[ReactiveButton]: + """Get the reactive buttons.""" + + async def _current_list_item( + self, + user_list: UserList, + store: UserListItem | None, + tvdb_id: int | None, + kind: UserListItemKind, + ) -> UserListItem | None: + """Get the current list item from the store or the database.""" + if not tvdb_id: + raise ValueError("TVDB ID is not set but callback was triggered.") + if not (store and store.tvdb_id == tvdb_id): + store = await get_list_item(self.bot.db_session, user_list, tvdb_id, kind) + return store + + +class EpisodeView(_ReactiveView): + """View for displaying episodes of a series and interacting with them.""" + + def __init__( + self, bot: Bot, user_id: int, series: Series, watched_list: UserList, favorite_list: UserList + ) -> None: + super().__init__(timeout=None) + self.bot = bot + self.user_id = user_id + self.series = series + self.user: User + self.watched_list = watched_list + self.favorite_list = favorite_list + self.episodes: dict[int, list[Episode]] + self.season_idx: int = 1 + self.episode_idx: int = 1 + + self._reactive_buttons_store: list[ReactiveButton] | None = None + self._current_watched_list_item_store: UserListItem | None = None + self._current_favorite_list_item_store: UserListItem | None = None + + self.episode_dropdown = discord.ui.Select( + placeholder="Select an episode", + ) + self.episode_dropdown.callback = self._episode_dropdown_callback + self.add_item(self.episode_dropdown) + + self.season_dropdown = discord.ui.Select( + placeholder="Select a season", + ) + self.season_dropdown.callback = self._season_dropdown_callback + self.add_item(self.season_dropdown) + + self.watched_btn = discord.ui.Button( + row=2, + ) + self.watched_btn.callback = self._watched_callback + self.add_item(self.watched_btn) + + self.favorite_btn = discord.ui.Button( + row=2, + ) + self.favorite_btn.callback = self._favorite_callback + self.add_item(self.favorite_btn) + + @property + @override + async def _reactive_buttons(self) -> list[ReactiveButton]: + if self._reactive_buttons_store: + return self._reactive_buttons_store + return [ + ReactiveButton( + self.watched_btn, + await self._current_watched_list_item, + { + "label": "Mark as watched", + "style": discord.ButtonStyle.success, + "emoji": "✅", + }, + { + "label": "Unmark as watched", + "style": discord.ButtonStyle.primary, + "emoji": "❌", + }, + ), + ReactiveButton( + self.favorite_btn, + await self._current_favorite_list_item, + { + "label": "Favorite", + "style": discord.ButtonStyle.primary, + "emoji": "⭐", + }, + { + "label": "Unfavorite", + "style": discord.ButtonStyle.secondary, + "emoji": "❌", + }, + ), + ] + + @property + def _current_episode(self) -> "Episode": + return self.episodes[self.season_idx][self.episode_idx - 1] + + @property + async def _current_watched_list_item(self) -> UserListItem | None: + return await self._current_list_item( + self.watched_list, + self._current_watched_list_item_store, + self._current_episode.id, + UserListItemKind.EPISODE, + ) + + @property + async def _current_favorite_list_item(self) -> UserListItem | None: + return await self._current_list_item( + self.favorite_list, + self._current_favorite_list_item_store, + self._current_episode.id, + UserListItemKind.EPISODE, + ) + + async def _mark_callback(self, user_list: UserList, item: UserListItem | None) -> bool: + """Mark or unmark an item in a list. + + :param user_list: + :param item: + :return: + """ + if item: + await list_remove_item(self.bot.db_session, user_list, item) + return False + if not self._current_episode.id: + raise ValueError("Current episode has no ID but callback was triggered.") + await list_put_item( + self.bot.db_session, + user_list, + self._current_episode.id, + UserListItemKind.EPISODE, + self.series.id, + ) + return True + + async def _watched_callback(self, interaction: discord.Interaction) -> None: + """Callback for marking an episode as watched.""" + await interaction.response.defer() + + await self._mark_callback(self.watched_list, await self._current_watched_list_item) + + await self._refresh() + + async def _favorite_callback(self, interaction: discord.Interaction) -> None: + """Callback for favoriting an episode.""" + await interaction.response.defer() + + await self._mark_callback(self.favorite_list, await self._current_favorite_list_item) + + await self._refresh() + + @override + async def _update_states(self) -> None: + if not hasattr(self, "user"): + self.user = await user_get_safe(self.bot.db_session, self.user_id) + await refresh_list_items(self.bot.db_session, self.watched_list) + await refresh_list_items(self.bot.db_session, self.favorite_list) + + if self.series.episodes: + self.episodes = by_season(self.series.episodes) + else: + raise ValueError("Series has no episodes.") + + self.episode_dropdown.options = [ + discord.SelectOption( + label=episode.formatted_name, + value=str(episode.number), + description=episode.overview[:100] if episode.overview else None, + ) + for episode in self.episodes[self.season_idx] + ] + self.season_dropdown.options = [ + discord.SelectOption(label=f"Season {season}", value=str(season)) for season in self.episodes + ] + + await super()._update_states() + + @override + def _get_embed(self) -> discord.Embed: + embed = discord.Embed( + title=self._current_episode.formatted_name, + description=self._current_episode.overview, + color=discord.Color.blurple(), + url=f"https://www.thetvdb.com/series/{self.series.slug}", + ) + embed.set_image(url=f"https://www.thetvdb.com{self._current_episode.image}") + embed.set_footer(text=THETVDB_COPYRIGHT_FOOTER, icon_url=THETVDB_LOGO) + return embed + + @override + async def send(self, interaction: discord.Interaction) -> None: + """Send the view.""" + await self.series.ensure_seasons_and_episodes() + await self._update_states() + await interaction.respond(embed=self._get_embed(), view=self) + + async def _episode_dropdown_callback(self, interaction: discord.Interaction) -> None: + if not self.episode_dropdown.values or not isinstance(self.episode_dropdown.values[0], str): + raise ValueError("Episode dropdown values are empty or not a string but callback was triggered.") + self.episode_idx = int(self.episode_dropdown.values[0]) + await self._refresh() + await interaction.response.defer() + + async def _season_dropdown_callback(self, interaction: discord.Interaction) -> None: + if not self.season_dropdown.values or not isinstance(self.season_dropdown.values[0], str): + raise ValueError("Season dropdown values are empty or not a string but callback was triggered.") + self.season_idx = int(self.season_dropdown.values[0]) + self.episode_idx = 1 + await self._refresh() + await interaction.response.defer() + + +class InfoView(_ReactiveView): + """View for displaying information about a movie or series and interacting with it.""" + + def __init__(self, bot: Bot, user_id: int, results: Sequence[Movie | Series]) -> None: + super().__init__(disable_on_timeout=True) + self.index = 0 # the index of the current result + self.results = results + self.bot = bot + self.user_id = user_id + self.user: User + self.watched_list: UserList + self.favorite_list: UserList + + self._current_watched_list_item_store: UserListItem | None = None + self._current_favorite_list_item_store: UserListItem | None = None + + if len(self.results) > 1: + self.dropdown = discord.ui.Select( + placeholder="Not what you're looking for? Select a different result.", + options=[ + discord.SelectOption( + label=(result.bilingual_name or "")[:100], + value=str(i), + description=result.overview[:100] if result.overview else None, + ) + for i, result in enumerate(self.results) + ], + ) + self.dropdown.callback = self._dropdown_callback + self.add_item(self.dropdown) + + self.watched_btn = discord.ui.Button( + # styles are set by the reactive button system and omitted here + row=1, + ) + self.watched_btn.callback = self._mark_as_watched_callback + self.add_item(self.watched_btn) + + self.favorite_btn = discord.ui.Button( + row=1, + ) + self.favorite_btn.callback = self._favorite_callback + self.add_item(self.favorite_btn) + self._reactive_buttons_store: list[ReactiveButton] | None = None + + self.episodes_btn: discord.ui.Button[InfoView] | None = None + + if isinstance(self._current_result, Series): + self.episodes_btn = discord.ui.Button( + style=discord.ButtonStyle.danger, + label="View episodes", + emoji="📺", + row=1, + ) + self.add_item(self.episodes_btn) + + self.episodes_btn.callback = self._episode_callback + + @property + @override + async def _reactive_buttons(self) -> list[ReactiveButton]: + if self._reactive_buttons_store: + return self._reactive_buttons_store + return [ + ReactiveButton( + self.watched_btn, + await self._current_watched_list_item, + { + "label": "Mark as watched", + "style": discord.ButtonStyle.success, + "emoji": "✅", + }, + { + "label": "Unmark as watched", + # We avoid using .danger because of bad styling in conjunction with the emoji. + # It could be possible to use .danger, but would have to use an emoji that fits better. + "style": discord.ButtonStyle.primary, + "emoji": "❌", + }, + ), + ReactiveButton( + self.favorite_btn, + await self._current_favorite_list_item, + { + "label": "Favorite", + "style": discord.ButtonStyle.primary, + "emoji": "⭐", + }, + { + "label": "Unfavorite", + "style": discord.ButtonStyle.secondary, + "emoji": "❌", + }, + ), + ] + + @property + def _current_result(self) -> Movie | Series: + return self.results[self.index] + + @property + async def _current_watched_list_item(self) -> UserListItem | None: + return await self._current_list_item( + self.watched_list, self._current_watched_list_item_store, self._current_result.id, self._current_kind + ) + + @property + async def _current_favorite_list_item(self) -> UserListItem | None: + return await self._current_list_item( + self.favorite_list, self._current_favorite_list_item_store, self._current_result.id, self._current_kind + ) + + @property + def _current_kind(self) -> UserListItemKind: + return UserListItemKind.MOVIE if self._current_result.entity_type == "Movie" else UserListItemKind.SERIES + + @override + def _get_embed(self) -> discord.Embed: + result = self._current_result + if result.overview_eng: + overview = f"{result.overview_eng}" + elif not result.overview_eng and result.overview: + overview = f"{result.overview}\n\n*No English overview available.*" + else: + overview = "*No overview available.*" + title = result.bilingual_name + if result.entity_type == "Movie": + title = f"{MOVIE_EMOJI} {title}" + url = f"https://www.thetvdb.com/movies/{result.slug}" + else: + title = f"{SERIES_EMOJI} {title}" + url = f"https://www.thetvdb.com/series/{result.slug}" + embed = discord.Embed(title=title, color=discord.Color.blurple(), url=url) + embed.add_field(name="Overview", value=overview, inline=False) + embed.set_footer(text=THETVDB_COPYRIGHT_FOOTER, icon_url=THETVDB_LOGO) + embed.set_image(url=result.image_url) + return embed + + @override + async def _update_states(self) -> None: + if not hasattr(self, "user"): + self.user = await user_get_safe(self.bot.db_session, self.user_id) + if not hasattr(self, "watched_list"): + self.watched_list = await user_get_list_safe(self.bot.db_session, self.user, "watched") + await refresh_list_items(self.bot.db_session, self.watched_list) + if not hasattr(self, "favorite_list"): + self.favorite_list = await user_get_list_safe(self.bot.db_session, self.user, "favorite") + await refresh_list_items(self.bot.db_session, self.favorite_list) + + await super()._update_states() + + async def _dropdown_callback(self, interaction: discord.Interaction) -> None: + if not self.dropdown.values or not isinstance(self.dropdown.values[0], str): + raise ValueError("Dropdown values are empty or not a string but callback was triggered.") + self.index = int(self.dropdown.values[0]) + await self._refresh() + await interaction.response.defer() + + @override + async def send(self, interaction: discord.Interaction) -> None: + """Send the view.""" + await self._update_states() + await interaction.respond(embed=self._get_embed(), view=self) + + async def _mark_callback(self, user_list: UserList, item: UserListItem | None) -> bool: + """Mark or unmark an item in a list. + + :param user_list: + :param item: + :return: + """ + if item: + await list_remove_item(self.bot.db_session, user_list, item) + return False + await list_put_item( + self.bot.db_session, + user_list, + self._current_result.id, + self._current_kind, # pyright: ignore[reportArgumentType] + ) + return True + + async def _mark_as_watched_callback(self, interaction: discord.Interaction) -> None: + """Callback for marking a movie or series as watched.""" + # `defer` technically produces a response to the interaction, and allows us not to respond to the interaction + # to make the interface feel more intuitive, avoiding unnecessary responses. + await interaction.response.defer() + + await self._mark_callback(self.watched_list, await self._current_watched_list_item) + + await self._refresh() + + async def _favorite_callback(self, interaction: discord.Interaction) -> None: + """Callback for favoriting a movie or series.""" + await interaction.response.defer() + + await self._mark_callback(self.favorite_list, await self._current_favorite_list_item) + + await self._refresh() + + async def _episode_callback(self, interaction: discord.Interaction) -> None: + """Callback for viewing episodes of a series.""" + await interaction.response.defer() + series = self._current_result + if not isinstance(series, Series): + raise TypeError("Current result is not a series but callback was triggered.") + watched_list = await user_get_list_safe(self.bot.db_session, self.user, "watched") + favorite_list = await user_get_list_safe(self.bot.db_session, self.user, "favorite") + view = EpisodeView(self.bot, self.user_id, series, watched_list, favorite_list) + await view.send(interaction) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 01a4db5..d4900a6 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -247,6 +247,8 @@ def set_attributes(self, data: SearchResult | SeriesBaseRecord | SeriesExtendedR self.seasons: list[SeasonBaseRecord] | None = None if isinstance(self.data, SeriesExtendedRecord): self.seasons = self.data.seasons + if self.data.episodes: + self.episodes = [Episode(episode, client=self.client) for episode in self.data.episodes] @override @classmethod @@ -290,6 +292,7 @@ def __init__(self, data: EpisodeBaseRecord | EpisodeExtendedRecord, client: "Tvd self.image: str | None = self.data.image self.name: str | None = self.data.name self.overview: str | None = self.data.overview + self.number: int | None = self.data.number self.season_number: int | None = self.data.season_number self.eng_name: str | None = None self.eng_overview: str | None = None @@ -311,6 +314,11 @@ def __init__(self, data: EpisodeBaseRecord | EpisodeExtendedRecord, client: "Tvd if translation.language == "eng" ) + @property + def formatted_name(self) -> str: + """Returns the name in format SxxEyy - Name.""" + return f"S{self.season_number:02}E{self.number:02} - {self.name}" + @classmethod async def fetch(cls, media_id: str | int, *, client: "TvdbClient", extended: bool = True) -> "Episode": """Fetch episode.""" diff --git a/src/utils/tvdb.py b/src/utils/tvdb.py new file mode 100644 index 0000000..0a75690 --- /dev/null +++ b/src/utils/tvdb.py @@ -0,0 +1,12 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.tvdb.client import Episode + + +def by_season(episodes: list["Episode"]) -> dict[int, list["Episode"]]: + """Group episodes by season.""" + seasons = {} + for episode in episodes: + seasons.setdefault(episode.season_number, []).append(episode) + return seasons From bdf02d507d9dfd837f161cb5ac5c5b544229aceb Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 27 Jul 2024 16:48:00 +0200 Subject: [PATCH 128/166] Handle item already existing in user list --- src/db_adapters/lists.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/db_adapters/lists.py b/src/db_adapters/lists.py index b494b6f..3700a35 100644 --- a/src/db_adapters/lists.py +++ b/src/db_adapters/lists.py @@ -28,8 +28,15 @@ async def list_put_item( async def list_put_item( session: AsyncSession, user_list: UserList, tvdb_id: int, kind: UserListItemKind, series_id: int | None = None ) -> UserListItem: - """Add an item to a user list, raises an error if the item is already present.""" + """Add an item to a user list. + + :raises ValueError: If the item is already present in the list. + """ await ensure_media(session, tvdb_id, kind, series_id=series_id) + + if await session.get(UserListItem, (user_list.id, tvdb_id, kind)) is not None: + raise ValueError(f"Item {tvdb_id} is already in list {user_list.id}.") + item = UserListItem(list_id=user_list.id, tvdb_id=tvdb_id, kind=kind) session.add(item) await session.commit() From 8b38ea05a8d7b3ce2f87dcf01591490e05ed6ab8 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 27 Jul 2024 16:53:37 +0200 Subject: [PATCH 129/166] Forward the generic param for Button from ReactiveButton --- src/exts/tvdb_info/ui.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/exts/tvdb_info/ui.py b/src/exts/tvdb_info/ui.py index bc18b7e..dba65fc 100644 --- a/src/exts/tvdb_info/ui.py +++ b/src/exts/tvdb_info/ui.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from collections.abc import Sequence -from typing import NamedTuple, TYPE_CHECKING, TypedDict, override +from typing import NamedTuple, Self, TYPE_CHECKING, TypedDict, override import discord @@ -41,17 +41,17 @@ class ReactiveButtonState(TypedDict): # It allows us to programmatically change the button's appearance based on the state of the item in the list. -class ReactiveButton(NamedTuple): +class ReactiveButton[V: discord.ui.View](NamedTuple): """A tuple of the button, the item in the list, the state when active, and the state when inactive.""" - button: discord.ui.Button # pyright: ignore[reportMissingTypeArgument] + button: discord.ui.Button[V] item: UserListItem | None active_state: ReactiveButtonState inactive_state: ReactiveButtonState class _ReactiveView(discord.ui.View, ABC): - _reactive_buttons_store: list[ReactiveButton] | None = None + _reactive_buttons_store: list[ReactiveButton[Self]] | None = None bot: Bot async def _update_states(self) -> None: @@ -81,7 +81,7 @@ async def send(self, interaction: discord.Interaction) -> None: @property @abstractmethod - async def _reactive_buttons(self) -> list[ReactiveButton]: + async def _reactive_buttons(self) -> list[ReactiveButton[Self]]: """Get the reactive buttons.""" async def _current_list_item( @@ -116,7 +116,7 @@ def __init__( self.season_idx: int = 1 self.episode_idx: int = 1 - self._reactive_buttons_store: list[ReactiveButton] | None = None + self._reactive_buttons_store: list[ReactiveButton[Self]] | None = None self._current_watched_list_item_store: UserListItem | None = None self._current_favorite_list_item_store: UserListItem | None = None @@ -146,7 +146,7 @@ def __init__( @property @override - async def _reactive_buttons(self) -> list[ReactiveButton]: + async def _reactive_buttons(self) -> list[ReactiveButton[Self]]: if self._reactive_buttons_store: return self._reactive_buttons_store return [ @@ -343,7 +343,7 @@ def __init__(self, bot: Bot, user_id: int, results: Sequence[Movie | Series]) -> ) self.favorite_btn.callback = self._favorite_callback self.add_item(self.favorite_btn) - self._reactive_buttons_store: list[ReactiveButton] | None = None + self._reactive_buttons_store: list[ReactiveButton[Self]] | None = None self.episodes_btn: discord.ui.Button[InfoView] | None = None @@ -360,7 +360,7 @@ def __init__(self, bot: Bot, user_id: int, results: Sequence[Movie | Series]) -> @property @override - async def _reactive_buttons(self) -> list[ReactiveButton]: + async def _reactive_buttons(self) -> list[ReactiveButton[Self]]: if self._reactive_buttons_store: return self._reactive_buttons_store return [ From 68787b5c5f70444b76f11c562ba8284b08cfbdcb Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 27 Jul 2024 16:56:32 +0200 Subject: [PATCH 130/166] Move inline comment for ReactiveButton to docstring --- src/exts/tvdb_info/ui.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/exts/tvdb_info/ui.py b/src/exts/tvdb_info/ui.py index dba65fc..05af03d 100644 --- a/src/exts/tvdb_info/ui.py +++ b/src/exts/tvdb_info/ui.py @@ -37,12 +37,11 @@ class ReactiveButtonState(TypedDict): emoji: str -# A reactive button is a tuple of the button, the item in the list, the state when active, and the state when inactive. -# It allows us to programmatically change the button's appearance based on the state of the item in the list. - - class ReactiveButton[V: discord.ui.View](NamedTuple): - """A tuple of the button, the item in the list, the state when active, and the state when inactive.""" + """A tuple of the button, the item in the list, the state when active, and the state when inactive. + + This allows us to programmatically change the button's appearance based on the state of the item in the list. + """ button: discord.ui.Button[V] item: UserListItem | None From 0f7e9ea75d58c183aef096a69f5c7358aac50628 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 27 Jul 2024 16:25:55 +0200 Subject: [PATCH 131/166] Run sqlite migrations as batch --- alembic-migrations/env.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/alembic-migrations/env.py b/alembic-migrations/env.py index 17bdec3..d20a9e6 100644 --- a/alembic-migrations/env.py +++ b/alembic-migrations/env.py @@ -22,7 +22,7 @@ config = context.config -def run_migrations_offline(target_metadata: MetaData) -> None: +def run_migrations_offline(target_metadata: MetaData, *, render_as_batch: bool) -> None: """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable @@ -36,20 +36,25 @@ def run_migrations_offline(target_metadata: MetaData) -> None: target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + render_as_batch=render_as_batch, ) with context.begin_transaction(): context.run_migrations() -async def run_migrations_online(target_metadata: MetaData) -> None: +async def run_migrations_online(target_metadata: MetaData, *, render_as_batch: bool) -> None: """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=render_as_batch, + ) with context.begin_transaction(): context.run_migrations() @@ -78,12 +83,16 @@ def main() -> None: config.set_main_option("sqlalchemy.url", SQLALCHEMY_URL) load_db_models() + # Check if we're using SQLite database, as it requires special handling for migrations + # due to the lack of ALTER TABLE statements. (https://alembic.sqlalchemy.org/en/latest/batch.html) + render_as_batch = SQLALCHEMY_URL.startswith("sqlite") + target_metadata = Base.metadata if context.is_offline_mode(): - run_migrations_offline(target_metadata) + run_migrations_offline(target_metadata, render_as_batch=render_as_batch) else: - asyncio.run(run_migrations_online(target_metadata)) + asyncio.run(run_migrations_online(target_metadata, render_as_batch=render_as_batch)) main() From 7f71ab3ad4ca31857a1660f64ce0af43cc1ff140 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 27 Jul 2024 17:13:20 +0200 Subject: [PATCH 132/166] (Re)create the initial migration --- ..._27_1712-eeef1b453205_initial_migration.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 alembic-migrations/versions/2024_07_27_1712-eeef1b453205_initial_migration.py diff --git a/alembic-migrations/versions/2024_07_27_1712-eeef1b453205_initial_migration.py b/alembic-migrations/versions/2024_07_27_1712-eeef1b453205_initial_migration.py new file mode 100644 index 0000000..9b59948 --- /dev/null +++ b/alembic-migrations/versions/2024_07_27_1712-eeef1b453205_initial_migration.py @@ -0,0 +1,80 @@ +"""Initial migration + +Revision ID: eeef1b453205 +Revises: +Create Date: 2024-07-27 17:12:52.289591 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "eeef1b453205" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table("movies", sa.Column("tvdb_id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("tvdb_id")) + op.create_table("series", sa.Column("tvdb_id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("tvdb_id")) + op.create_table( + "users", sa.Column("discord_id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("discord_id") + ) + op.create_table( + "episodes", + sa.Column("tvdb_id", sa.Integer(), nullable=False), + sa.Column("series_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["series_id"], + ["series.tvdb_id"], + ), + sa.PrimaryKeyConstraint("tvdb_id"), + ) + op.create_table( + "user_lists", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column( + "item_kind", sa.Enum("SERIES", "MOVIE", "EPISODE", "MEDIA", "ANY", name="userlistkind"), nullable=False + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.discord_id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "name", name="unique_user_list_name"), + ) + with op.batch_alter_table("user_lists", schema=None) as batch_op: + batch_op.create_index("ix_user_lists_user_id_name", ["user_id", "name"], unique=True) + + op.create_table( + "user_list_items", + sa.Column("list_id", sa.Integer(), nullable=False), + sa.Column("tvdb_id", sa.Integer(), nullable=False), + sa.Column("kind", sa.Enum("SERIES", "MOVIE", "EPISODE", name="userlistitemkind"), nullable=False), + sa.ForeignKeyConstraint( + ["list_id"], + ["user_lists.id"], + ), + sa.PrimaryKeyConstraint("list_id", "tvdb_id", "kind"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("user_list_items") + with op.batch_alter_table("user_lists", schema=None) as batch_op: + batch_op.drop_index("ix_user_lists_user_id_name") + + op.drop_table("user_lists") + op.drop_table("episodes") + op.drop_table("users") + op.drop_table("series") + op.drop_table("movies") + # ### end Alembic commands ### From a5c19906423222f59e84d8d1ca05bf53e1f82018 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 27 Jul 2024 17:17:16 +0200 Subject: [PATCH 133/166] Add DB_ALWAYS_MIGRATE option for debugging migrations --- README.md | 1 + src/settings.py | 1 + src/utils/database.py | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9d817c2..8d8927f 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ TODO: Separate these to variables necessary to run the bot, and those only relev | `TVDB_RATE_LIMIT_PERIOD` | float | 5 | Period of time in seconds, within which the bot can make up to `TVDB_RATE_LIMIT_REQUESTS` requests to the TVDB API. | | `SQLITE_DATABASE_FILE` | path | ./database.db | Path to sqlite database file, can be relative to project root (if the file doesn't yet exists, it will be created) | | `ECHO_SQL` | bool | 0 | If `1`, print out every SQL command that SQLAlchemy library runs internally (can be useful when debugging) | +| `DB_ALWAYS_MIGRATE` | bool | 0 | If `1`, database migrations will always be performed, even on a new database (instead of just creating the tables). | | `DEBUG` | bool | 0 | If `1`, debug logs will be enabled, if `0` only info logs and above will be shown | | `LOG_FILE` | path | N/A | If set, also write the logs into given file, otherwise, only print them | | `TRACE_LEVEL_FILTER` | custom | N/A | Configuration for trace level logging, see: [trace logs config section](#trace-logs-config) | diff --git a/src/settings.py b/src/settings.py index 0e4248c..50cf5c4 100644 --- a/src/settings.py +++ b/src/settings.py @@ -8,6 +8,7 @@ SQLITE_DATABASE_FILE = get_config("SQLITE_DATABASE_FILE", cast=Path, default=Path("./database.db")) ECHO_SQL = get_config("ECHO_SQL", cast=bool, default=False) +DB_ALWAYS_MIGRATE = get_config("DB_ALWAYS_MIGRATE", cast=bool, default=False) FAIL_EMOJI = "❌" SUCCESS_EMOJI = "✅" diff --git a/src/utils/database.py b/src/utils/database.py index 3c7dab0..f397311 100644 --- a/src/utils/database.py +++ b/src/utils/database.py @@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase -from src.settings import ECHO_SQL, SQLITE_DATABASE_FILE +from src.settings import DB_ALWAYS_MIGRATE, ECHO_SQL, SQLITE_DATABASE_FILE from src.utils.log import get_logger log = get_logger(__name__) @@ -104,7 +104,7 @@ def retrieve_migrations(rev: str, context: MigrationContext) -> list[RevisionSte # If there is no current revision, this is a brand new database # instead of going through the migrations, we can instead use metadata.create_all # to create all tables and then stamp the database with the head revision. - if current_rev is None: + if current_rev is None and not DB_ALWAYS_MIGRATE: log.info("Performing initial database setup (creating tables)") Base.metadata.create_all(db_conn) context.stamp(script, "head") From c63350bc60b3ba9007e57313a9adcf829ded3f6a Mon Sep 17 00:00:00 2001 From: Benjiguy Date: Sat, 27 Jul 2024 18:58:52 +0300 Subject: [PATCH 134/166] fixed database --- src/db_adapters/lists.py | 6 ++++-- src/db_adapters/user.py | 28 ++++++++++++++-------------- src/exts/tvdb_info/ui.py | 4 ++-- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/db_adapters/lists.py b/src/db_adapters/lists.py index 3700a35..ec823c1 100644 --- a/src/db_adapters/lists.py +++ b/src/db_adapters/lists.py @@ -32,8 +32,10 @@ async def list_put_item( :raises ValueError: If the item is already present in the list. """ - await ensure_media(session, tvdb_id, kind, series_id=series_id) - + if series_id: + await ensure_media(session, tvdb_id, kind, series_id=series_id) + else: + await ensure_media(session, tvdb_id, kind) if await session.get(UserListItem, (user_list.id, tvdb_id, kind)) is not None: raise ValueError(f"Item {tvdb_id} is already in list {user_list.id}.") diff --git a/src/db_adapters/user.py b/src/db_adapters/user.py index a14029d..cc52170 100644 --- a/src/db_adapters/user.py +++ b/src/db_adapters/user.py @@ -21,12 +21,25 @@ async def user_get_safe(session: AsyncSession, discord_id: int) -> User: return user +async def user_get_list(session: AsyncSession, user: User, name: str) -> UserList | None: + """Get a user's list by name.""" + # use where clause on user.id and name + user_list = await session.execute( + select(UserList) + .where( + UserList.user_id == user.discord_id, + ) + .where(UserList.name == name) + ) + return user_list.scalars().first() + + async def user_create_list(session: AsyncSession, user: User, name: str, item_kind: UserListKind) -> UserList: """Create a new list for a user. :raises ValueError: If a list with the same name already exists for the user. """ - if await session.get(UserList, (user.discord_id, name)) is not None: + if await user_get_list(session, user, name) is not None: raise ValueError(f"List with name {name} already exists for user {user.discord_id}.") user_list = UserList(user_id=user.discord_id, name=name, item_kind=item_kind) session.add(user_list) @@ -36,19 +49,6 @@ async def user_create_list(session: AsyncSession, user: User, name: str, item_ki return user_list -async def user_get_list(session: AsyncSession, user: User, name: str) -> UserList | None: - """Get a user's list by name.""" - # use where clause on user.id and name - user_list = await session.execute( - select(UserList) - .where( - UserList.user_id == user.discord_id, - ) - .where(UserList.name == name) - ) - return user_list.scalars().first() - - async def user_get_list_safe( session: AsyncSession, user: User, name: str, kind: UserListKind = UserListKind.MEDIA ) -> UserList: diff --git a/src/exts/tvdb_info/ui.py b/src/exts/tvdb_info/ui.py index 05af03d..35d28ab 100644 --- a/src/exts/tvdb_info/ui.py +++ b/src/exts/tvdb_info/ui.py @@ -367,12 +367,12 @@ async def _reactive_buttons(self) -> list[ReactiveButton[Self]]: self.watched_btn, await self._current_watched_list_item, { - "label": "Mark as watched", + "label": "Mark all episodes as watched", "style": discord.ButtonStyle.success, "emoji": "✅", }, { - "label": "Unmark as watched", + "label": "Unmark all episodes as watched", # We avoid using .danger because of bad styling in conjunction with the emoji. # It could be possible to use .danger, but would have to use an emoji that fits better. "style": discord.ButtonStyle.primary, From 2b94e13b0f38c22c272ea6a885de580f2844b316 Mon Sep 17 00:00:00 2001 From: Benjiguy Date: Sat, 27 Jul 2024 19:47:42 +0300 Subject: [PATCH 135/166] :sparkles: Use last season episode to mark as watched / unwatched. --- src/db_adapters/__init__.py | 2 ++ src/db_adapters/lists.py | 12 +++++++-- src/db_adapters/user.py | 28 ++++++++++----------- src/exts/tvdb_info/ui.py | 50 ++++++++++++++++++++++++++++++++++--- 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/src/db_adapters/__init__.py b/src/db_adapters/__init__.py index cdc97be..e220e57 100644 --- a/src/db_adapters/__init__.py +++ b/src/db_adapters/__init__.py @@ -4,6 +4,7 @@ list_put_item, list_put_item_safe, list_remove_item, + list_remove_item_safe, refresh_list_items, ) from .user import user_create_list, user_get, user_get_list_safe, user_get_safe @@ -19,4 +20,5 @@ "list_remove_item", "refresh_list_items", "get_list_item", + "list_remove_item_safe", ] diff --git a/src/db_adapters/lists.py b/src/db_adapters/lists.py index 3700a35..186bebf 100644 --- a/src/db_adapters/lists.py +++ b/src/db_adapters/lists.py @@ -57,11 +57,19 @@ async def list_remove_item(session: AsyncSession, user_list: UserList, item: Use await session.refresh(user_list, ["items"]) -async def list_put_item_safe( +async def list_remove_item_safe( session: AsyncSession, user_list: UserList, tvdb_id: int, kind: UserListItemKind +) -> None: + """Removes an item from a user list if it exists.""" + if item := await list_get_item(session, user_list, tvdb_id, kind): + await list_remove_item(session, user_list, item) + + +async def list_put_item_safe( + session: AsyncSession, user_list: UserList, tvdb_id: int, kind: UserListItemKind, series_id: int | None = None ) -> UserListItem: """Add an item to a user list, or return the existing item if it is already present.""" - await ensure_media(session, tvdb_id, kind) + await ensure_media(session, tvdb_id, kind, series_id=series_id) item = await list_get_item(session, user_list, tvdb_id, kind) if item: return item diff --git a/src/db_adapters/user.py b/src/db_adapters/user.py index a14029d..cc52170 100644 --- a/src/db_adapters/user.py +++ b/src/db_adapters/user.py @@ -21,12 +21,25 @@ async def user_get_safe(session: AsyncSession, discord_id: int) -> User: return user +async def user_get_list(session: AsyncSession, user: User, name: str) -> UserList | None: + """Get a user's list by name.""" + # use where clause on user.id and name + user_list = await session.execute( + select(UserList) + .where( + UserList.user_id == user.discord_id, + ) + .where(UserList.name == name) + ) + return user_list.scalars().first() + + async def user_create_list(session: AsyncSession, user: User, name: str, item_kind: UserListKind) -> UserList: """Create a new list for a user. :raises ValueError: If a list with the same name already exists for the user. """ - if await session.get(UserList, (user.discord_id, name)) is not None: + if await user_get_list(session, user, name) is not None: raise ValueError(f"List with name {name} already exists for user {user.discord_id}.") user_list = UserList(user_id=user.discord_id, name=name, item_kind=item_kind) session.add(user_list) @@ -36,19 +49,6 @@ async def user_create_list(session: AsyncSession, user: User, name: str, item_ki return user_list -async def user_get_list(session: AsyncSession, user: User, name: str) -> UserList | None: - """Get a user's list by name.""" - # use where clause on user.id and name - user_list = await session.execute( - select(UserList) - .where( - UserList.user_id == user.discord_id, - ) - .where(UserList.name == name) - ) - return user_list.scalars().first() - - async def user_get_list_safe( session: AsyncSession, user: User, name: str, kind: UserListKind = UserListKind.MEDIA ) -> UserList: diff --git a/src/exts/tvdb_info/ui.py b/src/exts/tvdb_info/ui.py index 05af03d..9183385 100644 --- a/src/exts/tvdb_info/ui.py +++ b/src/exts/tvdb_info/ui.py @@ -8,7 +8,9 @@ from src.db_adapters import ( get_list_item, list_put_item, + list_put_item_safe, list_remove_item, + list_remove_item_safe, refresh_list_items, user_get_list_safe, user_get_safe, @@ -52,6 +54,7 @@ class ReactiveButton[V: discord.ui.View](NamedTuple): class _ReactiveView(discord.ui.View, ABC): _reactive_buttons_store: list[ReactiveButton[Self]] | None = None bot: Bot + refreshing: bool = False async def _update_states(self) -> None: for button, item, active_state, inactive_state in await self._reactive_buttons: @@ -360,8 +363,6 @@ def __init__(self, bot: Bot, user_id: int, results: Sequence[Movie | Series]) -> @property @override async def _reactive_buttons(self) -> list[ReactiveButton[Self]]: - if self._reactive_buttons_store: - return self._reactive_buttons_store return [ ReactiveButton( self.watched_btn, @@ -401,6 +402,23 @@ def _current_result(self) -> Movie | Series: @property async def _current_watched_list_item(self) -> UserListItem | None: + if ( + isinstance(self._current_result, Series) + and self._current_result.episodes + and self._current_result.episodes[-1].id + ): + if ( + not self._current_watched_list_item_store + or self._current_watched_list_item_store.tvdb_id != self._current_result.episodes[-1].id + or self.refreshing + ): + self._current_watched_list_item_store = await get_list_item( + self.bot.db_session, + self.watched_list, + self._current_result.episodes[-1].id, + UserListItemKind.EPISODE, + ) + return self._current_watched_list_item_store return await self._current_list_item( self.watched_list, self._current_watched_list_item_store, self._current_result.id, self._current_kind ) @@ -439,6 +457,9 @@ def _get_embed(self) -> discord.Embed: @override async def _update_states(self) -> None: + self.refreshing = True + if isinstance(self._current_result, Series): + await self._current_result.ensure_seasons_and_episodes() if not hasattr(self, "user"): self.user = await user_get_safe(self.bot.db_session, self.user_id) if not hasattr(self, "watched_list"): @@ -449,6 +470,7 @@ async def _update_states(self) -> None: await refresh_list_items(self.bot.db_session, self.favorite_list) await super()._update_states() + self.refreshing = False async def _dropdown_callback(self, interaction: discord.Interaction) -> None: if not self.dropdown.values or not isinstance(self.dropdown.values[0], str): @@ -486,8 +508,28 @@ async def _mark_as_watched_callback(self, interaction: discord.Interaction) -> N # `defer` technically produces a response to the interaction, and allows us not to respond to the interaction # to make the interface feel more intuitive, avoiding unnecessary responses. await interaction.response.defer() - - await self._mark_callback(self.watched_list, await self._current_watched_list_item) + if isinstance(self._current_result, Movie): + await self._mark_callback(self.watched_list, await self._current_watched_list_item) + elif await self._current_watched_list_item: + for episode in self._current_result.episodes: # pyright: ignore [reportOptionalIterable] + if not episode.id: + continue + await list_remove_item_safe( + self.bot.db_session, self.watched_list, episode.id, UserListItemKind.EPISODE + ) + await refresh_list_items(self.bot.db_session, self.watched_list) + await self._current_watched_list_item + elif self._current_result.episodes and self._current_result.episodes[-1].id: + for episode in self._current_result.episodes: + if not episode.id: + continue + await list_put_item_safe( + self.bot.db_session, + self.watched_list, + episode.id, + UserListItemKind.EPISODE, + self._current_result.id, + ) await self._refresh() From bbc1855a17b87140a3935d9b12a18cb65fa8ab01 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 27 Jul 2024 18:59:07 +0200 Subject: [PATCH 136/166] Fix overload declaration for Meida.fetch --- src/tvdb/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tvdb/client.py b/src/tvdb/client.py index d4900a6..636ac14 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -140,7 +140,7 @@ async def fetch( media_id: int | str, client: "TvdbClient", *, - extended: Literal[False], + extended: Literal[False] = False, short: Literal[False] | None = None, meta: None = None, ) -> Self: ... From 0920b9c1f69d1bef10ff1c13636d751c6a70a96d Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 27 Jul 2024 18:59:52 +0200 Subject: [PATCH 137/166] Mark _ReactiveView classes as final --- src/exts/tvdb_info/ui.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/exts/tvdb_info/ui.py b/src/exts/tvdb_info/ui.py index 689428f..8d92c44 100644 --- a/src/exts/tvdb_info/ui.py +++ b/src/exts/tvdb_info/ui.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from collections.abc import Sequence -from typing import NamedTuple, Self, TYPE_CHECKING, TypedDict, override +from typing import NamedTuple, Self, TYPE_CHECKING, TypedDict, final, override import discord @@ -101,6 +101,7 @@ async def _current_list_item( return store +@final class EpisodeView(_ReactiveView): """View for displaying episodes of a series and interacting with them.""" @@ -302,6 +303,7 @@ async def _season_dropdown_callback(self, interaction: discord.Interaction) -> N await interaction.response.defer() +@final class InfoView(_ReactiveView): """View for displaying information about a movie or series and interacting with it.""" From 342f5292035017803c6166ffcdd71d2a92b8e997 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 27 Jul 2024 19:00:36 +0200 Subject: [PATCH 138/166] Basic /profile support --- src/exts/tvdb_info/main.py | 26 ++++++++-- src/exts/tvdb_info/ui.py | 97 +++++++++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 4 deletions(-) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 7c02e8a..555f7f8 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -1,6 +1,6 @@ -from typing import Literal +from typing import Literal, cast -from discord import ApplicationContext, Cog, option, slash_command +from discord import ApplicationContext, Cog, Member, User, option, slash_command from src.bot import Bot from src.tvdb import FetchMeta, Movie, Series, TvdbClient @@ -8,7 +8,7 @@ from src.utils.log import get_logger from src.utils.ratelimit import rate_limited -from .ui import InfoView +from .ui import InfoView, ProfileView log = get_logger(__name__) @@ -23,6 +23,26 @@ def __init__(self, bot: Bot) -> None: self.bot = bot self.tvdb_client = TvdbClient(self.bot.http_session, self.bot.cache) + @slash_command() + @option("user", input_type=User, description="The user to show the profile for.", required=False) + async def profile(self, ctx: ApplicationContext, *, user: User | Member | None = None) -> None: + """Show a user's profile.""" + await ctx.defer() + + if user is None: + user = cast(User | Member, ctx.user) # for some reason, pyright thinks user can be None here + + # Convert Member to User (Member isn't a subclass of User...) + if isinstance(user, Member): + user = user._user # pyright: ignore[reportPrivateUsage] + + # TODO: Friend check (don't allow looking at other people's profiles, unless + # they are friends with the user, or it's their own profile) + # https://github.com/ItsDrike/code-jam-2024/issues/51 + + view = ProfileView(self.bot, self.tvdb_client, user) + await view.send(ctx.interaction) + @slash_command() @option("query", input_type=str, description="The query to search for.") @option( diff --git a/src/exts/tvdb_info/ui.py b/src/exts/tvdb_info/ui.py index 8d92c44..d579634 100644 --- a/src/exts/tvdb_info/ui.py +++ b/src/exts/tvdb_info/ui.py @@ -1,3 +1,4 @@ +import textwrap from abc import ABC, abstractmethod from collections.abc import Sequence from typing import NamedTuple, Self, TYPE_CHECKING, TypedDict, final, override @@ -18,12 +19,12 @@ from src.db_tables.user_list import UserList, UserListItem, UserListItemKind from src.settings import THETVDB_COPYRIGHT_FOOTER, THETVDB_LOGO from src.tvdb import Movie, Series +from src.tvdb.client import Episode, TvdbClient from src.utils.log import get_logger from src.utils.tvdb import by_season if TYPE_CHECKING: from src.db_tables.user import User - from src.tvdb.client import Episode log = get_logger(__name__) @@ -553,3 +554,97 @@ async def _episode_callback(self, interaction: discord.Interaction) -> None: favorite_list = await user_get_list_safe(self.bot.db_session, self.user, "favorite") view = EpisodeView(self.bot, self.user_id, series, watched_list, favorite_list) await view.send(interaction) + + +@final +class ProfileView(discord.ui.View): + """View for displaying user profiles with data about the user's added shows.""" + + def __init__(self, bot: Bot, tvdb_client: TvdbClient, user: discord.User) -> None: + super().__init__(timeout=None) + self.bot = bot + self.tvdb_client = tvdb_client + self.discord_user = user + self.user: User + self.watched_list: UserList + self.favorite_list: UserList + + self.watched_items: list[Episode | Series | Movie] + self.favorite_items: list[Episode | Series | Movie] + + def _get_embed(self) -> discord.Embed: + embed = discord.Embed( + title="Profile", + description=f"Profile for {self.discord_user.mention}", + color=discord.Color.blurple(), + thumbnail=self.discord_user.display_avatar.url, + ) + + total_movies = len([item for item in self.watched_items if isinstance(item, Movie)]) + total_series = len([item for item in self.watched_items if isinstance(item, Series)]) + total_episodes = len([item for item in self.watched_items if isinstance(item, Episode)]) + stats_str = textwrap.dedent( + f""" + **Total Shows:** {total_series} ({total_episodes} episode{'s' if total_episodes > 0 else ''}) + **Total Movies:** {total_movies} + """ + ) + embed.add_field(name="Stats", value=stats_str, inline=False) + + # TODO: This currently skips showing episodes, however, the entire system of series + # getting marked as watched should be reworked to use the latest episode in the series + # which will then need to be handled here. Currently, since a series can be marked + # as watched regardless of the status of its episodes, just use the series. + + # TODO: What if there's too many things here, we might need to paginate this. + + embed.add_field( + name="Favorites", + value="\n".join( + f"{MOVIE_EMOJI if isinstance(item, Movie) else SERIES_EMOJI} {item.bilingual_name}" + for item in self.favorite_items + if not isinstance(item, Episode) + ), + ) + embed.add_field( + name="Watched", + value="\n".join( + f"{MOVIE_EMOJI if isinstance(item, Movie) else SERIES_EMOJI} {item.bilingual_name}" + for item in self.watched_items + if not isinstance(item, Episode) + ), + ) + return embed + + async def _fetch_media(self, item: UserListItem) -> Episode | Series | Movie: + """Fetch given user list item from database.""" + match item.kind: + case UserListItemKind.EPISODE: + return await Episode.fetch(item.tvdb_id, client=self.tvdb_client) + case UserListItemKind.MOVIE: + return await Movie.fetch(item.tvdb_id, client=self.tvdb_client) + case UserListItemKind.SERIES: + return await Series.fetch(item.tvdb_id, client=self.tvdb_client) + + async def _update_states(self) -> None: + if not hasattr(self, "user"): + self.user = await user_get_safe(self.bot.db_session, self.discord_user.id) + + # TODO: Currently, this will result in a lot of API calls and we really just need + # the names of the items. We should consider storing those in the database directly. + # (note: what if the name changes? can that even happen?) + + if not hasattr(self, "watched_items"): + self.watched_list = await user_get_list_safe(self.bot.db_session, self.user, "watched") + await refresh_list_items(self.bot.db_session, self.watched_list) + self.watched_items = [await self._fetch_media(item) for item in self.watched_list.items] + + if not hasattr(self, "favorite_list"): + self.favorite_list = await user_get_list_safe(self.bot.db_session, self.user, "favorite") + await refresh_list_items(self.bot.db_session, self.favorite_list) + self.favorite_items = [await self._fetch_media(item) for item in self.favorite_list.items] + + async def send(self, interaction: discord.Interaction) -> None: + """Send the view.""" + await self._update_states() + await interaction.respond(embed=self._get_embed(), view=self) From 0501bc7be79cb85a1965da24861015fe9943f774 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 28 Jul 2024 00:05:45 +0200 Subject: [PATCH 139/166] Bump pylint max-args --- pyproject.toml | 2 +- src/utils/ratelimit.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 38c6ec0..5a24573 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,7 +151,7 @@ lines-between-types = 0 # Number of lines to place between "direct" and split-on-trailing-comma = false # if last member of multiline import has a comma, don't fold it to single line [tool.ruff.lint.pylint] -max-args = 6 +max-args = 15 max-branches = 15 max-locals = 15 max-nested-blocks = 5 diff --git a/src/utils/ratelimit.py b/src/utils/ratelimit.py index 624aaf7..0702cc4 100644 --- a/src/utils/ratelimit.py +++ b/src/utils/ratelimit.py @@ -15,7 +15,7 @@ class RateLimitExceededError(Exception): """Exception raised when a rate limit was exceeded.""" - def __init__( # noqa: PLR0913 + def __init__( self, msg: str | None, *, From 921ffe78901d778648a1d547c1a84f0178702ed0 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 28 Jul 2024 01:14:05 +0200 Subject: [PATCH 140/166] Support using episodes in list_put_item_safe --- src/db_adapters/lists.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/db_adapters/lists.py b/src/db_adapters/lists.py index b82a6fd..2b585f3 100644 --- a/src/db_adapters/lists.py +++ b/src/db_adapters/lists.py @@ -67,6 +67,25 @@ async def list_remove_item_safe( await list_remove_item(session, user_list, item) +@overload +async def list_put_item_safe( + session: AsyncSession, + user_list: UserList, + tvdb_id: int, + kind: Literal[UserListItemKind.MOVIE, UserListItemKind.SERIES], +) -> UserListItem: ... + + +@overload +async def list_put_item_safe( + session: AsyncSession, + user_list: UserList, + tvdb_id: int, + kind: Literal[UserListItemKind.EPISODE], + series_id: int, +) -> UserListItem: ... + + async def list_put_item_safe( session: AsyncSession, user_list: UserList, tvdb_id: int, kind: UserListItemKind, series_id: int | None = None ) -> UserListItem: From 207570b70b1f58cf66f5bacc84a380a0981b226f Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 28 Jul 2024 04:28:35 +0200 Subject: [PATCH 141/166] Completely rework tvdb_info ui classes --- src/exts/tvdb_info/main.py | 4 +- src/exts/tvdb_info/ui.py | 650 --------------------- src/exts/tvdb_info/ui/__init__.py | 12 + src/exts/tvdb_info/ui/_media_view.py | 184 ++++++ src/exts/tvdb_info/ui/_reactive_buttons.py | 75 +++ src/exts/tvdb_info/ui/episode_view.py | 203 +++++++ src/exts/tvdb_info/ui/movie_series_view.py | 264 +++++++++ src/exts/tvdb_info/ui/profile_view.py | 113 ++++ src/exts/tvdb_info/ui/search_view.py | 81 +++ 9 files changed, 934 insertions(+), 652 deletions(-) delete mode 100644 src/exts/tvdb_info/ui.py create mode 100644 src/exts/tvdb_info/ui/__init__.py create mode 100644 src/exts/tvdb_info/ui/_media_view.py create mode 100644 src/exts/tvdb_info/ui/_reactive_buttons.py create mode 100644 src/exts/tvdb_info/ui/episode_view.py create mode 100644 src/exts/tvdb_info/ui/movie_series_view.py create mode 100644 src/exts/tvdb_info/ui/profile_view.py create mode 100644 src/exts/tvdb_info/ui/search_view.py diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 555f7f8..ab3a5db 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -8,7 +8,7 @@ from src.utils.log import get_logger from src.utils.ratelimit import rate_limited -from .ui import InfoView, ProfileView +from .ui import ProfileView, search_view log = get_logger(__name__) @@ -100,7 +100,7 @@ async def search( await ctx.respond("No results found.") return - view = InfoView(self.bot, ctx.user.id, response) + view = await search_view(self.bot, ctx.user.id, response) await view.send(ctx.interaction) diff --git a/src/exts/tvdb_info/ui.py b/src/exts/tvdb_info/ui.py deleted file mode 100644 index d579634..0000000 --- a/src/exts/tvdb_info/ui.py +++ /dev/null @@ -1,650 +0,0 @@ -import textwrap -from abc import ABC, abstractmethod -from collections.abc import Sequence -from typing import NamedTuple, Self, TYPE_CHECKING, TypedDict, final, override - -import discord - -from src.bot import Bot -from src.db_adapters import ( - get_list_item, - list_put_item, - list_put_item_safe, - list_remove_item, - list_remove_item_safe, - refresh_list_items, - user_get_list_safe, - user_get_safe, -) -from src.db_tables.user_list import UserList, UserListItem, UserListItemKind -from src.settings import THETVDB_COPYRIGHT_FOOTER, THETVDB_LOGO -from src.tvdb import Movie, Series -from src.tvdb.client import Episode, TvdbClient -from src.utils.log import get_logger -from src.utils.tvdb import by_season - -if TYPE_CHECKING: - from src.db_tables.user import User - -log = get_logger(__name__) - -MOVIE_EMOJI = "🎬" -SERIES_EMOJI = "📺" - - -class ReactiveButtonState(TypedDict): - """Type definition for a reactive button state.""" - - label: str - style: discord.ButtonStyle - emoji: str - - -class ReactiveButton[V: discord.ui.View](NamedTuple): - """A tuple of the button, the item in the list, the state when active, and the state when inactive. - - This allows us to programmatically change the button's appearance based on the state of the item in the list. - """ - - button: discord.ui.Button[V] - item: UserListItem | None - active_state: ReactiveButtonState - inactive_state: ReactiveButtonState - - -class _ReactiveView(discord.ui.View, ABC): - _reactive_buttons_store: list[ReactiveButton[Self]] | None = None - bot: Bot - refreshing: bool = False - - async def _update_states(self) -> None: - for button, item, active_state, inactive_state in await self._reactive_buttons: - if item: - button.style = inactive_state["style"] - button.label = inactive_state["label"] - button.emoji = inactive_state["emoji"] - else: - button.style = active_state["style"] - button.label = active_state["label"] - button.emoji = active_state["emoji"] - - async def _refresh(self) -> None: - await self._update_states() - if not self.message: - raise ValueError("Message is not set but refresh was called.") - await self.message.edit(embed=self._get_embed(), view=self) - - @abstractmethod - def _get_embed(self) -> discord.Embed: - """Get the embed for the view.""" - - @abstractmethod - async def send(self, interaction: discord.Interaction) -> None: - """Send the view.""" - - @property - @abstractmethod - async def _reactive_buttons(self) -> list[ReactiveButton[Self]]: - """Get the reactive buttons.""" - - async def _current_list_item( - self, - user_list: UserList, - store: UserListItem | None, - tvdb_id: int | None, - kind: UserListItemKind, - ) -> UserListItem | None: - """Get the current list item from the store or the database.""" - if not tvdb_id: - raise ValueError("TVDB ID is not set but callback was triggered.") - if not (store and store.tvdb_id == tvdb_id): - store = await get_list_item(self.bot.db_session, user_list, tvdb_id, kind) - return store - - -@final -class EpisodeView(_ReactiveView): - """View for displaying episodes of a series and interacting with them.""" - - def __init__( - self, bot: Bot, user_id: int, series: Series, watched_list: UserList, favorite_list: UserList - ) -> None: - super().__init__(timeout=None) - self.bot = bot - self.user_id = user_id - self.series = series - self.user: User - self.watched_list = watched_list - self.favorite_list = favorite_list - self.episodes: dict[int, list[Episode]] - self.season_idx: int = 1 - self.episode_idx: int = 1 - - self._reactive_buttons_store: list[ReactiveButton[Self]] | None = None - self._current_watched_list_item_store: UserListItem | None = None - self._current_favorite_list_item_store: UserListItem | None = None - - self.episode_dropdown = discord.ui.Select( - placeholder="Select an episode", - ) - self.episode_dropdown.callback = self._episode_dropdown_callback - self.add_item(self.episode_dropdown) - - self.season_dropdown = discord.ui.Select( - placeholder="Select a season", - ) - self.season_dropdown.callback = self._season_dropdown_callback - self.add_item(self.season_dropdown) - - self.watched_btn = discord.ui.Button( - row=2, - ) - self.watched_btn.callback = self._watched_callback - self.add_item(self.watched_btn) - - self.favorite_btn = discord.ui.Button( - row=2, - ) - self.favorite_btn.callback = self._favorite_callback - self.add_item(self.favorite_btn) - - @property - @override - async def _reactive_buttons(self) -> list[ReactiveButton[Self]]: - if self._reactive_buttons_store: - return self._reactive_buttons_store - return [ - ReactiveButton( - self.watched_btn, - await self._current_watched_list_item, - { - "label": "Mark as watched", - "style": discord.ButtonStyle.success, - "emoji": "✅", - }, - { - "label": "Unmark as watched", - "style": discord.ButtonStyle.primary, - "emoji": "❌", - }, - ), - ReactiveButton( - self.favorite_btn, - await self._current_favorite_list_item, - { - "label": "Favorite", - "style": discord.ButtonStyle.primary, - "emoji": "⭐", - }, - { - "label": "Unfavorite", - "style": discord.ButtonStyle.secondary, - "emoji": "❌", - }, - ), - ] - - @property - def _current_episode(self) -> "Episode": - return self.episodes[self.season_idx][self.episode_idx - 1] - - @property - async def _current_watched_list_item(self) -> UserListItem | None: - return await self._current_list_item( - self.watched_list, - self._current_watched_list_item_store, - self._current_episode.id, - UserListItemKind.EPISODE, - ) - - @property - async def _current_favorite_list_item(self) -> UserListItem | None: - return await self._current_list_item( - self.favorite_list, - self._current_favorite_list_item_store, - self._current_episode.id, - UserListItemKind.EPISODE, - ) - - async def _mark_callback(self, user_list: UserList, item: UserListItem | None) -> bool: - """Mark or unmark an item in a list. - - :param user_list: - :param item: - :return: - """ - if item: - await list_remove_item(self.bot.db_session, user_list, item) - return False - if not self._current_episode.id: - raise ValueError("Current episode has no ID but callback was triggered.") - await list_put_item( - self.bot.db_session, - user_list, - self._current_episode.id, - UserListItemKind.EPISODE, - self.series.id, - ) - return True - - async def _watched_callback(self, interaction: discord.Interaction) -> None: - """Callback for marking an episode as watched.""" - await interaction.response.defer() - - await self._mark_callback(self.watched_list, await self._current_watched_list_item) - - await self._refresh() - - async def _favorite_callback(self, interaction: discord.Interaction) -> None: - """Callback for favoriting an episode.""" - await interaction.response.defer() - - await self._mark_callback(self.favorite_list, await self._current_favorite_list_item) - - await self._refresh() - - @override - async def _update_states(self) -> None: - if not hasattr(self, "user"): - self.user = await user_get_safe(self.bot.db_session, self.user_id) - await refresh_list_items(self.bot.db_session, self.watched_list) - await refresh_list_items(self.bot.db_session, self.favorite_list) - - if self.series.episodes: - self.episodes = by_season(self.series.episodes) - else: - raise ValueError("Series has no episodes.") - - self.episode_dropdown.options = [ - discord.SelectOption( - label=episode.formatted_name, - value=str(episode.number), - description=episode.overview[:100] if episode.overview else None, - ) - for episode in self.episodes[self.season_idx] - ] - self.season_dropdown.options = [ - discord.SelectOption(label=f"Season {season}", value=str(season)) for season in self.episodes - ] - - await super()._update_states() - - @override - def _get_embed(self) -> discord.Embed: - embed = discord.Embed( - title=self._current_episode.formatted_name, - description=self._current_episode.overview, - color=discord.Color.blurple(), - url=f"https://www.thetvdb.com/series/{self.series.slug}", - ) - embed.set_image(url=f"https://www.thetvdb.com{self._current_episode.image}") - embed.set_footer(text=THETVDB_COPYRIGHT_FOOTER, icon_url=THETVDB_LOGO) - return embed - - @override - async def send(self, interaction: discord.Interaction) -> None: - """Send the view.""" - await self.series.ensure_seasons_and_episodes() - await self._update_states() - await interaction.respond(embed=self._get_embed(), view=self) - - async def _episode_dropdown_callback(self, interaction: discord.Interaction) -> None: - if not self.episode_dropdown.values or not isinstance(self.episode_dropdown.values[0], str): - raise ValueError("Episode dropdown values are empty or not a string but callback was triggered.") - self.episode_idx = int(self.episode_dropdown.values[0]) - await self._refresh() - await interaction.response.defer() - - async def _season_dropdown_callback(self, interaction: discord.Interaction) -> None: - if not self.season_dropdown.values or not isinstance(self.season_dropdown.values[0], str): - raise ValueError("Season dropdown values are empty or not a string but callback was triggered.") - self.season_idx = int(self.season_dropdown.values[0]) - self.episode_idx = 1 - await self._refresh() - await interaction.response.defer() - - -@final -class InfoView(_ReactiveView): - """View for displaying information about a movie or series and interacting with it.""" - - def __init__(self, bot: Bot, user_id: int, results: Sequence[Movie | Series]) -> None: - super().__init__(disable_on_timeout=True) - self.index = 0 # the index of the current result - self.results = results - self.bot = bot - self.user_id = user_id - self.user: User - self.watched_list: UserList - self.favorite_list: UserList - - self._current_watched_list_item_store: UserListItem | None = None - self._current_favorite_list_item_store: UserListItem | None = None - - if len(self.results) > 1: - self.dropdown = discord.ui.Select( - placeholder="Not what you're looking for? Select a different result.", - options=[ - discord.SelectOption( - label=(result.bilingual_name or "")[:100], - value=str(i), - description=result.overview[:100] if result.overview else None, - ) - for i, result in enumerate(self.results) - ], - ) - self.dropdown.callback = self._dropdown_callback - self.add_item(self.dropdown) - - self.watched_btn = discord.ui.Button( - # styles are set by the reactive button system and omitted here - row=1, - ) - self.watched_btn.callback = self._mark_as_watched_callback - self.add_item(self.watched_btn) - - self.favorite_btn = discord.ui.Button( - row=1, - ) - self.favorite_btn.callback = self._favorite_callback - self.add_item(self.favorite_btn) - self._reactive_buttons_store: list[ReactiveButton[Self]] | None = None - - self.episodes_btn: discord.ui.Button[InfoView] | None = None - - if isinstance(self._current_result, Series): - self.episodes_btn = discord.ui.Button( - style=discord.ButtonStyle.danger, - label="View episodes", - emoji="📺", - row=1, - ) - self.add_item(self.episodes_btn) - - self.episodes_btn.callback = self._episode_callback - - @property - @override - async def _reactive_buttons(self) -> list[ReactiveButton[Self]]: - return [ - ReactiveButton( - self.watched_btn, - await self._current_watched_list_item, - { - "label": "Mark all episodes as watched", - "style": discord.ButtonStyle.success, - "emoji": "✅", - }, - { - "label": "Unmark all episodes as watched", - # We avoid using .danger because of bad styling in conjunction with the emoji. - # It could be possible to use .danger, but would have to use an emoji that fits better. - "style": discord.ButtonStyle.primary, - "emoji": "❌", - }, - ), - ReactiveButton( - self.favorite_btn, - await self._current_favorite_list_item, - { - "label": "Favorite", - "style": discord.ButtonStyle.primary, - "emoji": "⭐", - }, - { - "label": "Unfavorite", - "style": discord.ButtonStyle.secondary, - "emoji": "❌", - }, - ), - ] - - @property - def _current_result(self) -> Movie | Series: - return self.results[self.index] - - @property - async def _current_watched_list_item(self) -> UserListItem | None: - if ( - isinstance(self._current_result, Series) - and self._current_result.episodes - and self._current_result.episodes[-1].id - ): - if ( - not self._current_watched_list_item_store - or self._current_watched_list_item_store.tvdb_id != self._current_result.episodes[-1].id - or self.refreshing - ): - self._current_watched_list_item_store = await get_list_item( - self.bot.db_session, - self.watched_list, - self._current_result.episodes[-1].id, - UserListItemKind.EPISODE, - ) - return self._current_watched_list_item_store - return await self._current_list_item( - self.watched_list, self._current_watched_list_item_store, self._current_result.id, self._current_kind - ) - - @property - async def _current_favorite_list_item(self) -> UserListItem | None: - return await self._current_list_item( - self.favorite_list, self._current_favorite_list_item_store, self._current_result.id, self._current_kind - ) - - @property - def _current_kind(self) -> UserListItemKind: - return UserListItemKind.MOVIE if self._current_result.entity_type == "Movie" else UserListItemKind.SERIES - - @override - def _get_embed(self) -> discord.Embed: - result = self._current_result - if result.overview_eng: - overview = f"{result.overview_eng}" - elif not result.overview_eng and result.overview: - overview = f"{result.overview}\n\n*No English overview available.*" - else: - overview = "*No overview available.*" - title = result.bilingual_name - if result.entity_type == "Movie": - title = f"{MOVIE_EMOJI} {title}" - url = f"https://www.thetvdb.com/movies/{result.slug}" - else: - title = f"{SERIES_EMOJI} {title}" - url = f"https://www.thetvdb.com/series/{result.slug}" - embed = discord.Embed(title=title, color=discord.Color.blurple(), url=url) - embed.add_field(name="Overview", value=overview, inline=False) - embed.set_footer(text=THETVDB_COPYRIGHT_FOOTER, icon_url=THETVDB_LOGO) - embed.set_image(url=result.image_url) - return embed - - @override - async def _update_states(self) -> None: - self.refreshing = True - if isinstance(self._current_result, Series): - await self._current_result.ensure_seasons_and_episodes() - if not hasattr(self, "user"): - self.user = await user_get_safe(self.bot.db_session, self.user_id) - if not hasattr(self, "watched_list"): - self.watched_list = await user_get_list_safe(self.bot.db_session, self.user, "watched") - await refresh_list_items(self.bot.db_session, self.watched_list) - if not hasattr(self, "favorite_list"): - self.favorite_list = await user_get_list_safe(self.bot.db_session, self.user, "favorite") - await refresh_list_items(self.bot.db_session, self.favorite_list) - - await super()._update_states() - self.refreshing = False - - async def _dropdown_callback(self, interaction: discord.Interaction) -> None: - if not self.dropdown.values or not isinstance(self.dropdown.values[0], str): - raise ValueError("Dropdown values are empty or not a string but callback was triggered.") - self.index = int(self.dropdown.values[0]) - await self._refresh() - await interaction.response.defer() - - @override - async def send(self, interaction: discord.Interaction) -> None: - """Send the view.""" - await self._update_states() - await interaction.respond(embed=self._get_embed(), view=self) - - async def _mark_callback(self, user_list: UserList, item: UserListItem | None) -> bool: - """Mark or unmark an item in a list. - - :param user_list: - :param item: - :return: - """ - if item: - await list_remove_item(self.bot.db_session, user_list, item) - return False - await list_put_item( - self.bot.db_session, - user_list, - self._current_result.id, - self._current_kind, # pyright: ignore[reportArgumentType] - ) - return True - - async def _mark_as_watched_callback(self, interaction: discord.Interaction) -> None: - """Callback for marking a movie or series as watched.""" - # `defer` technically produces a response to the interaction, and allows us not to respond to the interaction - # to make the interface feel more intuitive, avoiding unnecessary responses. - await interaction.response.defer() - if isinstance(self._current_result, Movie): - await self._mark_callback(self.watched_list, await self._current_watched_list_item) - elif await self._current_watched_list_item: - for episode in self._current_result.episodes: # pyright: ignore [reportOptionalIterable] - if not episode.id: - continue - await list_remove_item_safe( - self.bot.db_session, self.watched_list, episode.id, UserListItemKind.EPISODE - ) - await refresh_list_items(self.bot.db_session, self.watched_list) - await self._current_watched_list_item - elif self._current_result.episodes and self._current_result.episodes[-1].id: - for episode in self._current_result.episodes: - if not episode.id: - continue - await list_put_item_safe( - self.bot.db_session, - self.watched_list, - episode.id, - UserListItemKind.EPISODE, - self._current_result.id, - ) - - await self._refresh() - - async def _favorite_callback(self, interaction: discord.Interaction) -> None: - """Callback for favoriting a movie or series.""" - await interaction.response.defer() - - await self._mark_callback(self.favorite_list, await self._current_favorite_list_item) - - await self._refresh() - - async def _episode_callback(self, interaction: discord.Interaction) -> None: - """Callback for viewing episodes of a series.""" - await interaction.response.defer() - series = self._current_result - if not isinstance(series, Series): - raise TypeError("Current result is not a series but callback was triggered.") - watched_list = await user_get_list_safe(self.bot.db_session, self.user, "watched") - favorite_list = await user_get_list_safe(self.bot.db_session, self.user, "favorite") - view = EpisodeView(self.bot, self.user_id, series, watched_list, favorite_list) - await view.send(interaction) - - -@final -class ProfileView(discord.ui.View): - """View for displaying user profiles with data about the user's added shows.""" - - def __init__(self, bot: Bot, tvdb_client: TvdbClient, user: discord.User) -> None: - super().__init__(timeout=None) - self.bot = bot - self.tvdb_client = tvdb_client - self.discord_user = user - self.user: User - self.watched_list: UserList - self.favorite_list: UserList - - self.watched_items: list[Episode | Series | Movie] - self.favorite_items: list[Episode | Series | Movie] - - def _get_embed(self) -> discord.Embed: - embed = discord.Embed( - title="Profile", - description=f"Profile for {self.discord_user.mention}", - color=discord.Color.blurple(), - thumbnail=self.discord_user.display_avatar.url, - ) - - total_movies = len([item for item in self.watched_items if isinstance(item, Movie)]) - total_series = len([item for item in self.watched_items if isinstance(item, Series)]) - total_episodes = len([item for item in self.watched_items if isinstance(item, Episode)]) - stats_str = textwrap.dedent( - f""" - **Total Shows:** {total_series} ({total_episodes} episode{'s' if total_episodes > 0 else ''}) - **Total Movies:** {total_movies} - """ - ) - embed.add_field(name="Stats", value=stats_str, inline=False) - - # TODO: This currently skips showing episodes, however, the entire system of series - # getting marked as watched should be reworked to use the latest episode in the series - # which will then need to be handled here. Currently, since a series can be marked - # as watched regardless of the status of its episodes, just use the series. - - # TODO: What if there's too many things here, we might need to paginate this. - - embed.add_field( - name="Favorites", - value="\n".join( - f"{MOVIE_EMOJI if isinstance(item, Movie) else SERIES_EMOJI} {item.bilingual_name}" - for item in self.favorite_items - if not isinstance(item, Episode) - ), - ) - embed.add_field( - name="Watched", - value="\n".join( - f"{MOVIE_EMOJI if isinstance(item, Movie) else SERIES_EMOJI} {item.bilingual_name}" - for item in self.watched_items - if not isinstance(item, Episode) - ), - ) - return embed - - async def _fetch_media(self, item: UserListItem) -> Episode | Series | Movie: - """Fetch given user list item from database.""" - match item.kind: - case UserListItemKind.EPISODE: - return await Episode.fetch(item.tvdb_id, client=self.tvdb_client) - case UserListItemKind.MOVIE: - return await Movie.fetch(item.tvdb_id, client=self.tvdb_client) - case UserListItemKind.SERIES: - return await Series.fetch(item.tvdb_id, client=self.tvdb_client) - - async def _update_states(self) -> None: - if not hasattr(self, "user"): - self.user = await user_get_safe(self.bot.db_session, self.discord_user.id) - - # TODO: Currently, this will result in a lot of API calls and we really just need - # the names of the items. We should consider storing those in the database directly. - # (note: what if the name changes? can that even happen?) - - if not hasattr(self, "watched_items"): - self.watched_list = await user_get_list_safe(self.bot.db_session, self.user, "watched") - await refresh_list_items(self.bot.db_session, self.watched_list) - self.watched_items = [await self._fetch_media(item) for item in self.watched_list.items] - - if not hasattr(self, "favorite_list"): - self.favorite_list = await user_get_list_safe(self.bot.db_session, self.user, "favorite") - await refresh_list_items(self.bot.db_session, self.favorite_list) - self.favorite_items = [await self._fetch_media(item) for item in self.favorite_list.items] - - async def send(self, interaction: discord.Interaction) -> None: - """Send the view.""" - await self._update_states() - await interaction.respond(embed=self._get_embed(), view=self) diff --git a/src/exts/tvdb_info/ui/__init__.py b/src/exts/tvdb_info/ui/__init__.py new file mode 100644 index 0000000..c4463a1 --- /dev/null +++ b/src/exts/tvdb_info/ui/__init__.py @@ -0,0 +1,12 @@ +from .episode_view import EpisodeView +from .movie_series_view import MovieView, SeriesView +from .profile_view import ProfileView +from .search_view import search_view + +__all__ = [ + "MovieView", + "SeriesView", + "EpisodeView", + "ProfileView", + "search_view", +] diff --git a/src/exts/tvdb_info/ui/_media_view.py b/src/exts/tvdb_info/ui/_media_view.py new file mode 100644 index 0000000..100a7e2 --- /dev/null +++ b/src/exts/tvdb_info/ui/_media_view.py @@ -0,0 +1,184 @@ +from abc import ABC, abstractmethod +from typing import override + +import discord + +from src.bot import Bot +from src.db_tables.user_list import UserList + +from ._reactive_buttons import ReactiveButton, ReactiveButtonStateStyle + + +class MediaView(discord.ui.View, ABC): + """Base class for views that display info about some media (movie/series/episode).""" + + def __init__(self, *, bot: Bot, user_id: int, watched_list: UserList, favorite_list: UserList) -> None: + """Initialize MediaView. + + :param bot: The bot instance. + :param user_id: + Discord ID of the user that invoked this view. + + Only this user will be able to interact with this view and the relevant information + will be tailored towards this user based on their data. (i.e. whether they have already + watched this media / marked it favorite.) + :param watched_list: The list of all watched items for this user. + :param favorite_list: The list of all favorited items for this user. + """ + super().__init__(disable_on_timeout=True) + + self.bot = bot + self.user_id = user_id + self.watched_list = watched_list + self.favorite_list = favorite_list + + self.watched_button = ReactiveButton( + initial_state=False, # This should be updated on _initialize + state_map={ + False: ReactiveButtonStateStyle( + label="Mark as watched", + style=discord.ButtonStyle.success, + emoji="✅", + ), + True: ReactiveButtonStateStyle( + label="Unmark as watched", + style=discord.ButtonStyle.primary, + emoji="❌", + ), + }, + row=1, + ) + self.watched_button.callback = self._watched_button_callback + + self.favorite_button = ReactiveButton( + initial_state=False, # This should be updated on _initialize + state_map={ + False: ReactiveButtonStateStyle( + label="Favorite", + style=discord.ButtonStyle.primary, + emoji="⭐", + ), + True: ReactiveButtonStateStyle( + label="Unfavorite", + style=discord.ButtonStyle.secondary, + emoji="❌", + ), + }, + row=1, + ) + self.favorite_button.callback = self._favorite_button_callback + + def _add_items(self) -> None: + """Add all relevant items to the view.""" + self.add_item(self.watched_button) + self.add_item(self.favorite_button) + + async def _initialize(self) -> None: + """Initialize the view to reflect the current state of the media. + + This will (likely) perform database lookups and other necessary operations to obtain + the current state of the media or the user, configuring the internal state accordingly. + + Tasks that need to be performed here: + - Ensure to run the super call to this method. + - Set the state of the watched and favorite buttons. + + This method will only be called once. + """ + self._add_items() + self.watched_button.set_state(await self.is_watched()) + self.favorite_button.set_state(await self.is_favorite()) + + @abstractmethod + def _get_embed(self) -> discord.Embed: + """Get the discord embed to be displayed in the message. + + This embed should contain all the relevant information about the media. + """ + raise NotImplementedError + + async def _refresh(self) -> None: + """Edit the message to reflect the current state of the view. + + Called whenever the user-facing view needs to be updated. + """ + if not self.message: + raise ValueError("View has no message (not yet sent?), can't refresh") + + await self.message.edit(embed=self._get_embed(), view=self) + + @abstractmethod + async def is_favorite(self) -> bool: + """Check if the current media is marked as favorite by the user. + + This will perform a database query. + """ + raise NotImplementedError + + @abstractmethod + async def set_favorite(self, state: bool) -> None: # noqa: FBT001 + """Mark or unmark the current media as favorite. + + This will perform a database operation. + """ + raise NotImplementedError + + @abstractmethod + async def is_watched(self) -> bool: + """Check if the current media is marked as watched by the user. + + This will perform a database query. + """ + raise NotImplementedError + + @abstractmethod + async def set_watched(self, state: bool) -> None: # noqa: FBT001 + """Mark or unmark the current media as watched. + + This will perform a database operation. + """ + raise NotImplementedError + + async def send(self, interaction: discord.Interaction) -> None: + """Send the view to the user.""" + await self._initialize() + await interaction.respond(embed=self._get_embed(), view=self) + + async def _watched_button_callback(self, interaction: discord.Interaction) -> None: + """Callback for when the user clicks on the mark as watched button.""" + cur_state = self.watched_button.state + await self.set_watched(not cur_state) + self.watched_button.set_state(not cur_state) + + await interaction.response.defer() + await self._refresh() + + async def _favorite_button_callback(self, interaction: discord.Interaction) -> None: + """Callback for when the user clicks on the mark as favorite button.""" + cur_state = self.favorite_button.state + await self.set_favorite(not cur_state) + self.favorite_button.set_state(not cur_state) + + await interaction.response.defer() + await self._refresh() + + +class DynamicMediaView(MediaView): + """Base class for dynamic views`that display info about some media (movie/series/episode). + + A dynamic view is one that can change its state after a user interaction. + For example, a view that displays different episodes based on user selection. + """ + + @abstractmethod + async def _update_state(self) -> None: + """Update the internal state to reflect the currently picked media. + + Called whenever the picked media is changed. + """ + raise NotImplementedError + + @override + async def _initialize(self) -> None: + await super()._initialize() + await self._update_state() diff --git a/src/exts/tvdb_info/ui/_reactive_buttons.py b/src/exts/tvdb_info/ui/_reactive_buttons.py new file mode 100644 index 0000000..93d6a46 --- /dev/null +++ b/src/exts/tvdb_info/ui/_reactive_buttons.py @@ -0,0 +1,75 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, override + +import discord +from discord.emoji import Emoji +from discord.enums import ButtonStyle +from discord.partial_emoji import PartialEmoji + + +@dataclass +class ReactiveButtonStateStyle: + """Style of a reactive button. + + This determines how the button will appear for this state. + """ + + label: str + style: discord.ButtonStyle + emoji: str + + def apply(self, button: discord.ui.Button[Any]) -> None: + """Apply this state to the button.""" + button.label = self.label + button.style = self.style + button.emoji = self.emoji + + +class ReactiveButton[S, V: discord.ui.View](discord.ui.Button[V]): + """A state-aware button, which can change its appearance based on the state it's in. + + This is very useful to quickly switch between various states of a button, such as "Add" and "Remove". + """ + + @override + def __init__( + self, + *, + initial_state: S, + state_map: Mapping[S, ReactiveButtonStateStyle], + style: ButtonStyle = ButtonStyle.secondary, + label: str | None = None, + disabled: bool = False, + custom_id: str | None = None, + url: str | None = None, + emoji: str | Emoji | PartialEmoji | None = None, + sku_id: int | None = None, + row: int | None = None, + ): + super().__init__( + style=style, + label=label, + disabled=disabled, + custom_id=custom_id, + url=url, + emoji=emoji, + sku_id=sku_id, + row=row, + ) + + _state = initial_state + self.state_map = state_map + + @property + def state(self) -> S: + """Get the current state of the button.""" + return self._state + + # This is intentionally not a state.setter, as this makes it clerer + # to the caller that this method changes modifies the button. + def set_state(self, state: S) -> None: + """Set the state of the button.""" + self._state = state + style = self.state_map[state] + style.apply(self) diff --git a/src/exts/tvdb_info/ui/episode_view.py b/src/exts/tvdb_info/ui/episode_view.py new file mode 100644 index 0000000..8347375 --- /dev/null +++ b/src/exts/tvdb_info/ui/episode_view.py @@ -0,0 +1,203 @@ +import warnings +from typing import final, override + +import discord + +from src.bot import Bot +from src.db_adapters.lists import get_list_item, list_put_item, list_remove_item +from src.db_tables.user_list import UserList, UserListItemKind +from src.settings import THETVDB_COPYRIGHT_FOOTER, THETVDB_LOGO +from src.tvdb.client import Episode, Series +from src.utils.tvdb import by_season + +from ._media_view import DynamicMediaView + + +@final +class EpisodeView(DynamicMediaView): + """View for displaying episodes of a series.""" + + def __init__( + self, + *, + bot: Bot, + user_id: int, + watched_list: UserList, + favorite_list: UserList, + series: Series, + season_idx: int = 1, + episode_idx: int = 1, + ) -> None: + super().__init__(bot=bot, user_id=user_id, watched_list=watched_list, favorite_list=favorite_list) + + self.series = series + + self.season_idx = season_idx + self.episode_idx = episode_idx + + self.episode_dropdown = discord.ui.Select(placeholder="Select an episode") + self.episode_dropdown.callback = self._episode_dropdown_callback + + self.season_dropdown = discord.ui.Select(placeholder="Select a season") + self.season_dropdown.callback = self._season_dropdown_callback + + self.watched_button.row = 2 + self.favorite_button.row = 2 + + @override + def _add_items(self) -> None: + self.add_item(self.episode_dropdown) + self.add_item(self.season_dropdown) + super()._add_items() + + # Episodes aren't favoritable + self.remove_item(self.favorite_button) + + @override + async def _initialize(self) -> None: + await self.series.ensure_seasons_and_episodes() + if self.series.episodes is None: + raise ValueError("Series has no episodes") + self.episodes = by_season(self.series.episodes) + + # Make the super call (must happen after we set self.episodes) + # This assumes is_favorite works properly, however, since we don't actually have + # this implemented for episodes, to make this call work, we'll need to temporarily + # set is_favorite to a dummy method. + _old_is_favorite = self.is_favorite + + async def _dummy_is_favorite() -> bool: + return False + + self.is_favorite = _dummy_is_favorite + await super()._initialize() + self.is_favorite = _old_is_favorite + + @override + async def _update_state(self) -> None: + self.episode_dropdown.options = [ + discord.SelectOption( + label=episode.formatted_name, + value=str(episode.number), + description=episode.overview[:100] if episode.overview else None, + ) + for episode in self.episodes[self.season_idx] + ] + + self.season_dropdown.options = [ + discord.SelectOption(label=f"Season {season}", value=str(season)) for season in self.episodes + ] + + # TODO: This is not ideal, we should support some way to paginate this, however + # implementing that isn't trivial. For now, just trim the list to prevent errors. + + if len(self.episode_dropdown.options) > 25: + self.episode_dropdown.options = self.episode_dropdown.options[:25] + warnings.warn("Too many episodes to display, truncating to 25", UserWarning, stacklevel=1) + + if len(self.season_dropdown.options) > 25: + self.season_dropdown.options = self.season_dropdown.options[:25] + warnings.warn("Too many seasons to display, truncating to 25", UserWarning, stacklevel=1) + + @property + def current_episode(self) -> "Episode": + """Get the current episode being displayed.""" + return self.episodes[self.season_idx][self.episode_idx - 1] + + @override + async def is_favorite(self) -> bool: + raise NotImplementedError("Individual episodes cannot be marked as favorite.") + + @override + async def set_favorite(self, state: bool) -> None: + raise NotImplementedError("Individual episodes cannot be marked as favorite.") + + @override + async def is_watched(self) -> bool: + if not self.current_episode.id: + raise ValueError("Episode has no ID") + + item = await get_list_item( + self.bot.db_session, + self.watched_list, + self.current_episode.id, + UserListItemKind.EPISODE, + ) + return item is not None + + @override + async def set_watched(self, state: bool) -> None: + if not self.current_episode.id: + raise ValueError("Episode has no ID") + + if state is False: + item = await get_list_item( + self.bot.db_session, + self.watched_list, + self.current_episode.id, + UserListItemKind.EPISODE, + ) + if item is None: + raise ValueError("Episode is not marked as watched, can't re-mark as unwatched.") + await list_remove_item(self.bot.db_session, self.watched_list, item) + else: + try: + await list_put_item( + self.bot.db_session, + self.watched_list, + self.current_episode.id, + UserListItemKind.EPISODE, + self.series.id, + ) + except ValueError: + raise ValueError("Episode is already marked as watched, can't re-mark as watched.") + + async def _episode_dropdown_callback(self, interaction: discord.Interaction) -> None: + """Callback for when the user selects an episode from the drop-down.""" + if not self.episode_dropdown.values or not isinstance(self.episode_dropdown.values[0], str): + raise ValueError("Episode dropdown values are empty or non-string, but callback was triggered.") + + new_episode_idx = int(self.episode_dropdown.values[0]) + if new_episode_idx == self.episode_idx: + await interaction.response.defer() + return + + self.episode_idx = new_episode_idx + await self._update_state() + await interaction.response.defer() + await self._refresh() + + async def _season_dropdown_callback(self, interaction: discord.Interaction) -> None: + """Callback for when the user selects a season from the drop-down.""" + if not self.season_dropdown.values or not isinstance(self.season_dropdown.values[0], str): + raise ValueError("Episode dropdown values are empty or non-string, but callback was triggered.") + + new_season_idx = int(self.season_dropdown.values[0]) + if new_season_idx == self.season_idx: + await interaction.response.defer() + return + + self.season_idx = new_season_idx + self.episode_idx = 1 + await self._update_state() + await interaction.response.defer() + await self._refresh() + + @override + def _get_embed(self) -> discord.Embed: + if self.current_episode.overview: + description = self.current_episode.overview + if len(description) > 1000: + description = description[:1000] + "..." + else: + description = None + + embed = discord.Embed( + title=self.current_episode.formatted_name, + description=description, + color=discord.Color.blurple(), + url=f"https://www.thetvdb.com/series/{self.series.slug}", + ) + embed.set_image(url=f"https://www.thetvdb.com{self.current_episode.image}") + embed.set_footer(text=THETVDB_COPYRIGHT_FOOTER, icon_url=THETVDB_LOGO) + return embed diff --git a/src/exts/tvdb_info/ui/movie_series_view.py b/src/exts/tvdb_info/ui/movie_series_view.py new file mode 100644 index 0000000..61ad03e --- /dev/null +++ b/src/exts/tvdb_info/ui/movie_series_view.py @@ -0,0 +1,264 @@ +from typing import Literal, final, override + +import discord + +from src.bot import Bot +from src.db_adapters.lists import ( + get_list_item, + list_put_item, + list_put_item_safe, + list_remove_item, + list_remove_item_safe, + refresh_list_items, +) +from src.db_tables.user_list import UserList, UserListItemKind +from src.settings import THETVDB_COPYRIGHT_FOOTER, THETVDB_LOGO +from src.tvdb.client import Movie, Series + +from ._media_view import MediaView +from .episode_view import EpisodeView + +MOVIE_EMOJI = "🎬" +SERIES_EMOJI = "📺" + + +class _SeriesOrMovieView(MediaView): + """View for displaying details about a movie or a series.""" + + @override + def __init__( + self, + *, + bot: Bot, + user_id: int, + watched_list: UserList, + favorite_list: UserList, + media_data: Movie | Series, + ) -> None: + super().__init__(bot=bot, user_id=user_id, watched_list=watched_list, favorite_list=favorite_list) + + self.media_data = media_data + + @property + def _db_item_kind(self) -> Literal[UserListItemKind.MOVIE, UserListItemKind.SERIES]: + """Return the kind of item this view represents.""" + if isinstance(self.media_data, Series): + return UserListItemKind.SERIES + return UserListItemKind.MOVIE + + @override + async def is_favorite(self) -> bool: + if not self.media_data.id: + raise ValueError("Media has no ID") + + item = await get_list_item(self.bot.db_session, self.favorite_list, self.media_data.id, self._db_item_kind) + return item is not None + + @override + async def set_favorite(self, state: bool) -> None: + if not self.media_data.id: + raise ValueError("Media has no ID") + + if state is False: + item = await get_list_item(self.bot.db_session, self.favorite_list, self.media_data.id, self._db_item_kind) + if item is None: + raise ValueError("Media is not marked as favorite, can't re-mark as favorite.") + await list_remove_item(self.bot.db_session, self.watched_list, item) + else: + try: + await list_put_item(self.bot.db_session, self.favorite_list, self.media_data.id, self._db_item_kind) + except ValueError: + raise ValueError("Media is already marked as favorite, can't re-mark as favorite.") + + @override + async def is_watched(self) -> bool: + if not self.media_data.id: + raise ValueError("Media has no ID") + + item = await get_list_item(self.bot.db_session, self.watched_list, self.media_data.id, self._db_item_kind) + return item is not None + + @override + async def set_watched(self, state: bool) -> None: + if not self.media_data.id: + raise ValueError("Media has no ID") + + if state is False: + item = await get_list_item(self.bot.db_session, self.watched_list, self.media_data.id, self._db_item_kind) + if item is None: + raise ValueError("Media is not marked as watched, can't re-mark as unwatched.") + await list_remove_item(self.bot.db_session, self.watched_list, item) + else: + try: + await list_put_item(self.bot.db_session, self.watched_list, self.media_data.id, self._db_item_kind) + except ValueError: + raise ValueError("Media is already marked as watched, can't re-mark as watched.") + + @override + def _get_embed(self) -> discord.Embed: + if self.media_data.overview_eng: + overview = f"{self.media_data.overview_eng}" + overview_extra = "" + elif not self.media_data.overview_eng and self.media_data.overview: + overview = f"{self.media_data.overview}" + overview_extra = "*No English overview available.*" + else: + overview = "" + overview_extra = "*No overview available.*" + + if len(overview) > 1000: + overview = overview[:100] + "..." + + if overview_extra: + if overview: + overview += f"\n\n{overview_extra}" + else: + overview = overview_extra + + title = self.media_data.bilingual_name + + if isinstance(self.media_data, Series): + title = f"{SERIES_EMOJI} {title}" + url = f"https://www.thetvdb.com/movies/{self.media_data.slug}" + else: + title = f"{MOVIE_EMOJI} {title}" + url = f"https://www.thetvdb.com/series/{self.media_data.slug}" + + embed = discord.Embed(title=title, color=discord.Color.blurple(), url=url) + embed.add_field(name="Overview", value=overview, inline=False) + embed.set_footer(text=THETVDB_COPYRIGHT_FOOTER, icon_url=THETVDB_LOGO) + embed.set_image(url=self.media_data.image_url) + return embed + + +@final +class SeriesView(_SeriesOrMovieView): + """View for displaying details about a series.""" + + media_data: Series + + @override + def __init__( + self, + *, + bot: Bot, + user_id: int, + watched_list: UserList, + favorite_list: UserList, + media_data: Series, + ) -> None: + super().__init__( + bot=bot, + user_id=user_id, + watched_list=watched_list, + favorite_list=favorite_list, + media_data=media_data, + ) + + self.episodes_button = discord.ui.Button( + style=discord.ButtonStyle.danger, + label="View episodes", + emoji="📺", + row=1, + ) + self.episodes_button.callback = self._episodes_button_callback + + @override + async def _initialize(self) -> None: + await self.media_data.ensure_seasons_and_episodes() + await super()._initialize() + + @override + def _add_items(self) -> None: + super()._add_items() + self.add_item(self.episodes_button) + + async def _episodes_button_callback(self, interaction: discord.Interaction) -> None: + """Callback for when the user clicks the "View Episodes" button.""" + view = EpisodeView( + bot=self.bot, + user_id=self.user_id, + watched_list=self.watched_list, + favorite_list=self.favorite_list, + series=self.media_data, + ) + await view.send(interaction) + + @override + async def is_watched(self) -> bool: + # Series uses a special method to determine whether it's watched. + # This approach uses the last episode of the series to determine if the series is watched. + + # If the series has no episodes, fall back to marking the season itself as watched. + if self.media_data.episodes is None: + return await super().is_watched() + + last_ep = self.media_data.episodes[-1] + + if last_ep.id is None: + raise ValueError("Episode has no ID") + + item = await get_list_item(self.bot.db_session, self.watched_list, last_ep.id, UserListItemKind.EPISODE) + return item is not None + + @override + async def set_watched(self, state: bool) -> None: + # When a series is marked as watched, we mark all of its episodes as watched. + # Similarly, unmarking will unmark all episodes. + + # If the series has no episodes, fall back to marking the season itself as watched / unwatched. + if self.media_data.episodes is None: + await super().set_watched(state) + return + + if state is False: + for episode in self.media_data.episodes: + if not episode.id: + raise ValueError("Episode has no ID") + + await list_remove_item_safe( + self.bot.db_session, + self.watched_list, + episode.id, + UserListItemKind.EPISODE, + ) + + await refresh_list_items(self.bot.db_session, self.watched_list) + else: + for episode in self.media_data.episodes: + if not episode.id: + raise ValueError("Episode has no ID") + + await list_put_item_safe( + self.bot.db_session, + self.watched_list, + episode.id, + UserListItemKind.EPISODE, + self.media_data.id, + ) + + +@final +class MovieView(_SeriesOrMovieView): + """View for displaying details about a movie.""" + + media_data: Movie + + # We override __init__ to provide a more specific type for media_data + @override + def __init__( + self, + *, + bot: Bot, + user_id: int, + watched_list: UserList, + favorite_list: UserList, + media_data: Movie, + ) -> None: + super().__init__( + bot=bot, + user_id=user_id, + watched_list=watched_list, + favorite_list=favorite_list, + media_data=media_data, + ) diff --git a/src/exts/tvdb_info/ui/profile_view.py b/src/exts/tvdb_info/ui/profile_view.py new file mode 100644 index 0000000..3e2ab20 --- /dev/null +++ b/src/exts/tvdb_info/ui/profile_view.py @@ -0,0 +1,113 @@ +import textwrap +from typing import TYPE_CHECKING, final + +import discord + +from src.bot import Bot +from src.db_adapters import refresh_list_items, user_get_list_safe, user_get_safe +from src.db_tables.user_list import UserList, UserListItem, UserListItemKind +from src.tvdb import Movie, Series +from src.tvdb.client import Episode, TvdbClient +from src.utils.log import get_logger + +if TYPE_CHECKING: + from src.db_tables.user import User + +log = get_logger(__name__) + +MOVIE_EMOJI = "🎬" +SERIES_EMOJI = "📺" + + +@final +class ProfileView(discord.ui.View): + """View for displaying user profiles with data about the user's added shows.""" + + def __init__(self, bot: Bot, tvdb_client: TvdbClient, user: discord.User) -> None: + super().__init__(timeout=None) + self.bot = bot + self.tvdb_client = tvdb_client + self.discord_user = user + self.user: User + self.watched_list: UserList + self.favorite_list: UserList + + self.watched_items: list[Episode | Series | Movie] + self.favorite_items: list[Episode | Series | Movie] + + def _get_embed(self) -> discord.Embed: + embed = discord.Embed( + title="Profile", + description=f"Profile for {self.discord_user.mention}", + color=discord.Color.blurple(), + thumbnail=self.discord_user.display_avatar.url, + ) + + total_movies = len([item for item in self.watched_items if isinstance(item, Movie)]) + total_series = len([item for item in self.watched_items if isinstance(item, Series)]) + total_episodes = len([item for item in self.watched_items if isinstance(item, Episode)]) + stats_str = textwrap.dedent( + f""" + **Total Shows:** {total_series} ({total_episodes} episode{'s' if total_episodes > 0 else ''}) + **Total Movies:** {total_movies} + """ + ) + embed.add_field(name="Stats", value=stats_str, inline=False) + + # TODO: This currently skips showing episodes, however, the entire system of series + # getting marked as watched should be reworked to use the latest episode in the series + # which will then need to be handled here. Currently, since a series can be marked + # as watched regardless of the status of its episodes, just use the series. + + # TODO: What if there's too many things here, we might need to paginate this. + + embed.add_field( + name="Favorites", + value="\n".join( + f"{MOVIE_EMOJI if isinstance(item, Movie) else SERIES_EMOJI} {item.bilingual_name}" + for item in self.favorite_items + if not isinstance(item, Episode) + ), + ) + embed.add_field( + name="Watched", + value="\n".join( + f"{MOVIE_EMOJI if isinstance(item, Movie) else SERIES_EMOJI} {item.bilingual_name}" + for item in self.watched_items + if not isinstance(item, Episode) + ), + ) + return embed + + async def _fetch_media(self, item: UserListItem) -> Episode | Series | Movie: + """Fetch given user list item from database.""" + match item.kind: + case UserListItemKind.EPISODE: + return await Episode.fetch(item.tvdb_id, client=self.tvdb_client) + case UserListItemKind.MOVIE: + return await Movie.fetch(item.tvdb_id, client=self.tvdb_client) + case UserListItemKind.SERIES: + return await Series.fetch(item.tvdb_id, client=self.tvdb_client) + + async def _update_states(self) -> None: + if not hasattr(self, "user"): + self.user = await user_get_safe(self.bot.db_session, self.discord_user.id) + + # TODO: Currently, this will result in a lot of API calls and we really just need + # the names of the items. We should consider storing those in the database directly. + # (note: what if the name changes? can that even happen?) + + if not hasattr(self, "watched_items"): + self.watched_list = await user_get_list_safe(self.bot.db_session, self.user, "watched") + await refresh_list_items(self.bot.db_session, self.watched_list) + self.watched_items = [await self._fetch_media(item) for item in self.watched_list.items] + + if not hasattr(self, "favorite_list"): + self.favorite_list = await user_get_list_safe(self.bot.db_session, self.user, "favorite") + await refresh_list_items(self.bot.db_session, self.favorite_list) + self.favorite_items = [await self._fetch_media(item) for item in self.favorite_list.items] + + async def send(self, interaction: discord.Interaction) -> None: + """Send the view.""" + await self._update_states() + await interaction.respond(embed=self._get_embed(), view=self) diff --git a/src/exts/tvdb_info/ui/search_view.py b/src/exts/tvdb_info/ui/search_view.py new file mode 100644 index 0000000..b844f70 --- /dev/null +++ b/src/exts/tvdb_info/ui/search_view.py @@ -0,0 +1,81 @@ +from collections.abc import Sequence + +import discord + +from src.bot import Bot +from src.db_adapters.lists import refresh_list_items +from src.db_adapters.user import user_get_list_safe, user_get_safe +from src.db_tables.user_list import UserList +from src.tvdb.client import Movie, Series + +from .movie_series_view import MovieView, SeriesView + + +def _search_view( + bot: Bot, + user_id: int, + watched_list: UserList, + favorite_list: UserList, + results: Sequence[Movie | Series], + cur_index: int = 0, +) -> MovieView | SeriesView: + result = results[cur_index] + + if isinstance(result, Movie): + view = MovieView( + bot=bot, + user_id=user_id, + watched_list=watched_list, + favorite_list=favorite_list, + media_data=result, + ) + else: + view = SeriesView( + bot=bot, + user_id=user_id, + watched_list=watched_list, + favorite_list=favorite_list, + media_data=result, + ) + + # Add support for switching between search results dynamically + search_result_dropdown = discord.ui.Select( + placeholder="Not what you're looking for? Select a different result.", + options=[ + discord.SelectOption( + label=(result.bilingual_name or "")[:100], + value=str(i), + description=result.overview[:100] if result.overview else None, + ) + for i, result in enumerate(results) + ], + row=2, + ) + + async def _search_dropdown_callback(interaction: discord.Interaction) -> None: + if not search_result_dropdown.values or not isinstance(search_result_dropdown.values[0], str): + raise ValueError("Dropdown values are empty or not a string but callback was triggered.") + + index = int(search_result_dropdown.values[0]) + view = _search_view(bot, user_id, watched_list, favorite_list, results, index) + await view.send(interaction) + + search_result_dropdown.callback = _search_dropdown_callback + + view.add_item(search_result_dropdown) + return view + + +async def search_view(bot: Bot, user_id: int, results: Sequence[Movie | Series]) -> MovieView | SeriesView: + """Construct a view showing the search results. + + This uses specific views to render a single result. This view is then modified to + add support for switching between the search results. + """ + user = await user_get_safe(bot.db_session, user_id) + watched_list = await user_get_list_safe(bot.db_session, user, "watched") + favorite_list = await user_get_list_safe(bot.db_session, user, "favorite") + await refresh_list_items(bot.db_session, watched_list) + await refresh_list_items(bot.db_session, favorite_list) + + return _search_view(bot, user_id, watched_list, favorite_list, results, 0) From 91243684d2de1e599a3f990eca705833e1397070 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 28 Jul 2024 05:22:44 +0200 Subject: [PATCH 142/166] Move movie & series emoji constants to settings --- src/exts/tvdb_info/ui/movie_series_view.py | 5 +---- src/exts/tvdb_info/ui/profile_view.py | 4 +--- src/settings.py | 2 ++ 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/exts/tvdb_info/ui/movie_series_view.py b/src/exts/tvdb_info/ui/movie_series_view.py index 61ad03e..57f4f7a 100644 --- a/src/exts/tvdb_info/ui/movie_series_view.py +++ b/src/exts/tvdb_info/ui/movie_series_view.py @@ -12,15 +12,12 @@ refresh_list_items, ) from src.db_tables.user_list import UserList, UserListItemKind -from src.settings import THETVDB_COPYRIGHT_FOOTER, THETVDB_LOGO +from src.settings import MOVIE_EMOJI, SERIES_EMOJI, THETVDB_COPYRIGHT_FOOTER, THETVDB_LOGO from src.tvdb.client import Movie, Series from ._media_view import MediaView from .episode_view import EpisodeView -MOVIE_EMOJI = "🎬" -SERIES_EMOJI = "📺" - class _SeriesOrMovieView(MediaView): """View for displaying details about a movie or a series.""" diff --git a/src/exts/tvdb_info/ui/profile_view.py b/src/exts/tvdb_info/ui/profile_view.py index 3e2ab20..88f08ee 100644 --- a/src/exts/tvdb_info/ui/profile_view.py +++ b/src/exts/tvdb_info/ui/profile_view.py @@ -6,6 +6,7 @@ from src.bot import Bot from src.db_adapters import refresh_list_items, user_get_list_safe, user_get_safe from src.db_tables.user_list import UserList, UserListItem, UserListItemKind +from src.settings import MOVIE_EMOJI, SERIES_EMOJI from src.tvdb import Movie, Series from src.tvdb.client import Episode, TvdbClient from src.utils.log import get_logger @@ -15,9 +16,6 @@ log = get_logger(__name__) -MOVIE_EMOJI = "🎬" -SERIES_EMOJI = "📺" - @final class ProfileView(discord.ui.View): diff --git a/src/settings.py b/src/settings.py index 50cf5c4..0c58939 100644 --- a/src/settings.py +++ b/src/settings.py @@ -12,6 +12,8 @@ FAIL_EMOJI = "❌" SUCCESS_EMOJI = "✅" +MOVIE_EMOJI = "🎬" +SERIES_EMOJI = "📺" GROUP_EMOJI = get_config("GROUP_EMOJI", default=":file_folder:") COMMAND_EMOJI = get_config("COMMAND_EMOJI", default=":arrow_forward:") From e5c9132bb72239eda406d1cea3e8969ff45e9699 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 28 Jul 2024 10:15:39 +0200 Subject: [PATCH 143/166] wip - this dosent work --- src/exts/tvdb_info/ui/_media_view.py | 4 +-- src/exts/tvdb_info/ui/profile_view.py | 44 +++++++++++++++++++++------ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/exts/tvdb_info/ui/_media_view.py b/src/exts/tvdb_info/ui/_media_view.py index 100a7e2..963b89c 100644 --- a/src/exts/tvdb_info/ui/_media_view.py +++ b/src/exts/tvdb_info/ui/_media_view.py @@ -146,20 +146,20 @@ async def send(self, interaction: discord.Interaction) -> None: async def _watched_button_callback(self, interaction: discord.Interaction) -> None: """Callback for when the user clicks on the mark as watched button.""" + await interaction.response.defer() cur_state = self.watched_button.state await self.set_watched(not cur_state) self.watched_button.set_state(not cur_state) - await interaction.response.defer() await self._refresh() async def _favorite_button_callback(self, interaction: discord.Interaction) -> None: """Callback for when the user clicks on the mark as favorite button.""" + await interaction.response.defer() cur_state = self.favorite_button.state await self.set_favorite(not cur_state) self.favorite_button.set_state(not cur_state) - await interaction.response.defer() await self._refresh() diff --git a/src/exts/tvdb_info/ui/profile_view.py b/src/exts/tvdb_info/ui/profile_view.py index 88f08ee..2978fec 100644 --- a/src/exts/tvdb_info/ui/profile_view.py +++ b/src/exts/tvdb_info/ui/profile_view.py @@ -8,7 +8,7 @@ from src.db_tables.user_list import UserList, UserListItem, UserListItemKind from src.settings import MOVIE_EMOJI, SERIES_EMOJI from src.tvdb import Movie, Series -from src.tvdb.client import Episode, TvdbClient +from src.tvdb.client import Episode, FetchMeta, TvdbClient from src.utils.log import get_logger if TYPE_CHECKING: @@ -32,8 +32,11 @@ def __init__(self, bot: Bot, tvdb_client: TvdbClient, user: discord.User) -> Non self.watched_items: list[Episode | Series | Movie] self.favorite_items: list[Episode | Series | Movie] + self.fetched_series: dict[int, Series] = {} + self.watched_episodes: set[UserListItem] = set() + self.watched_episodes_ids: set[int] = set() - def _get_embed(self) -> discord.Embed: + async def _get_embed(self) -> discord.Embed: embed = discord.Embed( title="Profile", description=f"Profile for {self.discord_user.mention}", @@ -42,7 +45,7 @@ def _get_embed(self) -> discord.Embed: ) total_movies = len([item for item in self.watched_items if isinstance(item, Movie)]) - total_series = len([item for item in self.watched_items if isinstance(item, Series)]) + total_series = len(self.fetched_series) total_episodes = len([item for item in self.watched_items if isinstance(item, Episode)]) stats_str = textwrap.dedent( f""" @@ -67,13 +70,22 @@ def _get_embed(self) -> discord.Embed: if not isinstance(item, Episode) ), ) + watched_str: str = "" + for item in self.watched_items: + if isinstance(item, Movie): + watched_str += f"{MOVIE_EMOJI} {item.bilingual_name}\n" + for series in self.fetched_series.values(): + if not series.episodes: + continue + for i, episode in enumerate(reversed(series.episodes)): + if episode.id in self.watched_episodes_ids: + watched_str += ( + f"{SERIES_EMOJI} {series.bilingual_name} - {episode.formatted_name if i != 0 else 'entirely'}" + ) + embed.add_field( name="Watched", - value="\n".join( - f"{MOVIE_EMOJI if isinstance(item, Movie) else SERIES_EMOJI} {item.bilingual_name}" - for item in self.watched_items - if not isinstance(item, Episode) - ), + value=watched_str, ) return embed @@ -98,7 +110,19 @@ async def _update_states(self) -> None: if not hasattr(self, "watched_items"): self.watched_list = await user_get_list_safe(self.bot.db_session, self.user, "watched") await refresh_list_items(self.bot.db_session, self.watched_list) - self.watched_items = [await self._fetch_media(item) for item in self.watched_list.items] + self.watched_items = [ + await self._fetch_media(item) for item in self.watched_list.items if item.kind != UserListItemKind.EPISODE + ] + self.watched_episodes = {item for item in self.watched_list.items if item.kind == UserListItemKind.EPISODE} + self.watched_episodes_ids = {item.tvdb_id for item in self.watched_episodes} + for episode in self.watched_episodes: + await self.bot.db_session.refresh(episode, ["episode"]) + if not self.fetched_series.get(episode.episode.series_id): + series = await Series.fetch( + episode.episode.series_id, client=self.tvdb_client, extended=True, meta=FetchMeta.TRANSLATIONS + ) + await series.fetch_episodes() + self.fetched_series[episode.episode.series_id] = series if not hasattr(self, "favorite_list"): self.favorite_list = await user_get_list_safe(self.bot.db_session, self.user, "favorite") @@ -108,4 +132,4 @@ async def _update_states(self) -> None: async def send(self, interaction: discord.Interaction) -> None: """Send the view.""" await self._update_states() - await interaction.respond(embed=self._get_embed(), view=self) + await interaction.respond(embed=await self._get_embed(), view=self) From 853cbc2fb20d9ddc1903997c73e8a3500a323cf9 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 28 Jul 2024 14:53:06 +0200 Subject: [PATCH 144/166] Fix some typos --- src/exts/tvdb_info/ui/_media_view.py | 2 +- src/exts/tvdb_info/ui/movie_series_view.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/exts/tvdb_info/ui/_media_view.py b/src/exts/tvdb_info/ui/_media_view.py index 963b89c..ac8f114 100644 --- a/src/exts/tvdb_info/ui/_media_view.py +++ b/src/exts/tvdb_info/ui/_media_view.py @@ -80,7 +80,7 @@ async def _initialize(self) -> None: the current state of the media or the user, configuring the internal state accordingly. Tasks that need to be performed here: - - Ensure to run the super call to this method. + - Call `self._add_items()` - Set the state of the watched and favorite buttons. This method will only be called once. diff --git a/src/exts/tvdb_info/ui/movie_series_view.py b/src/exts/tvdb_info/ui/movie_series_view.py index 57f4f7a..e1f0491 100644 --- a/src/exts/tvdb_info/ui/movie_series_view.py +++ b/src/exts/tvdb_info/ui/movie_series_view.py @@ -186,7 +186,7 @@ async def is_watched(self) -> bool: # Series uses a special method to determine whether it's watched. # This approach uses the last episode of the series to determine if the series is watched. - # If the series has no episodes, fall back to marking the season itself as watched. + # If the series has no episodes, fall back to marking the series itself as watched. if self.media_data.episodes is None: return await super().is_watched() From cd769f694eb3c6105927a99c8cbcc32c83d368af Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 28 Jul 2024 15:18:23 +0200 Subject: [PATCH 145/166] Fix UserListItem relationships --- src/db_tables/user_list.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/db_tables/user_list.py b/src/db_tables/user_list.py index 6c95004..c0b94fc 100644 --- a/src/db_tables/user_list.py +++ b/src/db_tables/user_list.py @@ -75,21 +75,21 @@ class UserListItem(Base): series: Mapped["Series"] = relationship( "Series", foreign_keys=[tvdb_id], - primaryjoin="and_(UserListItem.tvdb_id == Series.tvdb_id, UserListItem.kind == 'series')", + primaryjoin="and_(UserListItem.tvdb_id == Series.tvdb_id, UserListItem.kind == 'SERIES')", uselist=False, viewonly=True, ) movie: Mapped["Movie"] = relationship( "Movie", foreign_keys=[tvdb_id], - primaryjoin="and_(UserListItem.tvdb_id == Movie.tvdb_id, UserListItem.kind == 'movie')", + primaryjoin="and_(UserListItem.tvdb_id == Movie.tvdb_id, UserListItem.kind == 'MOVIE')", uselist=False, viewonly=True, ) episode: Mapped["Episode"] = relationship( "Episode", foreign_keys=[tvdb_id], - primaryjoin="and_(UserListItem.tvdb_id == Episode.tvdb_id, UserListItem.kind == 'episode')", + primaryjoin="and_(UserListItem.tvdb_id == Episode.tvdb_id, UserListItem.kind == 'EPISODE')", uselist=False, viewonly=True, ) From 4d99c56ccee02de96e76a568d0f65f89adaf6b42 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 28 Jul 2024 15:18:58 +0200 Subject: [PATCH 146/166] Add relationships between episode & series --- src/db_tables/media.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/db_tables/media.py b/src/db_tables/media.py index d8d0fee..a0191dc 100644 --- a/src/db_tables/media.py +++ b/src/db_tables/media.py @@ -8,7 +8,7 @@ """ from sqlalchemy import ForeignKey -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from src.utils.database import Base @@ -28,6 +28,12 @@ class Series(Base): tvdb_id: Mapped[int] = mapped_column(primary_key=True) + episodes: Mapped[list["Episode"]] = relationship( + lazy="selectin", + back_populates="series", + cascade="all, delete-orphan", + ) + class Episode(Base): """Table to store episodes of series.""" @@ -36,3 +42,5 @@ class Episode(Base): tvdb_id: Mapped[int] = mapped_column(primary_key=True) series_id: Mapped[int] = mapped_column(ForeignKey("series.tvdb_id")) + + series: Mapped[Series] = relationship(lazy="selectin", back_populates="episodes") From 3c5b728a803c9368f43d6b7cee5432e70bbdfb6e Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 28 Jul 2024 15:22:51 +0200 Subject: [PATCH 147/166] Increase tvdb rate limit significantly (100/5s) --- src/settings.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/settings.py b/src/settings.py index 0c58939..b05d271 100644 --- a/src/settings.py +++ b/src/settings.py @@ -22,12 +22,7 @@ ) THETVDB_LOGO = "https://www.thetvdb.com/images/attribution/logo1.png" -# The default rate-limit might be a bit too small for production-ready bots that live -# on multiple guilds. But it's good enough for our demonstration purposes and it's -# still actually quite hard to hit this rate-limit on a single guild, unless multiple -# people actually try to make many requests after each other.. -# # Note that tvdb doesn't actually have rate-limits (or at least they aren't documented), # but we should still be careful not to spam the API too much and be on the safe side. -TVDB_RATE_LIMIT_REQUESTS = get_config("TVDB_RATE_LIMIT_REQUESTS", cast=int, default=5) +TVDB_RATE_LIMIT_REQUESTS = get_config("TVDB_RATE_LIMIT_REQUESTS", cast=int, default=100) TVDB_RATE_LIMIT_PERIOD = get_config("TVDB_RATE_LIMIT_PERIOD", cast=float, default=5) # seconds From ecb4d1d6beae77e23b10b9fd7edd2eaa0f35e7e6 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 28 Jul 2024 15:23:37 +0200 Subject: [PATCH 148/166] wip: Rework profile view --- src/exts/tvdb_info/main.py | 10 +- src/exts/tvdb_info/ui/profile_view.py | 231 ++++++++++++++++---------- 2 files changed, 155 insertions(+), 86 deletions(-) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index ab3a5db..0913b59 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -3,6 +3,7 @@ from discord import ApplicationContext, Cog, Member, User, option, slash_command from src.bot import Bot +from src.db_adapters.user import user_get_list_safe, user_get_safe from src.tvdb import FetchMeta, Movie, Series, TvdbClient from src.tvdb.errors import InvalidIdError from src.utils.log import get_logger @@ -40,7 +41,14 @@ async def profile(self, ctx: ApplicationContext, *, user: User | Member | None = # they are friends with the user, or it's their own profile) # https://github.com/ItsDrike/code-jam-2024/issues/51 - view = ProfileView(self.bot, self.tvdb_client, user) + db_user = await user_get_safe(self.bot.db_session, user.id) + view = ProfileView( + bot=self.bot, + tvdb_client=self.tvdb_client, + user=user, + watched_list=await user_get_list_safe(self.bot.db_session, db_user, "watched"), + favorite_list=await user_get_list_safe(self.bot.db_session, db_user, "favorite"), + ) await view.send(ctx.interaction) @slash_command() diff --git a/src/exts/tvdb_info/ui/profile_view.py b/src/exts/tvdb_info/ui/profile_view.py index 2978fec..48c2e6a 100644 --- a/src/exts/tvdb_info/ui/profile_view.py +++ b/src/exts/tvdb_info/ui/profile_view.py @@ -1,19 +1,19 @@ import textwrap -from typing import TYPE_CHECKING, final +from itertools import chain, groupby +from typing import final import discord from src.bot import Bot -from src.db_adapters import refresh_list_items, user_get_list_safe, user_get_safe -from src.db_tables.user_list import UserList, UserListItem, UserListItemKind +from src.db_adapters import refresh_list_items +from src.db_tables.media import Episode as EpisodeTable, Movie as MovieTable, Series as SeriesTable +from src.db_tables.user_list import UserList, UserListItemKind from src.settings import MOVIE_EMOJI, SERIES_EMOJI from src.tvdb import Movie, Series -from src.tvdb.client import Episode, FetchMeta, TvdbClient +from src.tvdb.client import FetchMeta, TvdbClient +from src.utils.iterators import get_first from src.utils.log import get_logger -if TYPE_CHECKING: - from src.db_tables.user import User - log = get_logger(__name__) @@ -21,22 +21,136 @@ class ProfileView(discord.ui.View): """View for displaying user profiles with data about the user's added shows.""" - def __init__(self, bot: Bot, tvdb_client: TvdbClient, user: discord.User) -> None: - super().__init__(timeout=None) + fetched_favorite_movies: list[Movie] + fetched_favorite_shows: list[Series] + fetched_watched_movies: list[Movie] + fetched_watched_shows: list[Series] + fetched_partially_watched_shows: list[Series] + episodes_total: int + + def __init__( + self, + *, + bot: Bot, + tvdb_client: TvdbClient, + user: discord.User, + watched_list: UserList, + favorite_list: UserList, + ) -> None: + super().__init__() self.bot = bot self.tvdb_client = tvdb_client self.discord_user = user - self.user: User - self.watched_list: UserList - self.favorite_list: UserList + self.watched_list = watched_list + self.favorite_list = favorite_list - self.watched_items: list[Episode | Series | Movie] - self.favorite_items: list[Episode | Series | Movie] - self.fetched_series: dict[int, Series] = {} - self.watched_episodes: set[UserListItem] = set() - self.watched_episodes_ids: set[int] = set() + async def _initialize(self) -> None: + """Initialize the view, obtaining any necessary state.""" + await refresh_list_items(self.bot.db_session, self.watched_list) + await refresh_list_items(self.bot.db_session, self.favorite_list) - async def _get_embed(self) -> discord.Embed: + watched_movies: list[MovieTable] = [] + watched_shows: list[SeriesTable] = [] + partially_watched_shows: list[SeriesTable] = [] + watched_episodes: list[EpisodeTable] = [] + + for item in self.watched_list.items: + match item.kind: + case UserListItemKind.MOVIE: + await self.bot.db_session.refresh(item, ["movie"]) + watched_movies.append(item.movie) + case UserListItemKind.SERIES: + await self.bot.db_session.refresh(item, ["series"]) + watched_shows.append(item.series) + case UserListItemKind.EPISODE: + await self.bot.db_session.refresh(item, ["episode"]) + watched_episodes.append(item.episode) + + # We don't actually care about episodes in the profile view, however, we need them + # because of the way shows are marked as watched (last episode watched -> show watched). + + for series_id, episodes in groupby(watched_episodes, key=lambda x: x.series_id): + series = await Series.fetch(series_id, client=self.tvdb_client, extended=True, meta=FetchMeta.EPISODES) + if series.episodes is None: + raise ValueError("Found an episode in watched list for a series with no episodes") + + last_episode = series.episodes[-1] + if last_episode.id is None: + raise ValueError("Episode has no ID") + + episodes_it = iter(episodes) + first_db_episode = get_first(episodes_it) + if first_db_episode is None: + raise ValueError("No episodes found in a group (never)") + + group_episode_ids = {episode.tvdb_id for episode in episodes_it} + group_episode_ids.add(first_db_episode.tvdb_id) + await self.bot.db_session.refresh(first_db_episode, ["series"]) + + # TODO: This should not be happening, yet it is... It means that there is an episode which + # doesn't have a corresponding series, even though it has a foreign key to it that's not + # empty, it's almost like the Series got deleted or something. + if first_db_episode.series is None: # pyright: ignore[reportUnnecessaryComparison] + manual = await self.bot.db_session.get(SeriesTable, first_db_episode.series_id) + raise ValueError(f"DB series is None id={first_db_episode.series_id}, manual={manual}") + + if last_episode.id in group_episode_ids: + watched_shows.append(first_db_episode.series) + else: + partially_watched_shows.append(first_db_episode.series) + + favorite_movies: list[MovieTable] = [] + favorite_shows: list[SeriesTable] = [] + + for item in self.favorite_list.items: + match item.kind: + case UserListItemKind.MOVIE: + await self.bot.db_session.refresh(item, ["movie"]) + favorite_movies.append(item.movie) + case UserListItemKind.SERIES: + await self.bot.db_session.refresh(item, ["series"]) + favorite_shows.append(item.series) + case UserListItemKind.EPISODE: + raise TypeError("Found an episode in favorite list") + + # Fetch the data about all favorite & watched items from tvdb + # TODO: This is a lot of API calls, we should probably limit this to some maximum + self.fetched_favorite_movies = [ + await Movie.fetch(media_db_data.tvdb_id, client=self.tvdb_client) for media_db_data in favorite_movies + ] + self.fetched_favorite_shows = [ + await Series.fetch( + media_db_data.tvdb_id, client=self.tvdb_client, extended=True, meta=FetchMeta.TRANSLATIONS + ) + for media_db_data in favorite_movies + ] + self.fetched_watched_movies = [ + await Movie.fetch(media_db_data.tvdb_id, client=self.tvdb_client) for media_db_data in watched_movies + ] + self.fetched_watched_shows = [ + await Series.fetch( + media_db_data.tvdb_id, + client=self.tvdb_client, + extended=True, + meta=FetchMeta.TRANSLATIONS, + ) + for media_db_data in watched_shows + ] + self.fetched_partially_watched_shows = [ + await Series.fetch( + media_db_data.tvdb_id, + client=self.tvdb_client, + extended=True, + meta=FetchMeta.TRANSLATIONS, + ) + for media_db_data in partially_watched_shows + ] + + # Instead of fetching all episodes, just store the total number of episodes that the user has added + # as that's the only thing we need here and while it is a bit inconsistent, it's a LOT more efficient. + self.episodes_total = len(watched_episodes) + + def _get_embed(self) -> discord.Embed: embed = discord.Embed( title="Profile", description=f"Profile for {self.discord_user.mention}", @@ -44,92 +158,39 @@ async def _get_embed(self) -> discord.Embed: thumbnail=self.discord_user.display_avatar.url, ) - total_movies = len([item for item in self.watched_items if isinstance(item, Movie)]) - total_series = len(self.fetched_series) - total_episodes = len([item for item in self.watched_items if isinstance(item, Episode)]) stats_str = textwrap.dedent( f""" - **Total Shows:** {total_series} ({total_episodes} episode{'s' if total_episodes > 0 else ''}) - **Total Movies:** {total_movies} + **Total Shows:** {len(self.fetched_favorite_shows)} \ + ({self.episodes_total} episode{'s' if self.episodes_total > 0 else ''}) + **Total Movies:** {len(self.fetched_watched_movies)} """ ) embed.add_field(name="Stats", value=stats_str, inline=False) - # TODO: This currently skips showing episodes, however, the entire system of series - # getting marked as watched should be reworked to use the latest episode in the series - # which will then need to be handled here. Currently, since a series can be marked - # as watched regardless of the status of its episodes, just use the series. - # TODO: What if there's too many things here, we might need to paginate this. embed.add_field( name="Favorites", value="\n".join( f"{MOVIE_EMOJI if isinstance(item, Movie) else SERIES_EMOJI} {item.bilingual_name}" - for item in self.favorite_items - if not isinstance(item, Episode) + for item in chain(self.fetched_favorite_shows, self.fetched_favorite_movies) ), ) - watched_str: str = "" - for item in self.watched_items: - if isinstance(item, Movie): - watched_str += f"{MOVIE_EMOJI} {item.bilingual_name}\n" - for series in self.fetched_series.values(): - if not series.episodes: - continue - for i, episode in enumerate(reversed(series.episodes)): - if episode.id in self.watched_episodes_ids: - watched_str += ( - f"{SERIES_EMOJI} {series.bilingual_name} - {episode.formatted_name if i != 0 else 'entirely'}" - ) + watched_items: list[str] = [] + for item in self.fetched_watched_movies: + watched_items.append(f"{MOVIE_EMOJI} {item.bilingual_name}") # noqa: PERF401 + for item in self.fetched_watched_shows: + watched_items.append(f"{SERIES_EMOJI} {item.bilingual_name}") # noqa: PERF401 + for item in self.fetched_partially_watched_shows: + watched_items.append(f"{SERIES_EMOJI} {item.bilingual_name} partially") # noqa: PERF401 embed.add_field( name="Watched", - value=watched_str, + value="\n".join(watched_items), ) return embed - async def _fetch_media(self, item: UserListItem) -> Episode | Series | Movie: - """Fetch given user list item from database.""" - match item.kind: - case UserListItemKind.EPISODE: - return await Episode.fetch(item.tvdb_id, client=self.tvdb_client) - case UserListItemKind.MOVIE: - return await Movie.fetch(item.tvdb_id, client=self.tvdb_client) - case UserListItemKind.SERIES: - return await Series.fetch(item.tvdb_id, client=self.tvdb_client) - - async def _update_states(self) -> None: - if not hasattr(self, "user"): - self.user = await user_get_safe(self.bot.db_session, self.discord_user.id) - - # TODO: Currently, this will result in a lot of API calls and we really just need - # the names of the items. We should consider storing those in the database directly. - # (note: what if the name changes? can that even happen?) - - if not hasattr(self, "watched_items"): - self.watched_list = await user_get_list_safe(self.bot.db_session, self.user, "watched") - await refresh_list_items(self.bot.db_session, self.watched_list) - self.watched_items = [ - await self._fetch_media(item) for item in self.watched_list.items if item.kind != UserListItemKind.EPISODE - ] - self.watched_episodes = {item for item in self.watched_list.items if item.kind == UserListItemKind.EPISODE} - self.watched_episodes_ids = {item.tvdb_id for item in self.watched_episodes} - for episode in self.watched_episodes: - await self.bot.db_session.refresh(episode, ["episode"]) - if not self.fetched_series.get(episode.episode.series_id): - series = await Series.fetch( - episode.episode.series_id, client=self.tvdb_client, extended=True, meta=FetchMeta.TRANSLATIONS - ) - await series.fetch_episodes() - self.fetched_series[episode.episode.series_id] = series - - if not hasattr(self, "favorite_list"): - self.favorite_list = await user_get_list_safe(self.bot.db_session, self.user, "favorite") - await refresh_list_items(self.bot.db_session, self.favorite_list) - self.favorite_items = [await self._fetch_media(item) for item in self.favorite_list.items] - async def send(self, interaction: discord.Interaction) -> None: """Send the view.""" - await self._update_states() - await interaction.respond(embed=await self._get_embed(), view=self) + await self._initialize() + await interaction.respond(embed=self._get_embed(), view=self) From ec9de9c06a325f06f6d781c7892dfc4e6f930340 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 28 Jul 2024 17:32:30 +0200 Subject: [PATCH 149/166] Fix things --- src/db_adapters/lists.py | 5 ++- src/db_adapters/media.py | 7 +++ src/exts/tvdb_info/main.py | 12 ++++-- src/exts/tvdb_info/ui/episode_view.py | 5 ++- src/exts/tvdb_info/ui/movie_series_view.py | 5 ++- src/exts/tvdb_info/ui/profile_view.py | 33 +++++++------- src/tvdb/client.py | 50 +++++++++++++++++++--- 7 files changed, 87 insertions(+), 30 deletions(-) diff --git a/src/db_adapters/lists.py b/src/db_adapters/lists.py index 2b585f3..2230c98 100644 --- a/src/db_adapters/lists.py +++ b/src/db_adapters/lists.py @@ -90,7 +90,10 @@ async def list_put_item_safe( session: AsyncSession, user_list: UserList, tvdb_id: int, kind: UserListItemKind, series_id: int | None = None ) -> UserListItem: """Add an item to a user list, or return the existing item if it is already present.""" - await ensure_media(session, tvdb_id, kind, series_id=series_id) + if series_id: + await ensure_media(session, tvdb_id, kind, series_id=series_id) + else: + await ensure_media(session, tvdb_id, kind) item = await list_get_item(session, user_list, tvdb_id, kind) if item: return item diff --git a/src/db_adapters/media.py b/src/db_adapters/media.py index e7709bc..5366640 100644 --- a/src/db_adapters/media.py +++ b/src/db_adapters/media.py @@ -20,3 +20,10 @@ async def ensure_media(session: AsyncSession, tvdb_id: int, kind: UserListItemKi media = cls(tvdb_id=tvdb_id, **kwargs) session.add(media) await session.commit() + + if isinstance(media, Episode): + await session.refresh(media, ["series"]) + if not media.series: + series = Series(tvdb_id=kwargs["series_id"]) + session.add(series) + await session.commit() diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 0913b59..322e4ce 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -88,9 +88,11 @@ async def search( await Movie.fetch(query, self.tvdb_client, extended=True, meta=FetchMeta.TRANSLATIONS) ] case "series": - response = [ - await Series.fetch(query, self.tvdb_client, extended=True, meta=FetchMeta.TRANSLATIONS) - ] + series = await Series.fetch( + query, self.tvdb_client, extended=True, meta=FetchMeta.TRANSLATIONS + ) + await series.fetch_episodes() + response = [series] case None: await ctx.respond( "You must specify a type (movie or series) when searching by ID.", ephemeral=True @@ -104,6 +106,10 @@ async def search( return else: response = await self.tvdb_client.search(query, limit=5, entity_type=entity_type) + for element in response: + await element.ensure_translations() + if isinstance(element, Series): + await element.fetch_episodes() if not response: await ctx.respond("No results found.") return diff --git a/src/exts/tvdb_info/ui/episode_view.py b/src/exts/tvdb_info/ui/episode_view.py index 8347375..593f26a 100644 --- a/src/exts/tvdb_info/ui/episode_view.py +++ b/src/exts/tvdb_info/ui/episode_view.py @@ -196,8 +196,9 @@ def _get_embed(self) -> discord.Embed: title=self.current_episode.formatted_name, description=description, color=discord.Color.blurple(), - url=f"https://www.thetvdb.com/series/{self.series.slug}", + url=self.series.url, ) - embed.set_image(url=f"https://www.thetvdb.com{self.current_episode.image}") + if self.current_episode.image_url: + embed.set_image(url=self.current_episode.image_url) embed.set_footer(text=THETVDB_COPYRIGHT_FOOTER, icon_url=THETVDB_LOGO) return embed diff --git a/src/exts/tvdb_info/ui/movie_series_view.py b/src/exts/tvdb_info/ui/movie_series_view.py index e1f0491..ccdd186 100644 --- a/src/exts/tvdb_info/ui/movie_series_view.py +++ b/src/exts/tvdb_info/ui/movie_series_view.py @@ -116,10 +116,9 @@ def _get_embed(self) -> discord.Embed: if isinstance(self.media_data, Series): title = f"{SERIES_EMOJI} {title}" - url = f"https://www.thetvdb.com/movies/{self.media_data.slug}" else: title = f"{MOVIE_EMOJI} {title}" - url = f"https://www.thetvdb.com/series/{self.media_data.slug}" + url = self.media_data.url embed = discord.Embed(title=title, color=discord.Color.blurple(), url=url) embed.add_field(name="Overview", value=overview, inline=False) @@ -225,6 +224,8 @@ async def set_watched(self, state: bool) -> None: for episode in self.media_data.episodes: if not episode.id: raise ValueError("Episode has no ID") + if not episode.aired: + continue await list_put_item_safe( self.bot.db_session, diff --git a/src/exts/tvdb_info/ui/profile_view.py b/src/exts/tvdb_info/ui/profile_view.py index 48c2e6a..262d779 100644 --- a/src/exts/tvdb_info/ui/profile_view.py +++ b/src/exts/tvdb_info/ui/profile_view.py @@ -1,5 +1,5 @@ import textwrap -from itertools import chain, groupby +from itertools import groupby from typing import final import discord @@ -64,6 +64,7 @@ async def _initialize(self) -> None: watched_shows.append(item.series) case UserListItemKind.EPISODE: await self.bot.db_session.refresh(item, ["episode"]) + await self.bot.db_session.refresh(item.episode, ["series"]) watched_episodes.append(item.episode) # We don't actually care about episodes in the profile view, however, we need them @@ -74,9 +75,9 @@ async def _initialize(self) -> None: if series.episodes is None: raise ValueError("Found an episode in watched list for a series with no episodes") - last_episode = series.episodes[-1] - if last_episode.id is None: - raise ValueError("Episode has no ID") + last_episode = get_first(episode for episode in reversed(series.episodes) if episode.aired) + if not last_episode or last_episode.id is None: + raise ValueError("Episode has no ID or is None") episodes_it = iter(episodes) first_db_episode = get_first(episodes_it) @@ -87,9 +88,6 @@ async def _initialize(self) -> None: group_episode_ids.add(first_db_episode.tvdb_id) await self.bot.db_session.refresh(first_db_episode, ["series"]) - # TODO: This should not be happening, yet it is... It means that there is an episode which - # doesn't have a corresponding series, even though it has a foreign key to it that's not - # empty, it's almost like the Series got deleted or something. if first_db_episode.series is None: # pyright: ignore[reportUnnecessaryComparison] manual = await self.bot.db_session.get(SeriesTable, first_db_episode.series_id) raise ValueError(f"DB series is None id={first_db_episode.series_id}, manual={manual}") @@ -160,7 +158,7 @@ def _get_embed(self) -> discord.Embed: stats_str = textwrap.dedent( f""" - **Total Shows:** {len(self.fetched_favorite_shows)} \ + **Total Shows:** {len(self.fetched_watched_shows)} \ ({self.episodes_total} episode{'s' if self.episodes_total > 0 else ''}) **Total Movies:** {len(self.fetched_watched_movies)} """ @@ -169,24 +167,27 @@ def _get_embed(self) -> discord.Embed: # TODO: What if there's too many things here, we might need to paginate this. + favorite_items: list[str] = [] + for item in self.fetched_favorite_movies: + favorite_items.append(f"[{MOVIE_EMOJI} {item.bilingual_name}]({item.url})") # noqa: PERF401 + for item in self.fetched_favorite_shows: + favorite_items.append(f"[{SERIES_EMOJI} {item.bilingual_name}]({item.url})") # noqa: PERF401 + embed.add_field( name="Favorites", - value="\n".join( - f"{MOVIE_EMOJI if isinstance(item, Movie) else SERIES_EMOJI} {item.bilingual_name}" - for item in chain(self.fetched_favorite_shows, self.fetched_favorite_movies) - ), + value="\n".join(favorite_items) or "No favorites", ) watched_items: list[str] = [] for item in self.fetched_watched_movies: - watched_items.append(f"{MOVIE_EMOJI} {item.bilingual_name}") # noqa: PERF401 + watched_items.append(f"[{MOVIE_EMOJI} {item.bilingual_name}]({item.url})") # noqa: PERF401 for item in self.fetched_watched_shows: - watched_items.append(f"{SERIES_EMOJI} {item.bilingual_name}") # noqa: PERF401 + watched_items.append(f"[{SERIES_EMOJI} {item.bilingual_name}]({item.url})") # noqa: PERF401 for item in self.fetched_partially_watched_shows: - watched_items.append(f"{SERIES_EMOJI} {item.bilingual_name} partially") # noqa: PERF401 + watched_items.append(f"[{SERIES_EMOJI} {item.bilingual_name}]({item.url}) partially") # noqa: PERF401 embed.add_field( name="Watched", - value="\n".join(watched_items), + value="\n".join(watched_items) or "No watched items", ) return embed diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 636ac14..4a65896 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from datetime import UTC, datetime from enum import Enum from typing import ClassVar, Literal, Self, final, overload, override @@ -209,6 +210,14 @@ async def fetch( return cls(client, response.data) + async def ensure_translations(self) -> None: + """Ensure that response contains translations.""" + if not isinstance(self.data, SeriesExtendedRecord): + series = await self.fetch( + media_id=self.id, client=self.client, extended=True, short=True, meta=FetchMeta.TRANSLATIONS + ) + self.set_attributes(series.data) + @final class Movie(_Media): @@ -220,6 +229,11 @@ class Movie(_Media): ResponseType = MoviesIdGetResponse ExtendedResponseType = MoviesIdExtendedGetResponse + @override + def set_attributes(self, data: AnyRecord | SearchResult) -> None: + super().set_attributes(data) + self.url: str | None = f"https://www.thetvdb.com/movies/{self.slug}" if self.slug else None + @override @classmethod async def supports_meta(cls, meta: FetchMeta) -> bool: @@ -249,6 +263,7 @@ def set_attributes(self, data: SearchResult | SeriesBaseRecord | SeriesExtendedR self.seasons = self.data.seasons if self.data.episodes: self.episodes = [Episode(episode, client=self.client) for episode in self.data.episodes] + self.url: str | None = f"https://www.thetvdb.com/series/{self.slug}" if self.slug else None @override @classmethod @@ -287,28 +302,36 @@ class Episode: """Represents an episode from Tvdb.""" def __init__(self, data: EpisodeBaseRecord | EpisodeExtendedRecord, client: "TvdbClient") -> None: + self.client = client + self.set_attributes(data) + + def set_attributes(self, data: EpisodeBaseRecord | EpisodeExtendedRecord) -> None: + """Set attributes.""" self.data = data self.id: int | None = self.data.id - self.image: str | None = self.data.image + self.image_url: str | None = self.data.image if self.data.image else None self.name: str | None = self.data.name self.overview: str | None = self.data.overview self.number: int | None = self.data.number self.season_number: int | None = self.data.season_number - self.eng_name: str | None = None - self.eng_overview: str | None = None + self.name_eng: str | None = None + self.overview_eng: str | None = None self.series_id: int | None = self.data.series_id - self.client = client + self.air_date: datetime | None = None + if self.data.aired: + self.air_date = datetime.strptime(self.data.aired, "%Y-%m-%d").replace(tzinfo=UTC) + self.aired: bool = self.air_date is not None and self.air_date <= datetime.now(UTC) if isinstance(self.data, EpisodeExtendedRecord): if self.data.translations and self.data.translations.name_translations: - self.eng_name = get_first( + self.name_eng = get_first( translation.name for translation in self.data.translations.name_translations if translation.language == "eng" ) if self.data.translations and self.data.translations.overview_translations: - self.eng_overview = get_first( + self.overview_eng = get_first( translation.overview for translation in self.data.translations.overview_translations if translation.language == "eng" @@ -335,6 +358,14 @@ async def fetch(cls, media_id: str | int, *, client: "TvdbClient", extended: boo raise ValueError("No data found for Episode") return cls(response.data, client=client) + async def ensure_translations(self) -> None: + """Ensure that response contains translations.""" + if not isinstance(self.data, EpisodeExtendedRecord): + if not self.id: + raise ValueError("Episode has no ID") + episode = await self.fetch(self.id, client=self.client, extended=True) + self.set_attributes(episode.data) + async def fetch_series( self, *, extended: bool = False, short: bool | None = None, meta: FetchMeta | None = None ) -> Series: @@ -349,6 +380,13 @@ async def fetch_series( meta=meta, ) + @property + def bilingual_name(self) -> str | None: + """Returns the name in both languages.""" + if self.name == self.name_eng: + return self.name + return f"{self.name} ({self.name_eng})" + class TvdbClient: """Class to interact with the TVDB API.""" From 7d9aadd359ffb40e4036238e12544d40fb479bce Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 28 Jul 2024 17:34:00 +0200 Subject: [PATCH 150/166] Properly handle errors in views --- src/exts/error_handler/error_handler.py | 135 +++++++++--------------- src/exts/error_handler/utils.py | 56 ++++++++++ src/exts/error_handler/view.py | 51 +++++++++ src/exts/tvdb_info/ui/_media_view.py | 3 +- src/exts/tvdb_info/ui/profile_view.py | 3 +- 5 files changed, 161 insertions(+), 87 deletions(-) create mode 100644 src/exts/error_handler/utils.py create mode 100644 src/exts/error_handler/view.py diff --git a/src/exts/error_handler/error_handler.py b/src/exts/error_handler/error_handler.py index 809b191..dfb3fba 100644 --- a/src/exts/error_handler/error_handler.py +++ b/src/exts/error_handler/error_handler.py @@ -1,14 +1,17 @@ -import textwrap +import sys +from itertools import chain from typing import cast -from discord import Any, ApplicationContext, Cog, Colour, Embed, EmbedField, EmbedFooter, errors +from discord import Any, ApplicationContext, Cog, EmbedField, EmbedFooter, errors from discord.ext.commands import errors as commands_errors from src.bot import Bot -from src.settings import FAIL_EMOJI, GITHUB_REPO +from src.settings import FAIL_EMOJI from src.utils.log import get_logger from src.utils.ratelimit import RateLimitExceededError +from .utils import build_error_embed, build_unhandled_application_embed + log = get_logger(__name__) @@ -18,51 +21,6 @@ class ErrorHandler(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - async def send_error_embed( - self, - ctx: ApplicationContext, - *, - title: str | None = None, - description: str | None = None, - fields: list[EmbedField] | None = None, - footer: EmbedFooter | None = None, - ) -> None: - """Send an embed regarding the unhandled exception that occurred.""" - if title is None and description is None: - raise ValueError("You need to provide either a title or a description.") - - embed = Embed( - title=title, - description=description, - color=Colour.red(), - fields=fields, - footer=footer, - ) - await ctx.respond(f"Sorry, {ctx.author.mention}", embed=embed) - - async def send_unhandled_embed(self, ctx: ApplicationContext, exc: BaseException) -> None: - """Send an embed regarding the unhandled exception that occurred.""" - msg = f"Exception {exc!r} has occurred from command {ctx.command.qualified_name} invoked by {ctx.author.id}" - if ctx.message: - msg += f" in message {ctx.message.content!r}" - if ctx.guild: - msg += f" on guild {ctx.guild.id}" - log.warning(msg) - - await self.send_error_embed( - ctx, - title="Unhandled exception", - description=textwrap.dedent( - f""" - Unknown error has occurred without being properly handled. - Please report this at the [GitHub repository]({GITHUB_REPO}) - - **Command**: `{ctx.command.qualified_name}` - **Exception details**: ```{exc.__class__.__name__}: {exc}``` - """ - ), - ) - async def _handle_check_failure( self, ctx: ApplicationContext, @@ -87,13 +45,9 @@ async def _handle_check_failure( raise ValueError("Never (hopefully), here's some random code: 0xd1ff0aaac") if isinstance(exc, commands_errors.NotOwner): - await self.send_error_embed( - ctx, - description=f"{FAIL_EMOJI} This command is limited to the bot owner.", - ) - return + embed = build_error_embed(description=f"{FAIL_EMOJI} This command is limited to the bot owner.") - if isinstance( + elif isinstance( exc, ( commands_errors.MissingPermissions, @@ -101,13 +55,9 @@ async def _handle_check_failure( commands_errors.MissingAnyRole, ), ): - await self.send_error_embed( - ctx, - description=f"{FAIL_EMOJI} You don't have permission to run this command.", - ) - return + embed = build_error_embed(description=f"{FAIL_EMOJI} You don't have permission to run this command.") - if isinstance( + elif isinstance( exc, ( commands_errors.BotMissingRole, @@ -115,27 +65,22 @@ async def _handle_check_failure( commands_errors.BotMissingPermissions, ), ): - await self.send_error_embed( - ctx, - description=f"{FAIL_EMOJI} I don't have the necessary permissions to perform this action.", + embed = build_error_embed( + description=f"{FAIL_EMOJI} I don't have the necessary permissions to perform this action." ) - if isinstance(exc, commands_errors.NoPrivateMessage): - await self.send_error_embed(ctx, description=f"{FAIL_EMOJI} This command can only be used in a server.") - return + elif isinstance(exc, commands_errors.NoPrivateMessage): + embed = build_error_embed(description=f"{FAIL_EMOJI} This command can only be used in a server.") - if isinstance(exc, commands_errors.PrivateMessageOnly): - await self.send_error_embed(ctx, description=f"{FAIL_EMOJI} This command can only be used in a DM.") - return + elif isinstance(exc, commands_errors.PrivateMessageOnly): + embed = build_error_embed(description=f"{FAIL_EMOJI} This command can only be used in a DM.") - if isinstance(exc, commands_errors.NSFWChannelRequired): - await self.send_error_embed( - ctx, - description=f"{FAIL_EMOJI} This command can only be used in an NSFW channel.", - ) - return + elif isinstance(exc, commands_errors.NSFWChannelRequired): + embed = build_error_embed(description=f"{FAIL_EMOJI} This command can only be used in an NSFW channel.") + else: + embed = build_unhandled_application_embed(ctx, exc) - await self.send_unhandled_embed(ctx, exc) + await ctx.send(f"Sorry {ctx.author.mention}", embed=embed) async def _handle_command_invoke_error( self, @@ -145,11 +90,10 @@ async def _handle_command_invoke_error( original_exception = exc.__cause__ if original_exception is None: - await self.send_unhandled_embed(ctx, exc) + embed = build_unhandled_application_embed(ctx, exc) log.exception("Got ApplicationCommandInvokeError without a cause.", exc_info=exc) - return - if isinstance(original_exception, RateLimitExceededError): + elif isinstance(original_exception, RateLimitExceededError): msg = original_exception.msg or "Hit a rate-limit, please try again later." time_remaining = f"Expected reset: " footer = None @@ -157,17 +101,17 @@ async def _handle_command_invoke_error( footer = EmbedFooter( text="Spamming the command will only increase the time you have to wait.", ) - await self.send_error_embed( - ctx, + embed = build_error_embed( title="Rate limit exceeded", description=f"{FAIL_EMOJI} {msg}", fields=[EmbedField(name="", value=time_remaining)], footer=footer, ) - return + else: + embed = build_unhandled_application_embed(ctx, original_exception) + log.exception("Unhandled exception occurred.", exc_info=original_exception) - await self.send_unhandled_embed(ctx, original_exception) - log.exception("Unhandled exception occurred.", exc_info=original_exception) + await ctx.send(f"Sorry {ctx.author.mention}", embed=embed) @Cog.listener() async def on_application_command_error(self, ctx: ApplicationContext, exc: errors.DiscordException) -> None: @@ -180,7 +124,28 @@ async def on_application_command_error(self, ctx: ApplicationContext, exc: error await self._handle_command_invoke_error(ctx, exc) return - await self.send_unhandled_embed(ctx, exc) + embed = build_unhandled_application_embed(ctx, exc) + await ctx.send(f"Sorry {ctx.author.mention}", embed=embed) + + @Cog.listener() + async def on_error(self, event_method: str, *args: object, **kwargs: object) -> None: + """Handle exception that have occurred in any event. + + This is a catch-all for errors that aren't handled by any other listeners, or fell through (were re-raised). + """ + log.exception(f"Unhandled excepton occurred {event_method=} {args=!r} {kwargs=!r}", exc_info=True) + + exc = sys.exc_info()[1] + if exc is None: + return + + for arg in chain(args, kwargs.values()): + if isinstance(arg, ApplicationContext): + ctx = arg + + embed = build_unhandled_application_embed(ctx, exc) + await ctx.send(f"Sorry {ctx.author.mention}", embed=embed) + return def setup(bot: Bot) -> None: diff --git a/src/exts/error_handler/utils.py b/src/exts/error_handler/utils.py new file mode 100644 index 0000000..aa88f1c --- /dev/null +++ b/src/exts/error_handler/utils.py @@ -0,0 +1,56 @@ +import textwrap + +from discord import ApplicationContext, Colour, Embed, EmbedField, EmbedFooter + +from src.settings import GITHUB_REPO +from src.utils.log import get_logger + +log = get_logger(__name__) + + +def build_error_embed( + *, + title: str | None = None, + description: str | None = None, + fields: list[EmbedField] | None = None, + footer: EmbedFooter | None = None, +) -> Embed: + """Create an embed regarding the unhandled exception that occurred.""" + if title is None and description is None: + raise ValueError("You need to provide either a title or a description.") + + return Embed( + title=title, + description=description, + color=Colour.red(), + fields=fields, + footer=footer, + ) + + +def build_unhandled_application_embed(ctx: ApplicationContext, exc: BaseException) -> Embed: + """Build an embed regarding the unhandled exception that occurred.""" + msg = f"Exception {exc!r} has occurred from command {ctx.command.qualified_name} invoked by {ctx.author.id}" + if ctx.message: + msg += f" in message {ctx.message.content!r}" + if ctx.guild: + msg += f" on guild {ctx.guild.id}" + log.warning(msg) + + return build_error_embed( + title="Unhandled exception", + description=textwrap.dedent( + f""" + Unknown error has occurred without being properly handled. + Please report this at the [GitHub repository]({GITHUB_REPO}) + + **Command**: `{ctx.command.qualified_name}` + **Exception details**: ```{exc.__class__.__name__}: {exc}``` + """ + ), + ) + + +async def send_unhandled_application_embed(ctx: ApplicationContext, exc: BaseException) -> None: + """Send an embed regarding the unhandled exception that occurred.""" + await ctx.send(f"Sorry {ctx.author.mention}", embed=build_unhandled_application_embed(ctx, exc)) diff --git a/src/exts/error_handler/view.py b/src/exts/error_handler/view.py new file mode 100644 index 0000000..17b1ecb --- /dev/null +++ b/src/exts/error_handler/view.py @@ -0,0 +1,51 @@ +import sys +import textwrap +from typing import Self, override + +import discord +from discord.interactions import Interaction +from discord.ui import Item + +from src.exts.error_handler.utils import build_error_embed +from src.settings import GITHUB_REPO +from src.utils.log import get_logger + +log = get_logger(__name__) + + +# TODO: Is this file really the right place for this? Or would utils work better? +class ErrorHandledView(discord.ui.View): + """View with error-handling support.""" + + @override + async def on_error(self, error: Exception, item: Item[Self], interaction: Interaction) -> None: + log.exception( + f"Unhandled exception in view: {self.__class__.__name__} (item={item.__class__.__name__})", + exc_info=True, + ) + + exc_info = sys.exc_info() + exc = exc_info[1] + if exc is None: + await super().on_error(error, item, interaction) + return + + embed = build_error_embed( + title="Unhandled exception", + description=textwrap.dedent( + f""" + Unknown error has occurred without being properly handled. + Please report this at the [GitHub repository]({GITHUB_REPO}) + + **View**: `{self.__class__.__name__}` + **Item**: `{item.__class__.__name__}` + **Exception details**: ```{exc.__class__.__name__}: {exc}``` + """ + ), + ) + if interaction.user is not None: + msg = f"Sorry {interaction.user.mention}" + else: + msg = "" + + await interaction.respond(msg, embed=embed) diff --git a/src/exts/tvdb_info/ui/_media_view.py b/src/exts/tvdb_info/ui/_media_view.py index ac8f114..b9a6a60 100644 --- a/src/exts/tvdb_info/ui/_media_view.py +++ b/src/exts/tvdb_info/ui/_media_view.py @@ -5,11 +5,12 @@ from src.bot import Bot from src.db_tables.user_list import UserList +from src.exts.error_handler.view import ErrorHandledView from ._reactive_buttons import ReactiveButton, ReactiveButtonStateStyle -class MediaView(discord.ui.View, ABC): +class MediaView(ErrorHandledView, ABC): """Base class for views that display info about some media (movie/series/episode).""" def __init__(self, *, bot: Bot, user_id: int, watched_list: UserList, favorite_list: UserList) -> None: diff --git a/src/exts/tvdb_info/ui/profile_view.py b/src/exts/tvdb_info/ui/profile_view.py index 262d779..5b21f45 100644 --- a/src/exts/tvdb_info/ui/profile_view.py +++ b/src/exts/tvdb_info/ui/profile_view.py @@ -8,6 +8,7 @@ from src.db_adapters import refresh_list_items from src.db_tables.media import Episode as EpisodeTable, Movie as MovieTable, Series as SeriesTable from src.db_tables.user_list import UserList, UserListItemKind +from src.exts.error_handler.view import ErrorHandledView from src.settings import MOVIE_EMOJI, SERIES_EMOJI from src.tvdb import Movie, Series from src.tvdb.client import FetchMeta, TvdbClient @@ -18,7 +19,7 @@ @final -class ProfileView(discord.ui.View): +class ProfileView(ErrorHandledView): """View for displaying user profiles with data about the user's added shows.""" fetched_favorite_movies: list[Movie] From 44a847639347965653e764be13ba4bdc24454d9b Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 28 Jul 2024 17:49:41 +0200 Subject: [PATCH 151/166] fixes --- src/exts/tvdb_info/ui/episode_view.py | 2 ++ src/exts/tvdb_info/ui/movie_series_view.py | 9 +++++---- src/exts/tvdb_info/ui/profile_view.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/exts/tvdb_info/ui/episode_view.py b/src/exts/tvdb_info/ui/episode_view.py index 593f26a..e62c074 100644 --- a/src/exts/tvdb_info/ui/episode_view.py +++ b/src/exts/tvdb_info/ui/episode_view.py @@ -99,6 +99,8 @@ async def _update_state(self) -> None: self.season_dropdown.options = self.season_dropdown.options[:25] warnings.warn("Too many seasons to display, truncating to 25", UserWarning, stacklevel=1) + self.watched_button.set_state(await self.is_watched()) + @property def current_episode(self) -> "Episode": """Get the current episode being displayed.""" diff --git a/src/exts/tvdb_info/ui/movie_series_view.py b/src/exts/tvdb_info/ui/movie_series_view.py index ccdd186..2c6e35f 100644 --- a/src/exts/tvdb_info/ui/movie_series_view.py +++ b/src/exts/tvdb_info/ui/movie_series_view.py @@ -14,6 +14,7 @@ from src.db_tables.user_list import UserList, UserListItemKind from src.settings import MOVIE_EMOJI, SERIES_EMOJI, THETVDB_COPYRIGHT_FOOTER, THETVDB_LOGO from src.tvdb.client import Movie, Series +from src.utils.iterators import get_first from ._media_view import MediaView from .episode_view import EpisodeView @@ -189,9 +190,9 @@ async def is_watched(self) -> bool: if self.media_data.episodes is None: return await super().is_watched() - last_ep = self.media_data.episodes[-1] + last_ep = get_first(episode for episode in reversed(self.media_data.episodes) if episode.aired) - if last_ep.id is None: + if not last_ep or last_ep.id is None: raise ValueError("Episode has no ID") item = await get_list_item(self.bot.db_session, self.watched_list, last_ep.id, UserListItemKind.EPISODE) @@ -199,8 +200,8 @@ async def is_watched(self) -> bool: @override async def set_watched(self, state: bool) -> None: - # When a series is marked as watched, we mark all of its episodes as watched. - # Similarly, unmarking will unmark all episodes. + # When a series is marked as watched, we mark all of its aired episodes as watched. + # Similarly, unmarking will unmark all episodes (aired or not). # If the series has no episodes, fall back to marking the season itself as watched / unwatched. if self.media_data.episodes is None: diff --git a/src/exts/tvdb_info/ui/profile_view.py b/src/exts/tvdb_info/ui/profile_view.py index 5b21f45..d86a953 100644 --- a/src/exts/tvdb_info/ui/profile_view.py +++ b/src/exts/tvdb_info/ui/profile_view.py @@ -121,7 +121,7 @@ async def _initialize(self) -> None: await Series.fetch( media_db_data.tvdb_id, client=self.tvdb_client, extended=True, meta=FetchMeta.TRANSLATIONS ) - for media_db_data in favorite_movies + for media_db_data in favorite_shows ] self.fetched_watched_movies = [ await Movie.fetch(media_db_data.tvdb_id, client=self.tvdb_client) for media_db_data in watched_movies From f72ce181e3d52cec2a5755d001f44538b908dbbe Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 28 Jul 2024 17:54:50 +0200 Subject: [PATCH 152/166] Tvdb says they send you 100 chars but then they send you more than that --- src/tvdb/generated_models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tvdb/generated_models.py b/src/tvdb/generated_models.py index 70e1988..13fd665 100644 --- a/src/tvdb/generated_models.py +++ b/src/tvdb/generated_models.py @@ -22,9 +22,8 @@ class Alias(BaseModel): language: str | None = Field( default=None, description="A 3-4 character string indicating the language of the alias, as defined in Language.", - max_length=4, ) - name: str | None = Field(default=None, description="A string containing the alias itself.", max_length=100) + name: str | None = Field(default=None, description="A string containing the alias itself.") class ArtworkBaseRecord(BaseModel): From a7407338e1942da6fbddbf1bfe242fbf403064d0 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 28 Jul 2024 17:58:26 +0200 Subject: [PATCH 153/166] Don't show result picker for single result --- src/exts/tvdb_info/ui/search_view.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/exts/tvdb_info/ui/search_view.py b/src/exts/tvdb_info/ui/search_view.py index b844f70..9434a4d 100644 --- a/src/exts/tvdb_info/ui/search_view.py +++ b/src/exts/tvdb_info/ui/search_view.py @@ -38,6 +38,9 @@ def _search_view( media_data=result, ) + if len(results) == 1: + return view + # Add support for switching between search results dynamically search_result_dropdown = discord.ui.Select( placeholder="Not what you're looking for? Select a different result.", From 126454c3f629bc45a2305cac8706c851b191ba0e Mon Sep 17 00:00:00 2001 From: Paillat Date: Sun, 28 Jul 2024 18:13:24 +0200 Subject: [PATCH 154/166] Fix groupby dosen't work for multiple groupable blocks --- src/exts/tvdb_info/ui/profile_view.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/exts/tvdb_info/ui/profile_view.py b/src/exts/tvdb_info/ui/profile_view.py index d86a953..26ea079 100644 --- a/src/exts/tvdb_info/ui/profile_view.py +++ b/src/exts/tvdb_info/ui/profile_view.py @@ -71,7 +71,9 @@ async def _initialize(self) -> None: # We don't actually care about episodes in the profile view, however, we need them # because of the way shows are marked as watched (last episode watched -> show watched). - for series_id, episodes in groupby(watched_episodes, key=lambda x: x.series_id): + for series_id, episodes in groupby( + sorted(watched_episodes, key=lambda x: x.series_id), key=lambda x: x.series_id + ): series = await Series.fetch(series_id, client=self.tvdb_client, extended=True, meta=FetchMeta.EPISODES) if series.episodes is None: raise ValueError("Found an episode in watched list for a series with no episodes") From 95de1b68cc8184e7419b051c2f3d6f835c512614 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 28 Jul 2024 18:41:03 +0200 Subject: [PATCH 155/166] Prevent interaction with views by others --- src/exts/tvdb_info/main.py | 7 +++--- src/exts/tvdb_info/ui/_media_view.py | 27 +++++++++++++++++++++- src/exts/tvdb_info/ui/episode_view.py | 15 +++++++++++- src/exts/tvdb_info/ui/movie_series_view.py | 17 +++++++++++++- src/exts/tvdb_info/ui/profile_view.py | 12 ++++++++++ src/exts/tvdb_info/ui/search_view.py | 19 +++++++++++---- 6 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 322e4ce..f6567d1 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -1,4 +1,4 @@ -from typing import Literal, cast +from typing import Literal from discord import ApplicationContext, Cog, Member, User, option, slash_command @@ -31,7 +31,7 @@ async def profile(self, ctx: ApplicationContext, *, user: User | Member | None = await ctx.defer() if user is None: - user = cast(User | Member, ctx.user) # for some reason, pyright thinks user can be None here + user = ctx.author # Convert Member to User (Member isn't a subclass of User...) if isinstance(user, Member): @@ -46,6 +46,7 @@ async def profile(self, ctx: ApplicationContext, *, user: User | Member | None = bot=self.bot, tvdb_client=self.tvdb_client, user=user, + invoker_user_id=ctx.author.id, watched_list=await user_get_list_safe(self.bot.db_session, db_user, "watched"), favorite_list=await user_get_list_safe(self.bot.db_session, db_user, "favorite"), ) @@ -114,7 +115,7 @@ async def search( await ctx.respond("No results found.") return - view = await search_view(self.bot, ctx.user.id, response) + view = await search_view(self.bot, ctx.user.id, ctx.user.id, response) await view.send(ctx.interaction) diff --git a/src/exts/tvdb_info/ui/_media_view.py b/src/exts/tvdb_info/ui/_media_view.py index b9a6a60..fbdb650 100644 --- a/src/exts/tvdb_info/ui/_media_view.py +++ b/src/exts/tvdb_info/ui/_media_view.py @@ -13,7 +13,15 @@ class MediaView(ErrorHandledView, ABC): """Base class for views that display info about some media (movie/series/episode).""" - def __init__(self, *, bot: Bot, user_id: int, watched_list: UserList, favorite_list: UserList) -> None: + def __init__( + self, + *, + bot: Bot, + user_id: int, + invoker_user_id: int, + watched_list: UserList, + favorite_list: UserList, + ) -> None: """Initialize MediaView. :param bot: The bot instance. @@ -30,6 +38,7 @@ def __init__(self, *, bot: Bot, user_id: int, watched_list: UserList, favorite_l self.bot = bot self.user_id = user_id + self.invoker_user_id = invoker_user_id self.watched_list = watched_list self.favorite_list = favorite_list @@ -108,6 +117,16 @@ async def _refresh(self) -> None: await self.message.edit(embed=self._get_embed(), view=self) + async def _ensure_correct_invoker(self, interaction: discord.Interaction) -> bool: + """Ensure that the interaction was invoked by the author of this view.""" + if interaction.user is None: + raise ValueError("Interaction user is None") + + if interaction.user.id != self.invoker_user_id: + await interaction.response.send_message("You can't interact with this view.", ephemeral=True) + return False + return True + @abstractmethod async def is_favorite(self) -> bool: """Check if the current media is marked as favorite by the user. @@ -147,6 +166,9 @@ async def send(self, interaction: discord.Interaction) -> None: async def _watched_button_callback(self, interaction: discord.Interaction) -> None: """Callback for when the user clicks on the mark as watched button.""" + if not await self._ensure_correct_invoker(interaction): + return + await interaction.response.defer() cur_state = self.watched_button.state await self.set_watched(not cur_state) @@ -156,6 +178,9 @@ async def _watched_button_callback(self, interaction: discord.Interaction) -> No async def _favorite_button_callback(self, interaction: discord.Interaction) -> None: """Callback for when the user clicks on the mark as favorite button.""" + if not await self._ensure_correct_invoker(interaction): + return + await interaction.response.defer() cur_state = self.favorite_button.state await self.set_favorite(not cur_state) diff --git a/src/exts/tvdb_info/ui/episode_view.py b/src/exts/tvdb_info/ui/episode_view.py index e62c074..c021ce9 100644 --- a/src/exts/tvdb_info/ui/episode_view.py +++ b/src/exts/tvdb_info/ui/episode_view.py @@ -22,13 +22,20 @@ def __init__( *, bot: Bot, user_id: int, + invoker_user_id: int, watched_list: UserList, favorite_list: UserList, series: Series, season_idx: int = 1, episode_idx: int = 1, ) -> None: - super().__init__(bot=bot, user_id=user_id, watched_list=watched_list, favorite_list=favorite_list) + super().__init__( + bot=bot, + user_id=user_id, + invoker_user_id=invoker_user_id, + watched_list=watched_list, + favorite_list=favorite_list, + ) self.series = series @@ -156,6 +163,9 @@ async def set_watched(self, state: bool) -> None: async def _episode_dropdown_callback(self, interaction: discord.Interaction) -> None: """Callback for when the user selects an episode from the drop-down.""" + if not await self._ensure_correct_invoker(interaction): + return + if not self.episode_dropdown.values or not isinstance(self.episode_dropdown.values[0], str): raise ValueError("Episode dropdown values are empty or non-string, but callback was triggered.") @@ -171,6 +181,9 @@ async def _episode_dropdown_callback(self, interaction: discord.Interaction) -> async def _season_dropdown_callback(self, interaction: discord.Interaction) -> None: """Callback for when the user selects a season from the drop-down.""" + if not await self._ensure_correct_invoker(interaction): + return + if not self.season_dropdown.values or not isinstance(self.season_dropdown.values[0], str): raise ValueError("Episode dropdown values are empty or non-string, but callback was triggered.") diff --git a/src/exts/tvdb_info/ui/movie_series_view.py b/src/exts/tvdb_info/ui/movie_series_view.py index 2c6e35f..95d7833 100644 --- a/src/exts/tvdb_info/ui/movie_series_view.py +++ b/src/exts/tvdb_info/ui/movie_series_view.py @@ -29,11 +29,18 @@ def __init__( *, bot: Bot, user_id: int, + invoker_user_id: int, watched_list: UserList, favorite_list: UserList, media_data: Movie | Series, ) -> None: - super().__init__(bot=bot, user_id=user_id, watched_list=watched_list, favorite_list=favorite_list) + super().__init__( + bot=bot, + user_id=user_id, + invoker_user_id=invoker_user_id, + watched_list=watched_list, + favorite_list=favorite_list, + ) self.media_data = media_data @@ -140,6 +147,7 @@ def __init__( *, bot: Bot, user_id: int, + invoker_user_id: int, watched_list: UserList, favorite_list: UserList, media_data: Series, @@ -147,6 +155,7 @@ def __init__( super().__init__( bot=bot, user_id=user_id, + invoker_user_id=invoker_user_id, watched_list=watched_list, favorite_list=favorite_list, media_data=media_data, @@ -172,9 +181,13 @@ def _add_items(self) -> None: async def _episodes_button_callback(self, interaction: discord.Interaction) -> None: """Callback for when the user clicks the "View Episodes" button.""" + if not await self._ensure_correct_invoker(interaction): + return + view = EpisodeView( bot=self.bot, user_id=self.user_id, + invoker_user_id=self.invoker_user_id, watched_list=self.watched_list, favorite_list=self.favorite_list, series=self.media_data, @@ -250,6 +263,7 @@ def __init__( *, bot: Bot, user_id: int, + invoker_user_id: int, watched_list: UserList, favorite_list: UserList, media_data: Movie, @@ -257,6 +271,7 @@ def __init__( super().__init__( bot=bot, user_id=user_id, + invoker_user_id=invoker_user_id, watched_list=watched_list, favorite_list=favorite_list, media_data=media_data, diff --git a/src/exts/tvdb_info/ui/profile_view.py b/src/exts/tvdb_info/ui/profile_view.py index 26ea079..d7fbda2 100644 --- a/src/exts/tvdb_info/ui/profile_view.py +++ b/src/exts/tvdb_info/ui/profile_view.py @@ -35,6 +35,7 @@ def __init__( bot: Bot, tvdb_client: TvdbClient, user: discord.User, + invoker_user_id: int, watched_list: UserList, favorite_list: UserList, ) -> None: @@ -42,6 +43,7 @@ def __init__( self.bot = bot self.tvdb_client = tvdb_client self.discord_user = user + self.invoker_user_id = invoker_user_id self.watched_list = watched_list self.favorite_list = favorite_list @@ -151,6 +153,16 @@ async def _initialize(self) -> None: # as that's the only thing we need here and while it is a bit inconsistent, it's a LOT more efficient. self.episodes_total = len(watched_episodes) + async def _ensure_correct_invoker(self, interaction: discord.Interaction) -> bool: + """Ensure that the interaction was invoked by the author of this view.""" + if interaction.user is None: + raise ValueError("Interaction user is None") + + if interaction.user.id != self.invoker_user_id: + await interaction.response.send_message("You can't interact with this view.", ephemeral=True) + return False + return True + def _get_embed(self) -> discord.Embed: embed = discord.Embed( title="Profile", diff --git a/src/exts/tvdb_info/ui/search_view.py b/src/exts/tvdb_info/ui/search_view.py index 9434a4d..b26eb9e 100644 --- a/src/exts/tvdb_info/ui/search_view.py +++ b/src/exts/tvdb_info/ui/search_view.py @@ -14,6 +14,7 @@ def _search_view( bot: Bot, user_id: int, + invoker_user_id: int, watched_list: UserList, favorite_list: UserList, results: Sequence[Movie | Series], @@ -25,6 +26,7 @@ def _search_view( view = MovieView( bot=bot, user_id=user_id, + invoker_user_id=invoker_user_id, watched_list=watched_list, favorite_list=favorite_list, media_data=result, @@ -33,6 +35,7 @@ def _search_view( view = SeriesView( bot=bot, user_id=user_id, + invoker_user_id=invoker_user_id, watched_list=watched_list, favorite_list=favorite_list, media_data=result, @@ -56,12 +59,15 @@ def _search_view( ) async def _search_dropdown_callback(interaction: discord.Interaction) -> None: + if not await view._ensure_correct_invoker(interaction): # pyright: ignore[reportPrivateUsage] + return + if not search_result_dropdown.values or not isinstance(search_result_dropdown.values[0], str): raise ValueError("Dropdown values are empty or not a string but callback was triggered.") index = int(search_result_dropdown.values[0]) - view = _search_view(bot, user_id, watched_list, favorite_list, results, index) - await view.send(interaction) + new_view = _search_view(bot, user_id, invoker_user_id, watched_list, favorite_list, results, index) + await new_view.send(interaction) search_result_dropdown.callback = _search_dropdown_callback @@ -69,7 +75,12 @@ async def _search_dropdown_callback(interaction: discord.Interaction) -> None: return view -async def search_view(bot: Bot, user_id: int, results: Sequence[Movie | Series]) -> MovieView | SeriesView: +async def search_view( + bot: Bot, + user_id: int, + invoker_user_id: int, + results: Sequence[Movie | Series], +) -> MovieView | SeriesView: """Construct a view showing the search results. This uses specific views to render a single result. This view is then modified to @@ -81,4 +92,4 @@ async def search_view(bot: Bot, user_id: int, results: Sequence[Movie | Series]) await refresh_list_items(bot.db_session, watched_list) await refresh_list_items(bot.db_session, favorite_list) - return _search_view(bot, user_id, watched_list, favorite_list, results, 0) + return _search_view(bot, user_id, invoker_user_id, watched_list, favorite_list, results, 0) From dd94770452b75050cf18723bddc2c1b67c6e46ce Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 1 Aug 2024 14:35:35 +0200 Subject: [PATCH 156/166] readme: Update bot configuration info --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8d8927f..b12d2c3 100644 --- a/README.md +++ b/README.md @@ -60,26 +60,27 @@ The bot is configured using environment variables. You can either create a `.env there, or you can set / export them manually. Using the `.env` file is generally a better idea and will likely be more convenient. - - | Variable name | Type | Default | Description | | -------------------------- | ------ | ------------- | ------------------------------------------------------------------------------------------------------------------- | | `BOT_TOKEN` | string | N/A | Bot token of the discord application (see: [this guide][bot-token-guide] if you don't have one yet) | | `TVDB_API_KEY` | string | N/A | API key for TVDB (see [this page][tvdb-api-page] if you don't have one yet) | | `TVDB_RATE_LIMIT_REQUESTS` | int | 5 | Amount of requests that the bot is allowed to make to the TVDB API within `TVDB_RATE_LIMIT_PERIOD` | -| `TVDB_RATE_LIMIT_PERIOD` | float | 5 | Period of time in seconds, within which the bot can make up to `TVDB_RATE_LIMIT_REQUESTS` requests to the TVDB API. | +| `TVDB_RATE_LIMIT_PERIOD` | float | 100 | Period of time in seconds, within which the bot can make up to `TVDB_RATE_LIMIT_REQUESTS` requests to the TVDB API. | | `SQLITE_DATABASE_FILE` | path | ./database.db | Path to sqlite database file, can be relative to project root (if the file doesn't yet exists, it will be created) | -| `ECHO_SQL` | bool | 0 | If `1`, print out every SQL command that SQLAlchemy library runs internally (can be useful when debugging) | -| `DB_ALWAYS_MIGRATE` | bool | 0 | If `1`, database migrations will always be performed, even on a new database (instead of just creating the tables). | -| `DEBUG` | bool | 0 | If `1`, debug logs will be enabled, if `0` only info logs and above will be shown | -| `LOG_FILE` | path | N/A | If set, also write the logs into given file, otherwise, only print them | -| `TRACE_LEVEL_FILTER` | custom | N/A | Configuration for trace level logging, see: [trace logs config section](#trace-logs-config) | [bot-token-guide]: https://guide.pycord.dev/getting-started/creating-your-first-bot#creating-the-bot-application [tvdb-api-page]: https://www.thetvdb.com/api-information +### Debug configuration variables + +| Variable name | Type | Default | Description | +| -------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------------------------- | +| `ECHO_SQL` | bool | 0 | If `1`, print out every SQL command that SQLAlchemy library runs internally (can be useful when debugging) | +| `DB_ALWAYS_MIGRATE` | bool | 0 | If `1`, database migrations will always be performed, even on a new database (instead of just creating the tables). | +| `DEBUG` | bool | 0 | If `1`, debug logs will be enabled, if `0` only info logs and above will be shown | +| `LOG_FILE` | path | N/A | If set, also write the logs into given file, otherwise, only print them | +| `TRACE_LEVEL_FILTER` | custom | N/A | Configuration for trace level logging, see: [trace logs config section](#trace-logs-config) | + ### Trace logs config We have a custom `trace` log level for the bot, which can be used for debugging purposes. This level is below `debug` From cf04c15e4bbed6c6edb6ba50a8a5eb6ba8eabe3e Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 1 Aug 2024 14:40:22 +0200 Subject: [PATCH 157/166] readme: Fix typo in poetry install cmd --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b12d2c3..e532c31 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ To run the bot, you'll first want to install all of the project's dependencies. it. To install the dependencies, you can run the `poetry install` command. If you only want to run the bot and you're not -interested in also developing / contributing, you can also run `poetry install --only-root`, which will skip the +interested in also developing / contributing, you can also run `poetry install --only main`, which will skip the development dependencies (tools for linting and testing). Once done, you will want to activate the virtual environment that poetry has just created for the project. To do so, From 29980179f4b22b2be472de354bc0abbabd62a150 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 1 Aug 2024 14:43:31 +0200 Subject: [PATCH 158/166] readme: Add missing closing parenthesis --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e532c31..dd5ca3b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ docker compose -f ./docker-compose.local.yaml up > [!IMPORTANT] > Note that you will still need to create a `.env` file with all of the configuration variables (see [the configuring -> section](#configuring-the-bot) +> section](#configuring-the-bot)) ## Configuring the bot From 130f7cfd2826ed1dcb1f74ef54061b2271cf567f Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 1 Aug 2024 14:53:20 +0200 Subject: [PATCH 159/166] Mention the lack of persistance for the database in docker-compose --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index dd5ca3b..e8aa1d9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,15 @@ docker compose -f ./docker-compose.local.yaml up > Note that you will still need to create a `.env` file with all of the configuration variables (see [the configuring > section](#configuring-the-bot)) +> [!NOTE] +> By default, the docker container will always use a brand new database. If you wish to persist the database across +> runs, make sure to modify the docker-compose file and mount the database file. In the container, this file will by +> default use the `/app/database.db` path. You can either mount this file from a [docker volume][docker-volumes] or +> from your file-system using a [bind mount][docker-bind-mount]. + +[docker-volumes]: https://docs.docker.com/storage/volumes/#use-a-volume-with-docker-compose +[docker-bind-mount]: https://docs.docker.com/storage/bind-mounts/#use-a-bind-mount-with-compose + ## Configuring the bot The bot is configured using environment variables. You can either create a `.env` file and define these variables From 10a2ad9b94e9fef50ec7b0501f8f0a92386a821a Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 1 Aug 2024 15:09:34 +0200 Subject: [PATCH 160/166] :memo: Add presentation and images --- presentation/img.png | Bin 0 -> 730095 bytes presentation/img_1.png | Bin 0 -> 33323 bytes presentation/img_2.png | Bin 0 -> 11934 bytes presentation/img_3.png | Bin 0 -> 17112 bytes presentation/img_4.png | Bin 0 -> 131658 bytes presentation/img_5.png | Bin 0 -> 42021 bytes presentation/presentation.md | 168 +++++++++++++++++++++++++++++++++++ 7 files changed, 168 insertions(+) create mode 100644 presentation/img.png create mode 100644 presentation/img_1.png create mode 100644 presentation/img_2.png create mode 100644 presentation/img_3.png create mode 100644 presentation/img_4.png create mode 100644 presentation/img_5.png create mode 100644 presentation/presentation.md diff --git a/presentation/img.png b/presentation/img.png new file mode 100644 index 0000000000000000000000000000000000000000..6352a4a0b3f73573f2aea693cee40a615ffb87d7 GIT binary patch literal 730095 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