From 6f3612d356aeabda5bbf22b0468e0ba69625dafb Mon Sep 17 00:00:00 2001 From: Owen-Cochell Date: Fri, 19 Aug 2022 15:08:48 -0400 Subject: [PATCH] Added support for the official CurseForge API --- cursepy/__init__.py | 2 +- cursepy/classes/base.py | 344 +++++++++++++---- cursepy/classes/search.py | 107 +++--- cursepy/errors.py | 23 +- cursepy/handlers/base.py | 8 +- cursepy/handlers/curseforge.py | 618 ++++++++++++++++++++++++++++++ cursepy/handlers/forgesvc.py | 8 +- cursepy/handlers/maps.py | 8 +- cursepy/proto.py | 6 +- cursepy/wrapper.py | 258 ++++++++----- docs/source/advn/builtin_hand.rst | 113 +++++- docs/source/advn/collection.rst | 24 +- docs/source/api.rst | 1 - docs/source/basic/collection.rst | 130 ++++--- docs/source/basic/curse_inst.rst | 268 ++++++++++--- docs/source/basic/intro.rst | 10 +- docs/source/basic/wrap.rst | 54 ++- docs/source/changelog.rst | 28 ++ 18 files changed, 1630 insertions(+), 380 deletions(-) create mode 100644 cursepy/handlers/curseforge.py diff --git a/cursepy/__init__.py b/cursepy/__init__.py index 84b77d8..44c34b3 100644 --- a/cursepy/__init__.py +++ b/cursepy/__init__.py @@ -9,5 +9,5 @@ # Define some metadata here: -__version__ = '1.3.1' +__version__ = '2.0.0' __author__ = 'Owen Cochell' diff --git a/cursepy/classes/base.py b/cursepy/classes/base.py index c93cf41..32f6e50 100644 --- a/cursepy/classes/base.py +++ b/cursepy/classes/base.py @@ -5,17 +5,23 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Optional, Tuple +from typing import Any, Optional, Tuple, TYPE_CHECKING from os.path import isdir, join from cursepy.classes.search import SearchParam from cursepy.formatters import BaseFormat, NullFormatter from cursepy.proto import URLProtocol +if TYPE_CHECKING: + + # Only import the HandlerCollection when type checking, + # resolves a circular dependency + + from cursepy.handlers.base import HandlerCollection + @dataclass class BaseCurseInstance(object): - """ BaseCurseInstance - Class all child instances must inherit! @@ -35,7 +41,7 @@ class BaseCurseInstance(object): Packets can also have the raw, unformatted data attached to them. This data is NOT standardized, - meaning that the raw data will most likely be diffrent across diffrent handlers. + meaning that the raw data will most likely be different across different handlers. Be aware, that handlers are under no obligation to attach raw data to the packet! We have the following parameters: @@ -47,7 +53,7 @@ class BaseCurseInstance(object): raw: Any = field(init=False, repr=False, default=None) # RAW packet data meta: Any = field(init=False, repr=False, default=None) # Metadata on this packet - hands: 'HandlerCollection' = field(init=False, repr=False, compare=False) # Handler Collection instance + hands: HandlerCollection = field(init=False, repr=False, compare=False) # Handler Collection instance @dataclass @@ -189,7 +195,7 @@ def download(self, path: str=None) -> bytes: Child classes should implement this function! This allows them to fine-tune the download operation automatically, - and pass the necessary parmeters without the user having to specify them. + and pass the necessary parameters without the user having to specify them. Implementations should use the 'low_download' function for this operation. @@ -233,9 +239,8 @@ class CurseAuthor(BaseCurseInstance): @dataclass class CurseDescription(BaseWriter): - """ - CurseDescription - Represents a decritpion of a addon. + CurseDescription - Represents a description of a addon. A 'description' is HTML code that the addon author can provide that describes something in detail. @@ -247,7 +252,7 @@ class CurseDescription(BaseWriter): We offer the ability to attach custom 'formatters' to this object. A 'formatter' will convert the HTML code into something. The implementation of formatters is left ambiguous, - as users may have diffrent use cases. + as users may have different use cases. Check out the formatter documentation for more info. A formatter can be attached to us manually, @@ -403,7 +408,7 @@ def download_thumbnail(self, path: str=None) -> bytes: to save the thumbnail to a file. Like the download method, - we automaticallygenerate a name if the path is None, + we automatically generate a name if the path is None, or is just a directory. :param path: Path to save the file to, defaults to None @@ -440,6 +445,7 @@ def _create_name(self, name: str=None) -> str: # Check if we are working with a directory: return self.url.split('/')[-1] if isdir(name) else name + # Otherwise, return the None: return None @@ -447,7 +453,6 @@ def _create_name(self, name: str=None) -> str: @dataclass class CurseFile(BaseDownloader): - """ CurseFile - Represents an addon file. @@ -469,7 +474,34 @@ class CurseFile(BaseDownloader): * download_url - Download URL of the file * length - Length in bytes of the file * version - Version of the game needed to work with this file - * dependencies - List of CurseDependency objects + * dependencies - Tuple of CurseDependency objects + * game_id - ID of the game this file is apart of + * is_available - Boolean determining if this file is available for download + * release_type - Release type of this file (beta, alpha, release), use class constants for identifying this! + * file_status - Status of the file (approved, under review, deprecated, ect.), use the constants below for this! + * hashes - Tuple of CurseHashes representing file hashes + * download_count - Number of times this addon has been downloaded + + We also contain the following constants: + + * RELEASE - Used if the release type is 'release' (For for production use) + * BETA - Used if the release type is 'beta' + * ALPHA - Used if the release type is 'alpha' + * PROCESSING - Used for file status if the file is currently being processed + * CHANGES_REQUIRED - Used for file status if the file needs changes before approval + * UNDER_REVIEW - Used for file status if the file is currently under review + * APPROVED - Used for file status if the file is approved + * REJECTED - Used for file status if the file is rejected + * MALWARE_DETECTED - Used for file status if malware is detected in the uploaded file + * DELETED - Used for file status if the file has been deleted + * ARCHIVED - Used for the file status if the file has been archived + * TESTING - Used for the file status if the file is in the testing phase + * RELEASED - Used for the file status if the file is released, if this is the status, then the file is ready to be used! + * READY_FOR_REVIEW - Used for the file status if the file is ready for review + * DEPRECATED - Used for the file status if the file has been marked as deprecated + * BAKING - Used for the file status TODO: What does this mean? + * AWAITING_PUBLISHING - Used for file status if the file is awaiting publishing + * FAILED_PUBLISHING - Used for file status if the file has failed publishing """ id: int @@ -482,6 +514,35 @@ class CurseFile(BaseDownloader): version: Tuple[str, ...] dependencies: Tuple[CurseDependency, ...] + # Optional arguments: + + game_id: int = -1 + is_available: bool = True + release_type: int = -1 + file_status: int = -1 + hashes: Tuple[CurseHash, ...] = field(default_factory=lambda: []) + download_count: int = -1 + + RELEASE = 1 + BETA = 2 + ALPHA = 3 + + PROCESSING = 1 + CHANGES_REQUIRED = 2 + UNDER_REVIEW = 3 + APPROVED = 4 + REJECTED = 5 + MALWARE_DETECTED = 6 + DELETED = 7 + ARCHIVED = 8 + TESTING = 9 + RELEASED = 10 + READY_FOR_REVIEW = 11 + DEPRECATED = 12 + BAKING = 13 + AWAITING_PUBLISHING = 14 + FAILED_PUBLISHING = 15 + INST_ID = 6 @property @@ -499,19 +560,17 @@ def changelog(self) -> CurseDescription: return self.hands.file_description(self.addon_id, self.id) - def get_dependencies(self, required = False, optional = False) -> Tuple[CurseDependency, ...]: + def get_dependencies(self, depen_type: int) -> Tuple[CurseDependency, ...]: """ Returns a tuple of CurseDependency objects that this addon file requires. - We have the option to return only required or optional dependencies. - You can use the parameters 'required' and 'optional' to determine - which dependencies to return. + Users can specify a dependency type, + which will only return dependencies that match the given type. + You can use the dependency - :param required: Boolean determining if we should return only required dependencies - :type required: bool, optional - :param optional: Boolean determining if we should return only optional dependencies - :type optional: bool, optional + :param depen_type: Dependency type to return + :type depen_type: int :return: Tuple of CurseDependency objects :rtype: Tuple[CurseDependency, ...] """ @@ -522,17 +581,9 @@ def get_dependencies(self, required = False, optional = False) -> Tuple[CurseDep for depen in self.dependencies: - if required and not depen.required: - - continue - - if optional and depen.required: - - continue - - # Get and create the CurseAddon instances: - - final.append(depen) + if depen.type == depen_type: + + final.append(depen) # Return the final tuple: @@ -570,14 +621,33 @@ def download(self, path: str=None) -> bytes: # Check if we are working with a directory: temp_path = join(path, self.file_name) if isdir(path) else path + # Do the download operation: return self.low_download(self.download_url, path=temp_path) + def good_file(self) -> bool: + """ + This method determines if this file is valid and ready to be used. + + We do this by checking the release type, file status, and if the file is available for download. + If this file is valid, then we return True. + + Just because a file is not good does not mean that it can't be used! + On top of this, just because a file is good does not mean it will work properly! + We only check if the file is downloadable and marked production ready. + Production ready files could be poorly made, + and non-production experimental files could also be valid. + + :return: Boolean determining if this file is 'good' + :rtype: bool + """ + + return self.is_available and self.release_type == CurseFile.RELEASE and self.file_status == CurseFile.RELEASED + @dataclass class CurseDependency(BaseCurseInstance): - """ CurseDependency - Represents a dependency of an addon. @@ -601,6 +671,15 @@ class CurseDependency(BaseCurseInstance): * file_id - ID of the file this dependency is apart of * type - Type of the dependency * required - Whether or not this dependency is required + + We also contain the following constants: + + * INCLUDE - TODO: Document this + * INCOMPATIBLE - This dependency is incompatible + * TOOL - This dependency is an optional tool + * REQUIRED - This dependency is required + * OPTIONAL - This dependency is optional + * EMBEDDED_LIBRARY - This dependency is an embedded library """ id: int @@ -608,8 +687,12 @@ class CurseDependency(BaseCurseInstance): file_id: str type: str + INCLUDE = 6 + INCOMPATIBLE = 5 + TOOL = 4 REQUIRED = 3 OPTIONAL = 2 + EMBEDDED_LIBRARY = 1 INST_ID = 8 @@ -634,17 +717,26 @@ def addon(self) -> CurseAddon: return self.hands.addon(self.addon_id) + def file(self) -> CurseFile: + """ + Gets the CurseFile this dependency is apart of. + + :return: CurseFile this dependency is apart of + :rtype: CurseFile + """ + + return self.hands.file(self,addon_id, self.file_id) + @dataclass class CurseAddon(BaseCurseInstance): - """ CurseAddon - Represents a addon for a specific game. An 'addon' is something(mod, modpack, skin) that is added onto a game to change or add certain features and aspects. - The definition of a generic addon is purposefly left ambiguous, + The definition of a generic addon is purposely left ambiguous, as it can be anything from a resource pack in minecraft to a mod in Kerbal Space Program. If you want something a bit more helpful and specific to a game, then the game wrappers might be helpful! @@ -666,15 +758,35 @@ class CurseAddon(BaseCurseInstance): * download_count - Number of downloads * game_id - ID of the game this addon is in * available - Boolean determining if the addon is available - * experimental - Boolean determining if the addon is expiremental + * experimental - Boolean determining if the addon is experimental * authors - Tuple of authors for this addon * attachments - Tuple of attachments attributed with this addon - * category_id - ID of the category this addon is in + * category_id - ID of the primary category this addon is apart of + * root_category - ID of the root category this addon is apart of + * all_categories - Tuple of all catagories this addon is affiliated with * is_featured - Boolean determining if this addon is featured * popularity_score - Float representing this addon's popularity score (Most likely used for popularity ranking) * popularity_rank - Int representing the game's popularity rank - * game_name - Name of the game + * allow_distribute - If addon is allowed for distribution + * main_file_id - ID of the main file for this addon + * status - Status of this addon (new, approved, under review, ect.), use the class constants for this! + * wiki_url - URL to the wiki of this mod, blank string if not specified + * issues_url - URL to the issues page of this mod, blank string if not specified + * source_url - URL to the source code of this mod, blank string if not specified + + We also contain the following constants for identifying the status: + + * NEW - Used if the addon is new, no further actions have been made + * CHANGES_REQUIRED - Used if the addon needs changes before approval + * UNDER_SOFT_REVIEW - Used if the addon is under soft review (elaborate?) + * APPROVED - Used if this addon is approved by the backend, good to be used + * REJECTED - Used if this addon is reject by the backend, not to be used + * CHANGES_MADE - used if changes have been made since the last review + * INACTIVE - Used if this addon is inactive + * ABANDONED - Used if this addon is abandoned + * DELETED - Used if this addon has been deleted + * UNDER_REVIEW - Used if this addon is under review """ name: str @@ -693,10 +805,30 @@ class CurseAddon(BaseCurseInstance): authors: Tuple[CurseAuthor, ...] attachments: Tuple[CurseAttachment, ...] category_id: int + root_category: int + all_categories: Tuple[CurseAddon, ...] is_featured: bool popularity_score: int popularity_rank: int - game_name: str + allow_distribute: bool + main_file_id: int + status: int + wiki_url: str + issues_url: str + source_url: str + + # Class constants for identifying status: + + NEW = 1 + CHANGES_REQUIRED = 2 + UNDER_SOFT_REVIEW = 3 + APPROVED = 4 + REJECTED = 5 + CHANGES_MADE = 6 + INACTIVE = 7 + ABANDONED = 8 + DELETED = 9 + UNDER_REVIEW = 10 INS_ID = 5 @@ -721,7 +853,7 @@ def files(self) -> Tuple[CurseFile, ...]: :return: Tuple of CurseFile instances :rtype: Tuple[CurseFile, ...] """ - + return self.hands.addon_files(self.id) def file(self, file_id: int) -> CurseFile: @@ -763,7 +895,6 @@ def category(self) -> CurseCategory: @dataclass class CurseCategory(BaseCurseInstance): - """ CurseCategory - Represents a category for a specific game. @@ -796,7 +927,9 @@ class CurseCategory(BaseCurseInstance): * root_id - ID of the root category * parent_id - ID of the parent category * icon - CurseAttachment of this catagories icon - * date - Date this category was created + * url - URL to the category page + * date - Date this category was created + * slug - Slug of the category """ id: int @@ -805,7 +938,9 @@ class CurseCategory(BaseCurseInstance): root_id: int parent_id: int icon: CurseAttachment + url: str date: str + slug: str INST_ID = 1 @@ -817,7 +952,7 @@ def sub_categories(self) -> Tuple[CurseAddon, ...]: :rtype: Tuple[CurseCategory, ...] """ - return self.hands.sub_category(self.id) + return self.hands.sub_category(self.game_id, self.id) def parent_category(self) -> CurseCategory: """ @@ -863,40 +998,9 @@ def root_category(self) -> CurseCategory: return self.hands.category(self.root_id) - def search(self, search_param: Optional[SearchParam]=None) -> Tuple[CurseAddon]: - """ - Searches this category with the given search parameters. - - :param search_param: Search parameter object to use, defaults to None - :type search_param: Optional[BaseSearch], optional - :return: Tuple of curse addons found in the search - :rtype: Tuple[CurseAddon, ...] - """ - - return self.hands.search(self.game_id, self.id, search_param) - - def iter_search(self, search_param: Optional[SearchParam]=None) -> CurseAddon: - """ - Invokes the 'iter_search' method of the HC with the given search parameters. - - You should check out HC's documentation on 'iter_search', - but in a nutshell it basically allows you to iterate - though all found addons, automatically incrementing the index when necessary. - - :param search_param: [description], defaults to None - :type search_param: Optional[SearchParam], optional - :return: Each curse addon found - :rtype: CurseAddon - """ - - # Return the results from 'iter_search': - - return self.hands.iter_search(self.game_id, self.id, search_param) - @dataclass class CurseGame(BaseCurseInstance): - """ CurseGame - Represents a game on curseforge. @@ -924,15 +1028,40 @@ class CurseGame(BaseCurseInstance): * slug - Slug of the game * id - ID of the game * support_addons - Boolean determining if this game supports addons - * cat_ids - ID's of the root categories associated with this game, - use the 'categories' method to get CategoryInstances for these ID's + * icon_url - URL to the icon of the game + * tile_url - URL to the tile picture of the game + * cover_url - URL to the banner/cover of the game + * status - Status of this game (draft, test, approved, ect.), use constants for defining this! + * api_status - Weather this game is public or private, use constants for defining this! """ name: str slug: str id: int support_addons: float - cat_ids: Tuple[int,...] + + # Optional values: + + icon_url: str = '' + tile_url: str = '' + cover_url: str = '' + + # Values only used by the official CurseForge API: + + status: int = -1 + api_status: int = -1 + + # Constants for determining status: + + DRAFT = 1 + TEST = 2 + PENDING_REVIEW = 3 + REJECTED = 4 + APPROVED = 5 + LIVE = 6 + + PRIVATE = 1 + PUBLIC = 2 INST_ID = 2 @@ -944,10 +1073,67 @@ def categories(self) -> Tuple[CurseCategory, ...]: :rtype: Tuple[CurseCategory, ...] """ - # Get all categories for this game: + return self.hands.catagories(self.id) - final = [self.hands.category(cat_id) for cat_id in self.cat_ids] + def search(self, search: Optional[SearchParam]=None) -> Tuple[CurseAddon, ...]: + """ + Searches for addons under this game. - # Return the final tuple: + :param search: Search object to use, defaults to None + :type search: BaseSearch, optional + :return: Tuple of CurseAddon objects + :rtype: Tuple[CurseAddon, ...] + """ - return tuple(final) + # Return the results from searching: + + return self.hands.search(self.id, search) + + def iter_search(self, search_param: Optional[SearchParam]=None) -> CurseAddon: + """ + Invokes the 'iter_search' method of the HC with the given search parameters. + + You should check out HC's documentation on 'iter_search', + but in a nutshell it basically allows you to iterate + though all found addons, automatically incrementing the index when necessary. + + :param search_param: [description], defaults to None + :type search_param: Optional[SearchParam], optional + :return: Each curse addon found + :rtype: CurseAddon + """ + + # Return the results from 'iter_search': + + return self.hands.iter_search(self.id, search_param) + + +@dataclass +class CurseHash(BaseCurseInstance): + """ + CurseHash - Represents a hash of some kind. + + Some backends distribute hashes for some reason or another. + In most cases, these hashes are of files to ensure + that the downloaded data is valid. + + This class keeps track of the hash value, + as well as the possible algorithms used to determine the hash. + You can use the constants attached to this class to determine the hashing algorithm. + + We contain the following parameters: + + * hash - Raw hash value + * algorithm - Hashing algorithm used, used constants to identify this! + + We also contain the following constants: + + * SHA1 - Secure Hash Algorithm 1 + * MD5 - Message Digest 5 algorithm + """ + + hash: str + algorithm: int + + SHA1 = 1 + MD5 = 2 diff --git a/cursepy/classes/search.py b/cursepy/classes/search.py index f33ae1f..0dd7ce5 100644 --- a/cursepy/classes/search.py +++ b/cursepy/classes/search.py @@ -1,7 +1,7 @@ """ Search objects for representing search parameters. -We also provide methods for conveting these values +We also provide methods for converting these values into something handlers can understand. """ @@ -11,43 +11,58 @@ @dataclass -class SearchParam: +class SearchParam(object): """ - SearchParam - Providing search options for efficient searching. + SearchParam - Object that contains various parameters for searching. - We define the following values: - - * filter - Term to search for(i.e, 'Inventory Mods') - * index - Addon index to start on - * pageSize - Number of items to display per page - * gameVersion - Game version to search under - * sort - Sorting method to use + This object is used for both addon searching, and file sorting. + It is up to the handler receiving this object to use and interpret these values! + The handler can ignore any values they see fit. - If any of these parameters are unnecessary, - then the handler can ignore them. - We use camel case for the parameter names. + We define the following values: - Users should probably use the setter methods for - configuring this class! - Users can also use the 'set' method to configure - all search parameters in only one call. + * gameId - ID of the game to search addons for + * rootCategoryId - Filter by section ID + * categoryId - Filter by category ID + * gameVersion - Filter by game version string + * searchFilter - Filter by free text search, just like a search bar + * sortField - Filter mods in a certain way (featured, popularity, total_downloads, ect.), use constants for defining this! + * sortOrder - Order of search results (ascending or descending), use constants for defining this! + * modLoaderType - Filter mods associated with a specific mod loader + * gameVersionTypeId - Only show files tagged with a specific version ID + * slug - Filter by slug + * index - Index of the first item to include in the results + * pageSize - Number of items to show in each page """ - searchFilter: Optional[str] = field(default=None) # Term to search for - index: Optional[int] = field(default=0) # Index of addon to start on - pageSize: Optional[int] = field(default=20) # Number of items to display per page - gameVersion: Optional[int] = field(default=None) # Game version to use - sort: Optional[int] = field(default=None) # Sort method to use - - # Some sort options defined here: - - FEATURED = 0 - POPULARITY = 1 - LAST_UPDATE = 2 - NAME = 3 - AUTHOR = 4 - TOTAL_DOWNLOADS = 5 - + gameId: int = field(default=None) + rootCategoryId: int = field(default=None) + categoryId: int = field(default=None) + gameVersion: str = field(default=None) + searchFilter: str = field(default=None) + sortField: int = field(default=None) + sortOrder: str = field(default=None) + modLoaderType: int = field(default=None) + gameVersionTypeId: int = field(default=None) + slug: str = field(default=None) + index: int = field(default=0) + pageSize: int = field(default=20) + + # Constants for defining sort filters: + + FEATURED = 1 + POPULARITY = 2 + LAST_UPDATE = 3 + NAME = 4 + AUTHOR = 5 + TOTAL_DOWNLOADS = 6 + CATEGORY = 7 + GAME_VERSION = 8 + + # Constants for defining sort order: + + ASCENDING = 'asc' + DESCENDING = 'desc' def asdict(self) -> dict: """ @@ -62,12 +77,12 @@ def asdict(self) -> dict: def set_page(self, num: int): """ Changes the page we are on. - + We change the index to reach the next page, we use this equation to determine this: - + index = num * pageSize - + This will set the index to the given page. For example, if we set the page number to two, have a page size of five, and an index of three, @@ -76,17 +91,17 @@ def set_page(self, num: int): :param page: Page number to set the index to :type page: int """ - + self.index = num * self.pageSize - + def bump_page(self, num: int=1): """ Bumps the page up or down. - + We add the page change to the current index using this equation: - + index += num * pageSize - + This will change the page in relation with the current index. For example, if you bump the page up twice, have a page size of five and an index of three, the resulting index after the operation will be 13. @@ -97,14 +112,15 @@ def bump_page(self, num: int=1): :param num: Number of pages to bump :type num: int """ - + self.index += num * self.pageSize self.index = max(self.index, 0) -def url_convert(search: SearchParam, url: str='') -> str: + +def url_convert(search: dict, url: str='') -> str: """ - Converts the given search object into valid URL parameters. + Converts the given dictionary into valid URL parameters. If the 'base_url' is provided, then we will append the converted values to the base_url. @@ -118,9 +134,8 @@ def url_convert(search: SearchParam, url: str='') -> str: # Get a dictionary of parameters: - params = search.asdict() - final = {key: params[key] for key in params if params[key] is not None} + final = {key: search[key] for key in search if search[key] is not None} # Encode and return the values: - return url + urlencode(final) + return url + '?' + urlencode(final) diff --git a/cursepy/errors.py b/cursepy/errors.py index 5041299..7043edd 100644 --- a/cursepy/errors.py +++ b/cursepy/errors.py @@ -4,7 +4,6 @@ class CurseBaseException(BaseException): - """ CFABaseException - Base exception all CFA exceptions will inherit! @@ -35,3 +34,25 @@ class HandlerRaise(CurseBaseException): """ pass + + +class HandlerNotImplemented(CurseBaseException): + """ + Exception raised when this handler implementation is not implemented. + + Usually, this occurs when no handlers are attached to the given operation. + """ + + pass + + +class HandlerNotSupported(CurseBaseException): + """ + Exception raised when an operation is not supported by a handler. + + For example, the CF handlers do not support getting info on a specific category. + Because of this, each time that handler is called, + this exception will be raised. + """ + + pass diff --git a/cursepy/handlers/base.py b/cursepy/handlers/base.py index c9ea82e..81c8748 100644 --- a/cursepy/handlers/base.py +++ b/cursepy/handlers/base.py @@ -483,7 +483,7 @@ class HandlerCollection(object): and work with handlers. We normalize calling, adding, and removing handlers. - This allows for handler autoconfiguration, + This allows for handler auto configuration, and other time saving features. We also provide a collective space for handlers @@ -505,7 +505,7 @@ class HandlerCollection(object): which is the data the handler is returned. Any other arguments can be optionally provided. - You can utilise wrappers to change what functions expect and return. + You can utilize wrappers to change what functions expect and return. Be aware, that default dataclasses will call the functions defined here with the given arguments! @@ -530,7 +530,7 @@ class HandlerCollection(object): ADDON_LIST_FILE = 8 # Gets a tuple of all files associated with an addon ADDON_FILE = 9 # Get information on a specific file for an addon FILE_DESCRIPTION = 10 # Description of a file - + DEFAULT_MAP: tuple = () # Default handler map def __init__(self, load_default=True): @@ -538,7 +538,7 @@ def __init__(self, load_default=True): self.handlers = {} # Dictionary of handler objects self.proto_map = {} # Maps handler names to protocol objects self.callbacks = {} # List of callbacks to run - self.formatter = NullFormatter() # Default formatter to attach to CurseDesciprion + self.formatter = NullFormatter() # Default formatter to attach to CurseDescription # Create a good starting state: diff --git a/cursepy/handlers/curseforge.py b/cursepy/handlers/curseforge.py new file mode 100644 index 0000000..94958f6 --- /dev/null +++ b/cursepy/handlers/curseforge.py @@ -0,0 +1,618 @@ +""" +Handlers for using the official CurseForge API: +https://docs.curseforge.com/ +""" + +import json + +from typing import Any, Tuple + +from cursepy.handlers.base import URLHandler +from cursepy.classes import base +from cursepy.classes.search import SearchParam, url_convert +from cursepy.errors import HandlerNotSupported + + +class BaseCFHandler(URLHandler): + """ + BaseCFHandler - Base class all curseforge handlers should inherit! + + We require an API key to access the API. + This can be set by using the 'set_ley()' method. + This handler will also attempt to extract the API key from + the handler collection we are attached to. + """ + + def __init__(self): + + super().__init__("CurseForge", "https://api.curseforge.com/", 'v1/', '') + + self.key = None # API Key to use + self.raw = "" # RAW data of the last event + + def start(self): + """ + We extract the API key from the HandlerCollection we are apart of. + """ + + self.set_key(self.hand_collection.curse_api_key) + + super().start() + + def set_key(self, key: str): + """ + Sets the API key for this handler. + + :param key: API key to use + :type key: str + """ + + # Set the key: + + self.key = key + + # Create the headers: + + self.proto.headers.update({"Accept": "application/json", + "x-api-key": self.key}) + + def pre_process(self, data: bytes) -> dict: + """ + Decodes the JSON data into a python dictionary. + + :param data: Data to be decoded + :type data: bytes + :return: Python dictionary + :rtype: dict + """ + + self.raw = json.loads(data) + + # Extract the data and return it: + + return self.raw['data'] + + def post_process(self, data: Any) -> Any: + """ + Post-processes the outgoing CurseInstance. + + We attach metadata and raw data to the given event. + + :param data: Event to process + :type data: Any + :return: Processed event + :rtype: Any + """ + + # Add raw data: + + data.raw = self.raw + + # Attach meta data: + + data.meta = self.make_meta() + + # Return final data: + + return data + + +class CFListGame(BaseCFHandler): + """ + Gets a list of all games available to our API key. + """ + + ID: int = 1 + + def build_url(self) -> str: + """ + Returns a valid URL for getting game data. + + :return: URL to get game data + :rtype: str + """ + + return self.proto.url_build("games") + + def format(self, data: dict) -> Tuple[base.CurseGame, ...]: + """ + Formats the data into a CurseGame object. + + :param data: Data to format + :type data: dict + """ + + # Iterate over games and create them: + + final = [CFGame.format(self, game) for game in data] + + return tuple(final) + + +class CFGame(BaseCFHandler): + """ + Gets info on a specific game. + """ + + ID: int = 2 + + def build_url(self, game_id: int) -> str: + """ + Returns a valid URL for getting game data. + + :param game_id: Game ID + :type game_id: int + :return: URL for getting game data + :rtype: str + """ + + return self.proto.url_build("games/{}".format(game_id)) + + def format(self, data: dict) -> base.CurseGame: + """ + Formats the given data into a CurseGame. + + :param data: Data to format + :type data: dict + :return: CurseGame + :rtype: base.CurseGame + """ + + # Figure out categories: + + return base.CurseGame(data['name'], data['slug'], data['id'], True, + data['assets']['iconUrl'], data['assets']['tileUrl'], data['assets']['coverUrl'], + data['status'], data['apiStatus']) + + +class CFListCategory(BaseCFHandler): + """ + Gets a list of all catagories for a given game. + """ + + ID: int = 4 + + def build_url(self, game_id: int) -> str: + """ + Gets a valid URL for getting game catagories. + + :param game_id: Game ID + :type game_id: int + :return: URL for getting category data + :rtype: str + """ + + return self.proto.url_build("categories?gameId={}".format(game_id)) + + @staticmethod + def format(data: dict) -> Tuple[base.CurseCategory, ...]: + """ + Formats the given data. + + :param data: Data to format + :type data: dict + :return: Tuple of CurseCategory objects + :rtype: Tuple[base.CurseCategory, ...] + """ + + temp = [CFCategory.format(cat) for cat in data] + + return tuple(temp) + + +class CFSubCategory(BaseCFHandler): + """ + Gets sub catagories for a given category. + """ + + ID: int = 5 + + def build_url(self, game_id: int, category_id: int) -> str: + """ + Creates a URL for getting sub catagories. + + :param game_id: Game ID to get catagories for + :type game_id: int + :param category_id: Category ID to get sub catagories for + :type category_id: int + :return: Valid URL for getting sub catagories. + :rtype: str + """ + + return self.proto.url_build("categories?gameId={}&classId={}".format(game_id, category_id)) + + @staticmethod + def format(data: dict) -> Tuple[base.CurseCategory, ...]: + """ + Formats the given data. + + :param data: Data to format + :type data: dict + :return: Tuple of CurseCategory objects + :rtype: Tuple[base.CurseCategory, ...] + """ + + temp = [CFCategory.format(cat) for cat in data] + + return tuple(temp) + + +class CFCategory(BaseCFHandler): + """ + In a perfect world, we get info on a specific category. + + Unfortunately, this is not a perfect world we live in. + The CF API only allows us to list categories, + not grab info on a specific one. + + Because of this, this addon raises 'HandlerNotSupported' + when the handle method is called. + + We are only here to do category formatting for other + handlers that may work with categories. + """ + + ID: int = 4 + + def handle(self, *args) -> Any: + """ + Because CurseForge does not support getting info on a specific addon, + we raise a HandlerNotSupported exception. + + :raises: HandlerNotSupported + """ + + raise HandlerNotSupported("CurseForge does not support individual handler lookup!") + + @staticmethod + def format(data: dict) -> base.CurseCategory: + """ + Formats the given data. + + :param data: Data to format + :type data: dict + :return: CurseCategory object representing the data + :rtype: base.CurseCategory + """ + + # Figure out attachment: + + attach = base.CurseAttachment('Category Icon', -1, data['iconUrl'], data['iconUrl'], True, -1, 'Icon of the category') + + # Determine if this is a class: + + return base.CurseCategory(data['id'], data['gameId'], data['name'], data['classId'] if 'classId' in data.keys() else data['id'], + data['parentCategoryId'] if 'parentCategoryId' in data.keys() else data['id'], attach, data['url'], data['dateModified'], data['slug']) + + +class CFAddon(BaseCFHandler): + """ + Gets a specific addon. + """ + + ID: int = 6 + + def build_url(self, addon_id: int) -> str: + """ + Creates a valid URL for getting addon info. + + :param addon_id: ID of the addon + :type addon_id: int + :return: URL for getting addon data + :rtype: str + """ + + return self.proto.url_build("mods/{}".format(addon_id)) + + @staticmethod + def format(data: dict) -> base.CurseAddon: + """ + Creates a CurseAddon instance from the given data. + + :param data: Data to work with + :type data: dict + :return: CurseAddon instance + :rtype: base.CurseAddon + """ + + # Convert the authors: + + authors = [ + base.CurseAuthor(auth['id'], auth['name'], auth['url']) + for auth in data['authors'] + ] + + # Create the logo attachment: + + logoa = data['logo'] + + logo = base.CurseAttachment(logoa['title'], logoa['id'], logoa['thumbnailUrl'], logoa['url'], True, data['id'], logoa['description']) + + # Create other attachments, named screenshots by CF: + + attach = [ + base.CurseAttachment(att['title'], att['id'], att['thumbnailUrl'], att['url'], False, data['id'], att['description']) + for att in data['screenshots'] + ] + + attach.insert(0, logo) + + # Create catagories: + + cats = [CFCategory.format(cat) for cat in data['categories']] + + return base.CurseAddon(data['name'], data['slug'], data['summary'], + data['links']['websiteUrl'], 'EN', data['dateCreated'], data['dateModified'], data['dateReleased'], data['id'], data['downloadCount'], data['gameId'], data['isAvailable'], False, + tuple(authors), tuple(attach), data['primaryCategoryId'], data['classId'], tuple(cats), data['isFeatured'], data['thumbsUpCount'], data['gamePopularityRank'], + data['allowModDistribution'], data['mainFileId'], data['status'], data['links']['wikiUrl'], data['links']['issuesUrl'], data['links']['sourceUrl']) + + +class CFAddonDescription(BaseCFHandler): + """ + Gets the description for a specific addon. + """ + + ID: int = 8 + + def pre_process(self, data: bytes) -> str: + """ + We do not decode the info via JSON, + as CurseForge gives us strings. + + :param data: Data to decode + :type data: bytes + :return: Decoded response data + :rtype: str + """ + + # Set the raw data: + + self.raw = data + + temp = json.loads(data) + + return temp['data'] + + def build_url(self, addon_id: int) -> str: + """ + Builds a valid URL for getting addon descriptions. + + :param addon_id: Addon ID + :type addon_id: int + :return: URL for getting addon descriptions + :rtype: str + """ + + return self.proto.url_build("mods/{}/description".format(addon_id)) + + @staticmethod + def format(data: str) -> base.CurseDescription: + """ + Creates a CurseDescription instance with the given data. + + :param data: Data to process + :type data: str + :return: CurseDescription object + :rtype: base.CurseDescription + """ + + return base.CurseDescription(data) + + +class CFAddonSearch(BaseCFHandler): + """ + Searches for a specific addon. + """ + + ID: int = 6 + + def build_url(self, game_id: int, search: SearchParam) -> str: + """ + Creates a valid URL for searching addons. + + :param game_id: ID of the game to search under + :type game_id: int + :param search: CFSearch object that contains search parameters + :type search: CFSearch + :return: URL for searching + :rtype: str + """ + + # Add the game ID to the search object: + + search.gameId = game_id + + # Create the params dict: + + params = search.asdict() + + # Switch some keys around: + + params['classId'] = params.pop('rootCategoryId') + + thing = self.proto.url_build( + url_convert( + params.asdict(), + url='mods/search' + ) + ) + + return thing + + def format(self, data: dict) -> Tuple[base.CurseAddon, ...]: + """ + Formats the given data. + + :param data: Data to format + :type data: dict + :return: Formatted data + :rtype: Tuple[base.CurseAddon, ...] + """ + + # Iterate over instances: + + final = [CFAddon.format(addon) for addon in data] + + return tuple(final) + + +class CFAddonFiles(BaseCFHandler): + """ + Gets all files for a specific addon. + """ + + ID: int = 9 + + def build_url(self, addon_id: int, search: SearchParam) -> str: + """ + Builds a valid URL for getting all files associated with an addon. + + :param addon_id: Addon ID + :type addon_id: int + :return: URL for getting all files + :rtype: str + """ + + # Create a dict: + + params = search.asdict() + + # Swap some keys around: + + # Switch some keys around: + + params['classId'] = params.pop('rootCategoryId') + + return self.proto.url_build( + url_convert( + search.asdict(), + url='mods/{}/files'.format(addon_id) + ) + ) + + @staticmethod + def format(data: dict) -> Tuple[base.CurseFile, ...]: + """ + Converts the given data into a tuple of CurseFile instances. + + :param data: Data to format + :type data: dict + :return: Tuple of all files + :rtype: Tuple[base.CurseFile, ...] + """ + + temp = [CFAddonFile.format(file) for file in data] + + return tuple(temp) + + +class CFAddonFile(BaseCFHandler): + """ + Gets a specific file from an addon. + """ + + ID: int = 10 + + def build_url(self, addon_id: int, file_id: int) -> str: + """ + Builds a valid URL for getting all files associated with an addon. + + :param addon_id: Addon ID + :type addon_id: int + :param file_id: File ID + :type file_id: int + :return: URL for getting a file + :rtype: str + """ + + return self.proto.url_build("mods/{}/files/{}".format(addon_id, file_id)) + + @staticmethod + def format(data: dict) -> base.CurseFile: + """ + Converts the given data into a curse file. + + :param data: Data to format + :type data: dict + :return: Curse file representing the given data + :rtype: base.CurseFile + """ + + # We fill in None for the dependency ID and the File ID, as that info is not available + + depends = [base.CurseDependency(None, dep['modId'], data['id'], dep['relationType']) for dep in data['dependencies']] + + # Figure out hashes: + + hashes = [base.CurseHash(hsh['value'], hsh['algo']) for hsh in data['hashes']] + + return base.CurseFile(data['id'], data['modId'], data['displayName'], data['fileName'], data['fileDate'], data['downloadUrl'], + data['fileLength'], tuple(data['gameVersions']), tuple(depends), data['gameId'], data['isAvailable'], data['releaseType'], + data['fileStatus'], tuple(hashes), data['downloadCount']) + + +class CFFileDescription(BaseCFHandler): + """ + Gets the description for a given file. + """ + + ID: int = 11 + + def pre_process(self, data: bytes) -> str: + """ + We do NOT decode the data via JSON, + as we are working with HTML. + + :param data: Data to be decoded + :type data: bytes + :return: String representing the description + :rtype: str + """ + + self.raw = data + + temp = json.loads(data) + + return temp['data'] + + def build_url(self, addon_id: int, file_id: int) -> str: + """ + Returns a valid URL for getting a file description. + + :param addon_id: Addon ID + :type addon_id: int + :param file_id: File ID + :type file_id: int + :return: URL for getting a file description + :rtype: str + """ + + return self.proto.url_build('mods/{}/files/{}/changelog'.format(addon_id, file_id)) + + @staticmethod + def format(data: str) -> base.CurseDescription: + """ + Formats the given data into a CurseDescription instance. + + :param data: Data to format + :type data: str + :return: CurseDescription containing the file description + :rtype: base.CurseDescription + """ + + return base.CurseDescription(data) + + +cf_map = ( + CFListGame(), + CFGame(), + CFListCategory(), + CFCategory(), + CFSubCategory(), + CFAddon(), + CFAddonSearch(), + CFAddonDescription(), + CFAddonFiles(), + CFAddonFile(), + CFFileDescription(), +) diff --git a/cursepy/handlers/forgesvc.py b/cursepy/handlers/forgesvc.py index d606ffa..a49b2e9 100644 --- a/cursepy/handlers/forgesvc.py +++ b/cursepy/handlers/forgesvc.py @@ -53,7 +53,7 @@ def post_process(self, data: Any, raw: dict=None, meta:dict=None) -> Any: and we add on the metadata generated by URLHandler. The user can optionally provide raw data and metadata to attach to - the instance. This will overide any instances of the data we currently have. + the instance. This will override any instances of the data we currently have. :param data: CurseInstance to be post-processed :type data: base.BaseCurseInstance @@ -155,12 +155,12 @@ class SVCListCategory(BaseSVCHandler): """ Handler for getting all valid categories on CurseForge. - We convert the given data into a tuple of CurseCategory instacnes. + We convert the given data into a tuple of CurseCategory instances. """ def build_url(self) -> str: """ - # Returns a valid URL for fetching category information + Returns a valid URL for fetching category information :return: URL for getting all catagories :rtype: str @@ -504,7 +504,7 @@ def low_format(data: dict, addon_id: int, limited=False) -> base.CurseFile: We are static to allow other classes to use us to format data. The actual 'format()' method will extract the addon ID from the URL and pass it along to us. - + In some cases, we are not given full dependence info, for example when all files for a given addon is requested. You can use the 'limited' parameter to prevent a key error. diff --git a/cursepy/handlers/maps.py b/cursepy/handlers/maps.py index db2e4c0..660108d 100644 --- a/cursepy/handlers/maps.py +++ b/cursepy/handlers/maps.py @@ -2,14 +2,18 @@ Stores Handler maps to be used by cursepy. We store these in a separate folder to resolve circular import dependencies! -Here is a list of the follwing maps: +Here is a list of the following maps: * DEFAULT_MAP - Default handler map """ from cursepy.handlers.forgesvc import svc_map +from cursepy.handlers.curseforge import cf_map # Default cursepy handler map: -DEFAULT_MAP = (svc_map) +DEFAULT_MAP = (cf_map) + +CURSEFORGE = (cf_map) +FORGESVC = (svc_map) diff --git a/cursepy/proto.py b/cursepy/proto.py index 058c6fa..b6ece67 100644 --- a/cursepy/proto.py +++ b/cursepy/proto.py @@ -189,8 +189,8 @@ def __init__(self, host: str, timeout: int=60) -> None: self.headers = {'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'} # Request headers to use self.extra = '/' # Extra information to add before the path when building URLs self.proto_id = 'URLProtocol' # Protocol ID - - self.meta = {} # MetaData from the last request + + self.meta = {} # MetaData from the last request def get_data(self, url: str, timeout: Optional[int]=None, data: Optional[dict]=None) -> bytes: """ @@ -243,7 +243,7 @@ def low_get(self, url: str, timeout: Optional[int]=None, heads: Optional[dict]=N :param timeout: Timeout value, uses default value if None :type timeout: int, optional :param heads: Extra headers to include with our defaults - :return: HTTPResponse object contaning response from server + :return: HTTPResponse object containing response from server :rtype: HTTPResponse """ diff --git a/cursepy/wrapper.py b/cursepy/wrapper.py index 9720078..bd699b6 100644 --- a/cursepy/wrapper.py +++ b/cursepy/wrapper.py @@ -14,9 +14,9 @@ from cursepy.handlers.maps import DEFAULT_MAP -class CurseClient(HandlerCollection): +class BaseClient(HandlerCollection): """ - CurseClient - the client class most people will use! + CurseClient - a class all clients must inherit! We implement the handler management and callback features outlined in the HandlerCollection. We do some extra things here and there to make life much easier for the end user. @@ -28,13 +28,15 @@ class CurseClient(HandlerCollection): This class also provides some entry points into the loaded handlers. This provides a standardized way of interacting with handlers. + + Other clients can add/change functionality where necessary. """ def load_default(self): """ We simply load the cursepy default handler map. """ - + self.load_handlers(DEFAULT_MAP) def games(self) -> Tuple[base.CurseGame]: @@ -45,7 +47,6 @@ def games(self) -> Tuple[base.CurseGame]: so it's a good idea to not call it often. Make a note of the relevant game's information! - :raises NotImplementedError: Must be overridden in child class! :return: Tuple of CurseGame instances :rtype: Tuple[base.CurseGame] """ @@ -56,41 +57,36 @@ def game(self, id: int) -> base.CurseGame: """ Returns information on a specific game. - You will need to provide the game ID. - Game information will be returned in a CurseGame instance. - :param id: ID of the game to get :type id: int - :raises NotImplementedError: Must be overridden in child class! :return: CurseGame instance representing the game :rtype: base.CurseGame """ return self.handle(1, id) - def catagories(self) -> Tuple[base.CurseCategory, ...]: + def catagories(self, game_id: int) -> Tuple[base.CurseCategory, ...]: """ - Gets ALL valid categories on CurseForge. + Gets ALL valid categories for a specific game. This call can get expensive, so call in moderation! + :param game_id: Game ID to get catagories for + :type game_id: int :return: Tuple of CurseCategory instances :rtype: Tuple[base.CurseCategory, ...] """ - return self.handle(2) + return self.handle(2, game_id) def category(self, category_id: int) -> base.CurseCategory: """ Returns information on a category for a specific game. - You will need to provide a category ID. - :param game_id: ID of the game :type game_id: int :param category_id: ID of the category :type category_id: int - :raises NotImplementedError: Must be overridden in child class! :return: CurseCategory instance representing the category :rtype: base.CurseCategory """ @@ -112,14 +108,59 @@ def sub_category(self, category_id: int) -> Tuple[base.CurseCategory, ...]: return self.handle(4, category_id) - def iter_search(self, game_id: int, category_id: int, search: SearchParam=None) -> base.CurseAddon: + def addon(self, addon_id: int) -> base.CurseAddon: + """ + Gets information on a specific addon. + + You will need to provide an addon ID, + which can be found by searching a game category. + + :param addon_id: Addon ID + :type addon_id: int + :return: CurseCategory instance representing the addon + :rtype: base.CurseAddon + """ + + return self.handle(5, addon_id) + + def search(self, game_id: int, search: SearchParam=None) -> Tuple[base.CurseAddon, ...]: + """ + Searches a given game for addons. + + The game_id parameter is required for searching, + but the search parameter can optionally be provided to fine-tune to search. + + Each implementation has different search backends, + so it is important to pass the correct search instance! + If a search instance is not passed, + then one will be automatically created. + + :param game_id: ID of the game to search under + :type game_id: int + :param category_id: Category to search under + :type category_id: int + :param search: Search options to fine tune the search, optional + :type search: Any + :return: Tuple of addons that matched the search parameters + :rtype: Tuple[base.CurseAddon, ...] + """ + + # Get a search object: + + if search is None: + + search = self.get_search() + + return self.handle(6, game_id, search) + + def iter_search(self, game_id: int, search: SearchParam=None) -> base.CurseAddon: """ Iterates over all results from the search operation. We automatically bump the SearchParam by one after each call. We will keep yielding until we receive a tuple that has a length of zero, - which at that point we will raise a 'StopIteration' exception. + which at that point we will break. Because we only bump the index value, we will start on the index that was provided by the @@ -144,22 +185,19 @@ def iter_search(self, game_id: int, category_id: int, search: SearchParam=None) :type search: SearchParam :return: Each CurseAddon that returned during the search operation. :rtype: base.CurseAddon - :raises: StopIteration: When we have run out of search content """ - # Check if we should generate a SearchParam: + # get a search object: if search is None: - # None provided, let's create one: - search = self.get_search() while True: # Get the current page of results: - results = self.search(game_id, category_id, search) + results = self.search(game_id, search) # Check to see if the page is empty: @@ -176,100 +214,44 @@ def iter_search(self, game_id: int, category_id: int, search: SearchParam=None) search.bump_page() - def addon(self, addon_id: int) -> base.CurseAddon: - """ - Gets information on a specific addon. - - You will need to provide an addon ID, - which can be found by searching a game category. - - :param addon_id: Addon ID - :type addon_id: int - :raises NotImplementedError: Must be overridden in child class - :return: CurseCategory instance representing the addon - :rtype: base.CurseAddon - """ - - return self.handle(5, addon_id) - - def search(self, game_id: int, category_id: int, search: SearchParam=None) -> Tuple[base.CurseAddon, ...]: - """ - Searches the given game and category for addons. - - The game_id and category_id parameters are required for searching, - but the search parameter can optionally be provided to fine-tune to search. - - Each implementation has diffrent search backends, - so it is important to pass the correct search instance! - - The wrappers will implement search checking, - to ensure only the correct search parameters are used. - - :param game_id: ID of the game to search under - :type game_id: int - :param category_id: Category to search under - :type category_id: int - :param search: Search options to fine tune the search, optional - :type search: Any - :return: Tuple of addons that matched the search parameters - :rtype: Tuple[base.CurseAddon, ...] - :raises: TypeError: If the search object fails the search check - """ - - # Check if we should generate a dummy SearchParam: - - if search is None: - - # Create dummy search param: - - search = SearchParam() - - return self.handle(6, game_id, category_id, search) - def addon_description(self, addon_id: int) -> base.CurseDescription: """ Gets the description of a specific addon. - You will need to provide an addon ID, - which can be found by searching a game category. - :param addon_id: Addon ID :type addon_id: int - :raises NotImplementedError: Must be overridden in child class! :return: CurseDescription instance representing the addon's description :rtype: base.CurseDescription """ return self.handle(7, addon_id) - def addon_files(self, addon_id: int) -> Tuple[base.CurseFile]: + def addon_files(self, addon_id: int, search: SearchParam=None) -> Tuple[base.CurseFile]: """ Gets a list of files associated with the addon. - You will need to provide an addon ID, - which can be found by searching a game category. - :param addon_id: Addon ID :type addon_id: int - :raises NotImplementedError: Must be overridden in child class! :return: Tuple of CurseFile instances representing the addon's files :rtype: Tuple[base.CurseFile] """ - return self.handle(8, addon_id) + # Get search object: + + if search is None: + + search = self.get_search() + + return self.handle(8, addon_id, search) def addon_file(self, addon_id: int, file_id:int) -> base.CurseFile: """ Gets information on a specific file associated with an addon. - You will need to provide an addon ID, - as well as a file ID, which can be found by getting a list of all files - :param addon_id: Addon ID :type addon_id: int :param file_id: File ID :type file_id: int - :raises NotImplementedError: Must be overridden in child class! :return: CurseFile instance representing the addon file :rtype: base.CurseFile """ @@ -280,9 +262,6 @@ def file_description(self, addon_id: int, file_id: int) -> base.CurseDescription """ Gets a description of an addon file. - You will need to provide a addon ID and a file ID. - We return a CurseDesciprion object representing the description. - :param addon_id: Addon ID :type addon_id: int :param file_id: File ID @@ -294,9 +273,41 @@ def file_description(self, addon_id: int, file_id: int) -> base.CurseDescription return self.handle(10, addon_id, file_id) +class CurseClient(BaseClient): + """ + CurseClient - Implements curseforge handlers. + + We require an API key to work correctly, + you can get one here: + https://docs.curseforge.com/#getting-started + """ + + def __init__(self, curse_api_key, load_default=True): + + self.curse_api_key = curse_api_key # API key to use + + super().__init__(load_default) + + def sub_category(self, game_id: int, category_id: int) -> Tuple[base.CurseCategory, ...]: + """ + Gets the sub-categories of the given category. + + We return a tuple of CurseCategory instances + representing the sub-categories. + + :param game_id: ID of the game to get catagories for + :param category_id: ID of category to get sub-catagories for + :type category_id: int + :return: Tuple of CurseCategories representing the sub-categories + :rtype: Tuple[base.CurseCategory, ...] + """ + + return self.handle(4, game_id, category_id) + + class MinecraftWrapper(CurseClient): """ - MinecraftWrap - Wrapper for working with Minecraft data! + MinecraftWrap - Wrapper for working with Minecraft data on CUrseForge! Minecraft is one of the most popular games on CurseForge. Because of this, we think it is appropriate to include a wrapper @@ -306,7 +317,9 @@ class MinecraftWrapper(CurseClient): most notably are the methods for searching/getting addons in a specific category. This will automate the search operation, filling in the necessary info when necessary. - This means that you as the user will not have to work(as much) with IDs! + This means that you as the user will not have to work(as much) with IDs! + + Do note that this wrapper is only valid for official CurseForge backends. """ # Game ID: @@ -321,6 +334,31 @@ class MinecraftWrapper(CurseClient): WORLDS = 17 BUKKIT = 5 + def get_minecraft(self) -> base.CurseGame: + """ + Returns the CurseGame client that represents minecraft. + + :return: Minecraft CurseGame instance + :rtype: base.CurseGame + """ + + return self.game(MinecraftWrapper.GAME_ID) + + def sub_category(self, category_id: int) -> Tuple[base.CurseCategory, ...]: + """ + Returns all sub-catagories for the given category. + + We automatically pass the game ID when called, + so the user can fetch sub-categories without passing a game ID. + This allows us to operate like the BaseClient method for getting sub-categories. + + :param category_id: _description_ + :type category_id: int + :return: _description_ + :rtype: Tuple[base.CurseCategory, ...] + """ + return super().sub_category(MinecraftWrapper.GAME_ID, category_id) + def search_resource_packs(self, search: SearchParam=None) -> Tuple[base.CurseAddon, ...]: """ Searches the Resource Packs category for addons. @@ -334,7 +372,13 @@ def search_resource_packs(self, search: SearchParam=None) -> Tuple[base.CurseAdd # Search the resource packs: - return self.search(MinecraftWrapper.GAME_ID, MinecraftWrapper.RESOURCE_PACKS, search) + if search is None: + + search = self.get_search() + + search.categoryId = MinecraftWrapper.RESOURCE_PACKS + + return self.search(MinecraftWrapper.GAME_ID, search) def search_modpacks(self, search: SearchParam=None) -> Tuple[base.CurseAddon, ...]: """ @@ -349,7 +393,13 @@ def search_modpacks(self, search: SearchParam=None) -> Tuple[base.CurseAddon, .. # Search the modpacks: - return self.search(MinecraftWrapper.GAME_ID, MinecraftWrapper.MODPACKS, search) + if search is None: + + search = self.get_search() + + search.categoryId = MinecraftWrapper.MODPACKS + + return self.search(MinecraftWrapper.GAME_ID, search) def search_mods(self, search: SearchParam=None) -> Tuple[base.CurseAddon, ...]: """ @@ -364,6 +414,12 @@ def search_mods(self, search: SearchParam=None) -> Tuple[base.CurseAddon, ...]: # Search the mods: + if search is None: + + search = self.get_search() + + search.categoryId = MinecraftWrapper.MODS + return self.search(MinecraftWrapper.GAME_ID, MinecraftWrapper.MODS, search) def search_worlds(self, search: SearchParam=None) -> Tuple[base.CurseAddon, ...]: @@ -379,7 +435,13 @@ def search_worlds(self, search: SearchParam=None) -> Tuple[base.CurseAddon, ...] # Search the worlds: - return self.search(MinecraftWrapper.GAME_ID, MinecraftWrapper.WORLDS, search) + if search is None: + + search = self.get_search() + + search.categoryId = MinecraftWrapper.WORLDS + + return self.search(MinecraftWrapper.GAME_ID, search) def search_plugins(self, search: SearchParam=None) -> Tuple[base.CurseAddon]: """ @@ -391,7 +453,13 @@ def search_plugins(self, search: SearchParam=None) -> Tuple[base.CurseAddon]: :return: Tuple of CurseAddons :rtype: Tuple[base.CurseAddon] """ - + # Search the plugins: - + + if search is None: + + search = self.get_search() + + search.categoryId = MinecraftWrapper.BUKKIT + return self.search(MinecraftWrapper.GAME_ID, MinecraftWrapper.BUKKIT, search) diff --git a/docs/source/advn/builtin_hand.rst b/docs/source/advn/builtin_hand.rst index 51a4e52..db727ab 100644 --- a/docs/source/advn/builtin_hand.rst +++ b/docs/source/advn/builtin_hand.rst @@ -11,21 +11,114 @@ This document outlines the handlers that are built into cursepy. We will go over the supported events, protocols, and backends in use by these handlers. +.. _curse_handlers: + +Official CurseForge Handlers +============================ + +CFHandlers handlers get info from the official CurseForge API. + +.. note:: + + These handlers require an `API key `_ to work correctly. + +CF Basic Info +------------- + +* Name: CurseForge +* Protocol Object: URLProtocol +* Raw Data: dictionary, or HTML string if event 7 or 10 +* Metadata: :ref:`URLProtocol Metadata` + +CF Supported Events +------------------- + +* [0]: LIST_GAMES +* [1]: GAME +* [2]: LIST_CATEGORY +* [4]: SUB_CATEGORY +* [5]: ADDON +* [6]: ADDON_SEARCH +* [7]: ADDON_DESC +* [8]: ADDON_LIST_FILE +* [9]: ADDON_FILE +* [10]: FILE_DESCRIPTION + + +CF Unsupported Events +---------------------- + +* [3]: CATEGORY + +CF Long Description +------------------- + +CurseForge handlers utilize HTTP to get CF data. +The retrieved data is in JSON format, +with the exception of events 7 and 8, +which is HTML text. +This text is converted into a string. +The raw data is attached to CurseInstances as dictionaries, +with the example of 7 and 8, in which the data is a HTML string. + +CFHandlers use :ref:`URLProtocol` +as our protocol object, and inherits :ref:`URLHandler` +to add this functionality. +We use the URLProtocol object to generate valid metadata on the connection, +which we attach to the CurseInstance. + +Due to a limitation in the CurseForge API, +we can't lookup individual catagories by ID. +We also require a game ID to lookup sub-catagories. + +You can load the handler map by using the svc_map: + +.. code-block:: python + + from cursepy.handlers.curseforge import cf_map + + client.load_handlers(cf_map) + +These handlers require an API key to work correctly. +One way to get an API key to the handlers is to attach the key to each handler: + +.. code-block:: python + + hand.key = API_KEY + +Simply attach it to the 'key' parameter of the handler. +CFhandlers will also attempt to extract the key from the :ref:`HandlerCollection` it is attached to. +Simply attach a key to the 'curse_api_key' parameter of the collection it is attached to: + +.. code-block:: python + + hands.curse_api_key - API_KEY + +Finally, you can use the :ref:`CurseClient`, which requires a key to instantiate, +and will put it in the required locations. + SVCHandlers =========== SVCHandlers get info from forgesvc.net. -Basic Info ----------- +.. warning:: + + These handlers are now deprecated and should not be used! + +Curse Forge has shut down the ForgeSVC API, +and all requests made to this service will fail. + +SCV Basic Info +-------------- * Name: ForgeSVC * Protocol Object: URLProtocol * Raw Data: dictionary, or HTML string if event 7 or 10 * Metadata: :ref:`URLProtocol Metadata` -Supported Events: ------------------ +SVC Supported Events +-------------------- * [0]: LIST_GAMES * [1]: GAME @@ -39,15 +132,15 @@ Supported Events: * [9]: ADDON_FILE * [10]: FILE_DESCRIPTION -Non-supported events: ---------------------- +SVC Unsupported events +---------------------- None! -Long Description ----------------- +SVC Long Description +-------------------- -SVCHandlers utilise HTTP to get CF data. +SVCHandlers utilize HTTP to get CF data. The retrieved data is in JSON format, with the exception of events 7 and 8, which is HTML text. @@ -68,5 +161,3 @@ You can load the handler map by using the svc_map: from cursepy.handlers.forgesvc import svc_map client.load_handlers(svc_map) - -SVCHandlers are loaded first by default! diff --git a/docs/source/advn/collection.rst b/docs/source/advn/collection.rst index 7f5c8cf..abcd13c 100644 --- a/docs/source/advn/collection.rst +++ b/docs/source/advn/collection.rst @@ -9,7 +9,7 @@ Introduction Welcome to the advanced tutorial for the HandlerCollection(HC) object! -In this document, we will be going over +In this document, we will be going over some advanced concepts behind the HC that will help developers understand and work with HC classes. Everything from loading handlers, to the internal structure @@ -124,7 +124,7 @@ then it removes the handler at the current event ID valid event ID's. When a HC is first created, it loads a 'NullHandler' object at each position. - + This ensures that requests are always handled by something! After this, the handler is officially added to the HC handler structure. @@ -189,9 +189,9 @@ Consider this example: If this handler map was passed to the 'load_handlers()' method, then the handlers will be bound to these events: -1. Hand1 -2. Hand2 -3. Hand3 +* [1]: Hand1 +* [2]: Hand2 +* [3]: Hand3 In other words, 'Hand1' will be bound to the 'LIST_GAMES' event, 'Hand2' will be bound to the 'GAME' event, @@ -213,9 +213,9 @@ For example, if this dictionary is provided: Then the handlers will be bound to these events: -0. Hand0 -1. Hand2 -2. Hand3 +* [0]: Hand0 +* [1]: Hand2 +* [2]: Hand3 The keys of dictionaries can technically be anything, although it is recommended that they are integers @@ -246,10 +246,10 @@ The order of these objects determine the order of the map, meaning that the lower the index, the higher it's priority. Using the above example, the handlers will be bound to these events: -0. Hand1 -1. Hand1 -2. Hand1 -3. Hand2 +* [0]: Hand1 +* [1]: Hand1 +* [2]: Hand1 +* [3]: Hand2 As you can see, the handlers in the first map are given priority over those in the second. diff --git a/docs/source/api.rst b/docs/source/api.rst index e75bb93..44df078 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -54,4 +54,3 @@ Exceptions .. automodule:: cursepy.errors :members: - diff --git a/docs/source/basic/collection.rst b/docs/source/basic/collection.rst index 04c9540..4f3dc4f 100644 --- a/docs/source/basic/collection.rst +++ b/docs/source/basic/collection.rst @@ -1,58 +1,58 @@ .. _collec_basic: ================== -CurseClient Basics +BaseClient Basics ================== Introduction ============ -This section contains documentation on how to use the 'CurseClient' class! +This section contains documentation on how to use the 'BaseClient' class! This is a general guide that will show you how to do basic operations, and the types of calls you can make to get information from CurseForge. -CurseClient +BaseClient =========== -Now, what is a 'CurseClient', and why is it relevant? +Now, what is a 'BaseClient', and why is it relevant? -The CurseClient (Hereafter referred to as CC), +The BaseClient (Hereafter referred to as BC), is the class that facilitates communication with CurseForge(CF). It does many things under the hood to make the communication with CF a very simple procedure. -Some classes will inherit CurseClient to add extra functionality. +Some classes will inherit BaseClient to add extra functionality. These components are called wrappers, and they are described in detail elsewhere in this documentation. Just know that most features defined here will be present in all -CC instances. +BC instances. -CC is a critical high level component of cursepy, and will be used extensively! +BC is a critical high level component of cursepy, and will be used extensively! -Creating a CurseClient +Creating a BaseClient ====================== -Creating a CC is simple procedure, and can be done like this: +Creating a BC is simple procedure, and can be done like this: .. code-block:: python - # Import the CC: + # Import the BC: - from cursepy import CurseClient + from cursepy import BaseClient - # Create the CC: + # Create the BC: - client = CurseClient() + client = BaseClient() -This will create a CC with the default handlers loaded. +This will create a BC with the default handlers loaded. If you do not want the default handlers to be loaded, then you can pass 'False' to the 'load_default' parameter, like so: .. code-block:: python - # Create a CC, but without default handlers: + # Create a BC, but without default handlers: - client = CurseClient(load_default=False) + client = BaseClient(load_default=False) If no default handlers are loaded, then every event will have a 'NullHandler' @@ -65,23 +65,23 @@ for more info! .. note:: In all following examples, - we assume that a CC is properly imported and instantiated + we assume that a BC is properly imported and instantiated under the name 'client'. Important Things to Keep in Mind ================================ -CC uses a collection of handlers to add functionality. +BC uses a collection of handlers to add functionality. These handlers are associated with certain events. When an event is 'fired', then the handler associated with the event is called. We derive all functionality from said handlers, -meaning that CC is only as useful as the handlers that +meaning that BC is only as useful as the handlers that are currently loaded! It is important to recognize that handlers are the components that do all the dirty work (Getting info, decoding it, and formatting it). -The only thing the CC does is organize +The only thing the BC does is organize and call these handlers with the relevant information. With that being said, @@ -94,7 +94,7 @@ but it is strongly recommended that they do so! Keep in mind that the built in handlers follow these recommendations, so you should either be wary, or have a keen understanding on any third party handlers you load! -To sum it all up: CC manages handlers, but handlers provide the functionality! +To sum it all up: BC manages handlers, but handlers provide the functionality! We will not be going into the dirty details about handler development and functionality. @@ -104,13 +104,13 @@ you should check out the :ref:`handler tutorial `. .. _collec-constants: -CurseClient Constants +BaseClient Constants ===================== As stated earlier, -CC objects organize handlers by attaching them to events. +BC objects organize handlers by attaching them to events. These events can be identified using integers. -CC contains constants that can be used to identify these events: +BC contains constants that can be used to identify these events: * [0]: LIST_GAMES - Gets a list of all valid games * [1]: GAME - Get information on a specific game @@ -137,10 +137,10 @@ However, if you want to use the lower-level 'handle()' method, or register callbacks, then having an understanding of these constants will be very useful! -CurseClient Methods +BaseClient Methods =================== -CC provides some entry points for getting information, +BC provides some entry points for getting information, so developers have a standardized way of interacting with handlers. All methods will take a number of events to pass to the handler, @@ -276,20 +276,19 @@ method for this: This will return a CurseDescription object representing the addon description. -.. _search: +.. _addon_search: You can also search for addons using the 'search' method: .. code-block:: python - result = client.search(GAME_ID, CAT_ID, search=search_param) + result = client.search(GAME_ID, search=search_param) -Where GAME_ID is the ID of the game to search under, -and CAT_ID is the category ID to search under. +Where GAME_ID is the ID of the game to search under. We return a tuple of CurseAddon objects representing the search results. Users can optionally provide a search object -to fine tune to search operation. +to fine tune the search operation. You can get a search object using the 'get_search' method: @@ -300,11 +299,18 @@ method: The 'SearchParam' objects contains the following values for fine-tuning the search operation: -* searchFilter - Value to search for +* gameId - Game ID to search under +* rootCategoryId - Search by root category ID +* categoryId - Search by category ID +* gameVersion - Game version to search under +* searchFilter - Value to search for +* sortField - Filter results in a certain way (featured, popularity, total downloads, ect.), use constants for defining this! +* sortOrder - Order the of the results (ascending or descending), use constants for defining this! +* modLoaderType - Filter mods associated by modloader +* gameVersionTypeId - Only show results tagged with a certain game version +* slug - Filter by slug * index - Page index to search under * pageSize - Number of items to display per page -* gameVersion - Game version to search under -* sort - Sorting method to use Explaining Search Parameters ____________________________ @@ -315,16 +321,26 @@ Most of these values are self-explanatory. 'gameVersion' is the game version to search under. This varies from game to game, and should be a string. +'gameVersionTypeId' is the same as the 'gameVersion' parameter, +but it takes a game version ID as an int instead of a string. -'sort' is an integer that represents the sorting type. +'rootCategoryId' and 'categoryId' are very similar fields, and are both related to category searching. +The 'categoryId' is the ID of the category to search under. +'rootCategoryId' is the root class to search under. +Some backends will use both, others may only use one or the other. +The official CurseForge API uses both values for example. + +'sortField' is an integer that represents the sorting type. You can use the search constants present in SearchParam to define this: -* [0]: FEATURED - Sort by featured -* [1]: POPULARITY - Sort by popularity -* [2]: LAST_UPDATE - Sort by last updated -* [3]: NAME - Sort by name -* [4]: AUTHOR - Sort by author -* [5]: TOTAL_DOWNLOADS - Sort by total downloads +* [1]: FEATURED - Sort by featured +* [2]: POPULARITY - Sort by popularity +* [3]: LAST_UPDATE - Sort by last updated +* [4]: NAME - Sort by name +* [5]: AUTHOR - Sort by author +* [6]: TOTAL_DOWNLOADS - Sort by total downloads +* [7]: CATEGORY - Sort by category +* [8]: GAME_VERSION - Sort by game version Check out this example of sorting by popularity: @@ -338,6 +354,15 @@ Check out this example of sorting by popularity: search.sort = search.POPULARITY +You can also change the order of the search by using the 'sortOrder' +field of the 'SearchParam' object. +You can use the search constants present in the SearchParam to define this: + +* [1]: ASCENDING - Sort in ascending order +* [2]: DESCENDING - Sort in descending order + +'slug' allows you to sort items by their slug. + 'index' and 'pageSize' are used since search results are usually limited to 'pages' to save some bandwidth. @@ -375,8 +400,10 @@ Here is an example of getting the second page of search results: result = client.search(GAME_ID, CAT_ID, search) +.. _iter_search: + If you want to iterate over ALL content over all valid pages, -CC has a method for that. +BC has a method for that. You can use the 'iter_search' method to iterate over all search results until we reach the end. We use the 'search' method to get each page of values, @@ -419,11 +446,12 @@ associated with an addon: .. code-block:: python - files = client.addon_files(ADDON_ID) + files = client.addon_files(ADDON_ID, search) Where ADDON_ID is the ID of the addon to get files for. This function will return a tuple of CurseFile instances representing each file. +You can also pass a SearchParam to the list to filter the results. To get info on a specific file, you can use the 'addon_file' method: @@ -472,16 +500,16 @@ Here is an example callback that prints the given data to the terminal: print(data) In this case, the callback is a simple function. -Now, let's bind this function to the CC under the 'FILE' event: +Now, let's bind this function to the BC under the 'FILE' event: .. code-block:: python client.bind_callback(client.FILE, dummy_callback) -Remember the event constants defined earlier? +Remember the :ref:`event constants` defined earlier? You can use those again here to define the event the callback should be bound to! After we receive the data from the handler associated with the FILE event, -the CC will automatically call this function, and pass the returned value to the callback. +the BC will automatically call this function, and pass the returned value to the callback. Consider this next example: @@ -535,11 +563,11 @@ Here is an example of this in action: @client.bind_callback(client.GAME) def callback(data): - + print("We have been ran!") In this example, the function 'callback()' -is automatically registered to the CC by using the 'bind_callback()' +is automatically registered to the BC by using the 'bind_callback()' as a decorator. As stated earlier, any other arguments will be saved and passed to the callback at runtime. @@ -583,7 +611,7 @@ Conclusion ========== That concludes the tutorial on basic -CC features! +BC features! -If you want some insight into advanced CC features, +If you want some insight into advanced BC features, such as handler loading, be sure to check out the :ref:`Advanced Tutorial `. diff --git a/docs/source/basic/curse_inst.rst b/docs/source/basic/curse_inst.rst index 225e201..1f02f83 100644 --- a/docs/source/basic/curse_inst.rst +++ b/docs/source/basic/curse_inst.rst @@ -162,6 +162,8 @@ that allows them to do extra things, such as write content to an external file, or download data from a remote source to a custom file. +.. _curse_write: + Writer ______ @@ -182,6 +184,8 @@ The CI determines what will be written to the external file. If a CI has this feature, then we will go over what exactly they write in this tutorial. +.. _curse_download: + Downloader __________ @@ -199,6 +203,8 @@ is a path like object giving the pathname of the file to be written to. Again, the CI determines what will be downloaded and written to the external file. If a CI has this feature, then we will go over exactly what they download and write. +.. _curse_attach: + CurseAttachment --------------- @@ -213,7 +219,7 @@ Represents an attachment on CF. * addon_id - ID this addon is apart of * description - Description of this attachment -CurseAttachments have the download feature, +CurseAttachments have the :ref:`download feature`, which means that you can download this attachment using the 'download' method: .. code-block:: python @@ -235,6 +241,8 @@ as the file to write to. You can also download the thumbnail using the 'download_thumbnail' method, which operates in the same way. +.. _curse_description: + CurseDescription ---------------- @@ -319,7 +327,9 @@ Here is an example of a custom formatter that appends 'Super Slick!' to the end desc.attach_formatter(SuperFormatter()) CurseDescription objects can write content to an external file, -as it has writing functionality. +as it has :ref:`writing functionality`. + +.. _curse_author: CurseAuthor ----------- @@ -333,6 +343,8 @@ Represents an author on CF. CurseAuthor classes is not necessary for CF development, and only acts as extra info if you want it. +.. _curse_game: + CurseGame --------- @@ -342,10 +354,35 @@ Represents a game on CF. * slug - Slug of the game * id - ID of the game * support_addons - Boolean determining if the game supports addons -* cat_ids - List of root category ID's associated with the game +* icon_url - URL to the game icon +* tile_url - URL to the image used for the game tile +* cover_url - URL to the image used for the game cover +* status - Used for determining the game status, defined by constants! +* api_status - Determining if this game is public or private + +Each game has a status, which is defined by the (you guessed it) 'status' parameter. +You can use these constants to identify the status: + +* [1]: DRAFT - This game is a draft, not meant to be used +* [2]: TEST - Game is in testing, not meant to be used +* [3]: PENDING_REVIEW - Game is pending review, not meant to be used +* [4]: REJECTED - Game has been rejected from the backend, definitely not meant to be used +* [5]: APPROVED - Game has been approved and is good to be used +* [6]: LIVE - Game is live and (in theory) being used. This is the best game status! + +So, if you wanted to see if a given game is valid and live, then you can check the status: + +.. code-block:: python + + if game.status == CurseGame.LIVE: + print('Game is live!') + +The 'api_status' parameter is used to determine if the game is public or private. +You can use these constants to identify the status: + +* [1]: PRIVATE - Game is private and not available for use +* [2]: PUBLIC - Game is public and available for use -The CurseGame instance does not have valid classes representing the root level catagories, -only there ID's. If you want to retrieve the objects that represent the catagories, you can use the 'categories' method to retrieve category info like so: @@ -353,7 +390,26 @@ you can use the 'categories' method to retrieve category info like so: cats = inst.catagories() -This will return a tuple of CurseCatagories objects representing each root category. +This will return a tuple of :ref:`CurseCategory` objects representing each root category. + +CurseGame also makes searching addons a breeze. +You can use the 'search' method to search for addons: + +.. code-block:: python + + addons = inst.search(SEARCH) + +Where SEARCH is a search param. +This method will automatically fill in the necessary game ID for you, +and will return a tuple of :ref:`CurseAddon` objects. +CurseGame also as an 'iter_search()' method, +which will traverse all pages of the search results. + +.. note:: + If you need a primer on searching, + check out the :ref:`CurseClient Tutorial `. + +.. _curse_category: CurseCategory ------------- @@ -361,15 +417,17 @@ CurseCategory Represents a CurseCategory, and provides methods for getting sub and parent catagories. -* id - ID of the catagory +* id - ID of the category * game_id - ID of the game the category is associated with * name - Name of the category * root_id - ID of this objects root category(None if there is no root ID) * parent_id - ID of this objects parent category(None if there is no root ID) -* icon - Icon of the category(CurseAttachment) +* icon - Icon of the category(:ref:`CurseAttachment`) +* url - URL to the category page * date - Date this category was created +* slug - Slug of the addon -If you read the intro tutorial +If you read the :ref:`intro tutorial` (You did read the into tutorial right?), then you will know that catagories can have parent and sub-catagories. @@ -390,44 +448,53 @@ None if there is no root category. representing the root category, returns None if there is no root category. -CurseAddon also makes searching a breeze. -We automatically provide the correct game and category ID's. -Users can provide a 'SearchParameter' object for -fine-tuning the search operation. +.. _curse_addon: -You can use the 'search' method to get a list of valid addons. -You can also use the 'iter_search' method to iterate -over each addon. - -.. note:: - If you need a primer on searching, - check out the :ref:`CurseClient Tutorial `. - -CurseAddon +CurseAddon ---------- Represents an addon on CurseForge. * name - Name of the addon * slug - Slug of the addon -* summary - Summary of the addon(Not a full description, +* summary - Summary of the addon(Not a full description) * url - URL of the addon page * lang - Language of the addon * date_created - Date this addon was created * date_modified - Date this addon was last modified * date_release - Date the addons latest release -* ID - ID of this addon +* id - ID of this addon * download_count - Number of times this addon has been downloaded * game_id - ID of the game this addon is in * available - Boolean determining if the addon is available * experimental - Boolean determining if the addon is experimental -* authors - Tuple of CurseAuthor instances for this addon -* attachments - Tuple of CurseAttachments associated with the object -* category_id - ID of the category this addon is in +* authors - Tuple of :ref:`CurseAuthor` instances for this addon +* attachments - Tuple of :ref:`CurseAttachments` associated with the object +* category_id - ID of the category this addon is in +* root_category - ID of the root category this addon is apart of +* all_categories - Tuple of CurseCategory objects representing all the categories this addon is apart of * is_featured - Boolean determining if this addon is featured * popularity_score - Float representing this popularity score(Most likely used for ranking) -* popularity_rank - int representing the addon game's popularity -* game_name - Name of the game +* popularity_rank - int representing the addon game's popularity +* allow_distribute - If this addon is allowed to be distributed +* main_file_id - ID of the main file for this addon +* status - Status of this addon, defined by constants! +* wiki_url - URL to the addon wiki page +* issues_url - URL to the addon issues page +* source_url - URL to the addon source code + +To determine the status of the addon, you can use the constants: + +* [1]: NEW - This addon is new, no further progress has been made +* [2]: CHANGED_REQUIRED - This addon needs to be changed in some way before it is approved +* [3]: UNDER_SOFT_REVIEW - This addon is under soft review +* [4]: APPROVED - This addon is approved and ready for use +* [5]: REJECTED - This addon has been rejected from the backend, definitely not meant to be used +* [6]: CHANGES_MADE - This addon has been changed since it's last review +* [7]: INACTIVE - This addon is inactive and not being maintained +* [8]: ABANDONED - This addon is abandoned and not being maintained +* [9]: DELETED - This addon has been deleted +* [10]: UNDER_REVIEW - This addon is under review CurseAddon objects do not keep the description info! A special call must be made to retrieve this. @@ -445,16 +512,16 @@ You can get the files associated with this addon by using the 'file' method: file = inst.file(ID) Where ID is the ID of the file to retrieve. -This method returns a CurseFile object representing the files -(We will go over CurseFile objects later in this tutorial!). +This method returns a :ref:`CurseFile` object representing the files +(We will go over :ref:`CurseFile` objects later in this tutorial!). If you want a list of all files associated with the addon, you can use the 'files()' method, -which returns a tuple of CurseFile objects. +which returns a tuple of :ref:`CurseFile` objects. -You can retrieve the CurseGame object representing the game -this addon is apart of using the 'game' method. You can also get a CurseCategory +You can retrieve the :ref:`CurseGame` object representing the game +this addon is apart of using the 'game()' method. You can also get a :ref:`CurseCategory` object representing the category this addon is apart of -by using the 'category' method: +by using the 'category()' method: .. code-block:: python @@ -466,51 +533,103 @@ by using the 'category' method: cat = inst.category() +.. _curse_file: + CurseFile --------- Represents a file on CF. -* id - ID of the file -* addon_id - ID of the addon this file is apart of -* display_name - Display name of the file +* id - ID of the file +* addon_id - ID of the addon this file is apart of +* display_name - Display name of the file * file_name - File name of the file * date - Date the file was uploaded -* download_url - Download URL of the file -* length - Length in bytes of the file -* version - Version of the game needed to work this file -* dependencies - Tuple of dependencies for this file +* download_url - Download URL of the file +* length - Length in bytes of the file +* version - Version of the game needed to work this file +* dependencies - Tuple of :ref:`CurseDependency` objects for this file +* game_id - ID of the game this file is apart of +* is_available - Boolean determining if the file is available +* release_type - Release type of the file, defined by constants! +* file_status - Status of the file, also defined by constants! +* hashes - Tuple of :ref:`CurseHash` objects representing file hashes +* download_count - Number of times this file has been downloaded + +To determine the release type of the file, you can use the constants: + +* [1]: RELEASE - This file is a release +* [2]: BETA - This is a beta file +* [3]: ALPHA - This is an alpha file + +To determine the file status, you can use the constants: + +* [1]: PROCESSING - This file is being processed and checked +* [2]: CHANGES_REQUIRED - This file needs to be changed in some way before it is approved +* [3]: UNDER_REVIEW - This file is under review +* [4]: APPROVED - This file is approved and ready for use +* [5]: REJECTED - This file has been rejected from the backend, definitely not meant to be used +* [6]: MALWARE_DETECTED - This file has been detected as malware, you *really* should not use it! +* [7]: DELETED - This file has been deleted +* [8]: ARCHIVED - This file has been archived +* [9]: TESTING - This file is being tested +* [10]: RELEASED - This file is released and ready to be used +* [11]: READY_FOR_REVIEW - This file is ready to be reviewed +* [12]: DEPRECATED - This file has been marked as deprecated +* [13]: BAKING - This file is being baked (?) +* [14]: AWAITING_PUBLISHING - This file is awaiting publishing +* [15]: FAILED_PUBLISHING - This file failed to publish + +To determine if a file is good to be used, +you can use the 'good_file()' method: + +.. code-block:: python + + if inst.good_file(): + print "This file is good to use!" + else: + print "This file is not good to use!" + +This method simply checks if the file is available, +if the file is released, and if the file status is RELEASED. +Just because a file is not good does not mean it can't be used! +On top of this, just because a file is good does not mean it will work properly. +Our only understanding of the file is what the backend says it is. +Production ready files could be poorly made, +and non-production ready experimental files could also be valid. To get the changelog of the file, you can use the 'changelog' property: .. code-block:: python - desc = inst.changelog + desc = inst.changelog -This will return a CurseCategory object representing the description. +This will return a :ref:`CurseDescription` object representing the description. As stated earlier, CI's use the entry point methods of the HC that returned them. -This means that the CurseDescription object will have the default formatter +This means that the :ref:`CurseDescription` object will have the default formatter attached to it. -If you want all dependencies for this file, +If you want all dependencies for a file, then you can find them under the 'dependencies' parameter. -You can also get dependencies by using the 'get_dependencies()' method: +You can also get certain dependencies by using the 'get_dependencies()' method: .. code-block:: python - deps = inst.get_dependencies() + deps = inst.get_dependencies(DEPEN_TYPE) -You can also pass 'required=True' to get only required dependencies, -and 'optional=True' to get only optional dependencies. +Where DEPEN_TYPE is a dependency type, +defined by the :ref:`CurseDependency` constants. +This method will only return dependencies of the specified type. -You can also get the 'get_addon()' method to retrieve a CurseAddon +You can also use the 'get_addon()' method to retrieve a :ref:`CurseAddon` object representing the addon this file is attached to. -The CurseFile class also has download functionality. -You can use the 'download()' method to download this -file. +The CurseFile class also has :ref:`download functionality`. +You can use the 'download()' method to download this file. + +.. _curse_dependency: CurseDependency --------------- @@ -520,20 +639,43 @@ Represents a file dependency on CF. * id - ID of the dependency * addon_id - ID of the addon this dependency is apart of * file_id - ID of the file this dependency is apart of -* type - Type of the dependency, an int determining if the dependency is required or optional +* type - Type of the dependency, defined using constants! * required - Boolean determining if the dependency is required +To determine the addon type, you can use the constants: + +* [1]: EMBEDDED_LIBRARY - This dependency is an embedded library +* [2]: OPTIONAL - This dependency is optional +* [3]: REQUIRED - This dependency is required +* [4]: TOOL - This dependency is an optional tool +* [5]: INCOMPATIBLE - This dependency is incompatible +* [6]: INCLUDE - This dependency is an include file + To determine if the dependency is required, you can use the 'required' parameter. -You can also compare the type of the dependency with the 'REQUIRED' and 'OPTIONAL' constants. +This parameter under the hood is a property which returns 'True' if the dependency is required. +and 'False' if it is not. + +To get the :ref:`CurseAddon` and :ref:`CurseFile` objects this dependency is a member of, +you can use the 'addon()' and 'file()' methods respectively. + +.. _curse_hash: + +CurseHash +--------- + +Represents a file hash on CF. + +* hash - Hash of the file +* algorithm - Algorithm used to generate the hash, defined using constants! + +You can determine the algorithm by using these constants: -To get the addon and addon file this dependency is a member of, -you can use the 'addon()' and 'file()' methods. +* [1]: SHA1 - SHA1 algorithm +* [2]: MD5 - MD5 algorithm -Sometimes, the CurseDependency will have limited information. -This can happen when you use the ForgeSVC handlers and get all addon files. -In this case, the CurseDependency will have limited information, -where the 'id' and 'file_id' will be None. -This is a limitation of the ForgeSVC backend, not cursepy. +These objects can be used to check the integrity of files, +and determine if they are valid. +Some backends may not provide hashes for certain files, or at all! Conclusion ========== diff --git a/docs/source/basic/intro.rst b/docs/source/basic/intro.rst index 1cda9ed..09c27e6 100644 --- a/docs/source/basic/intro.rst +++ b/docs/source/basic/intro.rst @@ -1,3 +1,5 @@ +.. _intro_tutorial: + ================== Usage Introduction ================== @@ -154,12 +156,12 @@ handlers are the components that offer the functionality! If you want to know more about handlers and handler development, then check out the - Advanced Tutorial! + :ref:`Advanced Tutorial!` -CurseClient +BaseClient ----------- -A 'CurseClient' is a class +A 'BaseClient' is a class that manages handlers, and offers entry points into them. This ensures that no matter the handler type, @@ -173,7 +175,7 @@ Wrappers Wrappers are similar to HandlerCollections, except they change or optimize one or all features -to work well with a specific game. +to work well with a specific game or backend. For example, wrappers would offer methods that ony return info relevant to a specific game. diff --git a/docs/source/basic/wrap.rst b/docs/source/basic/wrap.rst index e8d92b7..a4c2cc5 100644 --- a/docs/source/basic/wrap.rst +++ b/docs/source/basic/wrap.rst @@ -18,17 +18,49 @@ wrappers might also load certain handlers that work well with the game. Just to put this in perspective, -CurseClient is actually a wrapper! +BaseClient is actually a wrapper! It loads the default handlers and offers entry points into said handlers. -ALL built in handlers inherit CurseClient, +ALL built in wrappers inherit BaseClient, meaning that the entry point methods will always be available, regardless of the wrapper. Do keep in mind, that not all third party handlers -will inherit CurseClient, meaning that some methods may be unavailable. +will inherit BaseClient, meaning that some methods may be unavailable. Be sure that you know your handlers before you use them! +.. _curse_client: + +CurseClient +=========== + +The CurseClient is a wrapper that is altered to work with the official CurseForge backend. + +To do this, we load the official :ref:`CurseForge handlers`. +Because these handlers require an API key to work correctly, +you will need to provide an API key when this wrapper is instantiated: + +.. code-block:: python + + from cursepy.wrapper import CurseClient + + client = CurseClient(API_KEY) + +Where API_KEY is the API key you `obtained from CurseForge `_. + +CurseClient also requires a game ID when getting sub-catagories: + +.. code-block:: python + + client.sub_category(GAME_ID, CAT_ID) + +Where GAME_ID is the game ID the category lies under, +and CAT_ID is the category you wish to get sub-catagories for. + +Finally, because the :ref:`CurseForge handlers` do not support individual category lookup, +you will be unable to use the 'category()' method. +This is a limitation with the official CurseForge API. + MinecraftWrapper ================ @@ -43,6 +75,22 @@ We have the following constants: * BUKKIT - ID of the bukkit category(5) You can use these constants in the entry point methods. +This wrapper has the following methods: + +get_minecraft +------------- + +Returns a :ref:`CurseGame` object for the game 'Minecraft'. +This method takes no arguments. + +mine_sub_category +----------------- + +Returns all sub-catagories (tuple of :ref:`CurseCategory` objects) for the given category ID. +We automatically fill in the game ID for you, +so this makes working with Minecraft sub-catagories identical to the BaseClient. +We take one parameter, the category ID. + MinecraftWrapper also provides some methods to make searching easier. Each method takes one parameter, a SearchParam object. diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 4512850..559b0ec 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,34 @@ Changelog ========= +2.0.0 +===== + +This update adds some major functionality! + +Features Added +-------------- + +* Added two new errors, 'HandlerNotImplemented' and 'HandlerNotSupported' +* New class, BaseClient which defines the basic functionality for all wrappers (replaces old CurseClient) +* For listing catagories, we now need a game ID, as we only get all catagories for a specific game +* When listing files, a SearchParam can be provided to filter results +* New values in SearchParam that allows for more advanced searching and sorting +* Many curse instances have more parameters available +* New curse instance, CurseHash, represents fille hashes +* Added handlers for interacting with the official CurseForge API (Needs an API key!) +* Added new wrapper, CurseClient (different from old CurseClient) that makes working with the official API easier + +Bug Fixes +--------- + +* Various formatting and spelling corrections + +Other Fixes +----------- + +* Many additions and changes in the official documentation + 1.3.1 =====