├── qbreader ├── py.typed ├── _consts.py ├── __init__.py ├── _api_utils.py ├── types.py ├── synchronous.py └── asynchronous.py ├── docs ├── _static │ ├── logo.png │ └── favicon.ico ├── api │ ├── qbreader.types.rst │ ├── qbreader.asynchronous.rst │ ├── qbreader.synchronous.rst │ └── qbreader.rst ├── Makefile ├── make.bat ├── index.rst └── conf.py ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .readthedocs.yaml ├── LICENSE ├── tox.ini ├── tests ├── __init__.py ├── test_utils.py ├── test_types.py ├── test_sync.py └── test_async.py ├── pyproject.toml ├── .gitignore └── README.md /qbreader/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qbreader/python-module/HEAD/docs/_static/logo.png -------------------------------------------------------------------------------- /docs/_static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qbreader/python-module/HEAD/docs/_static/favicon.ico -------------------------------------------------------------------------------- /qbreader/_consts.py: -------------------------------------------------------------------------------- 1 | """Constants for the qbreader package.""" 2 | 3 | BASE_URL = "https://www.qbreader.org/api" 4 | -------------------------------------------------------------------------------- /docs/api/qbreader.types.rst: -------------------------------------------------------------------------------- 1 | qbreader.types module 2 | ===================== 3 | 4 | .. automodule:: qbreader.types 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/qbreader.asynchronous.rst: -------------------------------------------------------------------------------- 1 | qbreader.asynchronous module 2 | ============================ 3 | 4 | .. automodule:: qbreader.asynchronous 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/qbreader.synchronous.rst: -------------------------------------------------------------------------------- 1 | qbreader.synchronous module 2 | =========================== 3 | 4 | .. automodule:: qbreader.synchronous 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/qbreader.rst: -------------------------------------------------------------------------------- 1 | qbreader package 2 | ================ 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | qbreader.asynchronous 11 | qbreader.synchronous 12 | qbreader.types 13 | 14 | Module contents 15 | --------------- 16 | 17 | .. automodule:: qbreader 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /qbreader/__init__.py: -------------------------------------------------------------------------------- 1 | """The official qbreader API python wrapper. 2 | 3 | .. note:: 4 | Even though useful type aliases defined in `qbreader.types` are reexported here, 5 | they are not documented here by `sphinx-autodoc`, but are documented in the 6 | `types` module. 7 | 8 | See: 9 | * https://github.com/sphinx-doc/sphinx/issues/8547 10 | * https://github.com/sphinx-doc/sphinx/issues/1063 11 | """ 12 | 13 | import importlib.metadata 14 | 15 | import qbreader.types as types 16 | from qbreader.asynchronous import Async 17 | from qbreader.synchronous import Sync 18 | from qbreader.types import * # noqa: F401, F403 19 | 20 | __version__ = importlib.metadata.version("qbreader") 21 | __all__ = ( 22 | "Async", 23 | "Sync", 24 | "types", 25 | ) 26 | 27 | # add all symbols from qbreader.types to __all__ 28 | __all__ += types.__all__ # type: ignore 29 | 30 | del importlib 31 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Package and publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | environment: pypi 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4.2.2 14 | 15 | - name: Setup Python 3.13 16 | uses: actions/setup-python@v5.3.0 17 | with: 18 | python-version: "3.13" 19 | 20 | - name: Install Poetry 21 | run: pipx install poetry --python $(which python) 22 | 23 | - name: Install project 24 | run: poetry install 25 | 26 | - name: Test with tox 27 | run: poetry run tox 28 | 29 | - name: Build package 30 | run: poetry build 31 | 32 | - name: Publish package 33 | env: 34 | PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 35 | run: poetry publish -u __token__ -p "$PYPI_API_TOKEN" 36 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the OS, Python version and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.13" 13 | jobs: 14 | post_install: 15 | # Install poetry 16 | # https://python-poetry.org/docs/#installing-manually 17 | - pip install poetry 18 | # Install dependencies with 'docs' dependency group 19 | # https://python-poetry.org/docs/managing-dependencies/#dependency-groups 20 | # VIRTUAL_ENV needs to be set manually for now. 21 | # See https://github.com/readthedocs/readthedocs.org/pull/11152/ 22 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs 23 | 24 | # Build documentation in the "docs/" directory with Sphinx 25 | sphinx: 26 | configuration: docs/conf.py 27 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 QBreader 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>4 4 | envlist = 5 | lint 6 | type 7 | py311 8 | py312 9 | py313 10 | docs 11 | 12 | [testenv] 13 | allowlist_externals = poetry 14 | commands_pre = 15 | poetry install 16 | commands = 17 | poetry run pytest 18 | 19 | [testenv:lint] 20 | allowlist_externals = poetry 21 | commands = 22 | poetry run flake8 23 | poetry run pydocstyle qbreader 24 | poetry run isort --check --diff . 25 | poetry run black --check --diff . 26 | 27 | [testenv:format] 28 | allowlist_externals = poetry 29 | commands = 30 | poetry run black . 31 | poetry run isort . 32 | 33 | [testenv:type] 34 | allowlist_externals = poetry 35 | commands = 36 | poetry run mypy --no-incremental . 37 | 38 | [testenv:docs] 39 | description = generate and build docs 40 | allowlist_externals = poetry, rm 41 | commands = 42 | rm -rf docs/api 43 | poetry run sphinx-apidoc -Tefo docs/api qbreader 44 | poetry run sphinx-build -TE -b html -d docs/_build/doctrees docs docs/_build 45 | 46 | [testenv:autodoc] 47 | description = generate autodocs 48 | allowlist_externals = poetry, rm 49 | commands = 50 | rm -rf docs/api 51 | sphinx-apidoc -Tefo docs/api qbreader 52 | 53 | [testenv:build-docs] 54 | description = build docs from .rst files 55 | allowlist_externals = poetry 56 | commands = 57 | poetry run sphinx-build -TE -b html -d docs/_build/doctrees docs docs/_build 58 | 59 | [flake8] 60 | max_line_length = 88 61 | exclude = 62 | .vscode 63 | .git 64 | __pycache__ 65 | .venv 66 | .tox 67 | .mypy_cache 68 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from urllib.request import urlopen 3 | 4 | import pytest 5 | 6 | 7 | def check_internet_connection(): 8 | """Check if there is an internet connection.""" 9 | try: 10 | urlopen("https://www.qbreader.org") 11 | return True 12 | except Exception: 13 | # you may want to check if you've installed SSL certificates 14 | # https://stackoverflow.com/questions/44649449/brew-installation-of-python-3-6-1-ssl-certificate-verify-failed-certificate 15 | return 16 | 17 | 18 | # do not run tests if there is no internet connection 19 | assert check_internet_connection(), "No internet connection" 20 | 21 | 22 | def assert_exception( 23 | func: Callable, 24 | exception, 25 | *args, 26 | **kwargs, 27 | ): 28 | """Assert that a function raises an exception.""" 29 | with pytest.raises(exception): 30 | func(*args, **kwargs) 31 | 32 | 33 | async def async_assert_exception( 34 | func: Callable, 35 | exception, 36 | *args, 37 | **kwargs, 38 | ): 39 | """Assert that an async function raises an exception.""" 40 | with pytest.raises(exception): 41 | await func(*args, **kwargs) 42 | 43 | 44 | def assert_warning( 45 | func: Callable, 46 | warning, 47 | *args, 48 | **kwargs, 49 | ): 50 | """Assert that a function raises a warning.""" 51 | with pytest.warns(warning): 52 | return func(*args, **kwargs) 53 | 54 | 55 | async def async_assert_warning( 56 | func: Callable, 57 | warning, 58 | *args, 59 | **kwargs, 60 | ): 61 | """Assert that an async function raises a warning.""" 62 | with pytest.warns(warning): 63 | return await func(*args, **kwargs) 64 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. python-qbreader documentation master file, created by 2 | sphinx-quickstart on Fri Sep 22 19:37:45 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | python-qbreader 7 | =============== 8 | 9 | .. toctree:: 10 | :hidden: 11 | :caption: API Reference 12 | 13 | qbreader 14 | 15 | .. toctree:: 16 | :hidden: 17 | :caption: Indices 18 | 19 | genindex 20 | modindex 21 | 22 | ``qbreader`` is a Python wrapper to the qbreader_ API as well as a general quizbowl library. It provides 23 | both asynchronous and synchronous interfaces to the API along with functionality for representing questions. 24 | 25 | A small example 26 | --------------- 27 | 28 | .. code:: python 29 | 30 | >>> from qbreader import Sync as qbr # synchronous interface 31 | >>> tossup = qbr.random_tossup()[0] 32 | >>> tossup.question 33 | 'Tim Peters wrote 19 “guiding principles” of this programming language, which include the maxim “Complex is better than complicated.” The “pandas” library was written for this language. Unicode string values had to be defined with a “u” in version 2 of this language. Libraries in this language include Tkinter, Tensorflow, (*) NumPy (“numb pie”) and SciPy (“sigh pie”). The framework Django was written in this language. This language uses “duck typing.” Variables in this language are often named “spam” and “eggs.” Guido van Rossum invented, for 10 points, what programming language named for a British comedy troupe?' 34 | >>> tossup.answer 35 | 'Python' 36 | >>> tossup.category 37 | 38 | >>> tossup.subcategory 39 | 40 | >>> tossup.difficulty 41 | 42 | >>> tossup.set 43 | '2022 Prison Bowl' 44 | >>> (tossup.packet_number, tossup.question_number) 45 | (4, 20) 46 | 47 | .. _qbreader: https://www.qbreader.org/ -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | import sys 10 | from os import path 11 | 12 | import qbreader 13 | 14 | sys.path.insert(0, path.abspath("..")) 15 | 16 | project = "python-qbreader" 17 | copyright = "2024, Sky Hong, Rohan Arni, Geoffrey Wu" 18 | author = "Sky Hong, Rohan Arni, Geoffrey Wu" 19 | release = qbreader.__version__ 20 | 21 | # -- General configuration --------------------------------------------------- 22 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 23 | 24 | extensions = [ 25 | "sphinx.ext.autodoc", 26 | "sphinx.ext.duration", 27 | "sphinx.ext.intersphinx", 28 | "sphinx.ext.napoleon", 29 | ] 30 | 31 | templates_path = ["_templates"] 32 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 33 | default_role = "py:obj" 34 | 35 | highlight_language = "python3" 36 | 37 | autodoc_member_order = "bysource" 38 | autodoc_type_aliases = { 39 | "QuestionType": "qbreader.types.QuestionType", 40 | "SearchType": "qbreader.types.SearchType", 41 | "ValidDifficulties": "qbreader.types.ValidDifficulties", 42 | "UnnormalizedDifficulty": "qbreader.types.UnnormalizedDifficulty", 43 | "UnnormalizedCategory": "qbreader.types.UnnormalizedCategory", 44 | "UnnormalizedSubcategory": "qbreader.types.UnnormalizedSubcategory", 45 | } 46 | 47 | # -- Options for HTML output ------------------------------------------------- 48 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 49 | 50 | html_theme = "furo" 51 | html_favicon = "_static/favicon.ico" 52 | html_logo = "_static/logo.png" 53 | html_static_path = ["_static"] 54 | 55 | intersphinx_mapping = { 56 | "python": ("https://docs.python.org/3", None), 57 | "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test, lint, and build library 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test-library: 7 | 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest] 12 | python-version: [3.11, 3.12, 3.13] 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4.2.2 17 | 18 | - name: Setup Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5.3.0 20 | with: 21 | python-version: "${{ matrix.python-version }}" 22 | 23 | - name: Install Poetry 24 | run: pipx install poetry --python $(which python) 25 | 26 | - name: Install project 27 | run: poetry install 28 | 29 | - name: Test with tox 30 | run: poetry run tox 31 | 32 | test-docs: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4.2.2 36 | 37 | - name: Setup Python 3.13 38 | uses: actions/setup-python@v5.3.0 39 | with: 40 | python-version: "3.13" # use latest Python version 41 | 42 | - name: Install Poetry 43 | run: pipx install poetry --python $(which python) 44 | 45 | - name: Install project 46 | run: poetry install 47 | 48 | - name: Generate docs 49 | run: poetry run tox -e docs 50 | 51 | - name: Check if repo docs are up to date 52 | run: | 53 | git diff --exit-code HEAD 54 | 55 | test-deps: 56 | 57 | runs-on: ${{ matrix.os }} 58 | strategy: 59 | matrix: 60 | os: [ubuntu-latest] 61 | python-version: [3.11, 3.12, 3.13] 62 | 63 | steps: 64 | - name: Checkout 65 | uses: actions/checkout@v4.2.2 66 | 67 | - name: Setup Python 68 | uses: actions/setup-python@v5.3.0 69 | with: 70 | python-version: "${{ matrix.python-version }}" 71 | 72 | - name: Install Poetry 73 | run: pipx install poetry --python $(which python) 74 | 75 | - name: Check pyproject.toml and lockfile 76 | run: | 77 | poetry check 78 | poetry check --lock 79 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "qbreader" 3 | version = "1.0.1" 4 | requires-python = ">=3.11" 5 | description = "Quizbowl library and Python wrapper for the qbreader API" 6 | authors = [ 7 | { name = "Sky \"skysomorphic\" Hong", email = "skysomorphic@pm.me" }, 8 | { name = "Rohan Arni" }, 9 | { name = "Geoffrey Wu", email = "geoffreywu1000@gmail.com" }, 10 | ] 11 | maintainers = [ 12 | { name = "Sky \"skysomorphic\" Hong", email = "skysomorphic@pm.me" }, 13 | { name = "Geoffrey Wu", email = "geoffreywu1000@gmail.com" }, 14 | ] 15 | license = { text = "MIT" } 16 | readme = "README.md" 17 | keywords = ["quizbowl", "quiz bowl", "qbreader"] 18 | dynamic = ["classifiers"] 19 | dependencies = ["requests (>=2.31.0,<3.0.0)", "aiohttp (>=3.8.4,<4.0.0)"] 20 | 21 | [project.urls] 22 | homepage = "https://github.com/qbreader/python-module" 23 | repository = "https://github.com/qbreader/python-module" 24 | documentation = "https://python-qbreader.readthedocs.io/" 25 | 26 | [tool.poetry] 27 | classifiers = [ 28 | "Development Status :: 5 - Production/Stable", 29 | "Intended Audience :: Developers", 30 | "Intended Audience :: Education", 31 | "Intended Audience :: Science/Research", 32 | "Operating System :: OS Independent", 33 | "Topic :: Games/Entertainment", 34 | "Topic :: Software Development :: Libraries :: Python Modules", 35 | "Typing :: Typed", 36 | ] 37 | include = [{ path = "tests", format = "sdist" }] 38 | 39 | [tool.poetry.group.dev.dependencies] 40 | black = "^23.3.0" 41 | isort = "^5.12.0" 42 | flake8 = "^6.0.0" 43 | pydocstyle = "^6.3.0" 44 | mypy = "^1.4.1" 45 | types-requests = "^2.31.0.1" 46 | tox = "^4.6.4" 47 | pytest = "^7.4.0" 48 | pytest-cov = "^4.1.0" 49 | pytest-asyncio = "^0.21.1" 50 | 51 | [tool.poetry.group.docs.dependencies] 52 | sphinx = "^7.2.6" 53 | numpydoc = "^1.5.0" 54 | furo = "^2023.9.10" 55 | 56 | [tool.black] 57 | line-length = 88 58 | 59 | [tool.isort] 60 | profile = "black" 61 | line_length = 88 62 | 63 | [tool.pydocstyle] 64 | convention = "numpy" 65 | 66 | [tool.pytest.ini_options] 67 | addopts = "-v --cov=qbreader --cov-report=term-missing --cov-report=xml --cov-report=html" 68 | testpaths = "tests" 69 | asyncio_mode = "strict" 70 | 71 | [build-system] 72 | requires = ["poetry-core>=2.0"] 73 | build-backend = "poetry.core.masonry.api" 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | share/python-wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | *.py,cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | cover/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | .pybuilder/ 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | # For a library or package, you might want to ignore these files since the code is 86 | # intended to run in multiple environments; otherwise, check them in: 87 | # .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # poetry 97 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 98 | # This is especially recommended for binary packages to ensure reproducibility, and is more 99 | # commonly ignored for libraries. 100 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 101 | #poetry.lock 102 | 103 | # pdm 104 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 105 | #pdm.lock 106 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 107 | # in version control. 108 | # https://pdm.fming.dev/#use-with-ide 109 | .pdm.toml 110 | 111 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 112 | __pypackages__/ 113 | 114 | # Celery stuff 115 | celerybeat-schedule 116 | celerybeat.pid 117 | 118 | # SageMath parsed files 119 | *.sage.py 120 | 121 | # Environments 122 | .env 123 | .venv 124 | env/ 125 | venv/ 126 | ENV/ 127 | env.bak/ 128 | venv.bak/ 129 | 130 | # Spyder project settings 131 | .spyderproject 132 | .spyproject 133 | 134 | # Rope project settings 135 | .ropeproject 136 | 137 | # mkdocs documentation 138 | /site 139 | 140 | # mypy 141 | .mypy_cache/ 142 | .dmypy.json 143 | dmypy.json 144 | 145 | # Pyre type checker 146 | .pyre/ 147 | 148 | # pytype static type analyzer 149 | .pytype/ 150 | 151 | # Cython debug symbols 152 | cython_debug/ 153 | 154 | # PyCharm 155 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 156 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 157 | # and can be added to the global gitignore or merged into this file. For a more nuclear 158 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 159 | #.idea/ 160 | 161 | # vscode 162 | .vscode/ 163 | 164 | config.json 165 | .DS_Store 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qbreader/python-module 2 | 3 | [![pypi](https://img.shields.io/pypi/v/qbreader?logo=pypi&logoColor=f0f0f0)](https://pypi.org/project/qbreader/) 4 | [![downloads](https://img.shields.io/pypi/dm/qbreader?logo=pypi&logoColor=f0f0f0)](https://pypi.org/project/qbreader/) 5 | [![python](https://img.shields.io/pypi/pyversions/qbreader?logo=python&logoColor=f0f0f0)](https://pypi.org/project/qbreader/) 6 | [![build](https://img.shields.io/github/actions/workflow/status/qbreader/python-module/test.yml?logo=github&logoColor=f0f0f0)](https://github.com/qbreader/python-module/actions/workflows/test.yml) 7 | [![docs](https://readthedocs.org/projects/python-qbreader/badge/?version=latest)](https://python-qbreader.readthedocs.io/en/latest/?badge=latest) 8 | 9 | --- 10 | 11 | ## Introduction 12 | 13 | `qbreader` is a Python wrapper to the qbreader API as well as a general quizbowl library. It provides 14 | both asynchronous and synchronous interfaces to the API along with functionality for representing questions. 15 | 16 | Documentation for this package is available at . 17 | 18 | ## Installation 19 | 20 | ### PyPI 21 | 22 | ```sh 23 | $ pip install qbreader 24 | # or whatever environment/dependency management system you use 25 | ``` 26 | 27 | ### Git 28 | 29 | Alternatively, you may install the most recent, but potentially unstable, development version directly from this repository. 30 | 31 | ```sh 32 | $ pip install git+https://github.com/qbreader/python-module.git 33 | ``` 34 | 35 | ### Developing with Poetry 36 | 37 | 1. [Install poetry](https://python-poetry.org/docs/#installation). 38 | 2. Run `poetry install` 39 | 3. After making changes, run `poetry run pytest` 40 | - You can also run individual files with `poetry run python your_file.py` 41 | 42 | ## A quick glance 43 | 44 | ```py 45 | >>> from qbreader import Sync as qbr # synchronous interface 46 | >>> sync_client = qbr() 47 | >>> tossup = sync_client.random_tossup()[0] 48 | >>> tossup.question 49 | 'Tim Peters wrote 19 “guiding principles” of this programming language, which include the maxim “Complex is better than complicated.” The “pandas” library was written for this language. Unicode string values had to be defined with a “u” in version 2 of this language. Libraries in this language include Tkinter, Tensorflow, (*) NumPy (“numb pie”) and SciPy (“sigh pie”). The framework Django was written in this language. This language uses “duck typing.” Variables in this language are often named “spam” and “eggs.” Guido van Rossum invented, for 10 points, what programming language named for a British comedy troupe?' 50 | >>> tossup.question_sanitized 51 | 'Tim Peters wrote 19 “guiding principles” of this programming language, which include the maxim “Complex is better than complicated.” The “pandas” library was written for this language. Unicode string values had to be defined with a “u” in version 2 of this language. Libraries in this language include Tkinter, Tensorflow, (*) NumPy (“numb pie”) and SciPy (“sigh pie”). The framework Django was written in this language. This language uses “duck typing.” Variables in this language are often named “spam” and “eggs.” Guido van Rossum invented, for 10 points, what programming language named for a British comedy troupe?' 52 | >>> tossup.answer 53 | 'Python' 54 | >>> tossup.answer_sanitized 55 | 'Python' 56 | >>> tossup.category 57 | 58 | >>> tossup.subcategory 59 | 60 | >>> tossup.difficulty 61 | 62 | >>> tossup.set.name 63 | '2022 Prison Bowl' 64 | >>> (tossup.packet.number, tossup.number) 65 | (4, 20) 66 | >>> bonus = sync_client.random_bonus()[0] 67 | >>> bonus.leadin 68 | 'The Curry–Howard isomorphism states that computer programs are directly equivalent to these mathematical constructs, which can be automated using the languages Lean or Rocq (“rock”). For 10 points each:' 69 | >>> bonus.leadin_sanitized 70 | 'The Curry-Howard isomorphism states that computer programs are directly equivalent to these mathematical constructs, which can be automated using the languages Lean or Rocq ("rock"). For 10 points each:' 71 | >>> bonus.parts 72 | ('Name these mathematical constructs that are used to formally demonstrate the truth of a mathematical statement.', 'According to the Curry–Howard isomorphism, these programming concepts correspond to individual propositions of a proof. One method of “inferring” these things in programming languages like Python is named for the duck test.', 'Haskell Curry also lends his name to “currying,” a common tool in functional programming languages that transforms a function into a sequence of functions each with a smaller value for this property. A description is acceptable.') 73 | >>> bonus.parts_sanitized 74 | ('Name these mathematical constructs that are used to formally demonstrate the truth of a mathematical statement.', 'According to the Curry-Howard isomorphism, these programming concepts correspond to individual propositions of a proof. One method of "inferring" these things in programming languages like Python is named for the duck test.', 'Haskell Curry also lends his name to "currying," a common tool in functional programming languages that transforms a function into a sequence of functions each with a smaller value for this property. A description is acceptable.') 75 | >>> bonus.answers 76 | ('mathematical proofs [or formal proofs or proofs of correctness; accept proof assistant or theorem prover or Rocq prover]', 'data types [accept type inference or duck typing]', 'arity [accept descriptions of the number of arguments or the number of parameters or the number of inputs of a function]') 77 | >>> bonus.answers_sanitized 78 | ('mathematical proofs [or formal proofs or proofs of correctness; accept proof assistant or theorem prover or Rocq prover]', 'data types [accept type inference or duck typing]', 'arity [accept descriptions of the number of arguments or the number of parameters or the number of inputs of a function]') 79 | >>> bonus.difficultyModifiers 80 | ('e', 'm', 'h') 81 | >>> bonus.values 82 | (10, 10, 10) 83 | ``` 84 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import qbreader._api_utils as api_utils 4 | from qbreader.types import Category, Difficulty, Subcategory 5 | from tests import assert_exception, assert_warning 6 | 7 | 8 | class TestNormalization: 9 | """Test normalization functions.""" 10 | 11 | @pytest.mark.parametrize( 12 | "boolean, expected", 13 | [ 14 | (True, "true"), 15 | (False, "false"), 16 | ("true", "true"), 17 | ("false", "false"), 18 | ], 19 | ) 20 | def test_normalize_bool(self, boolean, expected): 21 | assert api_utils.normalize_bool(boolean) == expected 22 | 23 | @pytest.mark.parametrize( 24 | "boolean, exception", [(None, TypeError), (1, TypeError), ("1", ValueError)] 25 | ) 26 | def test_normalize_bool_exception(self, boolean, exception): 27 | assert_exception(api_utils.normalize_bool, exception, boolean) 28 | 29 | @pytest.mark.parametrize( 30 | "diff, expected", 31 | [ 32 | (Difficulty.HS_REGS, "3"), 33 | (10, "10"), 34 | ("2", "2"), 35 | ([Difficulty.HS_REGS], "3"), 36 | ([Difficulty.HS_REGS, Difficulty.HS_HARD], "3,4"), 37 | ([Difficulty.HS_REGS, "3", "4"], "3,4"), 38 | (["3", "4"], "3,4"), 39 | (["1", "4", 7], "1,4,7"), 40 | (["3", "2", 5, Difficulty.HS_HARD], "2,3,4,5"), 41 | (list(range(11)), "0,1,2,3,4,5,6,7,8,9,10"), 42 | (None, ""), 43 | ([], ""), 44 | ], 45 | ) 46 | def test_normalize_diff(self, diff, expected): 47 | assert set(api_utils.normalize_diff(diff).split(",")) == set( 48 | expected.split(",") 49 | ) # contains the same elements 50 | 51 | @pytest.mark.parametrize( 52 | "diff, exception", 53 | [ 54 | (3.14, TypeError), 55 | (3 + 4j, TypeError), 56 | (True, TypeError), 57 | ([3.14], TypeError), 58 | ], 59 | ) 60 | def test_normalize_diff_exception(self, diff, exception): 61 | assert_exception(api_utils.normalize_diff, exception, diff) 62 | 63 | @pytest.mark.parametrize( 64 | "diff, warning", 65 | [ 66 | ("3.14", UserWarning), 67 | ("11", UserWarning), 68 | (["-1"], UserWarning), 69 | (1000, UserWarning), 70 | ], 71 | ) 72 | def test_normalize_diff_warning(self, diff, warning): 73 | assert assert_warning(api_utils.normalize_diff, warning, diff) == "" 74 | 75 | @pytest.mark.parametrize( 76 | "cat, expected", 77 | [ 78 | (Category.SCIENCE, "Science"), 79 | (Category.SCIENCE.value, "Science"), 80 | ("Science", "Science"), 81 | (["Science"], "Science"), 82 | ([Category.SCIENCE], "Science"), 83 | ([Category.SCIENCE, Category.LITERATURE], "Science,Literature"), 84 | ([Category.SCIENCE, "Literature"], "Science,Literature"), 85 | (["Science", "Literature"], "Science,Literature"), 86 | (["Science", "Literature", "Literature"], "Science,Literature"), 87 | ( 88 | [ 89 | "Science", 90 | "Literature", 91 | "Literature", 92 | Category.SCIENCE, 93 | Category.HISTORY, 94 | ], 95 | "Science,Literature,History", 96 | ), 97 | ], 98 | ) 99 | def test_normalize_cat(self, cat, expected): 100 | assert set(api_utils.normalize_cat(cat).split(",")) == set(expected.split(",")) 101 | 102 | @pytest.mark.parametrize( 103 | "cat, exception", 104 | [ 105 | (3.14, TypeError), 106 | (3 + 4j, TypeError), 107 | (True, TypeError), 108 | ([3.14], TypeError), 109 | ], 110 | ) 111 | def test_normalize_cat_exception(self, cat, exception): 112 | assert_exception(api_utils.normalize_cat, exception, cat) 113 | 114 | @pytest.mark.parametrize( 115 | "cat, warning", 116 | [ 117 | ("3.14", UserWarning), 118 | ("11", UserWarning), 119 | (["-1"], UserWarning), 120 | (1000, UserWarning), 121 | ], 122 | ) 123 | def test_normalize_cat_warning(self, cat, warning): 124 | assert assert_warning(api_utils.normalize_cat, warning, cat) == "" 125 | 126 | @pytest.mark.parametrize( 127 | "subcat, expected", 128 | [ # reused because subcat is a superset of cat 129 | (Subcategory.SCIENCE, "Science"), 130 | (Subcategory.SCIENCE.value, "Science"), 131 | ("Science", "Science"), 132 | (["Science"], "Science"), 133 | ([Subcategory.SCIENCE], "Science"), 134 | ([Subcategory.SCIENCE, Subcategory.LITERATURE], "Science,Literature"), 135 | ([Subcategory.SCIENCE, "Literature"], "Science,Literature"), 136 | (["Science", "Literature"], "Science,Literature"), 137 | (["Science", "Literature", "Literature"], "Science,Literature"), 138 | ( 139 | [ 140 | "Science", 141 | "Literature", 142 | "Literature", 143 | Subcategory.SCIENCE, 144 | Subcategory.HISTORY, 145 | ], 146 | "Science,Literature,History", 147 | ), 148 | ], 149 | ) 150 | def test_normalize_subcat(self, subcat, expected): 151 | assert set(api_utils.normalize_subcat(subcat).split(",")) == set( 152 | expected.split(",") 153 | ) 154 | 155 | @pytest.mark.parametrize( 156 | "subcat, exception", 157 | [ 158 | (3.14, TypeError), 159 | (3 + 4j, TypeError), 160 | (True, TypeError), 161 | ([3.14], TypeError), 162 | ], 163 | ) 164 | def test_normalize_subcat_exception(self, subcat, exception): 165 | assert_exception(api_utils.normalize_subcat, exception, subcat) 166 | 167 | @pytest.mark.parametrize( 168 | "subcat, warning", 169 | [ 170 | ("3.14", UserWarning), 171 | ("11", UserWarning), 172 | (["-1"], UserWarning), 173 | (1000, UserWarning), 174 | ], 175 | ) 176 | def test_normalize_subcat_warning(self, subcat, warning): 177 | assert assert_warning(api_utils.normalize_cat, warning, subcat) == "" 178 | 179 | @pytest.mark.parametrize( 180 | "dict, expected", 181 | [ 182 | ({"a": 1, "b": 2, "c": 3}, {"a": 1, "b": 2, "c": 3}), 183 | ({"a": 1, "b": None, "c": 3}, {"a": 1, "c": 3}), 184 | ({"a": 1, "b": None, None: ""}, {"a": 1}), 185 | ( 186 | {"a": 1, "b": None, None: None, "c": True, "d": False}, 187 | {"a": 1, "c": True, "d": False}, 188 | ), 189 | ], 190 | ) 191 | def test_prune_none(self, dict, expected): 192 | assert api_utils.prune_none(dict) == expected 193 | -------------------------------------------------------------------------------- /qbreader/_api_utils.py: -------------------------------------------------------------------------------- 1 | """Useful functions used by both the asynchronous and synchronous API wrappers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import warnings 6 | from enum import Enum, EnumType 7 | from typing import Iterable, Optional, Tuple, Union 8 | 9 | from qbreader.types import ( 10 | AlternateSubcategory, 11 | Category, 12 | Difficulty, 13 | Subcategory, 14 | UnnormalizedAlternateSubcategory, 15 | UnnormalizedCategory, 16 | UnnormalizedDifficulty, 17 | UnnormalizedSubcategory, 18 | ) 19 | 20 | 21 | def normalize_bool(boolean: Optional[Union[bool, str]]) -> str: 22 | """Normalize a boolean value to a string for HTTP requests.""" 23 | if isinstance(boolean, bool): 24 | return str(boolean).lower() 25 | elif isinstance(boolean, str): 26 | if (boolean := boolean.lower()) in ("true", "false"): 27 | return boolean 28 | else: 29 | raise ValueError(f"Invalid str value: {boolean}") 30 | else: 31 | raise TypeError(f"Invalid type: {type(boolean).__name__}, expected bool or str") 32 | 33 | 34 | def normalize_enumlike( 35 | unnormalized: Optional[Union[Enum, str, int, Iterable[Union[Enum, str, int]]]], 36 | enum_type: EnumType, 37 | ) -> str: 38 | """Normalize a single or list of enum-like values into a comma separated string.""" 39 | 40 | def valid_enumlike(item: Union[Enum, str, int]) -> bool: 41 | """Check if an item is a valid enum-like value.""" 42 | return ( 43 | item in enum_type.__members__.values() 44 | or str(item) in enum_type.__members__.values() 45 | ) 46 | 47 | strs: list[str] = [] 48 | 49 | if unnormalized is None: 50 | return "" 51 | 52 | if isinstance(unnormalized, (str, int, enum_type)): # single item 53 | if isinstance(unnormalized, bool): # python bools are ints 54 | raise TypeError( 55 | f"Invalid type: {type(unnormalized).__name__}, expected int, str, or " 56 | + f"{enum_type}." 57 | ) 58 | if valid_enumlike(unnormalized): # type: ignore 59 | # this is ok to ignore because it's counting strings as iterables 60 | strs.append(str(unnormalized)) 61 | return ",".join(strs) 62 | else: 63 | warnings.warn( 64 | f"Invalid value: {unnormalized} for {enum_type}.", UserWarning 65 | ) 66 | return "" 67 | 68 | if isinstance(unnormalized, Iterable): # iterable of str, int, or Difficulty 69 | for item in unnormalized: 70 | if isinstance(item, (str, int, enum_type)): 71 | if valid_enumlike(item): 72 | strs.append(str(item)) 73 | else: 74 | warnings.warn( 75 | f"Invalid value: {item} for {enum_type}.", UserWarning 76 | ) 77 | 78 | else: 79 | raise TypeError( 80 | f"Invalid type: {type(item).__name__}, expected int, str, or " 81 | + f"{enum_type}." 82 | ) 83 | strs = list(set(strs)) # remove duplicates 84 | return ",".join(strs) 85 | 86 | raise TypeError( 87 | f"Invalid type: {type(unnormalized).__name__}, expected int, str, {enum_type}, " 88 | + "or Iterable of those." 89 | ) 90 | 91 | 92 | def normalize_diff(unnormalized_diffs: UnnormalizedDifficulty): 93 | """Normalize a single or list of difficulty values to a comma separated string.""" 94 | return normalize_enumlike(unnormalized_diffs, Difficulty) 95 | 96 | 97 | def normalize_cat(unnormalized_cats: UnnormalizedCategory): 98 | """Normalize a single or list of categories to a comma separated string.""" 99 | return normalize_enumlike(unnormalized_cats, Category) 100 | 101 | 102 | def normalize_subcat(unnormalized_subcats: UnnormalizedCategory): 103 | """Normalize a single or list of subcategories to a comma separated string.""" 104 | return normalize_enumlike(unnormalized_subcats, Category) 105 | 106 | 107 | def category_correspondence( 108 | typed_alt_subcat: AlternateSubcategory, 109 | ) -> Tuple[Category | None, Subcategory | None]: 110 | """Return the corresponding category/subcategory for a alternate_subcategory.""" 111 | if typed_alt_subcat in [ 112 | AlternateSubcategory.ASTRONOMY, 113 | AlternateSubcategory.COMPUTER_SCIENCE, 114 | AlternateSubcategory.MATH, 115 | AlternateSubcategory.EARTH_SCIENCE, 116 | AlternateSubcategory.ENGINEERING, 117 | AlternateSubcategory.MISC_SCIENCE, 118 | ]: 119 | return (None, Subcategory.OTHER_SCIENCE) 120 | 121 | if typed_alt_subcat in [ 122 | AlternateSubcategory.ARCHITECTURE, 123 | AlternateSubcategory.DANCE, 124 | AlternateSubcategory.FILM, 125 | AlternateSubcategory.JAZZ, 126 | AlternateSubcategory.OPERA, 127 | AlternateSubcategory.PHOTOGRAPHY, 128 | AlternateSubcategory.MISC_ARTS, 129 | ]: 130 | return (None, Subcategory.OTHER_FINE_ARTS) 131 | 132 | if typed_alt_subcat in [ 133 | AlternateSubcategory.ANTHROPOLOGY, 134 | AlternateSubcategory.ECONOMICS, 135 | AlternateSubcategory.LINGUISTICS, 136 | AlternateSubcategory.PSYCHOLOGY, 137 | AlternateSubcategory.SOCIOLOGY, 138 | AlternateSubcategory.OTHER_SOCIAL_SCIENCE, 139 | ]: 140 | return (None, Subcategory.SOCIAL_SCIENCE) 141 | 142 | if typed_alt_subcat in [ 143 | AlternateSubcategory.DRAMA, 144 | AlternateSubcategory.LONG_FICTION, 145 | AlternateSubcategory.POETRY, 146 | AlternateSubcategory.SHORT_FICTION, 147 | AlternateSubcategory.MISC_LITERATURE, 148 | ]: 149 | return (Category.LITERATURE, None) 150 | 151 | # Accounts for AlternateSubcategory.PRACTICES and AlternateSubcategory.BELIEFS 152 | return (None, None) 153 | 154 | 155 | def normalize_cats( 156 | unnormalized_cats: UnnormalizedCategory, 157 | unnormalized_subcats: UnnormalizedSubcategory, 158 | unnormalized_alt_subcats: UnnormalizedAlternateSubcategory, 159 | ) -> Tuple[str, str, str]: 160 | """Normalize a single or list of categories, subcategories, and\ 161 | alternate_subcategories to their corresponding comma-separated strings, taking into\ 162 | account categories and subcategories that must be added for the\ 163 | alternate_subcategories to work.""" 164 | typed_alt_subcats: list[AlternateSubcategory] = [] 165 | 166 | if isinstance(unnormalized_alt_subcats, str): 167 | typed_alt_subcats.append(AlternateSubcategory(unnormalized_alt_subcats)) 168 | elif isinstance(unnormalized_alt_subcats, Iterable): 169 | for alt_subcat in unnormalized_alt_subcats: 170 | typed_alt_subcats.append(AlternateSubcategory(alt_subcat)) 171 | 172 | to_be_pushed_cats: list[Category] = [] 173 | to_be_pushed_subcats: list[Subcategory] = [] 174 | 175 | for alt_subcat in typed_alt_subcats: 176 | cat, subcat = category_correspondence(alt_subcat) 177 | if cat: 178 | to_be_pushed_cats.append(cat) 179 | if subcat: 180 | to_be_pushed_subcats.append(subcat) 181 | 182 | final_cats = [] 183 | if unnormalized_cats is None: 184 | final_cats = to_be_pushed_cats 185 | elif isinstance(unnormalized_cats, str): 186 | final_cats = [Category(unnormalized_cats), *to_be_pushed_cats] 187 | elif isinstance(unnormalized_cats, Iterable): 188 | for unnormalized_cat in unnormalized_cats: 189 | final_cats.append(Category(unnormalized_cat)) 190 | final_cats.append(*to_be_pushed_cats) 191 | 192 | final_subcats = [] 193 | if unnormalized_subcats is None: 194 | final_subcats = to_be_pushed_subcats 195 | elif isinstance(unnormalized_subcats, str): 196 | final_subcats = [Subcategory(unnormalized_subcats), *to_be_pushed_subcats] 197 | elif isinstance(unnormalized_subcats, Iterable): 198 | for unnormalized_subcat in unnormalized_subcats: 199 | final_subcats.append(Subcategory(unnormalized_subcat)) 200 | final_subcats.append(*to_be_pushed_subcats) 201 | 202 | return ( 203 | normalize_enumlike(final_cats, Category), 204 | normalize_enumlike(final_subcats, Subcategory), 205 | normalize_enumlike(typed_alt_subcats, AlternateSubcategory), 206 | ) 207 | 208 | 209 | def prune_none(params: dict) -> dict: 210 | """Remove all None values from a dictionary.""" 211 | return { 212 | key: value 213 | for key, value in params.items() 214 | if (value is not None and key is not None) 215 | } 216 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | """Test the types, classes, and structures used by the qbreader library.""" 2 | 3 | import qbreader as qb 4 | from qbreader.types import Bonus, PacketMetadata, SetMetadata, Tossup 5 | 6 | 7 | class TestTossup: 8 | """Test the Tossup class.""" 9 | 10 | tu_json = { 11 | "_id": "64046cc6de59b8af97422da5", 12 | "question": "Radiative power is inversely proportional to this quantity cubed, times 6-pi-epsilon, according to the Larmor formula. This quantity is in the numerator in the formula for the index of refraction. When a charged particle exceeds this quantity while in a medium, it produces Cherenkov radiation. This (*) quantity is equal to one divided by the square root of the product of the vacuum permittivity and permeability. This quantity is constant in all inertial reference frames. For 10 points, name this value symbolized c, that is about 30 million meters per second.", # noqa: E501 13 | "answer": "Speed of Light", 14 | "category": "Science", 15 | "subcategory": "Physics", 16 | "packet": {"_id": "64046cc6de59b8af97422da2", "name": "03", "number": 3}, 17 | "set": { 18 | "_id": "64046cc6de59b8af97422d4f", 19 | "name": "2017 WHAQ", 20 | "year": 2017, 21 | "standard": True, 22 | }, 23 | "createdAt": "2023-03-05T10:19:50.469Z", 24 | "updatedAt": "2024-11-24T22:47:40.013Z", 25 | "difficulty": 3, 26 | "number": 3, 27 | "answer_sanitized": "Speed of Light", 28 | "question_sanitized": "Radiative power is inversely proportional to this quantity cubed, times 6-pi-epsilon, according to the Larmor formula. This quantity is in the numerator in the formula for the index of refraction. When a charged particle exceeds this quantity while in a medium, it produces Cherenkov radiation. This (*) quantity is equal to one divided by the square root of the product of the vacuum permittivity and permeability. This quantity is constant in all inertial reference frames. For 10 points, name this value symbolized c, that is about 30 million meters per second.", # noqa: E501 29 | } 30 | 31 | def test_from_json(self): 32 | """Test the from_json() classmethod.""" 33 | assert Tossup.from_json(self.tu_json) 34 | 35 | def test_eq(self): 36 | """Test the __eq__ method.""" 37 | tu1 = Tossup.from_json(self.tu_json) 38 | tu2 = Tossup.from_json(self.tu_json) 39 | assert tu1 == tu2 40 | assert tu1 != self.tu_json 41 | 42 | def test_str(self): 43 | """Test the __str__ method.""" 44 | tu = Tossup.from_json(self.tu_json) 45 | assert str(tu) == tu.question 46 | 47 | 48 | class TestBonus: 49 | """Test the Bonus class.""" 50 | 51 | b_json = { 52 | "_id": "673ec00f90236da031c2cedb", 53 | "leadin": "With George Jean Nathan, H. L. Mencken co-founded a newspaper called The [this adjective] Mercury, which eventually fell under far-right leadership. For 10 points each:", # noqa: E501 54 | "leadin_sanitized": "With George Jean Nathan, H. L. Mencken co-founded a newspaper called The [this adjective] Mercury, which eventually fell under far-right leadership. For 10 points each:", # noqa: E501 55 | "parts": [ 56 | "Name this adjective in the title of a Mencken book that pays homage to Noah Webster. That book claims that the sentence “who are you talking to” is “doubly” this adjective since it forgoes “whom” and puts a preposition at the end of a sentence.", # noqa: E501 57 | " The Baltimore Sun sent Mencken to cover one of these events in Dayton, Tennessee, where he gave it a famous nickname. That event of this type was fictionalized in the play Inherit the Wind.", # noqa: E501 58 | "At the end of Inherit the Wind, Henry Drummond picks up a book by Darwin in one hand and this book with the other. Mencken claimed to have coined the term for a “Belt” in the Southern United States named for this text.", # noqa: E501 59 | ], 60 | "parts_sanitized": [ 61 | 'Name this adjective in the title of a Mencken book that pays homage to Noah Webster. That book claims that the sentence "who are you talking to" is "doubly" this adjective since it forgoes "whom" and puts a preposition at the end of a sentence.', # noqa: E501 62 | "The Baltimore Sun sent Mencken to cover one of these events in Dayton, Tennessee, where he gave it a famous nickname. That event of this type was fictionalized in the play Inherit the Wind.", # noqa: E501 63 | 'At the end of Inherit the Wind, Henry Drummond picks up a book by Darwin in one hand and this book with the other. Mencken claimed to have coined the term for a "Belt" in the Southern United States named for this text.', # noqa: E501 64 | ], 65 | "answers": [ 66 | "American [accept The American Mercury or The American Language]", # noqa: E501 67 | "trial [accept Scopes trial or Scopes Monkey trial]", # noqa: E501 68 | "the Bible ", 69 | ], 70 | "answers_sanitized": [ 71 | "American [accept The American Mercury or The American Language]", 72 | "trial [accept Scopes trial or Scopes Monkey trial]", 73 | "the Bible", 74 | ], 75 | "updatedAt": "2024-11-21T05:07:27.318Z", 76 | "category": "Literature", 77 | "subcategory": "American Literature", 78 | "alternate_subcategory": "Misc Literature", 79 | "values": [10, 10, 10], 80 | "difficultyModifiers": ["h", "m", "e"], 81 | "number": 1, 82 | "createdAt": "2024-11-21T05:07:27.318Z", 83 | "difficulty": 7, 84 | "packet": { 85 | "_id": "673ec00f90236da031c2cec6", 86 | "name": "A - Claremont A, Edinburgh A, Haverford A, Georgia Tech B, Illinois C, Michigan B", # noqa: E501 87 | "number": 1, 88 | }, 89 | "set": { 90 | "_id": "673ec00f90236da031c2cec5", 91 | "name": "2024 ACF Winter", 92 | "year": 2024, 93 | "standard": True, 94 | }, 95 | } 96 | 97 | def test_from_json(self): 98 | """Test the from_json() classmethod.""" 99 | assert Bonus.from_json(self.b_json) 100 | 101 | def test_eq(self): 102 | """Test the __eq__ method.""" 103 | b = Bonus.from_json(self.b_json) 104 | b = Bonus.from_json(self.b_json) 105 | assert b == b 106 | assert b != self.b_json 107 | 108 | def test_str(self): 109 | """Test the __str__ method.""" 110 | b = Bonus.from_json(self.b_json) 111 | assert str(b) == "\n".join(b.parts) 112 | 113 | 114 | class TestPacket: 115 | """Test the Packet class.""" 116 | 117 | packet = qb.Sync().packet("2023 MRNA", 5) 118 | 119 | def test_eq(self): 120 | """Test the __eq__ method.""" 121 | p1 = p2 = self.packet 122 | assert p1 == p2 123 | assert p1 != "not a packet" 124 | 125 | def test_str(self): 126 | """Test the __str__ method.""" 127 | assert str(self.packet) 128 | 129 | def test_iter(self): 130 | """Test the __iter__ method.""" 131 | for i, (tu, b) in enumerate(self.packet): 132 | assert tu == self.packet.tossups[i] 133 | assert b == self.packet.bonuses[i] 134 | 135 | 136 | class TestPacketMetadata: 137 | """Test the PacketMetadata class.""" 138 | 139 | packetMetadata = PacketMetadata.from_json( 140 | TestTossup.tu_json["packet"] # type: ignore 141 | ) 142 | 143 | def test_eq(self): 144 | """Test the __eq__ method.""" 145 | p1 = p2 = self.packetMetadata 146 | assert p1 == p2 147 | assert p1 != "not packet metadata" 148 | 149 | def test_str(self): 150 | """Test the __str__ method.""" 151 | assert str(self.packetMetadata) 152 | 153 | 154 | class TestSetMetadata: 155 | """Test the SetMetadata class.""" 156 | 157 | setMetadata = SetMetadata.from_json(TestTossup.tu_json["set"]) # type: ignore 158 | 159 | def test_eq(self): 160 | """Test the __eq__ method.""" 161 | s1 = s2 = self.setMetadata 162 | assert s1 == s2 163 | assert s1 != "not set metadata" 164 | 165 | def test_str(self): 166 | """Test the __str__ method.""" 167 | assert str(self.setMetadata) 168 | 169 | 170 | class TestQueryResponse: 171 | """Test the QueryResponse class.""" 172 | 173 | query = qb.Sync().query(queryString="spacecraft", maxReturnLength=1) 174 | 175 | def test_str(self): 176 | """Test the __str__ method.""" 177 | assert str(self.query) 178 | 179 | 180 | class TestAnswerJudgement: 181 | """Test the AnswerJudgement class.""" 182 | 183 | judgement = qb.Sync().check_answer("spacecraft", "spacecraft") 184 | 185 | def test_str(self): 186 | """Test the __str__ method.""" 187 | assert str(self.judgement) 188 | -------------------------------------------------------------------------------- /tests/test_sync.py: -------------------------------------------------------------------------------- 1 | """Test the synchronous API functions. This module specifically tests API interaction, 2 | not the underlying data structures. See tests/test_types.py for that.""" 3 | 4 | from time import sleep 5 | from typing import Any 6 | 7 | import pytest 8 | import requests 9 | 10 | import qbreader as qb 11 | from qbreader import Sync 12 | from tests import assert_exception, check_internet_connection 13 | 14 | qbr = Sync() 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | def anti_rate_limiting(): 19 | """Sleep for a short time to avoid getting rate limited. Apparently the sync code is 20 | still too fast.""" 21 | sleep(0.07) 22 | 23 | 24 | class TestSync: 25 | """Test synchronous API functions.""" 26 | 27 | @pytest.fixture() 28 | def mock_get(self, monkeypatch): 29 | """Mock the requests.get function.""" 30 | 31 | def _set_get(mock_status_code: int = 200, mock_json=None, *args, **kwargs): 32 | class MockResponse: 33 | def __init__(self): 34 | self.status_code = mock_status_code 35 | 36 | def json(self): 37 | return mock_json 38 | 39 | monkeypatch.setattr(requests, "get", lambda *args, **kwargs: MockResponse()) 40 | 41 | return _set_get 42 | 43 | def test_internet(self): 44 | """Test that there is an internet connection.""" 45 | assert check_internet_connection(), "No internet connection" 46 | 47 | @pytest.mark.parametrize( 48 | "params, expected_answer", 49 | [ 50 | ( 51 | { 52 | "questionType": "tossup", 53 | "setName": "2023 PACE NSC", 54 | "queryString": "hashes", 55 | }, 56 | "password", 57 | ), 58 | ( 59 | { 60 | "questionType": qb.Tossup, 61 | "setName": "2023 PACE NSC", 62 | "queryString": "hashes", 63 | }, 64 | "password", 65 | ), 66 | ( 67 | { 68 | "questionType": "bonus", 69 | "setName": "2023 PACE NSC", 70 | "queryString": "bell labs", 71 | }, 72 | "C", 73 | ), 74 | ( 75 | { 76 | "questionType": qb.Bonus, 77 | "setName": "2023 PACE NSC", 78 | "queryString": "bell labs", 79 | }, 80 | "C", 81 | ), 82 | ], 83 | ) 84 | def test_query(self, params: dict[str, Any], expected_answer: str): 85 | query: qb.QueryResponse = qbr.query(**params) 86 | if params["questionType"] == "tossup": 87 | assert query.tossups[0].check_answer_sync(expected_answer).correct() 88 | elif params["questionType"] == "bonus": 89 | assert query.bonuses[0].check_answer_sync(0, expected_answer).correct() 90 | 91 | def test_query_min_max_year_range(self): 92 | min_year = 2010 93 | max_year = 2015 94 | 95 | query = qbr.query( 96 | questionType="tossup", 97 | searchType="question", 98 | min_year=min_year, 99 | max_year=max_year, 100 | maxReturnLength=10, 101 | ) 102 | 103 | for tossup in query.tossups: 104 | assert min_year <= tossup.set.year <= max_year 105 | 106 | @pytest.mark.parametrize( 107 | "params, exception", 108 | [ 109 | ( 110 | { 111 | "questionType": "no a valid question type", 112 | }, 113 | ValueError, 114 | ), 115 | ( 116 | { 117 | "searchType": "not a valid search type", 118 | }, 119 | ValueError, 120 | ), 121 | ( 122 | { 123 | "queryString": 1, 124 | }, 125 | TypeError, 126 | ), 127 | ( 128 | { 129 | "regex": "str not bool", 130 | }, 131 | TypeError, 132 | ), 133 | ( 134 | { 135 | "setName": 1, 136 | }, 137 | TypeError, 138 | ), 139 | ( 140 | { 141 | "maxReturnLength": "str not int", 142 | }, 143 | TypeError, 144 | ), 145 | ( 146 | { 147 | "maxReturnLength": -1, 148 | }, 149 | ValueError, 150 | ), 151 | ( 152 | { 153 | "min_year": "not an int", 154 | }, 155 | TypeError, 156 | ), 157 | ( 158 | { 159 | "max_year": "not an int", 160 | }, 161 | TypeError, 162 | ), 163 | ], 164 | ) 165 | def test_query_exception(self, params: dict[str, Any], exception: Exception): 166 | assert_exception(qbr.query, exception, **params) 167 | 168 | def test_query_bad_response(self, mock_get): 169 | mock_get(mock_status_code=404) 170 | assert_exception(qbr.query, Exception) 171 | 172 | @pytest.mark.parametrize("number", [1, 20, 50, 100]) 173 | def test_random_tossup(self, number: int): 174 | assert len(qbr.random_tossup(number=number)) == number 175 | 176 | @pytest.mark.parametrize( 177 | "number, exception", 178 | [(0, ValueError), (-1, ValueError), ("1", TypeError), (1.0, TypeError)], 179 | ) 180 | def test_random_tossup_exception(self, number: int, exception: Exception): 181 | assert_exception(qbr.random_tossup, exception, number=number) 182 | 183 | def test_random_tossup_bad_response(self, mock_get): 184 | mock_get(mock_status_code=404) 185 | assert_exception(qbr.random_tossup, Exception) 186 | 187 | @pytest.mark.parametrize("number", [1, 20, 50, 100]) 188 | def test_random_bonus(self, number: int): 189 | assert len(qbr.random_bonus(number=number)) == number 190 | 191 | @pytest.mark.parametrize( 192 | "number, three_part, exception", 193 | [ 194 | (0, False, ValueError), 195 | (-1, False, ValueError), 196 | ("1", False, TypeError), 197 | (1.0, False, TypeError), 198 | (1, "not a bool", TypeError), 199 | ], 200 | ) 201 | def test_random_bonus_exception( 202 | self, number: int, three_part: bool, exception: Exception 203 | ): 204 | assert_exception( 205 | qbr.random_bonus, exception, number=number, three_part_bonuses=three_part 206 | ) 207 | 208 | def test_random_bonus_bad_response(self, mock_get): 209 | mock_get(mock_status_code=404) 210 | assert_exception(qbr.random_bonus, Exception) 211 | 212 | def test_random_name(self): 213 | assert qbr.random_name() 214 | 215 | def test_random_name_bad_response(self, mock_get): 216 | mock_get(mock_status_code=404) 217 | assert_exception(qbr.random_name, Exception) 218 | 219 | @pytest.mark.parametrize( 220 | "params, question, expected_answer", 221 | [ 222 | ( 223 | { 224 | "setName": "2023 PACE NSC", 225 | "packetNumber": 1, 226 | }, 227 | 5, 228 | "negative", 229 | ), 230 | ( 231 | { 232 | "setName": "2023 PACE NSC", 233 | "packetNumber": 4, 234 | }, 235 | 16, 236 | "spin", 237 | ), 238 | ], 239 | ) 240 | def test_packet(self, params: dict[str, Any], question: int, expected_answer: str): 241 | packet: qb.Packet = qbr.packet(**params) 242 | assert packet.tossups[question - 1].check_answer_sync(expected_answer).correct() 243 | 244 | @pytest.mark.parametrize( 245 | "params, exception", 246 | [ 247 | ( 248 | { 249 | "setName": 1, 250 | "packetNumber": 1, 251 | }, 252 | TypeError, 253 | ), 254 | ( 255 | { 256 | "setName": "2023 PACE NSC", 257 | "packetNumber": "not an int", 258 | }, 259 | TypeError, 260 | ), 261 | ( 262 | { 263 | "setName": "2023 PACE NSC", 264 | "packetNumber": 0, 265 | }, 266 | ValueError, 267 | ), 268 | ], 269 | ) 270 | def test_packet_exception(self, params: dict[str, Any], exception: Exception): 271 | assert_exception(qbr.packet, exception, **params) 272 | 273 | def test_packet_bad_response(self, monkeypatch, mock_get): 274 | mock_get(mock_status_code=404) 275 | monkeypatch.setattr( 276 | qbr, "num_packets", lambda x: 21 277 | ) # mocking get requests breaks num_packets 278 | assert_exception(qbr.packet, Exception, setName="2023 PACE NSC", packetNumber=1) 279 | 280 | @pytest.mark.parametrize( 281 | "params, question, expected_answer", 282 | [ 283 | ( 284 | { 285 | "setName": "2023 PACE NSC", 286 | "packetNumber": 1, 287 | }, 288 | 5, 289 | "negative", 290 | ), 291 | ( 292 | { 293 | "setName": "2023 PACE NSC", 294 | "packetNumber": 4, 295 | }, 296 | 16, 297 | "spin", 298 | ), 299 | ], 300 | ) 301 | def test_packet_tossups( 302 | self, params: dict[str, Any], question: int, expected_answer: str 303 | ): 304 | tus = qbr.packet_tossups(**params) 305 | assert tus[question - 1].check_answer_sync(expected_answer).correct() 306 | 307 | @pytest.mark.parametrize( 308 | "params, exception", 309 | [ 310 | ( 311 | { 312 | "setName": 1, 313 | "packetNumber": 1, 314 | }, 315 | TypeError, 316 | ), 317 | ( 318 | { 319 | "setName": "2023 PACE NSC", 320 | "packetNumber": "not an int", 321 | }, 322 | TypeError, 323 | ), 324 | ( 325 | { 326 | "setName": "2023 PACE NSC", 327 | "packetNumber": 0, 328 | }, 329 | ValueError, 330 | ), 331 | ], 332 | ) 333 | def test_packet_tossups_exception( 334 | self, params: dict[str, Any], exception: Exception 335 | ): 336 | assert_exception(qbr.packet_tossups, exception, **params) 337 | 338 | def test_packet_tossups_bad_response(self, monkeypatch, mock_get): 339 | mock_get(mock_status_code=404) 340 | monkeypatch.setattr( 341 | qbr, "num_packets", lambda x: 21 342 | ) # mocking get requests breaks num_packets 343 | assert_exception( 344 | qbr.packet_tossups, Exception, setName="2023 PACE NSC", packetNumber=1 345 | ) 346 | 347 | @pytest.mark.parametrize( 348 | "params, question, expected_answer", 349 | [ 350 | ( 351 | { 352 | "setName": "2023 PACE NSC", 353 | "packetNumber": 1, 354 | }, 355 | 5, 356 | "church", 357 | ), 358 | ( 359 | { 360 | "setName": "2023 PACE NSC", 361 | "packetNumber": 4, 362 | }, 363 | 16, 364 | "bananafish", 365 | ), 366 | ], 367 | ) 368 | def test_packet_bonuses( 369 | self, params: dict[str, Any], question: int, expected_answer: str 370 | ): 371 | bs = qbr.packet_bonuses(**params) 372 | assert bs[question - 1].check_answer_sync(0, expected_answer).correct() 373 | 374 | @pytest.mark.parametrize( 375 | "params, exception", 376 | [ 377 | ( 378 | { 379 | "setName": 1, 380 | "packetNumber": 1, 381 | }, 382 | TypeError, 383 | ), 384 | ( 385 | { 386 | "setName": "2023 PACE NSC", 387 | "packetNumber": "not an int", 388 | }, 389 | TypeError, 390 | ), 391 | ( 392 | { 393 | "setName": "2023 PACE NSC", 394 | "packetNumber": 0, 395 | }, 396 | ValueError, 397 | ), 398 | ], 399 | ) 400 | def test_packet_bonuses_exception( 401 | self, params: dict[str, Any], exception: Exception 402 | ): 403 | assert_exception(qbr.packet_bonuses, exception, **params) 404 | 405 | def test_packet_bonuses_bad_response(self, monkeypatch, mock_get): 406 | mock_get(mock_status_code=404) 407 | monkeypatch.setattr( 408 | qbr, "num_packets", lambda x: 21 409 | ) # mocking get requests breaks num_packets 410 | assert_exception( 411 | qbr.packet_bonuses, Exception, setName="2023 PACE NSC", packetNumber=1 412 | ) 413 | 414 | @pytest.mark.parametrize( 415 | "setName, expected", 416 | [("2023 PACE NSC", 21), ("2022 SHOW-ME", 15)], 417 | ) 418 | def test_num_packets(self, setName: str, expected: int): 419 | assert qbr.num_packets(setName) == expected 420 | 421 | def test_num_packets_bad_response(self, mock_get): 422 | assert_exception(qbr.num_packets, ValueError, setName="not a set name") 423 | mock_get(mock_status_code=400) 424 | assert_exception(qbr.num_packets, Exception, setName="2023 PACE NSC") 425 | 426 | def test_set_list(self): 427 | assert qbr.set_list() 428 | 429 | def test_set_list_bad_response(self, mock_get): 430 | mock_get(mock_status_code=404) 431 | assert_exception(qbr.set_list, Exception) 432 | 433 | def test_room_list(self): 434 | assert qbr.room_list() 435 | 436 | def test_room_list_bad_response(self, mock_get): 437 | mock_get(mock_status_code=404) 438 | assert_exception(qbr.room_list, Exception) 439 | 440 | @pytest.mark.parametrize( 441 | "answerline, givenAnswer", 442 | [("Rubik's cubes [prompt on cubes and speedcubing]", "Rubik's cubes")], 443 | ) 444 | def test_check_answer(self, answerline: str, givenAnswer: str): 445 | assert qbr.check_answer( 446 | answerline=answerline, givenAnswer=givenAnswer 447 | ).correct() 448 | 449 | @pytest.mark.parametrize( 450 | "answerline, givenAnswer, exception", 451 | [ 452 | ("Rubik's cubes [prompt on cubes and speedcubing]", 1, TypeError), 453 | (1, "Rubik's cubes", TypeError), 454 | ], 455 | ) 456 | def test_check_answer_exception( 457 | self, answerline: str, givenAnswer: str, exception: Exception 458 | ): 459 | assert_exception(qbr.check_answer, exception, answerline, givenAnswer) 460 | 461 | def test_check_answer_bad_response(self, mock_get): 462 | mock_get(mock_status_code=404) 463 | assert_exception( 464 | qbr.check_answer, 465 | Exception, 466 | answerline="Rubik's cubes", 467 | givenAnswer="Rubik's cubes", 468 | ) 469 | 470 | @pytest.mark.parametrize( 471 | "id, expected_answer", 472 | [ 473 | ("657fd7d7de6df0163bbe3b3d", "Sweden"), 474 | ("657fd7d8de6df0163bbe3b43", "jQuery"), 475 | ], 476 | ) 477 | def test_tossup_by_id(self, id: str, expected_answer: str): 478 | tu: qb.Tossup = qbr.tossup_by_id(id) 479 | judgement: qb.AnswerJudgement = tu.check_answer_sync(expected_answer) 480 | assert judgement.correct() 481 | 482 | def test_tossup_by_id_bad_response(self, mock_get): 483 | assert_exception(qbr.tossup_by_id, ValueError, id="not a valid id") 484 | mock_get(mock_status_code=404) 485 | assert_exception(qbr.tossup_by_id, Exception, id="657fd7d7de6df0163bbe3b3d") 486 | 487 | @pytest.mark.parametrize( 488 | "id, expected_answers", 489 | [ 490 | ("648938e130bd7ab56b095a42", ["volcano", "Magellan", "terra"]), 491 | ("648938e130bd7ab56b095a60", ["pH", "NADPH", "perforin"]), 492 | ], 493 | ) 494 | def test_bonus_by_id(self, id: str, expected_answers: list[str]): 495 | b: qb.Bonus = qbr.bonus_by_id(id) 496 | for i, answer in enumerate(expected_answers): 497 | judgement: qb.AnswerJudgement = b.check_answer_sync(i, answer) 498 | assert judgement.correct() 499 | 500 | def test_bonus_by_id_bad_response(self, mock_get): 501 | assert_exception(qbr.bonus_by_id, ValueError, id="not a valid id") 502 | mock_get(mock_status_code=404) 503 | assert_exception(qbr.bonus_by_id, Exception, id="648938e130bd7ab56b095a42") 504 | -------------------------------------------------------------------------------- /tests/test_async.py: -------------------------------------------------------------------------------- 1 | """Test the asynchronous API functions. This module specifically tests API interaction, 2 | not the underlying data structures. See tests/test_types.py for that.""" 3 | 4 | import asyncio 5 | from random import random 6 | from typing import Any 7 | 8 | import pytest 9 | import pytest_asyncio 10 | 11 | import qbreader as qb 12 | from qbreader import Async 13 | from tests import async_assert_exception, check_internet_connection 14 | 15 | 16 | @pytest.fixture(scope="module") 17 | def event_loop(): 18 | """Rescope the event loop to the module.""" 19 | policy = asyncio.get_event_loop_policy() 20 | loop = policy.new_event_loop() 21 | yield loop 22 | loop.close() 23 | 24 | 25 | @pytest_asyncio.fixture(autouse=True) 26 | async def async_code_is_too_fast_lol(): 27 | """Sleep for up to 0.2 seconds during each test to avoid getting rate limited.""" 28 | await asyncio.sleep(random() / 5) 29 | 30 | 31 | class TestAsync: 32 | """Test asynchronous API functions.""" 33 | 34 | @pytest_asyncio.fixture(scope="class") 35 | async def qbr(self): 36 | """Create an Async instance shared by all tests.""" 37 | return await Async.create() 38 | 39 | @pytest.fixture() 40 | def mock_get(self, monkeypatch, qbr): 41 | """Mock aiohttp.ClientSession.get for Async.session""" 42 | 43 | def _set_get(mock_status_code: int = 200, mock_json=None, *args, **kwargs): 44 | class MockResponse: 45 | def __init__(self): 46 | self.status = mock_status_code 47 | 48 | async def __aenter__(self): 49 | return self 50 | 51 | async def __aexit__(self, exc_type, exc_val, exc_tb): 52 | pass 53 | 54 | async def json(self): 55 | return mock_json 56 | 57 | monkeypatch.setattr( 58 | qbr.session, "get", lambda *args, **kwargs: MockResponse() 59 | ) 60 | 61 | return _set_get 62 | 63 | def test_internet(self): 64 | """Test that there is an internet connection.""" 65 | assert check_internet_connection(), "No internet connection" 66 | 67 | @pytest.mark.asyncio 68 | @pytest.mark.parametrize( 69 | "params, expected_answer", 70 | [ 71 | ( 72 | { 73 | "questionType": "tossup", 74 | "setName": "2023 PACE NSC", 75 | "queryString": "hashes", 76 | }, 77 | "password", 78 | ), 79 | ( 80 | { 81 | "questionType": qb.Tossup, 82 | "setName": "2023 PACE NSC", 83 | "queryString": "hashes", 84 | }, 85 | "password", 86 | ), 87 | ( 88 | { 89 | "questionType": "bonus", 90 | "setName": "2023 PACE NSC", 91 | "queryString": "bell labs", 92 | }, 93 | "C", 94 | ), 95 | ( 96 | { 97 | "questionType": qb.Bonus, 98 | "setName": "2023 PACE NSC", 99 | "queryString": "bell labs", 100 | }, 101 | "C", 102 | ), 103 | ], 104 | ) 105 | async def test_query(self, qbr, params: dict[str, Any], expected_answer: str): 106 | query: qbr.QueryResponse = await qbr.query(**params) 107 | judgement: qbr.AnswerJudgement 108 | if params["questionType"] == "tossup": 109 | judgement = await query.tossups[0].check_answer_async( 110 | expected_answer, session=qbr.session 111 | ) 112 | assert judgement.correct() 113 | elif params["questionType"] == "bonus": 114 | judgement = await query.bonuses[0].check_answer_async( 115 | 0, expected_answer, session=qbr.session 116 | ) 117 | assert judgement.correct() 118 | 119 | @pytest.mark.asyncio 120 | async def test_query_min_max_year_range(self, qbr): 121 | min_year = 2010 122 | max_year = 2015 123 | 124 | query = await qbr.query( 125 | questionType="tossup", 126 | searchType="question", 127 | min_year=min_year, 128 | max_year=max_year, 129 | maxReturnLength=10, 130 | ) 131 | 132 | for tossup in query.tossups: 133 | assert min_year <= tossup.set.year <= max_year 134 | 135 | @pytest.mark.asyncio 136 | @pytest.mark.parametrize( 137 | "params, exception", 138 | [ 139 | ( 140 | { 141 | "questionType": "no a valid question type", 142 | }, 143 | ValueError, 144 | ), 145 | ( 146 | { 147 | "searchType": "not a valid search type", 148 | }, 149 | ValueError, 150 | ), 151 | ( 152 | { 153 | "queryString": 1, 154 | }, 155 | TypeError, 156 | ), 157 | ( 158 | { 159 | "regex": "str not bool", 160 | }, 161 | TypeError, 162 | ), 163 | ( 164 | { 165 | "setName": 1, 166 | }, 167 | TypeError, 168 | ), 169 | ( 170 | { 171 | "maxReturnLength": "str not int", 172 | }, 173 | TypeError, 174 | ), 175 | ( 176 | { 177 | "maxReturnLength": -1, 178 | }, 179 | ValueError, 180 | ), 181 | ], 182 | ) 183 | async def test_query_exception( 184 | self, qbr, params: dict[str, Any], exception: Exception 185 | ): 186 | await async_assert_exception(qbr.query, exception, **params) 187 | 188 | @pytest.mark.asyncio 189 | async def test_query_bad_response(self, qbr, mock_get): 190 | mock_get(mock_status_code=404) 191 | await async_assert_exception(qbr.query, Exception) 192 | 193 | @pytest.mark.asyncio 194 | @pytest.mark.parametrize("number", [1, 20, 50, 100]) 195 | async def test_random_tossup(self, qbr, number: int): 196 | assert len(await qbr.random_tossup(number=number)) == number 197 | 198 | @pytest.mark.asyncio 199 | @pytest.mark.parametrize( 200 | "number, exception", 201 | [(0, ValueError), (-1, ValueError), ("1", TypeError), (1.0, TypeError)], 202 | ) 203 | async def test_random_tossup_exception( 204 | self, qbr, number: int, exception: Exception 205 | ): 206 | await async_assert_exception(qbr.random_tossup, exception, number=number) 207 | 208 | @pytest.mark.asyncio 209 | async def test_random_tossup_bad_response(self, qbr, mock_get): 210 | mock_get(mock_status_code=404) 211 | await async_assert_exception(qbr.random_tossup, Exception) 212 | 213 | @pytest.mark.asyncio 214 | @pytest.mark.parametrize("number", [1, 20, 50, 100]) 215 | async def test_random_bonus(self, qbr, number: int): 216 | assert len(await qbr.random_bonus(number=number)) == number 217 | 218 | @pytest.mark.asyncio 219 | @pytest.mark.parametrize( 220 | "number, three_part, exception", 221 | [ 222 | (0, False, ValueError), 223 | (-1, False, ValueError), 224 | ("1", False, TypeError), 225 | (1.0, False, TypeError), 226 | (1, "not a bool", TypeError), 227 | ], 228 | ) 229 | async def test_random_bonus_exception( 230 | self, qbr, number: int, three_part: bool, exception: Exception 231 | ): 232 | await async_assert_exception( 233 | qbr.random_bonus, exception, number=number, three_part_bonuses=three_part 234 | ) 235 | 236 | @pytest.mark.asyncio 237 | async def test_random_bonus_bad_response(self, qbr, mock_get): 238 | mock_get(mock_status_code=404) 239 | await async_assert_exception(qbr.random_bonus, Exception) 240 | 241 | @pytest.mark.asyncio 242 | async def test_random_name(self, qbr): 243 | assert await qbr.random_name() 244 | 245 | @pytest.mark.asyncio 246 | async def test_random_name_bad_response(self, qbr, mock_get): 247 | mock_get(mock_status_code=404) 248 | await async_assert_exception(qbr.random_name, Exception) 249 | 250 | @pytest.mark.asyncio 251 | @pytest.mark.parametrize( 252 | "params, question, expected_answer", 253 | [ 254 | ( 255 | { 256 | "setName": "2023 PACE NSC", 257 | "packetNumber": 1, 258 | }, 259 | 5, 260 | "negative", 261 | ), 262 | ( 263 | { 264 | "setName": "2023 PACE NSC", 265 | "packetNumber": 4, 266 | }, 267 | 16, 268 | "spin", 269 | ), 270 | ], 271 | ) 272 | async def test_packet( 273 | self, qbr, params: dict[str, Any], question: int, expected_answer: str 274 | ): 275 | packet: qbr.Packet = await qbr.packet(**params) 276 | judgement: qbr.AnswerJudgement = await packet.tossups[ 277 | question - 1 278 | ].check_answer_async(expected_answer, session=qbr.session) 279 | assert judgement.correct() 280 | 281 | @pytest.mark.asyncio 282 | @pytest.mark.parametrize( 283 | "params, exception", 284 | [ 285 | ( 286 | { 287 | "setName": 1, 288 | "packetNumber": 1, 289 | }, 290 | TypeError, 291 | ), 292 | ( 293 | { 294 | "setName": "2023 PACE NSC", 295 | "packetNumber": "not an int", 296 | }, 297 | TypeError, 298 | ), 299 | ( 300 | { 301 | "setName": "2023 PACE NSC", 302 | "packetNumber": 0, 303 | }, 304 | ValueError, 305 | ), 306 | ], 307 | ) 308 | async def test_packet_exception( 309 | self, qbr, params: dict[str, Any], exception: Exception 310 | ): 311 | await async_assert_exception(qbr.packet, exception, **params) 312 | 313 | @pytest.mark.asyncio 314 | async def test_packet_bad_response(self, qbr, monkeypatch, mock_get): 315 | mock_get(mock_status_code=404) 316 | 317 | async def mock_num_packets(x): 318 | return 21 319 | 320 | monkeypatch.setattr( 321 | qbr, "num_packets", mock_num_packets 322 | ) # mocking get requests breaks num_packets 323 | await async_assert_exception( 324 | qbr.packet, Exception, setName="2023 PACE NSC", packetNumber=1 325 | ) 326 | 327 | @pytest.mark.asyncio 328 | @pytest.mark.parametrize( 329 | "params, question, expected_answer", 330 | [ 331 | ( 332 | { 333 | "setName": "2023 PACE NSC", 334 | "packetNumber": 1, 335 | }, 336 | 5, 337 | "negative", 338 | ), 339 | ( 340 | { 341 | "setName": "2023 PACE NSC", 342 | "packetNumber": 4, 343 | }, 344 | 16, 345 | "spin", 346 | ), 347 | ], 348 | ) 349 | async def test_packet_tossups( 350 | self, qbr, params: dict[str, Any], question: int, expected_answer: str 351 | ): 352 | tus = await qbr.packet_tossups(**params) 353 | judgement: qbr.AnswerJudgement = await tus[question - 1].check_answer_async( 354 | expected_answer, session=qbr.session 355 | ) 356 | assert judgement.correct() 357 | 358 | @pytest.mark.asyncio 359 | @pytest.mark.parametrize( 360 | "params, exception", 361 | [ 362 | ( 363 | { 364 | "setName": 1, 365 | "packetNumber": 1, 366 | }, 367 | TypeError, 368 | ), 369 | ( 370 | { 371 | "setName": "2023 PACE NSC", 372 | "packetNumber": "not an int", 373 | }, 374 | TypeError, 375 | ), 376 | ( 377 | { 378 | "setName": "2023 PACE NSC", 379 | "packetNumber": 0, 380 | }, 381 | ValueError, 382 | ), 383 | ], 384 | ) 385 | async def test_packet_tossups_exception( 386 | self, qbr, params: dict[str, Any], exception: Exception 387 | ): 388 | await async_assert_exception(qbr.packet_tossups, exception, **params) 389 | 390 | @pytest.mark.asyncio 391 | async def test_packet_tossups_bad_response(self, qbr, monkeypatch, mock_get): 392 | mock_get(mock_status_code=404) 393 | 394 | async def mock_num_packets(x): 395 | return 21 396 | 397 | monkeypatch.setattr( 398 | qbr, "num_packets", mock_num_packets 399 | ) # mocking get requests breaks num_packets 400 | await async_assert_exception( 401 | qbr.packet_tossups, Exception, setName="2023 PACE NSC", packetNumber=1 402 | ) 403 | 404 | @pytest.mark.asyncio 405 | @pytest.mark.parametrize( 406 | "params, question, expected_answer", 407 | [ 408 | ( 409 | { 410 | "setName": "2023 PACE NSC", 411 | "packetNumber": 1, 412 | }, 413 | 5, 414 | "church", 415 | ), 416 | ( 417 | { 418 | "setName": "2023 PACE NSC", 419 | "packetNumber": 4, 420 | }, 421 | 16, 422 | "bananafish", 423 | ), 424 | ], 425 | ) 426 | async def test_packet_bonuses( 427 | self, qbr, params: dict[str, Any], question: int, expected_answer: str 428 | ): 429 | bs = await qbr.packet_bonuses(**params) 430 | judgement: qbr.AnswerJudgement = await bs[question - 1].check_answer_async( 431 | 0, expected_answer, session=qbr.session 432 | ) 433 | assert judgement.correct() 434 | 435 | @pytest.mark.asyncio 436 | @pytest.mark.parametrize( 437 | "params, exception", 438 | [ 439 | ( 440 | { 441 | "setName": 1, 442 | "packetNumber": 1, 443 | }, 444 | TypeError, 445 | ), 446 | ( 447 | { 448 | "setName": "2023 PACE NSC", 449 | "packetNumber": "not an int", 450 | }, 451 | TypeError, 452 | ), 453 | ( 454 | { 455 | "setName": "2023 PACE NSC", 456 | "packetNumber": 0, 457 | }, 458 | ValueError, 459 | ), 460 | ], 461 | ) 462 | async def test_packet_bonuses_exception( 463 | self, qbr, params: dict[str, Any], exception: Exception 464 | ): 465 | await async_assert_exception(qbr.packet_bonuses, exception, **params) 466 | 467 | @pytest.mark.asyncio 468 | async def test_packet_bonuses_bad_response(self, qbr, monkeypatch, mock_get): 469 | mock_get(mock_status_code=404) 470 | 471 | async def mock_num_packets(x): 472 | return 21 473 | 474 | monkeypatch.setattr( 475 | qbr, "num_packets", mock_num_packets 476 | ) # mocking get requests breaks num_packets 477 | await async_assert_exception( 478 | qbr.packet_bonuses, Exception, setName="2023 PACE NSC", packetNumber=1 479 | ) 480 | 481 | @pytest.mark.asyncio 482 | @pytest.mark.parametrize( 483 | "setName, expected", 484 | [("2023 PACE NSC", 21), ("2022 SHOW-ME", 15)], 485 | ) 486 | async def test_num_packets(self, qbr, setName: str, expected: int): 487 | assert await qbr.num_packets(setName) == expected 488 | 489 | @pytest.mark.asyncio 490 | async def test_num_packets_bad_response(self, qbr, mock_get): 491 | await async_assert_exception( 492 | qbr.num_packets, ValueError, setName="not a set name" 493 | ) 494 | mock_get(mock_status_code=400) 495 | await async_assert_exception( 496 | qbr.num_packets, Exception, setName="2023 PACE NSC" 497 | ) 498 | 499 | @pytest.mark.asyncio 500 | async def test_set_list(self, qbr): 501 | assert await qbr.set_list() 502 | 503 | @pytest.mark.asyncio 504 | async def test_set_list_bad_response(self, qbr, mock_get): 505 | mock_get(mock_status_code=404) 506 | await async_assert_exception(qbr.set_list, Exception) 507 | 508 | @pytest.mark.asyncio 509 | async def test_room_list(self, qbr): 510 | assert await qbr.room_list() 511 | 512 | @pytest.mark.asyncio 513 | async def test_room_list_bad_response(self, qbr, mock_get): 514 | mock_get(mock_status_code=404) 515 | await async_assert_exception(qbr.room_list, Exception) 516 | 517 | @pytest.mark.asyncio 518 | @pytest.mark.parametrize( 519 | "answerline, givenAnswer", 520 | [("Rubik's cubes [prompt on cubes and speedcubing]", "Rubik's cubes")], 521 | ) 522 | async def test_check_answer(self, qbr, answerline: str, givenAnswer: str): 523 | judgement: qb.AnswerJudgement = await qbr.check_answer( 524 | answerline=answerline, givenAnswer=givenAnswer 525 | ) 526 | assert judgement.correct() 527 | judgement = await qb.AnswerJudgement.check_answer_async( 528 | answerline=answerline, givenAnswer=givenAnswer 529 | ) # testing no session provided 530 | assert judgement.correct() 531 | 532 | @pytest.mark.asyncio 533 | @pytest.mark.parametrize( 534 | "answerline, givenAnswer, exception", 535 | [ 536 | ("Rubik's cubes [prompt on cubes and speedcubing]", 1, TypeError), 537 | (1, "Rubik's cubes", TypeError), 538 | ], 539 | ) 540 | async def test_check_answer_exception( 541 | self, qbr, answerline: str, givenAnswer: str, exception: Exception 542 | ): 543 | await async_assert_exception( 544 | qbr.check_answer, exception, answerline, givenAnswer 545 | ) 546 | await async_assert_exception( 547 | qb.AnswerJudgement.check_answer_async, exception, answerline, givenAnswer 548 | ) 549 | 550 | @pytest.mark.asyncio 551 | async def test_check_answer_bad_response(self, qbr, mock_get): 552 | mock_get(mock_status_code=404) 553 | await async_assert_exception( 554 | qbr.check_answer, 555 | Exception, 556 | answerline="Rubik's cubes", 557 | givenAnswer="Rubik's cubes", 558 | ) 559 | 560 | @pytest.mark.asyncio 561 | @pytest.mark.parametrize( 562 | "id, expected_answer", 563 | [ 564 | ("657fd7d7de6df0163bbe3b3d", "Sweden"), 565 | ("657fd7d8de6df0163bbe3b43", "jQuery"), 566 | ], 567 | ) 568 | async def test_tossup_by_id(self, qbr, id: str, expected_answer: str): 569 | tu: qb.Tossup = await qbr.tossup_by_id(id) 570 | judgement: qb.AnswerJudgement = await tu.check_answer_async( 571 | expected_answer, session=qbr.session 572 | ) 573 | assert judgement.correct() 574 | 575 | @pytest.mark.asyncio 576 | async def test_tossup_by_id_bad_response(self, qbr, mock_get): 577 | await async_assert_exception(qbr.tossup_by_id, ValueError, id="not a valid id") 578 | mock_get(mock_status_code=404) 579 | await async_assert_exception( 580 | qbr.tossup_by_id, Exception, id="657fd7d7de6df0163bbe3b3d" 581 | ) 582 | 583 | @pytest.mark.asyncio 584 | @pytest.mark.parametrize( 585 | "id, expected_answers", 586 | [ 587 | ("648938e130bd7ab56b095a42", ["volcano", "Magellan", "terra"]), 588 | ("648938e130bd7ab56b095a60", ["pH", "NADPH", "perforin"]), 589 | ], 590 | ) 591 | async def test_bonus_by_id(self, qbr, id: str, expected_answers: list[str]): 592 | b: qb.Bonus = await qbr.bonus_by_id(id) 593 | for i, answer in enumerate(expected_answers): 594 | judgement: qb.AnswerJudgement = await b.check_answer_async( 595 | i, answer, session=qbr.session 596 | ) 597 | assert judgement.correct() 598 | 599 | @pytest.mark.asyncio 600 | async def test_bonus_by_id_bad_response(self, qbr, mock_get): 601 | await async_assert_exception(qbr.bonus_by_id, ValueError, id="not a valid id") 602 | mock_get(mock_status_code=404) 603 | await async_assert_exception( 604 | qbr.bonus_by_id, Exception, id="648938e130bd7ab56b095a42" 605 | ) 606 | 607 | @pytest.mark.asyncio 608 | async def test_close(self, qbr): 609 | await qbr.close() 610 | assert qbr.session.closed 611 | 612 | @pytest.mark.asyncio 613 | async def test_async_with(self): 614 | qbr = await Async.create() 615 | async with qbr: 616 | assert qbr.session 617 | assert qbr.session.closed 618 | -------------------------------------------------------------------------------- /qbreader/types.py: -------------------------------------------------------------------------------- 1 | """Types and classes used by the library.""" 2 | 3 | from __future__ import annotations 4 | 5 | import enum 6 | from collections.abc import Iterable, Sequence 7 | from typing import Any, Literal, Optional, Self, Type, TypeAlias, Union 8 | 9 | import aiohttp 10 | import requests 11 | 12 | from qbreader._consts import BASE_URL 13 | 14 | 15 | class Category(enum.StrEnum): 16 | """Question category enum.""" 17 | 18 | LITERATURE = "Literature" 19 | HISTORY = "History" 20 | SCIENCE = "Science" 21 | FINE_ARTS = "Fine Arts" 22 | RELIGION = "Religion" 23 | MYTHOLOGY = "Mythology" 24 | PHILOSOPHY = "Philosophy" 25 | SOCIAL_SCIENCE = "Social Science" 26 | CURRENT_EVENTS = "Current Events" 27 | GEOGRAPHY = "Geography" 28 | OTHER_ACADEMIC = "Other Academic" 29 | POP_CULTURE = "Pop Culture" 30 | 31 | 32 | class Subcategory(enum.StrEnum): 33 | """Question subcategory enum.""" 34 | 35 | LITERATURE = "Literature" # regular cats also included because of database quirks 36 | HISTORY = "History" 37 | SCIENCE = "Science" 38 | FINE_ARTS = "Fine Arts" 39 | RELIGION = "Religion" 40 | MYTHOLOGY = "Mythology" 41 | PHILOSOPHY = "Philosophy" 42 | SOCIAL_SCIENCE = "Social Science" 43 | CURRENT_EVENTS = "Current Events" 44 | GEOGRAPHY = "Geography" 45 | OTHER_ACADEMIC = "Other Academic" 46 | POP_CULTURE = "Pop Culture" 47 | 48 | AMERICAN_LITERATURE = "American Literature" 49 | BRITISH_LITERATURE = "British Literature" 50 | CLASSICAL_LITERATURE = "Classical Literature" 51 | EUROPEAN_LITERATURE = "European Literature" 52 | WORLD_LITERATURE = "World Literature" 53 | OTHER_LITERATURE = "Other Literature" 54 | 55 | AMERICAN_HISTORY = "American History" 56 | ANCIENT_HISTORY = "Ancient History" 57 | EUROPEAN_HISTORY = "European History" 58 | WORLD_HISTORY = "World History" 59 | OTHER_HISTORY = "Other History" 60 | 61 | BIOLOGY = "Biology" 62 | CHEMISTRY = "Chemistry" 63 | PHYSICS = "Physics" 64 | OTHER_SCIENCE = "Other Science" 65 | 66 | VISUAL_FINE_ARTS = "Visual Fine Arts" 67 | AUDITORY_FINE_ARTS = "Auditory Fine Arts" 68 | OTHER_FINE_ARTS = "Other Fine Arts" 69 | 70 | MOVIES = "Movies" 71 | MUSIC = "Music" 72 | SPORTS = "Sports" 73 | TELEVISION = "Television" 74 | VIDEO_GAMES = "Video Games" 75 | OTHER_POP_CULTURE = "Other Pop Culture" 76 | 77 | 78 | class AlternateSubcategory(enum.StrEnum): 79 | """Question alternate subcategory enum.""" 80 | 81 | DRAMA = "Drama" 82 | LONG_FICTION = "Long Fiction" 83 | POETRY = "Poetry" 84 | SHORT_FICTION = "Short Fiction" 85 | MISC_LITERATURE = "Misc Literature" 86 | 87 | MATH = "Math" 88 | ASTRONOMY = "Astronomy" 89 | COMPUTER_SCIENCE = "Computer Science" 90 | EARTH_SCIENCE = "Earth Science" 91 | ENGINEERING = "Engineering" 92 | MISC_SCIENCE = "Misc Science" 93 | 94 | ARCHITECTURE = "Architecture" 95 | DANCE = "Dance" 96 | FILM = "Film" 97 | JAZZ = "Jazz" 98 | MUSICALS = "Musicals" 99 | OPERA = "Opera" 100 | PHOTOGRAPHY = "Photography" 101 | MISC_ARTS = "Misc Arts" 102 | 103 | ANTHROPOLOGY = "Anthropology" 104 | ECONOMICS = "Economics" 105 | LINGUISTICS = "Linguistics" 106 | PSYCHOLOGY = "Psychology" 107 | SOCIOLOGY = "Sociology" 108 | OTHER_SOCIAL_SCIENCE = "Other Social Science" 109 | 110 | BELIEFS = "Beliefs" 111 | PRACTICES = "Practices" 112 | 113 | 114 | class Difficulty(enum.StrEnum): 115 | """Question difficulty enum.""" 116 | 117 | UNRATED = "0" 118 | MS = "1" 119 | HS_EASY = "2" 120 | HS_REGS = "3" 121 | HS_HARD = "4" 122 | HS_NATS = "5" 123 | ONE_DOT = "6" 124 | TWO_DOT = "7" 125 | THREE_DOT = "8" 126 | FOUR_DOT = "9" 127 | OPEN = "10" 128 | 129 | 130 | class DifficultyModifier(enum.StrEnum): 131 | """Difficulty modifier enum.""" 132 | 133 | EASY = "e" 134 | MEDIUM = "m" 135 | HARD = "h" 136 | 137 | 138 | class Directive(enum.StrEnum): 139 | """Directives given by `api/check-answer`.""" 140 | 141 | ACCEPT = "accept" 142 | REJECT = "reject" 143 | PROMPT = "prompt" 144 | 145 | 146 | class Year(enum.IntEnum): 147 | """Min/max year enum.""" 148 | 149 | MIN_YEAR = 2010 150 | CURRENT_YEAR = 2024 151 | 152 | 153 | class AnswerJudgement: 154 | """A judgement given by `api/check-answer`.""" 155 | 156 | def __init__( 157 | self: Self, directive: Directive, directed_prompt: Optional[str] = None 158 | ): 159 | self.directive: Directive = directive 160 | self.directed_prompt: Optional[str] = directed_prompt 161 | 162 | def __bool__(self: Self) -> bool: 163 | """Return whether the answer was correct.""" 164 | return self.directive == Directive.ACCEPT 165 | 166 | def __str__(self: Self) -> str: 167 | """Return a string representation of the judgement.""" 168 | return self.directive.value + ( 169 | f" ({self.directed_prompt})" if self.directed_prompt else "" 170 | ) 171 | 172 | def correct(self: Self) -> bool: 173 | """Return whether the answer was correct.""" 174 | return self.__bool__() 175 | 176 | @classmethod 177 | def from_json(cls: Type[Self], json: dict[str, Any]) -> Self: 178 | """Create an AnswerJudgement from a JSON object. 179 | 180 | See https://www.qbreader.org/api-docs/check-answer#returns for schema. 181 | """ 182 | return cls( 183 | directive=Directive(json["directive"]), 184 | directed_prompt=json.get("directedPrompt", None), 185 | ) 186 | 187 | @classmethod 188 | def check_answer_sync(cls: Type[Self], answerline: str, givenAnswer: str) -> Self: 189 | """Create an AnswerJudgement given an answerline and an answer. 190 | 191 | Original API doc at https://www.qbreader.org/api-docs/check-answer. 192 | 193 | Parameters 194 | ---------- 195 | answerline : str 196 | The answerline to check against. Preferably including the HTML tags and 197 | , if they are present. 198 | givenAnswer : str 199 | The answer to check. 200 | """ 201 | # normalize and type check parameters 202 | if not isinstance(answerline, str): 203 | raise TypeError( 204 | f"answerline must be a string, not {type(answerline).__name__}" 205 | ) 206 | 207 | if not isinstance(givenAnswer, str): 208 | raise TypeError( 209 | f"givenAnswer must be a string, not {type(givenAnswer).__name__}" 210 | ) 211 | 212 | url = BASE_URL + "/check-answer" 213 | 214 | data = {"answerline": answerline, "givenAnswer": givenAnswer} 215 | 216 | response: requests.Response = requests.get(url, params=data) 217 | 218 | if response.status_code != 200: 219 | raise Exception(str(response.status_code) + " bad request") 220 | 221 | return cls.from_json(response.json()) 222 | 223 | @classmethod 224 | async def check_answer_async( 225 | cls: Type[Self], 226 | answerline: str, 227 | givenAnswer: str, 228 | session: aiohttp.ClientSession | None = None, 229 | ) -> Self: 230 | """Asynchronously create an AnswerJudgement given an answerline and an answer. 231 | 232 | Original API doc at https://www.qbreader.org/api-docs/check-answer. 233 | 234 | Parameters 235 | ---------- 236 | answerline : str 237 | The answerline to check against. Preferably including the HTML tags and 238 | , if they are present. 239 | givenAnswer : str 240 | The answer to check. 241 | session : aiohttp.ClientSession 242 | The aiohttp session to use for the request. 243 | """ 244 | # normalize and type check parameters 245 | if not isinstance(answerline, str): 246 | raise TypeError( 247 | f"answerline must be a string, not {type(answerline).__name__}" 248 | ) 249 | 250 | if not isinstance(givenAnswer, str): 251 | raise TypeError( 252 | f"givenAnswer must be a string, not {type(givenAnswer).__name__}" 253 | ) 254 | 255 | url = BASE_URL + "/check-answer" 256 | 257 | data = {"answerline": answerline, "givenAnswer": givenAnswer} 258 | 259 | temp_session: bool = False 260 | if session is None: 261 | temp_session = True 262 | session = aiohttp.ClientSession() 263 | 264 | async with session.get(url, params=data) as response: 265 | if response.status != 200: 266 | raise Exception(str(response.status) + " bad request") 267 | 268 | json = await response.json() 269 | if temp_session: 270 | await session.close() 271 | return cls.from_json(json) 272 | 273 | 274 | class Tossup: 275 | """Tossup class.""" 276 | 277 | def __init__( 278 | self: Self, 279 | question: str, 280 | question_sanitized: str, 281 | answer: str, 282 | answer_sanitized: str, 283 | difficulty: Difficulty, 284 | category: Category, 285 | subcategory: Subcategory, 286 | packet: PacketMetadata, 287 | set: SetMetadata, 288 | number: int, 289 | alternate_subcategory: Optional[AlternateSubcategory] = None, 290 | ): 291 | self.question: str = question 292 | self.question_sanitized: str = question_sanitized 293 | self.answer: str = answer 294 | self.answer_sanitized: str = answer_sanitized 295 | self.difficulty: Difficulty = difficulty 296 | self.category: Category = category 297 | self.subcategory: Subcategory = subcategory 298 | self.packet: PacketMetadata = packet 299 | self.set: SetMetadata = set 300 | self.number: int = number 301 | self.alternate_subcategory: AlternateSubcategory | None = alternate_subcategory 302 | 303 | @classmethod 304 | def from_json(cls: Type[Self], json: dict[str, Any]) -> Self: 305 | """Create a Tossup from a JSON object. 306 | 307 | See https://www.qbreader.org/api-docs/schemas#tossups for schema. 308 | """ 309 | alternate_subcategory = json.get("alternate_subcategory", None) 310 | return cls( 311 | question=json["question"], 312 | question_sanitized=json["question_sanitized"], 313 | answer=json["answer"], 314 | answer_sanitized=json["answer_sanitized"], 315 | difficulty=Difficulty(str(json["difficulty"])), 316 | category=Category(json["category"]), 317 | subcategory=Subcategory(json["subcategory"]), 318 | packet=PacketMetadata.from_json(json["packet"]), 319 | set=SetMetadata.from_json(json["set"]), 320 | number=json["number"], 321 | alternate_subcategory=AlternateSubcategory(alternate_subcategory) 322 | if alternate_subcategory 323 | else None, 324 | ) 325 | 326 | def check_answer_sync(self, givenAnswer: str) -> AnswerJudgement: 327 | """Check whether an answer is correct.""" 328 | return AnswerJudgement.check_answer_sync(self.answer, givenAnswer) 329 | 330 | async def check_answer_async( 331 | self, givenAnswer: str, session: aiohttp.ClientSession | None = None 332 | ) -> AnswerJudgement: 333 | """Asynchronously check whether an answer is correct.""" 334 | return await AnswerJudgement.check_answer_async( 335 | self.answer, givenAnswer, session 336 | ) 337 | 338 | def __eq__(self, other: object) -> bool: 339 | """Return whether two tossups are equal.""" 340 | if not isinstance(other, Tossup): 341 | return NotImplemented 342 | 343 | return ( 344 | self.question == other.question 345 | and self.question_sanitized == other.question_sanitized 346 | and self.answer == other.answer 347 | and self.answer_sanitized == other.answer_sanitized 348 | and self.difficulty == other.difficulty 349 | and self.category == other.category 350 | and self.subcategory == other.subcategory 351 | and self.alternate_subcategory == other.alternate_subcategory 352 | and self.packet == other.packet 353 | and self.set == other.set 354 | and self.number == other.number 355 | ) 356 | 357 | def __str__(self) -> str: 358 | """Return the question.""" 359 | return self.question 360 | 361 | 362 | class Bonus: 363 | """Bonus class.""" 364 | 365 | def __init__( 366 | self: Self, 367 | leadin: str, 368 | leadin_sanitized: str, 369 | parts: Sequence[str], 370 | parts_sanitized: Sequence[str], 371 | answers: Sequence[str], 372 | answers_sanitized: Sequence[str], 373 | difficulty: Difficulty, 374 | category: Category, 375 | subcategory: Subcategory, 376 | set: SetMetadata, 377 | packet: PacketMetadata, 378 | number: int, 379 | alternate_subcategory: Optional[AlternateSubcategory] = None, 380 | values: Optional[Sequence[int]] = None, 381 | difficultyModifiers: Optional[Sequence[DifficultyModifier]] = None, 382 | ): 383 | self.leadin: str = leadin 384 | self.leadin_sanitized: str = leadin_sanitized 385 | self.parts: tuple[str, ...] = tuple(parts) 386 | self.parts_sanitized: tuple[str, ...] = tuple(parts_sanitized) 387 | self.answers: tuple[str, ...] = tuple(answers) 388 | self.answers_sanitized: tuple[str, ...] = tuple(answers_sanitized) 389 | self.difficulty: Difficulty = difficulty 390 | self.category: Category = category 391 | self.subcategory: Subcategory = subcategory 392 | self.set: SetMetadata = set 393 | self.packet: PacketMetadata = packet 394 | self.number: int = number 395 | self.alternate_subcategory: AlternateSubcategory | None = alternate_subcategory 396 | self.values: Optional[tuple[int, ...]] = tuple(values) if values else None 397 | self.difficultyModifiers: Optional[tuple[DifficultyModifier, ...]] = ( 398 | tuple(difficultyModifiers) if difficultyModifiers else None 399 | ) 400 | 401 | @classmethod 402 | def from_json(cls: Type[Self], json: dict[str, Any]) -> Self: 403 | """Create a Bonus from a JSON object. 404 | 405 | See https://www.qbreader.org/api-docs/schemas#bonus for schema. 406 | """ 407 | alternate_subcategory = json.get("alternate_subcategory", None) 408 | return cls( 409 | leadin=json["leadin"], 410 | leadin_sanitized=json["leadin_sanitized"], 411 | parts=json["parts"], 412 | parts_sanitized=json["parts_sanitized"], 413 | answers=json["answers"], 414 | answers_sanitized=json["answers_sanitized"], 415 | difficulty=Difficulty(str(json["difficulty"])), 416 | category=Category(json["category"]), 417 | subcategory=Subcategory(json["subcategory"]), 418 | set=SetMetadata.from_json(json["set"]), 419 | packet=PacketMetadata.from_json(json["packet"]), 420 | number=json["number"], 421 | alternate_subcategory=AlternateSubcategory(alternate_subcategory) 422 | if alternate_subcategory 423 | else None, 424 | values=json.get("values", None), 425 | difficultyModifiers=json.get("difficultyModifiers", None), 426 | ) 427 | 428 | def check_answer_sync(self, part: int, givenAnswer: str) -> AnswerJudgement: 429 | """Check whether an answer is correct.""" 430 | return AnswerJudgement.check_answer_sync(self.answers[part], givenAnswer) 431 | 432 | async def check_answer_async( 433 | self, part: int, givenAnswer: str, session: aiohttp.ClientSession 434 | ) -> AnswerJudgement: 435 | """Asynchronously check whether an answer is correct.""" 436 | return await AnswerJudgement.check_answer_async( 437 | self.answers[part], givenAnswer, session 438 | ) 439 | 440 | def __eq__(self, other: object) -> bool: 441 | """Return whether two bonuses are equal.""" 442 | if not isinstance(other, Bonus): 443 | return NotImplemented 444 | 445 | return ( 446 | self.leadin == other.leadin 447 | and self.leadin_sanitized == other.leadin_sanitized 448 | and self.parts == other.parts 449 | and self.parts_sanitized == other.parts_sanitized 450 | and self.answers == other.answers 451 | and self.answers_sanitized == other.answers_sanitized 452 | and self.difficulty == other.difficulty 453 | and self.category == other.category 454 | and self.subcategory == other.subcategory 455 | and self.alternate_subcategory == other.alternate_subcategory 456 | and self.set == other.set 457 | and self.packet == other.packet 458 | and self.number == other.number 459 | and self.values == other.values 460 | and self.difficultyModifiers == other.difficultyModifiers 461 | ) 462 | 463 | def __str__(self) -> str: 464 | """Return the parts of the bonus.""" 465 | return "\n".join(self.parts) 466 | 467 | 468 | class QueryResponse: 469 | """Class for responses to `api/query` requests.""" 470 | 471 | def __init__( 472 | self: Self, 473 | tossups: Sequence[Tossup], 474 | bonuses: Sequence[Bonus], 475 | tossups_found: int, 476 | bonuses_found: int, 477 | query_string: str, 478 | ): 479 | self.tossups: tuple[Tossup, ...] = tuple(tossups) 480 | self.bonuses: tuple[Bonus, ...] = tuple(bonuses) 481 | self.tossups_found: int = tossups_found 482 | self.bonuses_found: int = bonuses_found 483 | self.query_string: str = query_string 484 | 485 | @classmethod 486 | def from_json(cls: Type[Self], json: dict[str, Any]) -> Self: 487 | """Create a QueryResponse from a JSON object. 488 | 489 | See https://www.qbreader.org/api-docs/query#returns for schema. 490 | """ 491 | return cls( 492 | tossups=[ 493 | Tossup.from_json(tossup) for tossup in json["tossups"]["questionArray"] 494 | ], 495 | bonuses=[ 496 | Bonus.from_json(bonus) for bonus in json["bonuses"]["questionArray"] 497 | ], 498 | tossups_found=json["tossups"]["count"], 499 | bonuses_found=json["bonuses"]["count"], 500 | query_string=json["queryString"], 501 | ) 502 | 503 | def __str__(self) -> str: 504 | """Return the queried tossups and bonuses.""" 505 | return ( 506 | "\n\n".join([str(tossup) for tossup in self.tossups]) 507 | + "\n\n\n" 508 | + "\n\n".join([str(bonus) for bonus in self.bonuses]) 509 | ) 510 | 511 | 512 | class Packet: 513 | """Class for packets in sets.""" 514 | 515 | def __init__( 516 | self: Self, 517 | tossups: Sequence[Tossup], 518 | bonuses: Sequence[Bonus], 519 | number: Optional[int] = None, 520 | name: Optional[str] = None, 521 | year: Optional[int] = None, 522 | ): 523 | self.tossups: tuple[Tossup, ...] = tuple(tossups) 524 | self.bonuses: tuple[Bonus, ...] = tuple(bonuses) 525 | self.number: Optional[int] = number if number else self.tossups[0].packet.number 526 | self.name: Optional[str] = name if name else self.tossups[0].set.name 527 | self.year: Optional[int] = year if year else self.tossups[0].set.year 528 | 529 | @classmethod 530 | def from_json( 531 | cls: Type[Self], json: dict[str, Any], number: Optional[int] = None 532 | ) -> Self: 533 | """Create a Packet from a JSON object. 534 | 535 | See https://www.qbreader.org/api-docs/packet#returns for schema. 536 | """ 537 | return cls( 538 | tossups=[Tossup.from_json(tossup) for tossup in json["tossups"]], 539 | bonuses=[Bonus.from_json(bonus) for bonus in json["bonuses"]], 540 | number=number, 541 | ) 542 | 543 | def paired_questions(self) -> zip[tuple[Tossup, Bonus]]: 544 | """Yield pairs of tossups and bonuses.""" 545 | return zip(self.tossups, self.bonuses) 546 | 547 | def __iter__(self) -> zip[tuple[Tossup, Bonus]]: 548 | """Alias to `paired_questions()`.""" 549 | return self.paired_questions() 550 | 551 | def __eq__(self, other: object) -> bool: 552 | """Return whether two packets are equal.""" 553 | if not isinstance(other, Packet): 554 | return NotImplemented 555 | 556 | return ( 557 | self.tossups == other.tossups 558 | and self.bonuses == other.bonuses 559 | and self.number == other.number 560 | and self.name == other.name 561 | and self.year == other.year 562 | ) 563 | 564 | def __str__(self) -> str: 565 | """Return the tossups and bonuses in the packet.""" 566 | return ( 567 | "\n\n".join([str(tossup) for tossup in self.tossups]) 568 | + "\n\n\n" 569 | + "\n\n".join([str(bonus) for bonus in self.bonuses]) 570 | ) 571 | 572 | 573 | class PacketMetadata: 574 | def __init__( 575 | self: Self, 576 | _id: str, 577 | name: str, 578 | number: int, 579 | ): 580 | self._id: str = _id 581 | self.name: str = name 582 | self.number: int = number 583 | 584 | @classmethod 585 | def from_json(cls: Type[Self], json: dict[str, Any]) -> Self: 586 | return cls( 587 | _id=json["_id"], 588 | name=json["name"], 589 | number=json["number"], 590 | ) 591 | 592 | def __eq__(self, other: object) -> bool: 593 | if not isinstance(other, PacketMetadata): 594 | return NotImplemented 595 | 596 | return ( 597 | self._id == other._id 598 | and self.name == other.name 599 | and self.number == other.number 600 | ) 601 | 602 | def __str__(self) -> str: 603 | return self.name 604 | 605 | 606 | class SetMetadata: 607 | def __init__( 608 | self: Self, 609 | _id: str, 610 | name: str, 611 | year: int, 612 | standard: bool, 613 | ): 614 | self._id: str = _id 615 | self.name: str = name 616 | self.year: int = year 617 | self.standard: bool = standard 618 | 619 | @classmethod 620 | def from_json(cls: Type[Self], json: dict[str, Any]) -> Self: 621 | return cls( 622 | _id=json["_id"], 623 | name=json["name"], 624 | year=json["year"], 625 | standard=json["standard"], 626 | ) 627 | 628 | def __eq__(self, other: object) -> bool: 629 | if not isinstance(other, SetMetadata): 630 | return NotImplemented 631 | 632 | return ( 633 | self._id == other._id 634 | and self.name == other.name 635 | and self.year == other.year 636 | and self.standard == other.standard 637 | ) 638 | 639 | def __str__(self) -> str: 640 | return self.name 641 | 642 | 643 | QuestionType: TypeAlias = Union[ 644 | Literal["tossup", "bonus", "all"], Type[Tossup], Type[Bonus] 645 | ] 646 | """Type alias for question types.""" 647 | 648 | SearchType: TypeAlias = Literal["question", "answer", "all"] 649 | """Type alias for query search types.""" 650 | 651 | ValidDifficulties: TypeAlias = Literal[ 652 | 0, 653 | 1, 654 | 2, 655 | 3, 656 | 4, 657 | 5, 658 | 6, 659 | 7, 660 | 8, 661 | 9, 662 | 10, 663 | "0", 664 | "1", 665 | "2", 666 | "3", 667 | "4", 668 | "5", 669 | "6", 670 | "7", 671 | "8", 672 | "9", 673 | "10", 674 | ] 675 | """Type alias for valid difficulties.""" 676 | 677 | UnnormalizedDifficulty: TypeAlias = Optional[ 678 | Union[Difficulty, ValidDifficulties, Iterable[Union[Difficulty, ValidDifficulties]]] 679 | ] 680 | """Type alias for unnormalized difficulties. Union of `Difficulty`, `ValidDifficulties`, 681 | and `collections.abc.Iterable` containing either.""" 682 | 683 | UnnormalizedCategory: TypeAlias = Optional[ 684 | Union[Category, str, Iterable[Union[Category, str]]] 685 | ] 686 | """Type alias for unnormalized categories. Union of `Category`, `str`, and 687 | `collections.abc.Iterable` containing either.""" 688 | 689 | UnnormalizedSubcategory: TypeAlias = Optional[ 690 | Union[Subcategory, str, Iterable[Union[Subcategory, str]]] 691 | ] 692 | """Type alias for unnormalized subcategories. Union of `Subcategory`, `str`, and 693 | `collections.abc.Iterable` containing either.""" 694 | 695 | UnnormalizedAlternateSubcategory: TypeAlias = Optional[ 696 | Union[AlternateSubcategory, str, Iterable[Union[AlternateSubcategory, str]]] 697 | ] 698 | """Type alias for unnormalized alternate subcategories. Union of `AlternateSubcategory`, 699 | `str`, and `collections.abc.Iterable` containing either.""" 700 | 701 | 702 | __all__ = ( 703 | "Tossup", 704 | "Bonus", 705 | "Packet", 706 | "QueryResponse", 707 | "AnswerJudgement", 708 | "Category", 709 | "Subcategory", 710 | "AlternateSubcategory", 711 | "Difficulty", 712 | "Directive", 713 | "QuestionType", 714 | "SearchType", 715 | "ValidDifficulties", 716 | "UnnormalizedDifficulty", 717 | "UnnormalizedCategory", 718 | "UnnormalizedSubcategory", 719 | "UnnormalizedAlternateSubcategory", 720 | ) 721 | -------------------------------------------------------------------------------- /qbreader/synchronous.py: -------------------------------------------------------------------------------- 1 | """Directly access the qbreader API synchronously.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Optional, Self 6 | 7 | import requests 8 | 9 | import qbreader._api_utils as api_utils 10 | from qbreader._consts import BASE_URL 11 | from qbreader.types import ( 12 | AnswerJudgement, 13 | Bonus, 14 | Packet, 15 | QueryResponse, 16 | QuestionType, 17 | SearchType, 18 | Tossup, 19 | UnnormalizedAlternateSubcategory, 20 | UnnormalizedCategory, 21 | UnnormalizedDifficulty, 22 | UnnormalizedSubcategory, 23 | Year, 24 | ) 25 | 26 | 27 | class Sync: 28 | """The synchronous qbreader API wrapper.""" 29 | 30 | def query( 31 | self: Self, 32 | questionType: QuestionType = "all", 33 | searchType: SearchType = "all", 34 | queryString: Optional[str] = "", 35 | exactPhrase: Optional[bool] = False, 36 | ignoreDiacritics: Optional[bool] = False, 37 | ignoreWordOrder: Optional[bool] = False, 38 | regex: Optional[bool] = False, 39 | randomize: Optional[bool] = False, 40 | setName: Optional[str] = None, 41 | difficulties: UnnormalizedDifficulty = None, 42 | categories: UnnormalizedCategory = None, 43 | subcategories: UnnormalizedSubcategory = None, 44 | alternate_subcategories: UnnormalizedAlternateSubcategory = None, 45 | maxReturnLength: Optional[int] = 25, 46 | tossupPagination: Optional[int] = 1, 47 | bonusPagination: Optional[int] = 1, 48 | min_year: int = Year.MIN_YEAR, 49 | max_year: int = Year.CURRENT_YEAR, 50 | ) -> QueryResponse: 51 | """Query the qbreader database for questions. 52 | 53 | Original API doc at https://www.qbreader.org/api-docs/query. 54 | 55 | Parameters 56 | ---------- 57 | questionType : qbreader.types.QuestionType 58 | The type of question to search for. Can be either a string or a question 59 | class type. 60 | searchType : qbreader.types.SearchType 61 | Where to search for the query string. Can only be a string. 62 | queryString : str, optional 63 | The string to search for. 64 | exactPhrase : bool, default = False 65 | Ensure that the query string is an exact phrase. 66 | ignoreDiacritics : bool, default = False 67 | Ignore or transliterate diacritical marks in `queryString`. 68 | ignoreWordOrder : bool, default = False 69 | Treat `queryString` as a set of keywords that can appear in any order. 70 | regex : bool, default = False 71 | Treat `queryString` as a regular expression. 72 | randomize : bool, default = False 73 | Randomize the order of the returned questions. 74 | setName : str, optional 75 | The name of the set to search in. 76 | difficulties : qbreader.types.UnnormalizedDifficulty, optional 77 | The difficulties to search for. Can be a single or an array of `Difficulty` 78 | enums, strings, or integers. 79 | categories : qbreader.types.UnnormalizedCategory, optional 80 | The categories to search for. Can be a single or an array of `Category` 81 | enums or strings. 82 | subcategories : qbreader.types.UnnormalizedSubcategory, optional 83 | The subcategories to search for. Can be a single or an array of 84 | `Subcategory` enums or strings. The API does not check for consistency 85 | between categories and subcategories. 86 | alternate_subcategories: qbreaader.types.UnnormalizedAlternateSubcategory,\ 87 | optional 88 | The alternates subcategories to search for. Can be a single or an array of 89 | `AlternateSubcategory` enum variants or strings. The API does not check for 90 | consistency between categories, subcategories, and alternate subcategories. 91 | maxReturnLength : int, default = 25 92 | The maximum number of questions to return. 93 | tossupPagination : int, default = 1 94 | The page of tossups to return. 95 | bonusPagination : int, default = 1 96 | The page of bonuses to return. 97 | min_year : int, default = Year.MIN_YEAR 98 | The earliest year to search. 99 | max_year : int, default = Year.CURRENT_YEAR 100 | The latest year to search. 101 | Returns 102 | ------- 103 | QueryResponse 104 | A `QueryResponse` object containing the results of the query. 105 | """ 106 | # normalize and type check parameters 107 | if questionType == Tossup: 108 | questionType = "tossup" 109 | elif questionType == Bonus: 110 | questionType = "bonus" 111 | if questionType not in ["tossup", "bonus", "all"]: 112 | raise ValueError("questionType must be either 'tossup', 'bonus', or 'all'.") 113 | 114 | if searchType not in ["question", "answer", "all"]: 115 | raise ValueError( 116 | "searchType must be either 'question', 'answer', or 'all'." 117 | ) 118 | 119 | if not isinstance(queryString, str): 120 | raise TypeError( 121 | f"queryString must be a string, not {type(queryString).__name__}." 122 | ) 123 | 124 | for name, param in tuple( 125 | zip( 126 | ( 127 | "exactPhrase", 128 | "ignoreDiacritics", 129 | "ignoreWordOrder", 130 | "regex", 131 | "randomize", 132 | ), 133 | (exactPhrase, ignoreDiacritics, ignoreWordOrder, regex, randomize), 134 | ) 135 | ): 136 | if not isinstance(param, bool): 137 | raise TypeError( 138 | f"{name} must be a boolean, not {type(param).__name__}." 139 | ) 140 | 141 | if setName is not None and not isinstance(setName, str): 142 | raise TypeError(f"setName must be a string, not {type(setName).__name__}.") 143 | 144 | for name, param in tuple( # type: ignore 145 | zip( 146 | ("maxReturnLength", "tossupPagination", "bonusPagination"), 147 | (maxReturnLength, tossupPagination, bonusPagination), 148 | ) 149 | ): 150 | if not isinstance(param, int): 151 | raise TypeError( 152 | f"{name} must be an integer, not {type(param).__name__}." 153 | ) 154 | elif param < 1: 155 | raise ValueError(f"{name} must be at least 1.") 156 | 157 | for name, year in { 158 | "minYear": min_year, 159 | "maxYear": max_year, 160 | }.items(): 161 | if not isinstance(year, int): 162 | raise TypeError( 163 | f"{name} must be an integer, not {type(param).__name__}." 164 | ) 165 | 166 | url = BASE_URL + "/query" 167 | 168 | ( 169 | normalized_categories, 170 | normalized_subcategories, 171 | normalized_alternate_subcategories, 172 | ) = api_utils.normalize_cats(categories, subcategories, alternate_subcategories) 173 | 174 | data = { 175 | "questionType": questionType, 176 | "searchType": searchType, 177 | "queryString": queryString, 178 | "exactPhrase": api_utils.normalize_bool(exactPhrase), 179 | "ignoreDiacritics": api_utils.normalize_bool(ignoreDiacritics), 180 | "ignoreWordOrder": api_utils.normalize_bool(ignoreWordOrder), 181 | "regex": api_utils.normalize_bool(regex), 182 | "randomize": api_utils.normalize_bool(randomize), 183 | "setName": setName, 184 | "difficulties": api_utils.normalize_diff(difficulties), 185 | "categories": normalized_categories, 186 | "subcategories": normalized_subcategories, 187 | "alternateSubcategories": normalized_alternate_subcategories, 188 | "maxReturnLength": maxReturnLength, 189 | "tossupPagination": tossupPagination, 190 | "bonusPagination": bonusPagination, 191 | "minYear": min_year, 192 | "maxYear": max_year, 193 | } 194 | data = api_utils.prune_none(data) 195 | 196 | response: requests.Response = requests.get(url, params=data) 197 | 198 | if response.status_code != 200: 199 | raise Exception(str(response.status_code) + " bad request") 200 | 201 | return QueryResponse.from_json(response.json()) 202 | 203 | def random_tossup( 204 | self: Self, 205 | difficulties: UnnormalizedDifficulty = None, 206 | categories: UnnormalizedCategory = None, 207 | subcategories: UnnormalizedSubcategory = None, 208 | alternate_subcategories: UnnormalizedAlternateSubcategory = None, 209 | number: int = 1, 210 | min_year: int = Year.MIN_YEAR, 211 | max_year: int = Year.CURRENT_YEAR, 212 | ) -> tuple[Tossup, ...]: 213 | """Get random tossups from the database. 214 | 215 | Original API doc at https://www.qbreader.org/api-docs/random-tossup. 216 | 217 | Parameters 218 | ---------- 219 | difficulties : qbreader.types.UnnormalizedDifficulty, optional 220 | The difficulties to search for. Can be a single or an array of `Difficulty` 221 | enums, strings, or integers. 222 | categories : qbreader.types.UnnormalizedCategory, optional 223 | The categories to search for. Can be a single or an array of `Category` 224 | enums or strings. 225 | subcategories : qbreader.types.UnnormalizedSubcategory, optional 226 | The subcategories to search for. Can be a single or an array of 227 | `Subcategory` enums or strings. The API does not check for consistency 228 | between categories and subcategories. 229 | alternate_subcategories: qbreaader.types.UnnormalizedAlternateSubcategory, 230 | optional 231 | The alternates subcategories to search for. Can be a single or an array of 232 | `AlternateSubcategory` enum variants or strings. The API does not check for 233 | consistency between categories, subcategories, and alternate subcategories. 234 | number : int, default = 1 235 | The number of tossups to return. 236 | min_year : int, default = Year.MIN_YEAR 237 | The oldest year to search for. 238 | max_year : int, default = Year.CURRENT_YEAR 239 | The most recent year to search for. 240 | 241 | Returns 242 | ------- 243 | tuple[Tossup, ...] 244 | A tuple of `Tossup` objects. 245 | """ 246 | # normalize and type check parameters 247 | for name, param in tuple( 248 | zip( 249 | ("number", "min_year", "max_year"), 250 | (number, min_year, max_year), 251 | ) 252 | ): 253 | if not isinstance(param, int): 254 | raise TypeError( 255 | f"{name} must be an integer, not {type(param).__name__}." 256 | ) 257 | elif param < 1: 258 | raise ValueError(f"{name} must be at least 1.") 259 | 260 | url = BASE_URL + "/random-tossup" 261 | 262 | ( 263 | normalized_categories, 264 | normalized_subcategories, 265 | normalized_alternate_subcategories, 266 | ) = api_utils.normalize_cats(categories, subcategories, alternate_subcategories) 267 | 268 | data = { 269 | "difficulties": api_utils.normalize_diff(difficulties), 270 | "categories": normalized_categories, 271 | "subcategories": normalized_subcategories, 272 | "alternateSubcategories": normalized_alternate_subcategories, 273 | "number": number, 274 | "minYear": min_year, 275 | "maxYear": max_year, 276 | } 277 | data = api_utils.prune_none(data) 278 | 279 | response: requests.Response = requests.get(url, params=data) 280 | 281 | if response.status_code != 200: 282 | raise Exception(str(response.status_code) + " bad request") 283 | 284 | return tuple(Tossup.from_json(tu) for tu in response.json()["tossups"]) 285 | 286 | def random_bonus( 287 | self: Self, 288 | difficulties: UnnormalizedDifficulty = None, 289 | categories: UnnormalizedCategory = None, 290 | subcategories: UnnormalizedSubcategory = None, 291 | alternate_subcategories: UnnormalizedAlternateSubcategory = None, 292 | number: int = 1, 293 | min_year: int = Year.MIN_YEAR, 294 | max_year: int = Year.CURRENT_YEAR, 295 | three_part_bonuses: bool = False, 296 | ) -> tuple[Bonus, ...]: 297 | """Get random bonuses from the database. 298 | 299 | Original API doc at https://www.qbreader.org/api-docs/random-bonus. 300 | 301 | Parameters 302 | ---------- 303 | difficulties : qbreader.types.UnnormalizedDifficulty, optional 304 | The difficulties to search for. Can be a single or an array of 305 | `Difficulty` enums, strings, or integers. 306 | categories : qbreader.types.UnnormalizedCategory, optional 307 | The categories to search for. Can be a single or an array of 308 | `Category` enums or strings. 309 | subcategories : qbreader.types.UnnormalizedSubcategory, optional 310 | The subcategories to search for. Can be a single or an array of 311 | `Subcategory` enums or strings. The API does not check for consistency 312 | between categories and subcategories. 313 | alternate_subcategories : qbreaader.types.UnnormalizedAlternateSubcategory, 314 | optional 315 | The alternates subcategories to search for. Can be a single or an array of 316 | `AlternateSubcategory` enum variants or strings. The API does not check for 317 | consistency between categories, subcategories, and alternate subcategories. 318 | number : int, default = 1 319 | The number of bonuses to return. 320 | min_year : int, default = Year.MIN_YEAR 321 | The oldest year to search for. 322 | max_year : int, default = Year.CURRENT_YEAR 323 | The most recent year to search for. 324 | three_part_bonuses : bool, default = False 325 | Whether to only return bonuses with 3 parts. 326 | 327 | Returns 328 | ------- 329 | tuple[Bonus, ...] 330 | A tuple of `Bonus` objects. 331 | """ 332 | # normalize and type check parameters 333 | for name, param in tuple( 334 | zip( 335 | ("number", "min_year", "max_year"), 336 | (number, min_year, max_year), 337 | ) 338 | ): 339 | if not isinstance(param, int): 340 | raise TypeError( 341 | f"{name} must be an integer, not {type(param).__name__}." 342 | ) 343 | elif param < 1: 344 | raise ValueError(f"{name} must be at least 1.") 345 | 346 | if not isinstance(three_part_bonuses, bool): 347 | raise TypeError( 348 | "three_part_bonuses must be a boolean, not " 349 | + f"{type(three_part_bonuses).__name__}." 350 | ) 351 | 352 | url = BASE_URL + "/random-bonus" 353 | 354 | ( 355 | normalized_categories, 356 | normalized_subcategories, 357 | normalized_alternate_subcategories, 358 | ) = api_utils.normalize_cats(categories, subcategories, alternate_subcategories) 359 | 360 | data = { 361 | "difficulties": api_utils.normalize_diff(difficulties), 362 | "categories": normalized_categories, 363 | "subcategories": normalized_subcategories, 364 | "alternateSubcategories": normalized_alternate_subcategories, 365 | "number": number, 366 | "minYear": min_year, 367 | "maxYear": max_year, 368 | } 369 | data = api_utils.prune_none(data) 370 | 371 | response: requests.Response = requests.get(url, params=data) 372 | 373 | if response.status_code != 200: 374 | raise Exception(str(response.status_code) + " bad request") 375 | 376 | return tuple(Bonus.from_json(b) for b in response.json()["bonuses"]) 377 | 378 | def random_name(self: Self) -> str: 379 | """Get a random adjective-noun pair that can be used as a name. 380 | 381 | Original API doc at https://www.qbreader.org/api-docs/random-name. 382 | 383 | Returns 384 | ------- 385 | str 386 | A string containing the random name. 387 | 388 | """ 389 | url = BASE_URL + "/random-name" 390 | 391 | response: requests.Response = requests.get(url) 392 | 393 | if response.status_code != 200: 394 | raise Exception(str(response.status_code) + " bad request") 395 | 396 | return response.json()["randomName"] 397 | 398 | def packet(self: Self, setName: str, packetNumber: int) -> Packet: 399 | """Get a specific packet from a set. 400 | 401 | Original API doc at https://www.qbreader.org/api-docs/packet. 402 | 403 | Parameters 404 | ---------- 405 | setName : str 406 | The name of the set. See `set_list()` for a list of valid set names. 407 | packetNumber : int 408 | The number of the packet in the set, starting from 1. 409 | 410 | Returns 411 | ------- 412 | Packet 413 | A `Packet` object containing the packet's tossups and bonuses. 414 | """ 415 | # normalize and type check parameters 416 | if not isinstance(setName, str): 417 | raise TypeError(f"setName must be a string, not {type(setName).__name__}.") 418 | 419 | if not isinstance(packetNumber, int): 420 | raise TypeError( 421 | f"packetNumber must be an integer, not {type(packetNumber).__name__}." 422 | ) 423 | 424 | if packetNumber < 1 or packetNumber > self.num_packets(setName): 425 | raise ValueError( 426 | f"packetNumber must be between 1 and {self.num_packets(setName)} " 427 | + f"inclusive for {setName}." 428 | ) 429 | 430 | url = BASE_URL + "/packet" 431 | 432 | data: dict[str, str | int] = {"setName": setName, "packetNumber": packetNumber} 433 | data = api_utils.prune_none(data) 434 | 435 | response: requests.Response = requests.get(url, params=data) 436 | if response.status_code != 200: 437 | raise Exception(str(response.status_code) + " bad request") 438 | 439 | return Packet.from_json(json=response.json(), number=packetNumber) 440 | 441 | def packet_tossups( 442 | self: Self, setName: str, packetNumber: int 443 | ) -> tuple[Tossup, ...]: 444 | """Get only tossups from a packet. 445 | 446 | Original API doc at https://www.qbreader.org/api-docs/packet-tossups. 447 | 448 | Parameters 449 | ---------- 450 | setName : str 451 | The name of the set. See `set_list()` for a list of valid set names. 452 | packetNumber : int 453 | The number of the packet in the set, starting from 1. 454 | 455 | Returns 456 | ------- 457 | tuple[Tossup, ...] 458 | A tuple of `Tossup` objects. 459 | """ 460 | # normalize and type check parameters 461 | if not isinstance(setName, str): 462 | raise TypeError(f"setName must be a string, not {type(setName).__name__}.") 463 | 464 | if not isinstance(packetNumber, int): 465 | raise TypeError( 466 | f"packetNumber must be an integer, not {type(packetNumber).__name__}." 467 | ) 468 | 469 | if packetNumber < 1 or packetNumber > self.num_packets(setName): 470 | raise ValueError( 471 | f"packetNumber must be between 1 and {self.num_packets(setName)} " 472 | + f"inclusive for {setName}." 473 | ) 474 | 475 | url = BASE_URL + "/packet-tossups" 476 | 477 | data: dict[str, str | int] = {"setName": setName, "packetNumber": packetNumber} 478 | data = api_utils.prune_none(data) 479 | 480 | response = requests.get(url, params=data) 481 | 482 | if response.status_code != 200: 483 | raise Exception(str(response.status_code) + " bad request") 484 | 485 | return tuple(Tossup.from_json(tu) for tu in response.json()["tossups"]) 486 | 487 | def packet_bonuses( 488 | self: Self, setName: str, packetNumber: int 489 | ) -> tuple[Bonus, ...]: 490 | """Get only bonuses from a packet. 491 | 492 | Original API doc at https://www.qbreader.org/api-docs/packet-bonuses. 493 | 494 | Parameters 495 | ---------- 496 | setName : str 497 | The name of the set. See `set_list()` for a list of valid set names. 498 | packetNumber : int 499 | The number of the packet in the set, starting from 1. 500 | 501 | Returns 502 | ------- 503 | tuple[Bonus, ...] 504 | A tuple of `Bonus` objects. 505 | """ 506 | # normalize and type check parameters 507 | if not isinstance(setName, str): 508 | raise TypeError(f"setName must be a string, not {type(setName).__name__}.") 509 | 510 | if not isinstance(packetNumber, int): 511 | raise TypeError( 512 | f"packetNumber must be an integer, not {type(packetNumber).__name__}." 513 | ) 514 | 515 | if packetNumber < 1 or packetNumber > self.num_packets(setName): 516 | raise ValueError( 517 | f"packetNumber must be between 1 and {self.num_packets(setName)} " 518 | + f"inclusive for {setName}." 519 | ) 520 | 521 | url = BASE_URL + "/packet-bonuses" 522 | 523 | data: dict[str, str | int] = {"setName": setName, "packetNumber": packetNumber} 524 | data = api_utils.prune_none(data) 525 | 526 | response = requests.get(url, params=data) 527 | 528 | if response.status_code != 200: 529 | raise Exception(str(response.status_code) + " bad request") 530 | 531 | return tuple(Bonus.from_json(b) for b in response.json()["bonuses"]) 532 | 533 | def num_packets(self: Self, setName: str) -> int: 534 | """Get the number of packets in a set. 535 | 536 | Original API doc at https://www.qbreader.org/api-docs/num-packets. 537 | 538 | Parameters 539 | ---------- 540 | setName : str 541 | The name of the set to search. Can be obtained from set_list(). 542 | 543 | Returns 544 | ------- 545 | int 546 | The number of packets in the set. 547 | """ 548 | url = BASE_URL + "/num-packets" 549 | 550 | data = { 551 | "setName": setName, 552 | } 553 | 554 | response = requests.get(url, params=data) 555 | 556 | if response.status_code != 200: 557 | if response.status_code == 404: 558 | raise ValueError(f"Requested set, {setName}, not found.") 559 | raise Exception(str(response.status_code) + " bad request") 560 | 561 | return response.json()["numPackets"] 562 | 563 | def set_list(self: Self) -> tuple[str, ...]: 564 | """Get a list of all the sets in the database. 565 | 566 | Original API doc at https://www.qbreader.org/api-docs/set-list. 567 | 568 | Returns 569 | ------- 570 | tuple[str, ...] 571 | A tuple containing the names of all the sets in the database, sorted in 572 | reverse alphanumeric order. 573 | """ 574 | url = BASE_URL + "/set-list" 575 | 576 | response: requests.Response = requests.get(url) 577 | 578 | if response.status_code != 200: 579 | raise Exception(str(response.status_code) + " bad request") 580 | 581 | return response.json()["setList"] 582 | 583 | def room_list(self: Self) -> tuple[dict, ...]: 584 | """Get a list of public rooms. 585 | 586 | Original API doc at https://www.qbreader.org/api-docs/multiplayer/room-list. 587 | 588 | Returns 589 | ------- 590 | tuple[dict, ...] 591 | A tuple containing the room data for all the public rooms. 592 | """ 593 | url = BASE_URL + "/multiplayer/room-list" 594 | 595 | response: requests.Response = requests.get(url) 596 | 597 | if response.status_code != 200: 598 | raise Exception(str(response.status_code) + " bad request") 599 | 600 | return response.json()["roomList"] 601 | 602 | def check_answer(self: Self, answerline: str, givenAnswer: str) -> AnswerJudgement: 603 | """Judge an answer to be correct, incorrect, or prompt (can be directed). 604 | 605 | Original API doc at https://www.qbreader.org/api-docs/check-answer. 606 | 607 | Parameters 608 | ---------- 609 | answerline : str 610 | The answerline to check against. Preferably including the HTML tags and 611 | , if they are present. 612 | givenAnswer : str 613 | The answer to check. 614 | 615 | Returns 616 | ------- 617 | AnswerJudgement 618 | A `AnswerJudgement` object containing the response. 619 | """ 620 | return AnswerJudgement.check_answer_sync(answerline, givenAnswer) 621 | 622 | def tossup_by_id(self: Self, id: str) -> Tossup: 623 | """Get a tossup by its ID. 624 | 625 | Original API doc at https://www.qbreader.org/api-docs/tossup-by-id. 626 | 627 | Parameters 628 | ---------- 629 | id : str 630 | The ID of the tossup to get. 631 | 632 | Returns 633 | ------- 634 | Tossup 635 | A `Tossup` object. 636 | """ 637 | url = BASE_URL + "/tossup-by-id" 638 | 639 | data = { 640 | "id": id, 641 | } 642 | 643 | response: requests.Response = requests.get(url, params=data) 644 | 645 | if response.status_code != 200: 646 | if response.status_code == 400: 647 | raise ValueError(f"Invalid tossup ID: {id}") 648 | raise Exception(str(response.status_code) + " bad request") 649 | 650 | return Tossup.from_json(response.json()["tossup"]) 651 | 652 | def bonus_by_id(self: Self, id: str) -> Bonus: 653 | """Get a bonus by its ID. 654 | 655 | Original API doc at https://www.qbreader.org/api-docs/bonus-by-id. 656 | 657 | Parameters 658 | ---------- 659 | id : str 660 | The ID of the bonus to get. 661 | 662 | Returns 663 | ------- 664 | Bonus 665 | A `Bonus` object. 666 | """ 667 | url = BASE_URL + "/bonus-by-id" 668 | 669 | data = { 670 | "id": id, 671 | } 672 | 673 | response: requests.Response = requests.get(url, params=data) 674 | 675 | if response.status_code != 200: 676 | if response.status_code == 400: 677 | raise ValueError(f"Invalid bonus ID: {id}") 678 | raise Exception(str(response.status_code) + " bad request") 679 | 680 | return Bonus.from_json(response.json()["bonus"]) 681 | -------------------------------------------------------------------------------- /qbreader/asynchronous.py: -------------------------------------------------------------------------------- 1 | """Directly access the qbreader API asynchronously.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Optional, Self, Type 6 | 7 | import aiohttp 8 | 9 | import qbreader._api_utils as api_utils 10 | from qbreader._consts import BASE_URL 11 | from qbreader.types import ( 12 | AnswerJudgement, 13 | Bonus, 14 | Packet, 15 | QueryResponse, 16 | QuestionType, 17 | SearchType, 18 | Tossup, 19 | UnnormalizedAlternateSubcategory, 20 | UnnormalizedCategory, 21 | UnnormalizedDifficulty, 22 | UnnormalizedSubcategory, 23 | Year, 24 | ) 25 | 26 | 27 | class Async: 28 | """The asynchronous qbreader API wrapper.""" 29 | 30 | session: aiohttp.ClientSession 31 | 32 | @classmethod 33 | async def create( 34 | cls: Type[Self], session: Optional[aiohttp.ClientSession] = None 35 | ) -> Self: 36 | """Create a new Async instance. `__init__()` is not async, so this is necessary. 37 | 38 | Parameters 39 | ---------- 40 | session : aiohttp.ClientSession, optional 41 | The aiohttp session to use for requests. If none is provided, a new session 42 | will be created. 43 | 44 | Returns 45 | ------- 46 | Async 47 | The new Async instance. 48 | """ 49 | self = cls() 50 | self.session = session or aiohttp.ClientSession() 51 | return self 52 | 53 | async def close(self: Self) -> None: 54 | """Close the aiohttp session.""" 55 | await self.session.close() 56 | 57 | async def __aenter__(self: Self) -> Self: 58 | """Enter an async context.""" 59 | return self 60 | 61 | async def __aexit__(self: Self, exc_type, exc_val, exc_tb) -> None: 62 | """Exit an async context.""" 63 | await self.close() 64 | 65 | async def query( 66 | self: Self, 67 | questionType: QuestionType = "all", 68 | searchType: SearchType = "all", 69 | queryString: Optional[str] = "", 70 | exactPhrase: Optional[bool] = False, 71 | ignoreDiacritics: Optional[bool] = False, 72 | ignoreWordOrder: Optional[bool] = False, 73 | regex: Optional[bool] = False, 74 | randomize: Optional[bool] = False, 75 | setName: Optional[str] = None, 76 | difficulties: UnnormalizedDifficulty = None, 77 | categories: UnnormalizedCategory = None, 78 | subcategories: UnnormalizedSubcategory = None, 79 | alternate_subcategories: UnnormalizedAlternateSubcategory = None, 80 | maxReturnLength: Optional[int] = 25, 81 | tossupPagination: Optional[int] = 1, 82 | bonusPagination: Optional[int] = 1, 83 | min_year: int = Year.MIN_YEAR, 84 | max_year: int = Year.CURRENT_YEAR, 85 | ) -> QueryResponse: 86 | """Query the qbreader database for questions. 87 | 88 | Original API doc at https://www.qbreader.org/api-docs/query. 89 | 90 | Parameters 91 | ---------- 92 | questionType : qbreader.types.QuestionType 93 | The type of question to search for. Can be either a string or a question 94 | class type. 95 | searchType : qbreader.types.SearchType 96 | Where to search for the query string. Can only be a string. 97 | queryString : str, optional 98 | The string to search for. 99 | exactPhrase : bool, default = False 100 | Ensure that the query string is an exact phrase. 101 | ignoreDiacritics : bool, default = False 102 | Ignore or transliterate diacritical marks in `queryString`. 103 | ignoreWordOrder : bool, default = False 104 | Treat `queryString` as a set of keywords that can appear in any order. 105 | regex : bool, default = False 106 | Treat `queryString` as a regular expression. 107 | randomize : bool, default = False 108 | Randomize the order of the returned questions. 109 | setName : str, optional 110 | The name of the set to search in. 111 | difficulties : qbreader.types.UnnormalizedDifficulty, optional 112 | The difficulties to search for. Can be a single or an array of `Difficulty` 113 | enums, strings, or integers. 114 | categories : qbreader.types.UnnormalizedCategory, optional 115 | The categories to search for. Can be a single or an array of `Category` 116 | enums or strings. 117 | subcategories : qbreader.types.UnnormalizedSubcategory, optional 118 | The subcategories to search for. Can be a single or an array of 119 | `Subcategory` enums or strings. The API does not check for consistency 120 | between categories and subcategories. 121 | alternate_subcategories : qbreader.types.UnnormalizedAlternateSubcategory,\ 122 | optional 123 | The alternate subcategories to search for. Can be a single or an array of 124 | `AlternateSubcategory` enums or strings. The API does not check for 125 | consistency between categories and subcategories 126 | maxReturnLength : int, default = 25 127 | The maximum number of questions to return. 128 | tossupPagination : int, default = 1 129 | The page of tossups to return. 130 | bonusPagination : int, default = 1 131 | The page of bonuses to return. 132 | min_year : int, default = Year.MIN_YEAR 133 | The earliest year to search. 134 | max_year : int, default = Year.CURRENT_YEAR 135 | The latest year to search. 136 | Returns 137 | ------- 138 | QueryResponse 139 | A `QueryResponse` object containing the results of the query. 140 | """ 141 | # normalize and type check parameters 142 | if questionType == Tossup: 143 | questionType = "tossup" 144 | elif questionType == Bonus: 145 | questionType = "bonus" 146 | if questionType not in ["tossup", "bonus", "all"]: 147 | raise ValueError("questionType must be either 'tossup', 'bonus', or 'all'.") 148 | 149 | if searchType not in ["question", "answer", "all"]: 150 | raise ValueError( 151 | "searchType must be either 'question', 'answer', or 'all'." 152 | ) 153 | 154 | if not isinstance(queryString, str): 155 | raise TypeError( 156 | f"queryString must be a string, not {type(queryString).__name__}." 157 | ) 158 | 159 | for name, param in tuple( 160 | zip( 161 | ( 162 | "exactPhrase", 163 | "ignoreDiacritics", 164 | "ignoreWordOrder", 165 | "regex", 166 | "randomize", 167 | ), 168 | (exactPhrase, ignoreDiacritics, ignoreWordOrder, regex, randomize), 169 | ) 170 | ): 171 | if not isinstance(param, bool): 172 | raise TypeError( 173 | f"{name} must be a boolean, not {type(param).__name__}." 174 | ) 175 | 176 | if setName is not None and not isinstance(setName, str): 177 | raise TypeError(f"setName must be a string, not {type(setName).__name__}.") 178 | 179 | for name, param in tuple( # type: ignore 180 | zip( 181 | ("maxReturnLength", "tossupPagination", "bonusPagination"), 182 | (maxReturnLength, tossupPagination, bonusPagination), 183 | ) 184 | ): 185 | if not isinstance(param, int): 186 | raise TypeError( 187 | f"{name} must be an integer, not {type(param).__name__}." 188 | ) 189 | elif param < 1: 190 | raise ValueError(f"{name} must be at least 1.") 191 | 192 | for name, year in { 193 | "minYear": min_year, 194 | "maxYear": max_year, 195 | }.items(): 196 | if not isinstance(year, int): 197 | raise TypeError( 198 | f"{name} must be an integer, not {type(param).__name__}." 199 | ) 200 | 201 | url = BASE_URL + "/query" 202 | 203 | ( 204 | normalized_categories, 205 | normalized_subcategories, 206 | normalized_alternate_subcategories, 207 | ) = api_utils.normalize_cats(categories, subcategories, alternate_subcategories) 208 | 209 | data = { 210 | "questionType": questionType, 211 | "searchType": searchType, 212 | "queryString": queryString, 213 | "exactPhrase": api_utils.normalize_bool(exactPhrase), 214 | "ignoreDiacritics": api_utils.normalize_bool(ignoreDiacritics), 215 | "ignoreWordOrder": api_utils.normalize_bool(ignoreWordOrder), 216 | "regex": api_utils.normalize_bool(regex), 217 | "randomize": api_utils.normalize_bool(randomize), 218 | "setName": setName, 219 | "difficulties": api_utils.normalize_diff(difficulties), 220 | "categories": normalized_categories, 221 | "subcategories": normalized_subcategories, 222 | "alternateSubcategories": normalized_alternate_subcategories, 223 | "maxReturnLength": maxReturnLength, 224 | "tossupPagination": tossupPagination, 225 | "bonusPagination": bonusPagination, 226 | "minYear": min_year, 227 | "maxYear": max_year, 228 | } 229 | data = api_utils.prune_none(data) 230 | 231 | async with self.session.get(url, params=data) as response: 232 | if response.status != 200: 233 | raise Exception(str(response.status) + " bad request") 234 | 235 | json = await response.json() 236 | return QueryResponse.from_json(json) 237 | 238 | async def random_tossup( 239 | self: Self, 240 | difficulties: UnnormalizedDifficulty = None, 241 | categories: UnnormalizedCategory = None, 242 | subcategories: UnnormalizedSubcategory = None, 243 | alternate_subcategories: UnnormalizedAlternateSubcategory = None, 244 | number: int = 1, 245 | min_year: int = Year.MIN_YEAR, 246 | max_year: int = Year.CURRENT_YEAR, 247 | ) -> tuple[Tossup, ...]: 248 | """Get random tossups from the database. 249 | 250 | Original API doc at https://www.qbreader.org/api-docs/random-tossup. 251 | 252 | Parameters 253 | ---------- 254 | difficulties : qbreader.types.UnnormalizedDifficulty, optional 255 | The difficulties to search for. Can be a single or an array of `Difficulty` 256 | enums, strings, or integers. 257 | categories : qbreader.types.UnnormalizedCategory, optional 258 | The categories to search for. Can be a single or an array of `Category` 259 | enums or strings. 260 | subcategories : qbreader.types.UnnormalizedSubcategory, optional 261 | The subcategories to search for. Can be a single or an array of 262 | `Subcategory` enums or strings. The API does not check for consistency 263 | between categories and subcategories. 264 | alternate_subcategories : qbreader.types.UnnormalizedAlternateSubcategory,\ 265 | optional 266 | The alternate subcategories to search for. Can be a single or an array of 267 | `AlternateSubcategory` enums or strings. The API does not check for 268 | consistency between categories and subcategories 269 | number : int, default = 1 270 | The number of tossups to return. 271 | min_year : int, default = Year.MIN_YEAR 272 | The oldest year to search for. 273 | max_year : int, default = Year.CURRENT_YEAR 274 | The most recent year to search for. 275 | 276 | Returns 277 | ------- 278 | tuple[Tossup, ...] 279 | A tuple of `Tossup` objects. 280 | """ 281 | # normalize and type check parameters 282 | for name, param in tuple( 283 | zip( 284 | ("number", "min_year", "max_year"), 285 | (number, min_year, max_year), 286 | ) 287 | ): 288 | if not isinstance(param, int): 289 | raise TypeError( 290 | f"{name} must be an integer, not {type(param).__name__}." 291 | ) 292 | elif param < 1: 293 | raise ValueError(f"{name} must be at least 1.") 294 | 295 | url = BASE_URL + "/random-tossup" 296 | 297 | ( 298 | normalized_categories, 299 | normalized_subcategories, 300 | normalized_alternate_subcategories, 301 | ) = api_utils.normalize_cats(categories, subcategories, alternate_subcategories) 302 | 303 | data = { 304 | "difficulties": api_utils.normalize_diff(difficulties), 305 | "categories": normalized_categories, 306 | "subcategories": normalized_subcategories, 307 | "alternateSubcategories": normalized_alternate_subcategories, 308 | "number": number, 309 | "minYear": min_year, 310 | "maxYear": max_year, 311 | } 312 | data = api_utils.prune_none(data) 313 | 314 | async with self.session.get(url, params=data) as response: 315 | if response.status != 200: 316 | raise Exception(str(response.status) + " bad request") 317 | 318 | json = await response.json() 319 | return tuple(Tossup.from_json(tu) for tu in json["tossups"]) 320 | 321 | async def random_bonus( 322 | self: Self, 323 | difficulties: UnnormalizedDifficulty = None, 324 | categories: UnnormalizedCategory = None, 325 | subcategories: UnnormalizedSubcategory = None, 326 | alternate_subcategories: UnnormalizedAlternateSubcategory = None, 327 | number: int = 1, 328 | min_year: int = Year.MIN_YEAR, 329 | max_year: int = Year.CURRENT_YEAR, 330 | three_part_bonuses: bool = False, 331 | ) -> tuple[Bonus, ...]: 332 | """Get random bonuses from the database. 333 | 334 | Original API doc at https://www.qbreader.org/api-docs/random-bonus. 335 | 336 | Parameters 337 | ---------- 338 | difficulties : qbreader.types.UnnormalizedDifficulty, optional 339 | The difficulties to search for. Can be a single or an array of `Difficulty` 340 | enums, strings, or integers. 341 | categories : qbreader.types.UnnormalizedCategory, optional 342 | The categories to search for. Can be a single or an array of `Category` 343 | enums or strings. 344 | subcategories : qbreader.types.UnnormalizedSubcategory, optional 345 | The subcategories to search for. Can be a single or an array of 346 | `Subcategory` enums or strings. The API does not check for consistency 347 | between categories and subcategories. 348 | alternate_subcategories: qbreader.types.UnnormalizedAlternateSubcategory, \ 349 | optional 350 | The alternates subcategories to search for. Can be a single or an array of 351 | `AlternateSubcategory` enum variants or strings. The API does not check for 352 | consistency between categories, subcategories, and alternate subcategories. 353 | number : int, default = 1 354 | The number of bonuses to return. 355 | min_year : int, default = Year.MIN_YEAR 356 | The oldest year to search for. 357 | max_year : int, default = Year.CURRENT_YEAR 358 | The most recent year to search for. 359 | three_part_bonuses : bool, default = False 360 | Whether to only return bonuses with 3 parts. 361 | 362 | Returns 363 | ------- 364 | tuple[Bonus, ...] 365 | A tuple of `Bonus` objects. 366 | """ 367 | # normalize and type check parameters 368 | for name, param in tuple( 369 | zip( 370 | ("number", "min_year", "max_year"), 371 | (number, min_year, max_year), 372 | ) 373 | ): 374 | if not isinstance(param, int): 375 | raise TypeError( 376 | f"{name} must be an integer, not {type(param).__name__}." 377 | ) 378 | elif param < 1: 379 | raise ValueError(f"{name} must be at least 1.") 380 | 381 | if not isinstance(three_part_bonuses, bool): 382 | raise TypeError( 383 | "three_part_bonuses must be a boolean, not " 384 | + f"{type(three_part_bonuses).__name__}." 385 | ) 386 | 387 | url = BASE_URL + "/random-bonus" 388 | 389 | ( 390 | normalized_categories, 391 | normalized_subcategories, 392 | normalized_alternate_subcategories, 393 | ) = api_utils.normalize_cats(categories, subcategories, alternate_subcategories) 394 | 395 | data = { 396 | "difficulties": api_utils.normalize_diff(difficulties), 397 | "categories": normalized_categories, 398 | "subcategories": normalized_subcategories, 399 | "alternateSubcategories": normalized_alternate_subcategories, 400 | "number": number, 401 | "minYear": min_year, 402 | "maxYear": max_year, 403 | } 404 | data = api_utils.prune_none(data) 405 | 406 | async with self.session.get(url, params=data) as response: 407 | if response.status != 200: 408 | raise Exception(str(response.status) + " bad request") 409 | 410 | json = await response.json() 411 | return tuple(Bonus.from_json(b) for b in json["bonuses"]) 412 | 413 | async def random_name(self: Self) -> str: 414 | """Get a random adjective-noun pair that can be used as a name. 415 | 416 | Original API doc at https://www.qbreader.org/api-docs/random-name. 417 | 418 | Returns 419 | ------- 420 | str 421 | A string containing the random name. 422 | 423 | """ 424 | url = BASE_URL + "/random-name" 425 | 426 | async with self.session.get(url) as response: 427 | if response.status != 200: 428 | raise Exception(str(response.status) + " bad request") 429 | 430 | json = await response.json() 431 | return json["randomName"] 432 | 433 | async def packet(self: Self, setName: str, packetNumber: int) -> Packet: 434 | """Get a specific packet from a set. 435 | 436 | Original API doc at https://www.qbreader.org/api-docs/packet. 437 | 438 | Parameters 439 | ---------- 440 | setName : str 441 | The name of the set. See `set_list()` for a list of valid set names. 442 | packetNumber : int 443 | The number of the packet in the set, starting from 1. 444 | 445 | Returns 446 | ------- 447 | Packet 448 | A `Packet` object containing the packet's tossups and bonuses. 449 | """ 450 | # normalize and type check parameters 451 | if not isinstance(setName, str): 452 | raise TypeError(f"setName must be a string, not {type(setName).__name__}.") 453 | 454 | if not isinstance(packetNumber, int): 455 | raise TypeError( 456 | f"packetNumber must be an integer, not {type(packetNumber).__name__}." 457 | ) 458 | 459 | if packetNumber < 1 or packetNumber > await self.num_packets(setName): 460 | raise ValueError( 461 | f"packetNumber must be between 1 and {await self.num_packets(setName)} " 462 | + f"inclusive for {setName}." 463 | ) 464 | 465 | url = BASE_URL + "/packet" 466 | 467 | data: dict[str, str | int] = {"setName": setName, "packetNumber": packetNumber} 468 | data = api_utils.prune_none(data) 469 | 470 | async with self.session.get(url, params=data) as response: 471 | if response.status != 200: 472 | raise Exception(str(response.status) + " bad request") 473 | 474 | json = await response.json() 475 | return Packet.from_json(json=json, number=packetNumber) 476 | 477 | async def packet_tossups( 478 | self: Self, setName: str, packetNumber: int 479 | ) -> tuple[Tossup, ...]: 480 | """Get only tossups from a packet. 481 | 482 | Original API doc at https://www.qbreader.org/api-docs/packet-tossups. 483 | 484 | Parameters 485 | ---------- 486 | setName : str 487 | The name of the set. See `set_list()` for a list of valid set names. 488 | packetNumber : int 489 | The number of the packet in the set, starting from 1. 490 | 491 | Returns 492 | ------- 493 | tuple[Tossup, ...] 494 | A tuple of `Tossup` objects. 495 | """ 496 | # normalize and type check parameters 497 | if not isinstance(setName, str): 498 | raise TypeError(f"setName must be a string, not {type(setName).__name__}.") 499 | 500 | if not isinstance(packetNumber, int): 501 | raise TypeError( 502 | f"packetNumber must be an integer, not {type(packetNumber).__name__}." 503 | ) 504 | 505 | if packetNumber < 1 or packetNumber > await self.num_packets(setName): 506 | raise ValueError( 507 | f"packetNumber must be between 1 and {await self.num_packets(setName)} " 508 | + f"inclusive for {setName}." 509 | ) 510 | 511 | url = BASE_URL + "/packet-tossups" 512 | 513 | data: dict[str, str | int] = {"setName": setName, "packetNumber": packetNumber} 514 | data = api_utils.prune_none(data) 515 | 516 | async with self.session.get(url, params=data) as response: 517 | if response.status != 200: 518 | raise Exception(str(response.status) + " bad request") 519 | 520 | json = await response.json() 521 | return tuple(Tossup.from_json(tu) for tu in json["tossups"]) 522 | 523 | async def packet_bonuses( 524 | self: Self, setName: str, packetNumber: int 525 | ) -> tuple[Bonus, ...]: 526 | """Get only bonuses from a packet. 527 | 528 | Original API doc at https://www.qbreader.org/api-docs/packet-bonuses. 529 | 530 | Parameters 531 | ---------- 532 | setName : str 533 | The name of the set. See `set_list()` for a list of valid set names. 534 | packetNumber : int 535 | The number of the packet in the set, starting from 1. 536 | 537 | Returns 538 | ------- 539 | tuple[Bonus, ...] 540 | A tuple of `Bonus` objects. 541 | """ 542 | # normalize and type check parameters 543 | if not isinstance(setName, str): 544 | raise TypeError(f"setName must be a string, not {type(setName).__name__}.") 545 | 546 | if not isinstance(packetNumber, int): 547 | raise TypeError( 548 | f"packetNumber must be an integer, not {type(packetNumber).__name__}." 549 | ) 550 | 551 | if packetNumber < 1 or packetNumber > await self.num_packets(setName): 552 | raise ValueError( 553 | f"packetNumber must be between 1 and {await self.num_packets(setName)} " 554 | + f"inclusive for {setName}." 555 | ) 556 | 557 | url = BASE_URL + "/packet-bonuses" 558 | 559 | data: dict[str, str | int] = {"setName": setName, "packetNumber": packetNumber} 560 | data = api_utils.prune_none(data) 561 | 562 | async with self.session.get(url, params=data) as response: 563 | if response.status != 200: 564 | raise Exception(str(response.status) + " bad request") 565 | 566 | json = await response.json() 567 | return tuple(Bonus.from_json(b) for b in json["bonuses"]) 568 | 569 | async def num_packets(self: Self, setName: str) -> int: 570 | """Get the number of packets in a set. 571 | 572 | Original API doc at https://www.qbreader.org/api-docs/num-packets. 573 | 574 | Parameters 575 | ---------- 576 | setName : str 577 | The name of the set to search. Can be obtained from set_list(). 578 | 579 | Returns 580 | ------- 581 | int 582 | The number of packets in the set. 583 | """ 584 | url = BASE_URL + "/num-packets" 585 | data = { 586 | "setName": setName, 587 | } 588 | 589 | async with self.session.get(url, params=data) as response: 590 | if response.status != 200: 591 | if response.status == 404: 592 | raise ValueError(f"Requested set, {setName}, not found.") 593 | raise Exception(str(response.status) + " bad request") 594 | 595 | json = await response.json() 596 | return json["numPackets"] 597 | 598 | async def set_list(self: Self) -> tuple[str, ...]: 599 | """Get a list of all the sets in the database. 600 | 601 | Original API doc at https://www.qbreader.org/api-docs/set-list. 602 | 603 | Returns 604 | ------- 605 | tuple[str, ...] 606 | A tuple containing the names of all the sets in the database, sorted in 607 | reverse alphanumeric order. 608 | """ 609 | url = BASE_URL + "/set-list" 610 | 611 | async with self.session.get(url) as response: 612 | if response.status != 200: 613 | raise Exception(str(response.status) + " bad request") 614 | 615 | json = await response.json() 616 | return json["setList"] 617 | 618 | async def room_list(self: Self) -> tuple[dict, ...]: 619 | """Get a list of public rooms. 620 | 621 | Original API doc at https://www.qbreader.org/api-docs/multiplayer/room-list. 622 | 623 | Returns 624 | ------- 625 | tuple[dict, ...] 626 | A tuple containing the room data for all the public rooms. 627 | """ 628 | url = BASE_URL + "/multiplayer/room-list" 629 | 630 | async with self.session.get(url) as response: 631 | if response.status != 200: 632 | raise Exception(str(response.status) + " bad request") 633 | 634 | json = await response.json() 635 | return json["roomList"] 636 | 637 | async def check_answer( 638 | self: Self, answerline: str, givenAnswer: str 639 | ) -> AnswerJudgement: 640 | """Judge an answer to be correct, incorrect, or prompt (can be directed). 641 | 642 | Original API doc at https://www.qbreader.org/api-docs/check-answer. 643 | 644 | Parameters 645 | ---------- 646 | answerline : str 647 | The answerline to check against. Preferably including the HTML tags and 648 | , if they are present. 649 | givenAnswer : str 650 | The answer to check. 651 | 652 | Returns 653 | ------- 654 | AnswerJudgement 655 | A `AnswerJudgement` object containing the response. 656 | """ 657 | return await AnswerJudgement.check_answer_async( 658 | answerline, givenAnswer, self.session 659 | ) 660 | 661 | async def tossup_by_id(self: Self, id: str) -> Tossup: 662 | """Get a tossup by its ID. 663 | 664 | Original API doc at https://www.qbreader.org/api-docs/tossup-by-id. 665 | 666 | Parameters 667 | ---------- 668 | id : str 669 | The ID of the tossup to get. 670 | 671 | Returns 672 | ------- 673 | Tossup 674 | A `Tossup` object. 675 | """ 676 | url = BASE_URL + "/tossup-by-id" 677 | 678 | data = { 679 | "id": id, 680 | } 681 | 682 | async with self.session.get(url, params=data) as response: 683 | if response.status != 200: 684 | if response.status == 400: 685 | raise ValueError(f"Invalid tossup ID: {id}") 686 | raise Exception(str(response.status) + " bad request") 687 | 688 | json = await response.json() 689 | return Tossup.from_json(json["tossup"]) 690 | 691 | async def bonus_by_id(self: Self, id: str) -> Bonus: 692 | """Get a bonus by its ID. 693 | 694 | Original API doc at https://www.qbreader.org/api-docs/bonus-by-id. 695 | 696 | Parameters 697 | ---------- 698 | id : str 699 | The ID of the bonus to get. 700 | 701 | Returns 702 | ------- 703 | Bonus 704 | A `Bonus` object. 705 | """ 706 | url = BASE_URL + "/bonus-by-id" 707 | 708 | data = { 709 | "id": id, 710 | } 711 | 712 | async with self.session.get(url, params=data) as response: 713 | if response.status != 200: 714 | if response.status == 400: 715 | raise ValueError(f"Invalid bonus ID: {id}") 716 | raise Exception(str(response.status) + " bad request") 717 | 718 | json = await response.json() 719 | return Bonus.from_json(json["bonus"]) 720 | --------------------------------------------------------------------------------