diff --git a/qbreader/_api_utils.py b/qbreader/_api_utils.py index daf6074..23ab000 100644 --- a/qbreader/_api_utils.py +++ b/qbreader/_api_utils.py @@ -4,15 +4,17 @@ import warnings from enum import Enum, EnumType -from typing import Iterable, Optional, Union +from typing import Iterable, Optional, Union, Tuple from qbreader.types import ( - Category, Difficulty, + Category, Subcategory, + AlternateSubcategory, UnnormalizedCategory, UnnormalizedDifficulty, UnnormalizedSubcategory, + UnnormalizedAlternateSubcategory, ) @@ -97,9 +99,109 @@ def normalize_cat(unnormalized_cats: UnnormalizedCategory): return normalize_enumlike(unnormalized_cats, Category) -def normalize_subcat(unnormalized_subcats: UnnormalizedSubcategory): +def normalize_subcat(unnormalized_subcats: UnnormalizedCategory): """Normalize a single or list of subcategories to a comma separated string.""" - return normalize_enumlike(unnormalized_subcats, Subcategory) + return normalize_enumlike(unnormalized_subcats, Category) + + +def category_correspondence( + typed_alt_subcat: AlternateSubcategory, +) -> Tuple[Category, Subcategory]: + if typed_alt_subcat in [ + AlternateSubcategory.ASTRONOMY, + AlternateSubcategory.COMPUTER_SCIENCE, + AlternateSubcategory.MATH, + AlternateSubcategory.EARTH_SCIENCE, + AlternateSubcategory.ENGINEERING, + AlternateSubcategory.MISC_SCIENCE, + ]: + return (None, Subcategory.OTHER_SCIENCE) + + if typed_alt_subcat in [ + AlternateSubcategory.ARCHITECTURE, + AlternateSubcategory.DANCE, + AlternateSubcategory.FILM, + AlternateSubcategory.JAZZ, + AlternateSubcategory.OPERA, + AlternateSubcategory.PHOTOGRAPHY, + AlternateSubcategory.MISC_ARTS, + ]: + return (None, Subcategory.OTHER_FINE_ARTS) + + if typed_alt_subcat in [ + AlternateSubcategory.ANTHROPOLOGY, + AlternateSubcategory.ECONOMICS, + AlternateSubcategory.LINGUISTICS, + AlternateSubcategory.PSYCHOLOGY, + AlternateSubcategory.SOCIOLOGY, + AlternateSubcategory.OTHER_SOCIAL_SCIENCE, + ]: + return (None, Subcategory.SOCIAL_SCIENCE) + + if typed_alt_subcat in [ + AlternateSubcategory.DRAMA, + AlternateSubcategory.LONG_FICTION, + AlternateSubcategory.POETRY, + AlternateSubcategory.SHORT_FICTION, + AlternateSubcategory.MISC_LITERATURE, + ]: + return (Category.LITERATURE, None) + + +def normalize_cats( + unnormalized_cats: UnnormalizedCategory, + unnormalized_subcats: UnnormalizedSubcategory, + unnormalized_alt_subcats: UnnormalizedAlternateSubcategory, +) -> Tuple[Category, Subcategory, AlternateSubcategory]: + """ + Normalize a single or list of categories, subcategories, and alternate_subcategories + to their corresponding comma-separated strings, taking into account categories and + subcategories that must be added for the alternate_subcategories to work. + """ + + typed_alt_subcats: list[AlternateSubcategory] = [] + + if isinstance(unnormalized_alt_subcats, str): + typed_alt_subcats.append(AlternateSubcategory(unnormalized_alt_subcats)) + elif isinstance(unnormalized_alt_subcats, Iterable): + for alt_subcat in unnormalized_alt_subcats: + typed_alt_subcats.append(AlternateSubcategory(alt_subcat)) + + to_be_pushed_cats: list[Category] = [] + to_be_pushed_subcats: list[Subcategory] = [] + + for alt_subcat in typed_alt_subcats: + cat, subcat = category_correspondence(alt_subcat) + if cat: + to_be_pushed_cats.append(cat) + if subcat: + to_be_pushed_subcats.append(subcat) + + final_cats = [] + if unnormalized_cats is None: + final_cats = to_be_pushed_cats + elif isinstance(unnormalized_cats, str): + final_cats = [Category(unnormalized_cats), *to_be_pushed_cats] + elif isinstance(unnormalized_cats, Iterable): + for subcat in unnormalized_cats: + final_cats.append(Subcategory(subcat)) + final_cats.append(*to_be_pushed_cats) + + final_subcats = [] + if unnormalized_subcats is None: + final_subcats = to_be_pushed_subcats + elif isinstance(unnormalized_subcats, str): + final_subcats = [Subcategory(unnormalized_subcats), *to_be_pushed_subcats] + elif isinstance(unnormalized_subcats, Iterable): + for subcat in unnormalized_subcats: + final_subcats.append(Subcategory(subcat)) + final_subcats.append(*to_be_pushed_subcats) + + return ( + normalize_enumlike(final_cats, Category), + normalize_enumlike(final_subcats, Subcategory), + normalize_enumlike(typed_alt_subcats, AlternateSubcategory), + ) def prune_none(params: dict) -> dict: diff --git a/qbreader/asynchronous.py b/qbreader/asynchronous.py index 55cc767..414b184 100644 --- a/qbreader/asynchronous.py +++ b/qbreader/asynchronous.py @@ -19,6 +19,7 @@ UnnormalizedCategory, UnnormalizedDifficulty, UnnormalizedSubcategory, + UnnormalizedAlternateSubcategory, Year, ) @@ -75,6 +76,7 @@ async def query( difficulties: UnnormalizedDifficulty = None, categories: UnnormalizedCategory = None, subcategories: UnnormalizedSubcategory = None, + alternate_subcategories: UnnormalizedAlternateSubcategory = None, maxReturnLength: Optional[int] = 25, tossupPagination: Optional[int] = 1, bonusPagination: Optional[int] = 1, @@ -114,6 +116,10 @@ class type. The subcategories to search for. Can be a single or an array of `Subcategory` enums or strings. The API does not check for consistency between categories and subcategories. + alternate_subcategories : qbreader.types.UnnormalizedAlternateSubcategory, optional + The alternate subcategories to search for. Can be a single or an array of + `AlternateSubcategory` enums or strings. The API does not check for + consistency between categories and subcategories maxReturnLength : int, default = 25 The maximum number of questions to return. tossupPagination : int, default = 1 @@ -179,6 +185,12 @@ class type. url = BASE_URL + "/query" + ( + normalized_categories, + normalized_subcategories, + normalized_alternate_subcategories, + ) = api_utils.normalize_cats(categories, subcategories, alternate_subcategories) + data = { "questionType": questionType, "searchType": searchType, @@ -190,8 +202,9 @@ class type. "randomize": api_utils.normalize_bool(randomize), "setName": setName, "difficulties": api_utils.normalize_diff(difficulties), - "categories": api_utils.normalize_cat(categories), - "subcategories": api_utils.normalize_subcat(subcategories), + "categories": normalized_categories, + "subcategories": normalized_subcategories, + "alternateSubcategories": normalized_alternate_subcategories, "maxReturnLength": maxReturnLength, "tossupPagination": tossupPagination, "bonusPagination": bonusPagination, @@ -210,6 +223,7 @@ async def random_tossup( difficulties: UnnormalizedDifficulty = None, categories: UnnormalizedCategory = None, subcategories: UnnormalizedSubcategory = None, + alternate_subcategories: UnnormalizedAlternateSubcategory = None, number: int = 1, min_year: int = Year.MIN_YEAR, max_year: int = Year.CURRENT_YEAR, @@ -230,6 +244,10 @@ async def random_tossup( The subcategories to search for. Can be a single or an array of `Subcategory` enums or strings. The API does not check for consistency between categories and subcategories. + alternate_subcategories : qbreader.types.UnnormalizedAlternateSubcategory, optional + The alternate subcategories to search for. Can be a single or an array of + `AlternateSubcategory` enums or strings. The API does not check for + consistency between categories and subcategories number : int, default = 1 The number of tossups to return. min_year : int, default = Year.MIN_YEAR @@ -258,13 +276,20 @@ async def random_tossup( url = BASE_URL + "/random-tossup" + ( + normalized_categories, + normalized_subcategories, + normalized_alternate_subcategories, + ) = api_utils.normalize_cats(categories, subcategories, alternate_subcategories) + data = { "difficulties": api_utils.normalize_diff(difficulties), - "categories": api_utils.normalize_cat(categories), - "subcategories": api_utils.normalize_subcat(subcategories), + "categories": normalized_categories, + "subcategories": normalized_subcategories, + "alternateSubcategories": normalized_alternate_subcategories, "number": number, - "min_year": min_year, - "max_year": max_year, + "minYear": min_year, + "maxYear": max_year, } data = api_utils.prune_none(data) @@ -280,6 +305,7 @@ async def random_bonus( difficulties: UnnormalizedDifficulty = None, categories: UnnormalizedCategory = None, subcategories: UnnormalizedSubcategory = None, + alternate_subcategories: UnnormalizedAlternateSubcategory = None, number: int = 1, min_year: int = Year.MIN_YEAR, max_year: int = Year.CURRENT_YEAR, @@ -301,6 +327,10 @@ async def random_bonus( The subcategories to search for. Can be a single or an array of `Subcategory` enums or strings. The API does not check for consistency between categories and subcategories. + alternate_subcategories: qbreaader.types.UnnormalizedAlternateSubcategory, optional + The alternates subcategories to search for. Can be a single or an array of + `AlternateSubcategory` enum variants or strings. The API does not check for consistency + between categories, subcategories, and alternate subcategories. number : int, default = 1 The number of bonuses to return. min_year : int, default = Year.MIN_YEAR @@ -337,13 +367,20 @@ async def random_bonus( url = BASE_URL + "/random-bonus" + ( + normalized_categories, + normalized_subcategories, + normalized_alternate_subcategories, + ) = api_utils.normalize_cats(categories, subcategories, alternate_subcategories) + data = { "difficulties": api_utils.normalize_diff(difficulties), - "categories": api_utils.normalize_cat(categories), - "subcategories": api_utils.normalize_subcat(subcategories), + "categories": normalized_categories, + "subcategories": normalized_subcategories, + "alternateSubcategories": normalized_alternate_subcategories, "number": number, - "min_year": min_year, - "max_year": max_year, + "minYear": min_year, + "maxYear": max_year, } data = api_utils.prune_none(data) diff --git a/qbreader/synchronous.py b/qbreader/synchronous.py index ae5b426..217528d 100644 --- a/qbreader/synchronous.py +++ b/qbreader/synchronous.py @@ -16,9 +16,10 @@ QuestionType, SearchType, Tossup, - UnnormalizedCategory, UnnormalizedDifficulty, + UnnormalizedCategory, UnnormalizedSubcategory, + UnnormalizedAlternateSubcategory, Year, ) @@ -40,6 +41,7 @@ def query( difficulties: UnnormalizedDifficulty = None, categories: UnnormalizedCategory = None, subcategories: UnnormalizedSubcategory = None, + alternate_subcategories: UnnormalizedAlternateSubcategory = None, maxReturnLength: Optional[int] = 25, tossupPagination: Optional[int] = 1, bonusPagination: Optional[int] = 1, @@ -79,6 +81,10 @@ class type. The subcategories to search for. Can be a single or an array of `Subcategory` enums or strings. The API does not check for consistency between categories and subcategories. + alternate_subcategories: qbreaader.types.UnnormalizedAlternateSubcategory, optional + The alternates subcategories to search for. Can be a single or an array of + `AlternateSubcategory` enum variants or strings. The API does not check for consistency + between categories, subcategories, and alternate subcategories. maxReturnLength : int, default = 25 The maximum number of questions to return. tossupPagination : int, default = 1 @@ -144,6 +150,12 @@ class type. url = BASE_URL + "/query" + ( + normalized_categories, + normalized_subcategories, + normalized_alternate_subcategories, + ) = api_utils.normalize_cats(categories, subcategories, alternate_subcategories) + data = { "questionType": questionType, "searchType": searchType, @@ -155,8 +167,9 @@ class type. "randomize": api_utils.normalize_bool(randomize), "setName": setName, "difficulties": api_utils.normalize_diff(difficulties), - "categories": api_utils.normalize_cat(categories), - "subcategories": api_utils.normalize_subcat(subcategories), + "categories": normalized_categories, + "subcategories": normalized_subcategories, + "alternateSubcategories": normalized_alternate_subcategories, "maxReturnLength": maxReturnLength, "tossupPagination": tossupPagination, "bonusPagination": bonusPagination, @@ -175,6 +188,7 @@ def random_tossup( difficulties: UnnormalizedDifficulty = None, categories: UnnormalizedCategory = None, subcategories: UnnormalizedSubcategory = None, + alternate_subcategories: UnnormalizedAlternateSubcategory = None, number: int = 1, min_year: int = Year.MIN_YEAR, max_year: int = Year.CURRENT_YEAR, @@ -195,6 +209,10 @@ def random_tossup( The subcategories to search for. Can be a single or an array of `Subcategory` enums or strings. The API does not check for consistency between categories and subcategories. + alternate_subcategories: qbreaader.types.UnnormalizedAlternateSubcategory, optional + The alternates subcategories to search for. Can be a single or an array of + `AlternateSubcategory` enum variants or strings. The API does not check for consistency + between categories, subcategories, and alternate subcategories. number : int, default = 1 The number of tossups to return. min_year : int, default = Year.MIN_YEAR @@ -223,13 +241,20 @@ def random_tossup( url = BASE_URL + "/random-tossup" + ( + normalized_categories, + normalized_subcategories, + normalized_alternate_subcategories, + ) = api_utils.normalize_cats(categories, subcategories, alternate_subcategories) + data = { "difficulties": api_utils.normalize_diff(difficulties), - "categories": api_utils.normalize_cat(categories), - "subcategories": api_utils.normalize_subcat(subcategories), + "categories": normalized_categories, + "subcategories": normalized_subcategories, + "alternateSubcategories": normalized_alternate_subcategories, "number": number, - "min_year": min_year, - "max_year": max_year, + "minYear": min_year, + "maxYear": max_year, } data = api_utils.prune_none(data) @@ -245,6 +270,7 @@ def random_bonus( difficulties: UnnormalizedDifficulty = None, categories: UnnormalizedCategory = None, subcategories: UnnormalizedSubcategory = None, + alternate_subcategories: UnnormalizedAlternateSubcategory = None, number: int = 1, min_year: int = Year.MIN_YEAR, max_year: int = Year.CURRENT_YEAR, @@ -266,6 +292,10 @@ def random_bonus( The subcategories to search for. Can be a single or an array of `Subcategory` enums or strings. The API does not check for consistency between categories and subcategories. + alternate_subcategories: qbreaader.types.UnnormalizedAlternateSubcategory, optional + The alternates subcategories to search for. Can be a single or an array of + `AlternateSubcategory` enum variants or strings. The API does not check for consistency + between categories, subcategories, and alternate subcategories. number : int, default = 1 The number of bonuses to return. min_year : int, default = Year.MIN_YEAR @@ -302,13 +332,20 @@ def random_bonus( url = BASE_URL + "/random-bonus" + ( + normalized_categories, + normalized_subcategories, + normalized_alternate_subcategories, + ) = api_utils.normalize_cats(categories, subcategories, alternate_subcategories) + data = { "difficulties": api_utils.normalize_diff(difficulties), - "categories": api_utils.normalize_cat(categories), - "subcategories": api_utils.normalize_subcat(subcategories), + "categories": normalized_categories, + "subcategories": normalized_subcategories, + "alternateSubcategories": normalized_alternate_subcategories, "number": number, - "min_year": min_year, - "max_year": max_year, + "minYear": min_year, + "maxYear": max_year, } data = api_utils.prune_none(data) diff --git a/qbreader/types.py b/qbreader/types.py index 903afc3..c90fdb3 100644 --- a/qbreader/types.py +++ b/qbreader/types.py @@ -68,6 +68,41 @@ class Subcategory(enum.StrEnum): OTHER_FINE_ARTS = "Other Fine Arts" +class AlternateSubcategory(enum.StrEnum): + """Question alternate subcategory enum.""" + + DRAMA = "Drama" + LONG_FICTION = "Long Fiction" + POETRY = "Poetry" + SHORT_FICTION = "Short Fiction" + MISC_LITERATURE = "Misc Literature" + + MATH = "Math" + ASTRONOMY = "Astronomy" + COMPUTER_SCIENCE = "Computer Science" + EARTH_SCIENCE = "Earth Science" + ENGINEERING = "Engineering" + MISC_SCIENCE = "Misc Science" + + ARCHITECTURE = "Architecture" + DANCE = "Dance" + FILM = "Film" + JAZZ = "Jazz" + OPERA = "Opera" + PHOTOGRAPHY = "Photography" + MISC_ARTS = "Misc Arts" + + ANTHROPOLOGY = "Anthropology" + ECONOMICS = "Economics" + LINGUISTICS = "Linguistics" + PSYCHOLOGY = "Psychology" + SOCIOLOGY = "Sociology" + OTHER_SOCIAL_SCIENCE = "Other Social Science" + + BELIEFS = "Beliefs" + PRACTICES = "Practices" + + class Difficulty(enum.StrEnum): """Question difficulty enum.""" @@ -243,6 +278,7 @@ def __init__( packet: PacketMetadata, set: SetMetadata, number: int, + alternate_subcategory: Optional[AlternateSubcategory] = None, ): self.question: str = question self.question_sanitized: str = question_sanitized @@ -254,6 +290,7 @@ def __init__( self.packet: PacketMetadata = packet self.set: SetMetadata = set self.number: int = number + self.alternate_subcategory: AlternateSubcategory = alternate_subcategory @classmethod def from_json(cls: Type[Self], json: dict[str, Any]) -> Self: @@ -261,6 +298,7 @@ def from_json(cls: Type[Self], json: dict[str, Any]) -> Self: See https://www.qbreader.org/api-docs/schemas#tossups for schema. """ + alternate_subcategory = json.get("alternate_subcategory", None) return cls( question=json["question"], question_sanitized=json["question_sanitized"], @@ -272,6 +310,9 @@ def from_json(cls: Type[Self], json: dict[str, Any]) -> Self: packet=PacketMetadata.from_json(json["packet"]), set=SetMetadata.from_json(json["set"]), number=json["number"], + alternate_subcategory=AlternateSubcategory(alternate_subcategory) + if alternate_subcategory + else None, ) def check_answer_sync(self, givenAnswer: str) -> AnswerJudgement: @@ -299,6 +340,7 @@ def __eq__(self, other: object) -> bool: and self.difficulty == other.difficulty and self.category == other.category and self.subcategory == other.subcategory + and self.alternate_subcategory == other.alternate_subcategory and self.packet == other.packet and self.set == other.set and self.number == other.number @@ -326,6 +368,7 @@ def __init__( set: SetMetadata, packet: PacketMetadata, number: int, + alternate_subcategory: Optional[AlternateSubcategory] = None, values: Optional[Sequence[int]] = None, difficultyModifiers: Optional[Sequence[DifficultyModifier]] = None, ): @@ -341,6 +384,7 @@ def __init__( self.set: SetMetadata = set self.packet: PacketMetadata = packet self.number: int = number + self.alternate_subcategory: AlternateSubcategory = alternate_subcategory self.values: Optional[tuple[int, ...]] = tuple(values) if values else None self.difficultyModifiers: Optional[tuple[DifficultyModifier, ...]] = ( tuple(difficultyModifiers) if difficultyModifiers else None @@ -352,6 +396,7 @@ def from_json(cls: Type[Self], json: dict[str, Any]) -> Self: See https://www.qbreader.org/api-docs/schemas#bonus for schema. """ + alternate_subcategory = json.get("alternate_subcategory", None) return cls( leadin=json["leadin"], leadin_sanitized=json["leadin_sanitized"], @@ -365,6 +410,9 @@ def from_json(cls: Type[Self], json: dict[str, Any]) -> Self: set=SetMetadata.from_json(json["set"]), packet=PacketMetadata.from_json(json["packet"]), number=json["number"], + alternate_subcategory=AlternateSubcategory(alternate_subcategory) + if alternate_subcategory + else None, values=json.get("values", None), difficultyModifiers=json.get("difficultyModifiers", None), ) @@ -396,6 +444,7 @@ def __eq__(self, other: object) -> bool: and self.difficulty == other.difficulty and self.category == other.category and self.subcategory == other.subcategory + and self.alternate_subcategory == other.alternate_subcategory and self.set == other.set and self.packet == other.packet and self.number == other.number @@ -635,6 +684,12 @@ def __str__(self) -> str: """Type alias for unnormalized subcategories. Union of `Subcategory`, `str`, and `collections.abc.Iterable` containing either.""" +UnnormalizedAlternateSubcategory: TypeAlias = Optional[ + Union[AlternateSubcategory, str, Iterable[Union[AlternateSubcategory, str]]] +] +"""Type alias for unnormalized alternate subcategories. Union of `AlternateSubcategory`, `str`, and +`collections.abc.Iterable` containing either.""" + __all__ = ( "Tossup", @@ -644,6 +699,7 @@ def __str__(self) -> str: "AnswerJudgement", "Category", "Subcategory", + "AlternateSubcategory", "Difficulty", "Directive", "QuestionType", @@ -652,4 +708,5 @@ def __str__(self) -> str: "UnnormalizedDifficulty", "UnnormalizedCategory", "UnnormalizedSubcategory", + "UnnormalizedAlternateSubcategory", )