From c38cf31eaae7139e562a2ef7d3bfba5b2b55fa4f Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Tue, 8 Apr 2025 18:54:24 -0400 Subject: [PATCH 01/31] docs: initial sphinx docs implementation Add an initial implementation for sphinx documentation. Auto-generate module documentation for the rule34Py module. Signed-off-by: Riparian Commit --- docs/api/index.rst | 8 ++++++ docs/api/rule34Py/__init__.rst | 22 +++++++++++++++ docs/api/rule34Py/api_urls.rst | 7 +++++ docs/api/rule34Py/html.rst | 7 +++++ docs/api/rule34Py/icame.rst | 7 +++++ docs/api/rule34Py/pool.rst | 7 +++++ docs/api/rule34Py/post.rst | 7 +++++ docs/api/rule34Py/post_comment.rst | 7 +++++ docs/api/rule34Py/rule34.rst | 7 +++++ docs/api/rule34Py/toptag.rst | 7 +++++ docs/conf.py | 44 ++++++++++++++++++++++++++++++ docs/index.rst | 11 ++++++++ 12 files changed, 141 insertions(+) create mode 100644 docs/api/index.rst create mode 100644 docs/api/rule34Py/__init__.rst create mode 100644 docs/api/rule34Py/api_urls.rst create mode 100644 docs/api/rule34Py/html.rst create mode 100644 docs/api/rule34Py/icame.rst create mode 100644 docs/api/rule34Py/pool.rst create mode 100644 docs/api/rule34Py/post.rst create mode 100644 docs/api/rule34Py/post_comment.rst create mode 100644 docs/api/rule34Py/rule34.rst create mode 100644 docs/api/rule34Py/toptag.rst create mode 100644 docs/conf.py create mode 100644 docs/index.rst diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 0000000..48ed2a2 --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,8 @@ +API Reference +============= + +.. toctree:: + :maxdepth: 2 + :caption: Modules: + + rule34Py/__init__ diff --git a/docs/api/rule34Py/__init__.rst b/docs/api/rule34Py/__init__.rst new file mode 100644 index 0000000..2419a8a --- /dev/null +++ b/docs/api/rule34Py/__init__.rst @@ -0,0 +1,22 @@ +rule34Py +======== + + +.. automodule:: rule34Py + :members: + :private-members: + :undoc-members: + + +.. toctree:: + :maxdepth: 1 + :caption: Submodules + + api_urls + html + icame + pool + post + post_comment + rule34 + toptag diff --git a/docs/api/rule34Py/api_urls.rst b/docs/api/rule34Py/api_urls.rst new file mode 100644 index 0000000..e034475 --- /dev/null +++ b/docs/api/rule34Py/api_urls.rst @@ -0,0 +1,7 @@ +rule34Py.api\_urls +================== + +.. automodule:: rule34Py.api_urls + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/rule34Py/html.rst b/docs/api/rule34Py/html.rst new file mode 100644 index 0000000..f9b105c --- /dev/null +++ b/docs/api/rule34Py/html.rst @@ -0,0 +1,7 @@ +rule34Py.html +============= + +.. automodule:: rule34Py.html + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/rule34Py/icame.rst b/docs/api/rule34Py/icame.rst new file mode 100644 index 0000000..3dfabe4 --- /dev/null +++ b/docs/api/rule34Py/icame.rst @@ -0,0 +1,7 @@ +rule34Py.icame +============== + +.. automodule:: rule34Py.icame + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/rule34Py/pool.rst b/docs/api/rule34Py/pool.rst new file mode 100644 index 0000000..479daf9 --- /dev/null +++ b/docs/api/rule34Py/pool.rst @@ -0,0 +1,7 @@ +rule34Py.pool +============= + +.. automodule:: rule34Py.pool + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/rule34Py/post.rst b/docs/api/rule34Py/post.rst new file mode 100644 index 0000000..774bfc8 --- /dev/null +++ b/docs/api/rule34Py/post.rst @@ -0,0 +1,7 @@ +rule34Py.post +============= + +.. automodule:: rule34Py.post + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/rule34Py/post_comment.rst b/docs/api/rule34Py/post_comment.rst new file mode 100644 index 0000000..d28f09d --- /dev/null +++ b/docs/api/rule34Py/post_comment.rst @@ -0,0 +1,7 @@ +rule34Py.post\_comment +====================== + +.. automodule:: rule34Py.post_comment + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/rule34Py/rule34.rst b/docs/api/rule34Py/rule34.rst new file mode 100644 index 0000000..6d9f75e --- /dev/null +++ b/docs/api/rule34Py/rule34.rst @@ -0,0 +1,7 @@ +rule34Py.rule34 +=============== + +.. automodule:: rule34Py.rule34 + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/rule34Py/toptag.rst b/docs/api/rule34Py/toptag.rst new file mode 100644 index 0000000..c1fab06 --- /dev/null +++ b/docs/api/rule34Py/toptag.rst @@ -0,0 +1,7 @@ +rule34Py.toptag +=============== + +.. automodule:: rule34Py.toptag + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..3b0e51b --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,44 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +from datetime import datetime +from pathlib import Path +import tomllib as toml + +PROJECT_ROOT = Path(__file__).parent.resolve() / ".." + +# Read the pyproject.toml +with open(PROJECT_ROOT / "pyproject.toml", "rb") as fp_pyproject: + pyproject = toml.load(fp_pyproject) +authors = [m["name"] for m in pyproject["project"]["authors"]] + +project = pyproject["project"]["name"] +copyright = str(datetime.now().year) + ', b3yc0d3' +author = ", ".join(authors) +release = pyproject["project"]["version"] + + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.duration", + "sphinx.ext.napoleon", +] + +templates_path = ['_templates'] +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'sphinx_rtd_theme' +html_static_path = ['_static'] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..0f26422 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,11 @@ +======== +rule34Py +======== + +Welcome to the rule34Py module documentation! + +.. toctree:: + :maxdepth: 1 + :caption: Sections: + + api/index From 8d5f1d795c7f8298ab731e494d6759e01fbe69fb Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 3 May 2025 16:09:07 -0400 Subject: [PATCH 02/31] docs/conf: add the intersphinx extension Add the intersphinx extension to automatically link to external sphinx docs. Also enhance the autodoc configuration. Signed-off-by: Riparian Commit --- docs/conf.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 3b0e51b..4f53216 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,11 +31,24 @@ "sphinx.ext.autosummary", "sphinx.ext.duration", "sphinx.ext.napoleon", + "sphinx.ext.intersphinx", ] templates_path = ['_templates'] exclude_patterns = [] +# sphinx.ext.autodoc configuration # +autodoc_default_options = { + "special-members": "__init__", # document class __init__() +} +autodoc_typehints = "both" # Show typehints in the signature and as content of the function or method + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "requests": ("https://requests.readthedocs.io/en/latest/", None), +} + + # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output From 0ab60cfa75ef213aebf921e058c72d47e49ebb8e Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 3 May 2025 16:10:34 -0400 Subject: [PATCH 03/31] rule34: update module documentation Signed-off-by: Riparian Commit --- docs/api/rule34Py/rule34.rst | 2 +- rule34Py/rule34.py | 221 +++++++++++++++++++++++------------ 2 files changed, 145 insertions(+), 78 deletions(-) diff --git a/docs/api/rule34Py/rule34.rst b/docs/api/rule34Py/rule34.rst index 6d9f75e..0e7602a 100644 --- a/docs/api/rule34Py/rule34.rst +++ b/docs/api/rule34Py/rule34.rst @@ -3,5 +3,5 @@ rule34Py.rule34 .. automodule:: rule34Py.rule34 :members: - :show-inheritance: :undoc-members: + :private-members: _get diff --git a/rule34Py/rule34.py b/rule34Py/rule34.py index a1e3780..147be7b 100644 --- a/rule34Py/rule34.py +++ b/rule34Py/rule34.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""This module contains the top-level Rule34 API client class.""" +"""A module containing the top-level Rule34 API client class.""" from collections.abc import Iterator from urllib.parse import parse_qs @@ -41,32 +41,44 @@ from rule34Py.toptag import TopTag -DEFAULT_USER_AGENT = f"Mozilla/5.0 (compatible; rule34Py/{__version__})" -SEARCH_RESULT_MAX = 1000 # API-defined maximum number of search results per request. +#: The default client user_agent, if one is not specified in the environment. +DEFAULT_USER_AGENT: str = f"Mozilla/5.0 (compatible; rule34Py/{__version__})" +#: API-defined maximum number of search results per request. +#: [`Rule34 Docs `_] +SEARCH_RESULT_MAX: int = 1000 class rule34Py(): """The rule34.xxx API client. - Usage: - ```python - client = rule34Py() - post = client.get_post(1234) - ``` + This class is the primary broker for interactions with the real Rule34 servers. + It transparently chooses to interact with either the REST server endpoint, or the PHP interactive site, depending on what methods are executed. + + Example: + .. code-block:: python + + client = rule34Py() + post = client.get_post(1234) """ - CAPTCHA_COOKIE_KEY: str = "cf_clearance" + CAPTCHA_COOKIE_KEY: str = "cf_clearance" #: Captcha clearance HTML cookie key _base_site_rate_limiter = LimiterAdapter(per_second=1) + #: Client captcha clearance token. Defaults to either the value of the ``R34_CAPTCHA_CLEARANCE`` environment variable; or None, if the environment variable is not asserted. + #: Can be overridden at runtime by the user. captcha_clearance: str | None = os.environ.get("R34_CAPTCHA_CLEARANCE", None) + #: The ``requests.Session`` object used when the client makes HTML requests. session: requests.Session = None + #: The ``User-Agent`` HTML header value used when the client makes HTML requests. + #: Defaults to either the value of the ``R34_USER_AGENT`` environment variable; or the ``rule34Py.rule34.DEFAULT_USER_AGENT``, if not asserted. + #: Can be overridden by the user at runtime to change User-Agents. user_agent: str = os.environ.get("R34_USER_AGENT", DEFAULT_USER_AGENT) def __init__(self): """Initialize a new rule34 API client instance. - :return: A new rule34 API client instance. - :rtype: rule34Py + Returns: + A new rule34 API client instance. """ self.session = requests.session() self.session.mount(__base_url__, self._base_site_rate_limiter) @@ -77,8 +89,8 @@ def _get(self, *args, **kwargs) -> requests.Response: This method largely passes its arguments to the requests.get() method, while also inserting a valid User-Agent. - :return: A requests.Response object from the GET request. - :rtype: requests.Response + Returns: + The Response object from the GET request. """ # headers kwargs.setdefault("headers", {}) @@ -91,14 +103,18 @@ def _get(self, *args, **kwargs) -> requests.Response: return self.session.get(*args, **kwargs) - def get_comments(self, post_id: int) -> list: - """Retrieve comments of post by its ID. + def get_comments(self, post_id: int) -> list[PostComment]: + """Retrieve the comments left on a post. + + Args: + post_id: The Post's ID number. - :param post_id: The Post's ID number. - :type post_id: int + Returns: + List of comments returned from the request. + If the post has no comments, an empty list will be returned. - :return: List of comments. - :rtype: list[PostComment] + Raises: + requests.HTTPError: The backing HTTP GET operation failed. """ params = [ @@ -123,15 +139,19 @@ def get_comments(self, post_id: int) -> list: return comments def get_pool(self, pool_id: int) -> Pool: - """Retrieve a pool of Posts by its pool ID. + """Retrieve a pool of Posts. - **Be aware that if "fast" is set to False, it may takes longer.** + Note: + This method uses the interactive website and is rate-limited. - :param pool_id: Pools id. - :type pool_id: int + Args: + pool_id: The pool's object ID on Rule34. - :return: A Pool object representing the requested pool. - :rtype: Pool + Returns: + A Pool object representing the requested pool. + + Raises: + requests.HTTPError: The backing HTTP GET operation failed. """ params = [ @@ -144,11 +164,14 @@ def get_pool(self, pool_id: int) -> Pool: def get_post(self, post_id: int) -> Post | None: """Get a Post by its ID. - :param post_id: The Post's ID number. - :type post_id: int + Args: + post_id: The Post's Rule34 ID. + + Returns: + The Post object matching the post_id; or None, if the post_id is not found. - :return: The Post object matching the post_id; or None, if the post_id is not found. - :rtype: Post | None + Raises: + requests.HTTPError: The backing HTTP GET operation failed. """ params = [ ["POST_ID", str(post_id)] @@ -164,11 +187,17 @@ def get_post(self, post_id: int) -> Post | None: post_json = response.json() return Post.from_json(post_json[0]) - def icame(self) -> list: - """Retrieve list of top 100 iCame list. + def icame(self) -> list["ICame"]: + """Retrieve a list of the top 100 iCame objects. + + Note: + This method uses the interactive website and is rate-limited. - :return: List of iCame objects. - :rtype: list[ICame] + Returns: + The current top 100 iCame objects. + + Raises: + requests.HTTPError: The backing HTTP GET operation failed. """ response = self._get(API_URLS.ICAME.value) response.raise_for_status() @@ -181,16 +210,20 @@ def iter_search( ) -> Iterator[Post]: """Iterate through Post search results, one element at a time. - This method transparently requests additional results pages until either max_results is reached, or there are no more results. It is possible that additional Posts may be added to the results between page calls, and so it is recommended that you deduplicate results if that is important to you. + This method transparently requests additional results pages until either ``max_results`` is reached, or there are no more results. + It is possible that additional Posts may be added to the results between page calls, and so it is recommended that you deduplicate results if that is important to you. - :param tags: Tag list to search. - :type tags: list[str] + Args: + tags: A list of tags to search. + If the tags list is empty, all posts will be returned. + max_results: The maximum number of results to return before ending the iteration. + If ``None``, then iteration will continue until the end of the results. - :param max_results: The maximum number of results to return before ending the iteration. If 'None', then iteration will continue until the end of the results. Defaults to 'None'. - :type max_results: int|None + Yields: + An iterator representing each Post element of the search results. - :return: Yields a Post Iterator. - :rtype: Iterator[Post] + Raises: + requests.HTTPError: The backing HTTP GET operation failed. """ page_id = 0 # what page of the search results we're on results_count = 0 # accumulator of how many results have been returned @@ -207,19 +240,23 @@ def iter_search( page_id += 1 def _parseUrlParams(self, url: str, params: list) -> str: - """ - Parse url parameters. + """Parse url parameters. - **This function is only used internally.** + Args: + url: URL, containing placeholder values. + params: A list of parameter values, to substitute into the URL's placeholders. - :return: Url filed with filled in placeholders. - :rtype: str + Example: + .. code-block:: python - :Example: - self._parseUrlParams("domain.com/index.php?v={{VERSION}}", [["VERSION", "1.10"]]) - """ + formatted_url = _parseUrlParams( + "domain.com/index.php?v={{VERSION}}", + [["VERSION", "1.10"]] + ) - # Usage: _parseUrlParams("domain.com/index.php?v={{VERSION}}", [["VERSION", "1.10"]]) + Returns: + The input URL, reformatted to contain the parameters in place of their placeholders. + """ retURL = url for g in params: @@ -235,8 +272,14 @@ def random_post(self) -> Post: This method behaves similarly to the website's Post > Random function. - :return: Post object. - :rtype: Post + Note: + This method uses the interactive website and is rate-limited. + + Returns: + A random Post. + + Raises: + requests.HTTPError: The backing HTTP GET operation failed. """ return self.get_post(self.random_post_id()) @@ -244,10 +287,13 @@ def random_post_id(self) -> int: """Get a random Post ID. This method returns the Post ID contained in the 302 redirect the - website responds with, when you request use random post function. + website responds with, when you use the "random post" function. - :return: A random Post ID. - :rtype: int + Note: + This method uses the interactive website and is rate-limited. + + Raises: + requests.HTTPError: The backing HTTP GET operation failed. """ response = self._get(API_URLS.RANDOM_POST.value) @@ -257,25 +303,25 @@ def random_post_id(self) -> int: def search(self, tags: list[str] = [], - page_id: int = None, + page_id: int | None = None, limit: int = SEARCH_RESULT_MAX, ) -> list[Post]: """Search for posts. - :param tags: List of tags. - :type tags: list[str] - - :param page_id: Page number/id. - :type page_id: int - - :param limit: Limit for posts returned per page (max. 1000). - :type limit: int + Args: + tags: A list of tags to search for. + page_id: The search page number to request, or None. + If None, search will eventually return all pages. + limit: The maximum number of post results to return per page. + Defaults to ``SEARCH_RESULT_MAX`` (1000, by Rule34 policy). - :return: List of Post objects for matching posts. - :rtype: list[Post] + Returns: + A list of Post objects, representing the search results. - For more information, see: + Raises: + requests.HTTPError: The backing HTTP GET operation failed. + References: - `rule34.xxx API Documentation `_ """ if limit < 0 or limit > SEARCH_RESULT_MAX: @@ -316,10 +362,15 @@ def set_base_site_rate_limit(self, enabled: bool): def tag_map(self) -> dict[str, str]: """Retrieve the tag map points. - This method uses the tagmap static HTML. - - :return: A mapping of country and district codes to their top tag. 3-letter keys are ISO-3 character country codes, 2-letter keys are US-state codes. - :rtype: dict[str, str] + Note: + This method uses the interactive website and is rate-limited. + + Returns: + A mapping of country and district codes to their top tag. + 3-letter keys are ISO-3 character country codes, 2-letter keys are US-state codes. + + Raises: + requests.HTTPError: The backing HTTP GET operation failed. """ resp = self._get(__base_url__ + "static/tagmap.html") resp.raise_for_status() @@ -328,10 +379,17 @@ def tag_map(self) -> dict[str, str]: def tagmap(self) -> list[TopTag]: """Retrieve list of top 100 global tags. - This method is deprecated in favor of the top_tags() method. + Warning: + This method is deprecated. - :return: List of top 100 tags, globally. - :rtype: list[TopTag] + Warn: + This method is deprecated in favor of the top_tags() method. + + Returns: + A list of the current top 100 tags, globally. + + Raises: + requests.HTTPError: The backing HTTP GET operation failed. """ warnings.warn( "The rule34Py.tagmap() method is scheduled for deprecation in a future release. If you want to retrieve the Global Top-100 tags list, use the rule34Py.top_tags() method. If you want to retrieve the tag map data points, use the rule34Py.tag_map() method (with an underscore.). See `https://github.com/b3yc0d3/rule34Py/tree/master/docs#functions` for more information.", @@ -342,8 +400,11 @@ def tagmap(self) -> list[TopTag]: def top_tags(self) -> list[TopTag]: """Retrieve list of top 100 global tags. - :return: List of top 100 tags, globally. - :rtype: list[TopTag] + Returns: + A list of the current top 100 tags, globally. + + Raises: + requests.HTTPError: The backing HTTP GET operation failed. """ response = self._get(API_URLS.TOPMAP.value) response.raise_for_status() @@ -353,8 +414,14 @@ def top_tags(self) -> list[TopTag]: def version(self) -> str: """Rule34Py version. - :return: Version of rule34py. - :rtype: str + Warning: + This method is deprecated. + + Warns: + This method is deprecated in favor of rule34Py.version. + + Returns: + The version string of the rule34Py package. """ warnings.warn( "This method is scheduled for deprecation in a future release of rule34Py. Use `rule34Py.version` instead.", From 73ade39a185c827b34ad7baf9dd31d843133c18b Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 3 May 2025 16:11:08 -0400 Subject: [PATCH 04/31] rule34Py: update package docstring Signed-off-by: Riparian Commit --- rule34Py/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rule34Py/__init__.py b/rule34Py/__init__.py index 0b19ab3..55ec858 100644 --- a/rule34Py/__init__.py +++ b/rule34Py/__init__.py @@ -1,4 +1,4 @@ -"""""" +"""Python api wrapper for rule34.xxx""" """ rule34Py - Python api wrapper for rule34.xxx From f9ef360c28114f1006e6ed2afaf333e1df5ceab1 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 3 May 2025 16:13:44 -0400 Subject: [PATCH 05/31] docs: remove old README document The current canonical project documentation is a README.rst file that is rather out of date, and has been replaced by the sphinx-based documentation. Remove the old file. Signed-off-by: Riparian Commit --- docs/README.md | 566 ------------------------------------------------- 1 file changed, 566 deletions(-) delete mode 100644 docs/README.md diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index af4ed45..0000000 --- a/docs/README.md +++ /dev/null @@ -1,566 +0,0 @@ -# Rule34Py Documentation - -Last edited at `31-12-2024` by [ripariancommit](https://github.com/ripariancommit) and [b3yc0d3](https://github.com/b3yc0d3) - -Parameters that are prefixed by an `?` are optional. - -## rule34Py -rule34.xxx API wrapper. - -### Functions -
-__init__() - rule34.xxx API wrapper - -rule34.xxx API wrapper. - -
- -
- -get_comments(post_id: int) -> list[PostComment] - Retrieve comments of post by its id - -Retrieve comments of post by its id. - -##### Parameters -| Parameter | Type | Description | -|:----------|:------|:------------| -| `post_id` | `int` | Posts id. | - -##### Returns -**`list[PostComment]`** - List of [PostComment](#postcomment)s. -
- -
-get_pool(pool_id: int, ?fast: bool) -> list[Post|int] - Retrieve pool by its id - -Retrieve pool by its id. - -**Be aware that if "fast" is set to False, it may takes longer.** - -##### Parameters -| Parameter | Type | Description | -|:----------|:-------|:-----------------------------------------------------------------------------------------| -| `pool_id` | `int` | Pools id. | -| `fast` | `bool` | Fast "mode", if set to true only a list of post ids will be returned. (default *false*). | - -##### Returns -**`list[Post|int]`** - List of [post](#post) objects or post ids if `fast` is set to true. -
- -
-get_post(post_id: int) -> Post - Get post by its id - -Get post by its id. - -##### Parameters -| Parameter | Type | Description | -|:----------|:------|:------------| -| `post_id` | `int` | Id of post. | - -##### Returns -**`Post`** - [Post](#post) object. -
- -
-icame() -> list[ICame] - Retrieve list of top 100 iCame list - -Retrieve list of top 100 iCame list. - -##### Returns -**`list[ICame]`** - List of [iCame](#icame) objects. -
- -
-random_post(?tags: list) -> Post - Get a random post - -Get a random post. - -##### Parameters -| Parameter | Type | Description | -|:----------|:------------|:----------------------------------------------------------------------| -| `tags` | `list[str]` | Tag list to search. If none, post will be used regardless of it tags. | - -##### Returns -**`Post`** - [Post](#post) object. -
- -
-search(self, tags: list[str], ?page_id: int, ?limit: int, ?deleted: bool) -> list[Post] - Search for posts - -Search for posts - -##### Parameters -| Parameter | Type | Description | -|:-------------------|:------------|:---------------------------------------------------------------| -| `tags` | `list[str]` | List of tags. | -| `page_id` | `int` | Page number/id. | -| `limit` | `init` | Limit for posts returned per page (max. 1000). | - -##### Returns -**`list[Post]`** - List of [Post](#post) objects for matching posts. -
- -
-iter_search(self, tags: list[str], max_results: (int | None)) -> Iterator[Post] - Search for posts - -Search for posts, iterate through results, one element at a time. - -##### Parameters -| Parameter | Type | Description | -|:-------------------|:------------ |:---------------------------------------------------------------| -| `tags` | `list[str]` | List of tags. | -| `max_results` | `int | None` | The maximum number of results to return before ending the iteration.
If 'None', then iteration will continue until the end of the results. Defaults to 'None'. | - -##### Returns -**`Iterator[Post]`** - Iterator of [Post](#post) objects for matching posts. -
- -
-tag_map() -> dict[str, str] - Retrieve the tag map points - -Retrieve the tag map points. - -##### Returns -**`dict[str, str]`** - A mapping of country and district codes to their top tag. 3-letter keys are ISO-3 character country codes, 2-letter keys are US-state codes. -
- -
-tagmap() -> list[TopTag] - Retrieve list of top 100 global tags - -Retrieve list of top 100 global tags. - -**This method is deprecated in favor of the top_tags() method.** - -##### Returns -**`list[TopTag]`** - List of global top 100 tags. See [TopTag](#toptag). -
- -
-top_tags() -> list[TopTag] - Retrieve list of top 100 global tags - -Retrieve list of top 100 global tags. - -##### Returns -**`list[TopTag]`** - List of top 100 tags, globally. See [TopTag](#toptag). -
- -
-version -> str - Rule34Py version - -Rule34Py version. - -##### Returns -**`str`** - Version of rule34py. -
- -## Post - -### Functions - -
-__init__(id: int, hash: str, score: int, size: list[int, int], image: str, preview: str, sample: str, owner: str, tags: list[str], file_type: str, directory: int, change: int) - Post Class - -Post Class - -##### Parameters -| Parameter | Type | -|:------------|:-----------------| -| `id` | `int` | -| `hash` | `str` | -| `score` | `int` | -| `size` | `list[int, int]` | -| `image` | `str` | -| `preview` | `str` | -| `sample` | `str` | -| `owner` | `str` | -| `tags` | `list[str]` | -| `file_type` | `str` | -| `directory` | `int` | -| `change` | `int` | - -
- -
-from_json(json: str) -> Post - Create Post from json data - -Create Post from json data. - -##### Parameters -| Parameter | Type | Description | -|:----------|:------|:------------------------------------| -| `json` | `str` | Json data from rule34.xxx REST Api. | - -##### Returns -**`Post`** - Post object. -
- -### Properties -
-change -> int - Post last update time - -Post last update time. - -Retrieve the timestamp indicating the last update/change of the post, as unix time epoch. - -##### Returns -**`int`** - UNIX Timestamp representing the post's last update/change. -
- -
-content_type -> str - Get type of post data (e.g. gif, image etc.) - -Get type of post data (e.g. gif, image etc.). - -Represents the value of `file_type` from the api. - -##### Returns -**`str`** - A string indicating the type of the post. -**Possible values** -- `image` Post is of an image. -- `gif` Post is of an animation (gif, webm, or other format). -- `video` Post is of a video. -
- -
-directory -> int - Get directory id of post - -Get directory id of post. - -##### Returns -**`int`** - Unknown Data. -
- -
-hash -> str - Obtain the unique hash of post - -Obtain the unique hash of post. - -##### Returns -**`str`** - The hash associated with the post. -
- -
-id -> int - Obtain the unique identifier of post - -Obtain the unique identifier of post. - -##### Returns -**`int`** - The unique identifier associated with the post. -
- -
-image -> str - Get the image of the post - -Get the image of the post. - -##### Returns -**`str`** - Image url for the post. -
- -
-owner -> str - Get username of post creator - -Get username of post creator. - -##### Returns -**`str`** - Username of post creator. -
- -
-rating -> str - Retrieve the content rating of the post - -Retrieve the content rating of the post. - -##### Returns -**`str`** - A string representing the post's rating. -**Possible Values:** -- `e` Explicit -- `s` Safe -- `q` Questionable -
- -
-sample -> str - Get the sample image/video of the post - -Get the sample image/video of the post. - -##### Returns -**`str`** - Sample data url for the post. -
- -
-score -> int - Get the score of post - -Get the score of post. - -##### Returns -**`int`** - The post's score. -
- -
-size -> list[int, int] - Retrieve actual size of post's image - -Retrieve actual size of post's image. - -##### Returns -**`list[int, int]`** - List of [width, height] representing the image dimensions. -
- -
-tags -> list[str] - Get tags of post - -Get tags of post. - -##### Returns -**`list[str]`** - List of posts tags. -
- -
-thumbnail -> str - Get the thumbnail image of the post - -Get the thumbnail image of the post. - -##### Returns -**`str`** - Thumbnail url for the post. -
- -
-video -> str - Get the video of the post - -Get the video of the post. - -##### Returns -**`str`** - Video url for the post. -
- -## PostComment -PostComment - -Class to represent a comment under a post. - -### Functions - -
-__init__(id: int, owner_id: int, body: str, post_id: int, creation: str) - PostComment Class - -PostComment Class - -##### Parameters -| Parameter | Type | -|:-----------|:------| -| `id` | `int` | -| `owner_id` | `int` | -| `body` | `str` | -| `post_id` | `int` | -| `creation` | `int` | -
- -### Properties - -
-author_id -> int - Id of the comments author - -Id of the comments author. - -##### Returns -**`int`** - Id of comment author. -
- -
-body -> str - Content of the comment - -Content of the comment. - -##### Returns -**`str`** - Content of the comment. -
- -
-creation -> int - Timestamp when the comment was created [ATTENTION] - -Timestamp when the comment was created. - -**Important: currently rule34.xxx api returns the time *when your -api request was made* and _not_ the time when the comment was created.** - -##### Returns -**`int`** - Timestamp when comment was created. -
- -
-id -> int - Comments unique id - -Comments unique id. - -##### Returns -**`int`** - Comments unique id. -
- -
-post_id -> int - Id of post, to whom the comment belongs - -Id of post, to whom the comment belongs. - -##### Returns -**`int`** - Id of parent post. -
- -## Stat -Stat Class. - -Generic Stat class, mostly used to Top nth lists. - -### Functions - -
-__init__(place: int, amount: int, username: str) - Stat class - -Stat class. - -##### Parameters -| Parameter | Type | -|:-----------|:------| -| `place` | `int` | -| `amount` | `int` | -| `username` | `str` | -
- -### Properties - -
-amount -> int - Get amount/count of it - -Get amount/count of it. - -##### Returns -**`int`** - Amount of something related to this stat. -
- -
-place -> int - Get index/positional place of the stat - -Get index/positional place of the stat. - -##### Returns -**`int`** - Positional index. -
- -
-username -> str - Get username or name of character related to this stat - -Get username or name of character related to this stat. - -##### Returns -**`str`** - Related username / name of a character to this stat. -
- -## TopTag -TopTag Class. - -### Functions - -
-__init__(rank: int, tagname: str, percentage: int) - TopTag Class - -TopTag Class. - -##### Parameters -| Parameter | Type | -|:-------------|:------| -| `rank` | `int` | -| `tagname` | `str` | -| `percentage` | `int` | -
- -
-__from_dict(json: dict) -> TopTag - Create TopTag class from JSON data - -Create TopTag class from JSON data. - -##### Parameters -| Parameter | Type | Description | -|:----------|:-------|:------------------------------------| -| `json` | `dict` | JSON data from rule34.xxx REST Api. | - -##### Returns -**`TopTag`** - TopTag object. -
- -### Properties - -
-percentage -> float - Get tags percentage in use - -Get tags percentage in use. - -##### Returns -**`int`** - Tags usage as percentage value. -
- -
-rank -> int - Get tags rank - -Get tags rank. - -##### Returns -**`int`** - Get rank of the tag. -
- -
-tagname -> str - Get tags name - -Get tags name. - -##### Returns -**`str`** - Get name of the tag. -
- -## ICame -ICame Class. - -ICame chart item. - -### Functions - -
-__init__(character_name: str, count: int) - ICame Class - -ICame Class. - -iCame chart item. - -##### Parameters -| Parameter | Type | -|:-----------------|:------| -| `character_name` | `str` | -| `count` | `int` | -
- -### Properties - -
-character_name -> str - Get name of character - -Get name of character. - -##### Returns -**`str`** - Name of character. -
- -
-count -> int - Get count of how often people came on the character - -Get count of how often people came on the character. - -##### Returns -**`int`** - Cum count. -
- -
-tag_url -> str - Get url of tag - -Get url of tag. - -##### Returns -**`str`** - Url of tag. -
From 3bd837ea06ab0adfa4f2a1fcd156194b86ded86d Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 3 May 2025 17:06:43 -0400 Subject: [PATCH 06/31] docs: add downloader tutorial Convert the previous downloader example into a piece of tutorial documentation. Signed-off-by: Riparian Commit --- docs/index.rst | 1 + docs/tutorials/downloader.py | 19 +++++++++++ docs/tutorials/downloader.rst | 63 +++++++++++++++++++++++++++++++++++ docs/tutorials/index.rst | 7 ++++ examples/README.md | 5 --- examples/downloader.py | 33 ------------------ 6 files changed, 90 insertions(+), 38 deletions(-) create mode 100644 docs/tutorials/downloader.py create mode 100644 docs/tutorials/downloader.rst create mode 100644 docs/tutorials/index.rst delete mode 100644 examples/README.md delete mode 100644 examples/downloader.py diff --git a/docs/index.rst b/docs/index.rst index 0f26422..06a098a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,3 +9,4 @@ Welcome to the rule34Py module documentation! :caption: Sections: api/index + tutorials/index diff --git a/docs/tutorials/downloader.py b/docs/tutorials/downloader.py new file mode 100644 index 0000000..cc97519 --- /dev/null +++ b/docs/tutorials/downloader.py @@ -0,0 +1,19 @@ +from rule34Py import rule34Py + +client = rule34Py() + +TAGS = ["neko", "sort:score", "-video"] + +results = client.search(tags=TAGS) + +from pathlib import Path +import requests + +DOWNLOAD_LIMIT = 3 + +for result in results[0:DOWNLOAD_LIMIT]: + print(f"Downloading post {result.id} ({result.image}).") + with open(Path(result.image).name, "wb") as fp_output: + resp = requests.get(result.image) + resp.raise_for_status() + fp_output.write(resp.content) diff --git a/docs/tutorials/downloader.rst b/docs/tutorials/downloader.rst new file mode 100644 index 0000000..31259f2 --- /dev/null +++ b/docs/tutorials/downloader.rst @@ -0,0 +1,63 @@ +=============================== +A Simple Rule34 Post Downloader +=============================== + +This tutorial walks you through creating a simple script that searches for and downloads a selection of posts from the `rule34.xxx `_ website using the rule34Py module. + + +1. Install python dependencies from PyPI, using PIP (or your python package manager of choice). + +.. code-block:: bash + + python -m pip install rule34Py + python -m pip install requests + +2. Import the ``rule34Py`` module and instantiate a ``rule34Py`` client object. + +The client brokers interactions with the public Rule34.xxx API endpoints. +It will transparently use the correct endpoint for whatever operation you request. +Data returned by the API will be marshalled into native python data types. + + +.. literalinclude:: downloader.py + :language: python + :lineno-start: 1 + :lines: 1-3 + +3. Use the ``rule34Py.search()`` method to search your tags. + +The ``search()`` method accepts a list of tags. +You can use the same tag syntaxes that are supported on the interactive site's `Searching Cheatsheet `_. + +.. literalinclude:: downloader.py + :language: python + :lineno-start: 5 + :lines: 5-7 + +.. important:: + + In this example, we are excluding posts tagged "video", so that we do not have to handle them specially during the download step. + +.. note:: + + By default, the ``search()`` method will return the first 1000 search results. + You can change this behavior by setting the ``limit`` method parameter. For this example, we will only download the first 3 results, and ignore the remainder. + +4. Download the images and save them to your local disk. + +.. literalinclude:: downloader.py + :language: python + :lineno-start: 9 + :lines: 9- + + +-------------- +Example Script +-------------- + +The complete, example script might look like this. + +.. literalinclude:: downloader.py + :language: python + :linenos: + :name: downloader_py diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst new file mode 100644 index 0000000..3ceed14 --- /dev/null +++ b/docs/tutorials/index.rst @@ -0,0 +1,7 @@ +Tutorials +========= + +.. toctree:: + :maxdepth: 1 + + downloader diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index ee84b0e..0000000 --- a/examples/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Examples -Here you can find some example code for rule34Py. - -## download.py -A simple example script for downloading images/videos \ No newline at end of file diff --git a/examples/downloader.py b/examples/downloader.py deleted file mode 100644 index 5d0d189..0000000 --- a/examples/downloader.py +++ /dev/null @@ -1,33 +0,0 @@ -import requests # request img from web -import shutil # save img locally -import os # just for the file extension in line 24 - -from rule34Py import rule34Py -r34Py = rule34Py() -search = r34Py.search(["nekopara"], limit=3) - -def download(url, file_name): - res = requests.get(url, stream = True) - - # rule34.xxx apis will always return a status - # of 200 (which kinda sucks) - if res.status_code == 200: - - # open a file to write to - with open(file_name,'wb') as f: - - # write the raw body data of the requests - # response to that file - shutil.copyfileobj(res.raw, f) - - print('Image successfully Downloaded: ',file_name) - else: - print('Image Couldn\'t be retrieved') - - - -for result in search: - filename, file_extension = os.path.splitext(result.image) - - # call function to start downloading - download(result.image, str(result.id) + file_extension) \ No newline at end of file From 2859a74f89c63c10dc0e643e33ae5124a9d1417d Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Wed, 14 May 2025 18:28:21 -0400 Subject: [PATCH 07/31] pyproject.toml: add documentation dependencies Add python dependencies necessary to build the project documentation. Signed-off-by: Riparian Commit --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 72c0698..f91f20f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,3 +51,7 @@ test = [ "pytest >= 8.3.2", "responses >= 0.25.3", ] +docs = [ + "sphinx >= 8.2.3", + "sphinx-rtd-theme >= 3.0.2", +] From 3eba46ef0d7f5abbe4f164f1800fad6ffdb6491e Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Wed, 14 May 2025 18:28:55 -0400 Subject: [PATCH 08/31] .github: add pr check workflow Add a github workflow to build the documentation on Pull Requests, so that we can confirm the quality of the changes. Signed-off-by: Riparian Commit --- .github/actions/build-project/action.yml | 21 +++++++++++++++++++ .github/workflows/pr-checks.yml | 26 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 .github/actions/build-project/action.yml create mode 100644 .github/workflows/pr-checks.yml diff --git a/.github/actions/build-project/action.yml b/.github/actions/build-project/action.yml new file mode 100644 index 0000000..69aee02 --- /dev/null +++ b/.github/actions/build-project/action.yml @@ -0,0 +1,21 @@ +name: Build the project +description: Installs project dependencies and builds the project + +inputs: + latest_python: + default: false + +runs: + using: "composite" + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ">=3.5" + check-latest: ${{ inputs.latest_python }} + + - name: Build documentation + shell: bash + run: | + python3 -m pip install .[docs] + make html diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..97e1fd4 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,26 @@ +name: PR Checks + +on: + pull_request: + branches: + - "**" + +jobs: + build-linux: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build the project + uses: ./.github/actions/build-project + with: + latest_python: true # always use the latest python in PR checks + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-${{ github.run_id }}-${{ github.run_number }} + path: ./build + retention-days: 14 From ad92ff1ac1c618e3d1cdc15261c2240dd7f8d8d6 Mon Sep 17 00:00:00 2001 From: ripariancommit <37054787+ripariancommit@users.noreply.github.com> Date: Wed, 14 May 2025 18:33:57 -0400 Subject: [PATCH 09/31] .github: add windows pr-check job Add a job to the PR Checks workflow which confirms that the project builds on Windows runners. This workflow does not generate artifacts because they should be the same as the linux run. Signed-off-by: Riparian Commit --- .github/workflows/pr-checks.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 97e1fd4..88eb727 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -24,3 +24,14 @@ jobs: name: build-${{ github.run_id }}-${{ github.run_number }} path: ./build retention-days: 14 + + build-windows: + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build the project + uses: ./.github/actions/build-project + with: + latest_python: true # always use the latest python in PR checks From 0bb1b25848a1235d8db443025a0a4c1a1341271e Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Wed, 14 May 2025 19:30:19 -0400 Subject: [PATCH 10/31] Add project Makefile Add a simple Makefile that comprehends how to build the project wheel, source distribution, and HTML docs; and how to run the unit tests and clean the project. Update the PR Checks to use the Makefile. Signed-off-by: Riparian Commit --- Makefile | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cc7a6d9 --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ + +.DEFAULT_GOAL = all + +PROJECT = rule34py +VERSION = $(shell git describe --tags) + +PYTHON3 ?= python3 +PYTHON_BUILD = $(PYTHON3) -m build +SPHINX_BUILD = $(PYTHON3) -m sphinx +PYTEST = $(PYTHON3) -m pytest + +builddir ?= build +srcdir = rule34Py + +# Installation Directories +prefix ?= /usr/local + +docdir ?= $(prefix)/share/doc/$(project) +htmldir ?= $(docdir)/html + + +# REAL TARGETS # +################ + + +# PHONY TARGETS # +################# + +all : html + $(PYTHON3) -m build --wheel --outdir $(builddir) --verbose +.PHONY : all + + +check : + PYTHONPATH=$(srcdir)/.. $(PYTEST) tests/unit/ +.PHONY : check + + +clean : mostlyclean + find ./ -depth -path '**/.pytest_cache*' -print -delete + find ./ -depth -path '**/__pycache__*' -print -delete +.PHONY : clean + + +dist : + $(PYTHON_BUILD) --sdist --outdir $(builddir) +.PHONY : dist + + +distclean : clean +.PHONY : distclean + + +html : + $(SPHINX_BUILD) --builder html docs $(builddir)/html +.PHONY : html + + +mostlyclean : + rm -rf $(builddir) + find ./ -depth -path '**/rule34Py.egg-info*' -print -delete +.PHONY : mostlyclean From ad26e32eee960a2cb46c46b12b5716ce595ea5ee Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Wed, 14 May 2025 20:10:11 -0400 Subject: [PATCH 11/31] .github: add GH pages workflow Add a workflow to build and publish the project documentation to Github Pages, on each push to the master ref. Signed-off-by: Riparian Commit --- .github/workflows/gh-pages.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/gh-pages.yml diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml new file mode 100644 index 0000000..03d6f10 --- /dev/null +++ b/.github/workflows/gh-pages.yml @@ -0,0 +1,29 @@ +name: Publish GH Pages + +on: + push: + branches: + - master + - dev/docs + +permissions: + contents: write + pages: write + id-token: write + +jobs: + build-docs: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build the project + uses: ./.github/actions/build-project + + - name: Deploy to Github Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./build/html From 4f1035970f2d7fffb09b0ee02eefcc5a81619800 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Wed, 14 May 2025 20:59:34 -0400 Subject: [PATCH 12/31] rule34Py: document the post module Revitalize the module and class documentation for the Post class. This commit contains no intentional changes to functionality. Signed-off-by: Riparian Commit --- docs/api/rule34Py/post.rst | 2 - rule34Py/post.py | 223 +++++++++++++++++++------------------ 2 files changed, 112 insertions(+), 113 deletions(-) diff --git a/docs/api/rule34Py/post.rst b/docs/api/rule34Py/post.rst index 774bfc8..872494e 100644 --- a/docs/api/rule34Py/post.rst +++ b/docs/api/rule34Py/post.rst @@ -3,5 +3,3 @@ rule34Py.post .. automodule:: rule34Py.post :members: - :show-inheritance: - :undoc-members: diff --git a/rule34Py/post.py b/rule34Py/post.py index 0361653..1e32873 100644 --- a/rule34Py/post.py +++ b/rule34Py/post.py @@ -1,31 +1,41 @@ -"""""" -""" -rule34Py - Python api wrapper for rule34.xxx +# rule34Py - Python api wrapper for rule34.xxx +# +# Copyright (C) 2022-2024 b3yc0d3 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""A module for representing Rule34 Post objects.""" + +# TODO: Restructure internal variable names -Copyright (C) 2022-2024 b3yc0d3 -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" +class Post: + """A Rule34 Post object. -""" -TODO: Restructure internal variable names -""" + This class is mostly a pythonic representation of the Rule34.xxx JSON API post object. + """ -class Post: - @staticmethod - def from_json(json): + def from_json(json: str) -> "Post": + """Initialize a Post object from an ``api.rule34.xxx`` JSON Post element. + + Args: + json: The JSON string to parse. + + Returns: + The rule34Py.Post representation of the object. + """ pFileUrl = json["file_url"] pHash = json["hash"] pId = json["id"] @@ -34,7 +44,7 @@ def from_json(json): pOwner = json["owner"] pTags = json["tags"].split(" ") preview = json["preview_url"] - sample = json["sample_url"] # thumbnail + sample = json["sample_url"] change = json["change"] directory = json["directory"] @@ -42,7 +52,26 @@ def from_json(json): return Post(pId, pHash, pScore, pSize, pFileUrl, preview, sample, pOwner, pTags, img_type, directory, change) - def __init__(self, id: int, hash: str, score: int, size: list, image: str, preview: str, sample: str, owner: str, tags: list, file_type: str, directory: int, change: int): + def __init__(self, id: int, hash: str, score: int, size: list, image: str, preview: str, sample: str, owner: str, tags: list, file_type: str, directory: int, change: int) -> "Post": + """Create a new Post object. + + Args: + id: The Post's Rule34 ID number. + hash: The Post's Rule34 object hash. + score: The Post's voted user score. + size: A two-element list of image dimensions. [width, height] + image: URL to the Post's rule34 image server location. + preview: A URL to the Post's preview image. + sample: A URL to the Post's sample image. + owner: The user who owns the Post. + tags: A list of tags to assign to the Post. + file_type: The Post's image file type. One of ["image", "gif", "video"]. + directory: The Post's image directory on the Rule34 image server. + change: The Post's change ID. + + Returns: + Post: A reference to the new Post object. + """ self._file_type = file_type self._video = "" self._image = "" @@ -55,11 +84,11 @@ def __init__(self, id: int, hash: str, score: int, size: list, image: str, previ self._id = id self._hash = hash self._score = score - self._size = size # > [WIDTH:int, HEIGHT:int] - self._preview = preview # thumbnail + self._size = size + self._preview = preview self._sample = sample self._owner = owner - self._tags = tags # > [TAG:str, TAG:str,...] + self._tags = tags self._directory = directory self._change = change self._rating = None @@ -67,162 +96,134 @@ def __init__(self, id: int, hash: str, score: int, size: list, image: str, previ @property def id(self) -> int: - """ - Obtain the unique identifier of post. + """The unique numeric identifier of post. - :return: The unique identifier associated with the post. - :rtype: int + Returns: + The unique numeric identifier associated with the post. """ return self._id @property def hash(self) -> str: - """ - Obtain the unique hash of post. + """The unique rule34 hash of post. - :return: The hash associated with the post. - :rtype: str + Returns: + The hash associated with the post. """ return self._hash @property def score(self) -> int: - """ - Get the score of post. + """Get the score of post. - :return: The post's score. - :rtype: int + Returns: + The post's score. """ return self._score @property - def size(self) -> list: - """ - Retrieve actual size of post's image. + def size(self) -> list[int]: + """The Post's graphical dimension size. - :return: List of [width, height] representing the image dimensions. - :rtype: list[int, int] + Returns: + A list of the image's graphical dimensions, as [width, height]. """ return self._size @property def rating(self) -> str: - """ - Retrieve the content rating of the post. + """The Post's content objectionability rating. - :return: A string representing the post's rating. - - 'e' Explicit - - 's' Safe - - 'q' Questionable - :rtype: str + Returns: + A string representing the post's rating. + - ``e`` = Explicit + - ``s`` = Safe + - ``q`` = Questionable """ return self._rating @property def image(self) -> str: - """ - Get the image of the post. + """The Post's full-resolution image URL. - :return: Image url for the post. - :rtype: str + Returns: + A string URL to the full-resolution image of the Post. """ - return self._image @property def video(self) -> str: - """ - Get the video of the post. + """The Post's full-resolution video URL. - :return: Video url for the post. - :rtype: str + Returns: + A string URL to the full-resolution video URL. """ - return self._video @property def thumbnail(self) -> str: - """ - Get the thumbnail image of the post. + """The Post's thumbnail image URL. - :return: Thumbnail url for the post. - :rtype: str + Returns: + A string URL to the Post's thumbnail image. """ - return self._preview @property def sample(self) -> str: - """ - Get the sample image/video of the post. + """The Post's sample image URL. - :return: Sample data url for the post. - :rtype: str + Returns: + A string URL to the sample image. """ - return self._sample @property def owner(self) -> str: - """ - Get username of post creator. - - :return: Username of post creator. - :rtype: str - """ + """The Post's creator username. + Returns: + The string username of the Post creator. + """ return self._owner @property def tags(self) -> list: - """ - Get tags of post. + """The Post's tags. - :return: List of posts tags - :rtype: list[str] + Returns: + A List of the Post's tags. """ - return self._tags @property def content_type(self) -> str: - """ - Get type of post data (e.g. gif, image etc.) + """The Post's content type. - Represents the value of ``file_type`` from the api. - - :return: A string indicating the type of the post. - Possible values: - - - 'image': Post is of an image. - - 'gif': Post is of an animation (gif, webm, or other format). - - 'video': Post is of a video. - :rtype: str + Represents the value of ``file_type`` from the JSON. + + Returns: + A string indicating the Post's content type. + - ``image``: A static image. + - ``gif``: An animated image (gif, webm, or other format). + - ``video``: A video file. """ - return self._file_type - + @property def change(self) -> int: - """ - Post last update time - - Retrieve the timestamp indicating the last update/change of - the post, as unix time epoch. + """The Post's latest update time. - :return: UNIX Timestamp representing the post's last update/change. - :rtype: int + Returns: + An int representing the UNIX timestamp of the Post's latest update/change. """ - return self._change - + @property def directory(self) -> int: - """ - Get directory id of post + """The Post's storage directory id. - :return: Unknown Data - :rtype: int + Returns: + A numeric representation of the Post's storage directory. """ - - return self._directory \ No newline at end of file + return self._directory From c288002e58068587c75163e5589fe600bf922730 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Wed, 14 May 2025 21:17:10 -0400 Subject: [PATCH 13/31] rule34Py: document the api_urls module Signed-off-by: Riparian Commit --- docs/api/rule34Py/api_urls.rst | 2 - rule34Py/api_urls.py | 67 +++++++++++++++++++--------------- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/docs/api/rule34Py/api_urls.rst b/docs/api/rule34Py/api_urls.rst index e034475..3a13cda 100644 --- a/docs/api/rule34Py/api_urls.rst +++ b/docs/api/rule34Py/api_urls.rst @@ -3,5 +3,3 @@ rule34Py.api\_urls .. automodule:: rule34Py.api_urls :members: - :show-inheritance: - :undoc-members: diff --git a/rule34Py/api_urls.py b/rule34Py/api_urls.py index 834dbed..6a7ee75 100644 --- a/rule34Py/api_urls.py +++ b/rule34Py/api_urls.py @@ -1,40 +1,47 @@ -"""""" -""" -rule34Py - Python api wrapper for rule34.xxx +# rule34Py - Python api wrapper for rule34.xxx +# +# Copyright (C) 2022 Tal A. Baskin +# Copyright (C) 2023-2024 b3yc0d3 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""A module containing URL templates for the rule34.xxx websites.""" -Copyright (C) 2022 Tal A. Baskin -Copyright (C) 2023-2024 b3yc0d3 - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" from enum import Enum from rule34Py.__vars__ import __base_url__, __api_url__ + class API_URLS(str, Enum): - """ - Api Urls + """rule34.xxx API endpoint URLs. Internal class used to change easily urls, if they should ever change. """ - - SEARCH = f"{__api_url__}index.php?page=dapi&s=post&q=index&limit={{LIMIT}}&tags={{TAGS}}&json=1" # returns: JSON - COMMENTS = f"{__api_url__}index.php?page=dapi&s=comment&q=index&post_id={{POST_ID}}" # returns: XML - USER_FAVORITES = f"{__api_url__}index.php?page=favorites&s=view&id={{USR_ID}}" # returns: HTML - GET_POST = f"{__api_url__}index.php?page=dapi&s=post&q=index&id={{POST_ID}}&json=1" # returns: JSON - ICAME = f"{__base_url__}index.php?page=icame" # returns: HTML - RANDOM_POST = f"{__base_url__}index.php?page=post&s=random" # returns: HTML - USER_PAGE = f"{__api_url__}index.php?page=account&s=profile&id={{USER_ID}}" # returns: HTML - POOL = f"{__base_url__}index.php?page=pool&s=show&id={{POOL_ID}}" # returns: HTML + #: The JSON search endpoint. + SEARCH = f"{__api_url__}index.php?page=dapi&s=post&q=index&limit={{LIMIT}}&tags={{TAGS}}&json=1" + #: The XML Post comments endpoint. + COMMENTS = f"{__api_url__}index.php?page=dapi&s=comment&q=index&post_id={{POST_ID}}" + #: An HTML User favorites endpoint. + USER_FAVORITES = f"{__api_url__}index.php?page=favorites&s=view&id={{USR_ID}}" + #: The JSON Post endpoint. + GET_POST = f"{__api_url__}index.php?page=dapi&s=post&q=index&id={{POST_ID}}&json=1" + #: The HTML ICAME page URL. + ICAME = f"{__base_url__}index.php?page=icame" + #: The HTML Random post URL. + RANDOM_POST = f"{__base_url__}index.php?page=post&s=random" + #: An HTML User profile URL. + USER_PAGE = f"{__api_url__}index.php?page=account&s=profile&id={{USER_ID}}" + #: An HTML Pool URL. + POOL = f"{__base_url__}index.php?page=pool&s=show&id={{POOL_ID}}" + #: The HTML toptags URL. TOPMAP = f"{__base_url__}index.php?page=toptags" From 5016463f5078ebe49c02b30cdd68230f42367ddf Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Thu, 15 May 2025 18:09:22 -0400 Subject: [PATCH 14/31] rule34Py: document the html module Signed-off-by: Riparian Commit --- docs/api/rule34Py/html.rst | 2 - rule34Py/html.py | 90 ++++++++++++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/docs/api/rule34Py/html.rst b/docs/api/rule34Py/html.rst index f9b105c..a30c777 100644 --- a/docs/api/rule34Py/html.rst +++ b/docs/api/rule34Py/html.rst @@ -3,5 +3,3 @@ rule34Py.html .. automodule:: rule34Py.html :members: - :show-inheritance: - :undoc-members: diff --git a/rule34Py/html.py b/rule34Py/html.py index 04f75bf..1059821 100644 --- a/rule34Py/html.py +++ b/rule34Py/html.py @@ -7,29 +7,44 @@ from bs4 import BeautifulSoup from rule34Py.icame import ICame -from rule34Py.toptag import TopTag from rule34Py.pool import Pool, PoolHistoryEvent +from rule34Py.toptag import TopTag class ICamePage(): """The Rule34 'icame' page. - + + https://rule34.xxx/index.php?page=icame + This class can be instantiated as an object that automatically parses the useful information from the page's html, or used as a static class to parse the page's html directly. """ + #: The top icame results, in descending order. + #: ie. element 0 is the most popular tag. top_chart: list[ICame] = [] - def __init__(self, html: str): + def __init__(self, html: str) -> "ICamePage": + """Create a new ICamePage object. + + Args: + html: The page HTML to parse. + + Returns: + An ICamePage object containing information parsed from the page. + """ self.top_chart = ICamePage.top_chart_from_html(html) @staticmethod def top_chart_from_html(html: str) -> list[ICame]: """Parse the ICame Top 100 chart from the page html. - - :returns: A list of the top 100 ICame characters and their counts. - :rtype: list[ICame] + + Args: + html: The ICame page HTML, as a string. + + Returns: + A list of the top 100 ICame characters and their counts. """ e_doc = BeautifulSoup(html, features="html.parser") @@ -48,12 +63,18 @@ def top_chart_from_html(html: str) -> list[ICame]: class PoolHistoryPage(): - """A Rule34 Pool history page. - """ + """A Rule34 Pool history page.""" @staticmethod def events_from_html(html: str) -> list[PoolHistoryEvent]: - """Parse the history event entries from the page html.""" + """Parse the history event entries from the page html. + + Args: + html: The pool history page HTML to parse. + + Returns: + A list of ``PoolHistoryEvents`` representing the changes in this Pool. + """ e_doc = BeautifulSoup(html, "html.parser") events = [] @@ -78,16 +99,24 @@ def events_from_html(html: str) -> list[PoolHistoryEvent]: class PoolPage(): """A Rule34 post Pool page.""" + #: Regex matcher for the pool's ID. RE_PAGE_ID = re.compile(r"id=(\d+)") @staticmethod def pool_from_html(html: str) -> Pool: - """Generate a Pool object from the page HTML.""" + """Generate a Pool object from the page HTML. + + Args: + html: The pool page HTML, as a string. + + Returns: + A Pool object representing the parsed page information. + """ e_doc = BeautifulSoup(html, "html.parser") e_content = e_doc.find("div", id="content") # The pool html does not explicitly describe the pool ID anywhere, so - # extract it implictly from the pool history link href. + # extract it implicitly from the pool history link href. e_subnavbar = e_doc.find("ul", id="subnavbar") e_history = e_subnavbar.find_all("a")[-1] # last link on the bar assert e_history.text == "History" # check for safety @@ -113,7 +142,6 @@ def pool_from_html(html: str) -> Pool: return pool - class TagMapPage(): """The rule34.xxx/static/tagmap.html page. @@ -122,17 +150,38 @@ class TagMapPage(): to parse the page's html directly. """ + #: Regex matcher for the TagMap page's plotly plot RE_NEWPLOT = re.compile(r"newPlot\((.*)\)", flags=re.S) + #: Regex matcher for each plotly point on the tag map RE_PLOT_POINT = re.compile(r"({[^}]+})", flags=re.S) + #: The JSON keys of the data points embedded in the plotly block MAP_POINT_KEYS = {"locationmode", "locations", "text"} + #: The most popular tag in each country or region code. + #: Formatted as ``[location_code, top_tag]``. map_points: dict[str, str] = {} - def __init__(self, html: str): + def __init__(self, html: str) -> "TagMapPage": + """Create a new TagMapPage from the page's HTML. + + Args: + html: The Tag Map page HTML, as a string. + + Returns: + The parsed TagMapPage, with ``map_points`` extracted. + """ self.map_points = TagMapPage.map_points_from_html(html) @staticmethod def map_points_from_html(html: str) -> dict[str, str]: + """Parse the map data from a Tag Map Page. + + Args: + html: The Tag Map page HTML as a string. + + Returns: + The map data as a dictionary of ``[location_code, top_tag]``. + """ e_doc = BeautifulSoup(html, "html.parser") map_points = {} @@ -162,17 +211,22 @@ class TopTagsPage(): to parse the page's html directly. """ + #: The top-tags ranking list on this page. top_tags: list[TopTag] = [] - def __init__(self, html: str): + def __init__(self, html: str) -> "TopTagsPage": + """Create a new TopTagsPage from the page's HTML.""" self.top_tags = TopTagsPage.top_tags_from_html(html) - + @staticmethod def top_tags_from_html(html: str) -> list[TopTag]: """Parse the "Top 100 tags, global" table from the page. - - :returns: A list of TopTags representing the top 100 chart. - :rtype: list[TopTag] + + Args: + html: The Top Tags page HTML, as a string. + + Returns: + A list of TopTags representing the top 100 chart. """ e_doc = BeautifulSoup(html, features="html.parser") e_rows = e_doc.find("table", class_="server-assigns").find_all("tr") From be82d6e378349b8857daa9c18fc3fa6601843731 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Thu, 15 May 2025 19:28:43 -0400 Subject: [PATCH 15/31] rule34Py: document the icame module Signed-off-by: Riparian Commit --- docs/api/rule34Py/icame.rst | 2 - rule34Py/icame.py | 81 +++++++++++++++---------------------- 2 files changed, 32 insertions(+), 51 deletions(-) diff --git a/docs/api/rule34Py/icame.rst b/docs/api/rule34Py/icame.rst index 3dfabe4..c920164 100644 --- a/docs/api/rule34Py/icame.rst +++ b/docs/api/rule34Py/icame.rst @@ -3,5 +3,3 @@ rule34Py.icame .. automodule:: rule34Py.icame :members: - :show-inheritance: - :undoc-members: diff --git a/rule34Py/icame.py b/rule34Py/icame.py index d4e6c8c..ee79556 100644 --- a/rule34Py/icame.py +++ b/rule34Py/icame.py @@ -1,72 +1,55 @@ -"""""" +# rule34Py - Python api wrapper for rule34.xxx +# +# Copyright (C) 2022-2024 b3yc0d3 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""A module containing content related to the rule34.xxx iCame popularity contest. + +https://rule34.xxx/index.php?page=icame """ -rule34Py - Python api wrapper for rule34.xxx -Copyright (C) 2022-2024 b3yc0d3 - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" class ICame: - """ - ICame chart item + """An iCame contestant. + + Represents an entry on the Rule34.xxx iCame top 100 rankings. """ - def __init__(self, character_name: str, count: int): - """ - iCame chart item. - - :param character_name: Name of the character. - :type character_name: str + def __init__(self, character_name: str, count: int) -> "ICame": + """An iCame contestant. - :param count: Count of how often people came on the character. - :type count: int + Args: + character_name: The name of the character. + May be an empty string, representing posts that have no tagged character. + count: The 'iCame(TM) count'. + A count of how often people came on the character. """ - self._character_name = character_name self._tag_url = "https://rule34.xxx/index.php?page=post&s=list&tags={0}".format(character_name.replace(" ", "_")) self._count = count @property def character_name(self) -> str: - """ - Get name of character. - - :return: Name of character. - :rtype: str - """ - + """The name of the character.""" return self._character_name @property def tag_url(self) -> str: - """ - Get url of tag. - - :return: Url of tag. - :rtype: str - """ - + """The character tag page URL.""" return self._tag_url @property def count(self) -> int: - """ - Get count of how often people came on the character. - - :return: Cum count. - :rtype: int - """ - + """A count of how often people came on the character.""" return self._count From 8f7382b7e53d2cb5038564506f391e0dd8e96c19 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Thu, 15 May 2025 19:35:28 -0400 Subject: [PATCH 16/31] conf.py: stop documenting class __init__() methods Sphinx already does a good-enough job autodocumenting class initialization under the class's heading in the docs. There is no need to override the default value and force explicit documentation. Signed-off-by: Riparian Commit --- docs/conf.py | 1 - rule34Py/rule34.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 4f53216..f73cb48 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -39,7 +39,6 @@ # sphinx.ext.autodoc configuration # autodoc_default_options = { - "special-members": "__init__", # document class __init__() } autodoc_typehints = "both" # Show typehints in the signature and as content of the function or method diff --git a/rule34Py/rule34.py b/rule34Py/rule34.py index 147be7b..d9b0680 100644 --- a/rule34Py/rule34.py +++ b/rule34Py/rule34.py @@ -86,8 +86,7 @@ def __init__(self): def _get(self, *args, **kwargs) -> requests.Response: """Send an HTTP GET request. - This method largely passes its arguments to the requests.get() method, - while also inserting a valid User-Agent. + This method largely passes its arguments to the `requests.session.get() `_ method, while also inserting a valid User-Agent and captcha clearance. Returns: The Response object from the GET request. From 35f14dc97ca37ede6d0eef5e5de59047c277490d Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Thu, 15 May 2025 19:57:25 -0400 Subject: [PATCH 17/31] rule34Py: document the pool module Signed-off-by: Riparian Commit --- docs/api/rule34Py/pool.rst | 2 -- rule34Py/pool.py | 25 +++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/api/rule34Py/pool.rst b/docs/api/rule34Py/pool.rst index 479daf9..c59c2c7 100644 --- a/docs/api/rule34Py/pool.rst +++ b/docs/api/rule34Py/pool.rst @@ -3,5 +3,3 @@ rule34Py.pool .. automodule:: rule34Py.pool :members: - :show-inheritance: - :undoc-members: diff --git a/rule34Py/pool.py b/rule34Py/pool.py index 0b5dbe7..0fd98bb 100644 --- a/rule34Py/pool.py +++ b/rule34Py/pool.py @@ -6,20 +6,41 @@ @dataclass class PoolHistoryEvent(): - """A Rule34 Pool history record.""" + """A Rule34 Pool history record. + Parameters: + date: The datetime of the historical event. + updater_uname: The user name who initiated the event. + post_ids: A list of the Pool posts that were affected by the event. + """ + + #: The datetime of the pool history event. date: datetime + #: The user name who initiated the event. updater_uname: str + + #: A list of the Pool posts that were affected by the event. post_ids: list[int] = field(default_factory=list()) @dataclass class Pool(): - """A collection of Rule34 Posts.""" + """A collection of Rule34 Posts. + + Parameters: + pool_id: The pool's unique numeric ID. + name: The pool's title. + description: A summary description of the pool's contents. + posts: A list of Post ID numbers that are members of the pool. + """ + #: The pool's unique numeric ID. pool_id: int + #: The pool's title. name: str + #: A summary description of the pool's contents. description: str + #: A list of Post ID numbers that are members of the pool. posts: list[int] = field(default_factory = list) From 4900b2b97573c679e8e619ab1da424c084b00df7 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Thu, 15 May 2025 20:27:55 -0400 Subject: [PATCH 18/31] rule34Py: document the post_comment module Signed-off-by: Riparian Commit --- docs/api/rule34Py/post_comment.rst | 2 - rule34Py/post_comment.py | 117 +++++++++-------------------- rule34Py/rule34.py | 3 + 3 files changed, 39 insertions(+), 83 deletions(-) diff --git a/docs/api/rule34Py/post_comment.rst b/docs/api/rule34Py/post_comment.rst index d28f09d..a762413 100644 --- a/docs/api/rule34Py/post_comment.rst +++ b/docs/api/rule34Py/post_comment.rst @@ -3,5 +3,3 @@ rule34Py.post\_comment .. automodule:: rule34Py.post_comment :members: - :show-inheritance: - :undoc-members: diff --git a/rule34Py/post_comment.py b/rule34Py/post_comment.py index 2720e7b..a8ddcee 100644 --- a/rule34Py/post_comment.py +++ b/rule34Py/post_comment.py @@ -1,54 +1,40 @@ -"""""" -""" -rule34Py - Python api wrapper for rule34.xxx +# rule34Py - Python api wrapper for rule34.xxx +# +# Copyright (C) 2022-2024 b3yc0d3 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""A module providing classes representing Rule34 Post comments.""" -Copyright (C) 2022-2024 b3yc0d3 - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" class PostComment: - """ - PostComment - - Class to represent a comment under a post. + """A post comment. + + Args: + id: The comment's numeric ID on Rule34. + owner_id: The numeric ID of the comment author. + body: The comment's body text. + post_id: The numeric ID of the attached post. + creation: The timestamp string of when the post was made. """ def __init__(self, - id: int, - owner_id: int, - body: str, - post_id: int, - creation: str): - """ - Post Comment - - :param id: Comments id. - :type id: int - - :param owner_id: Comment creators id. - :type owner_id: int - - :param body: Content/body of the comment. - :type body: str - - :param post_id: Id of post to whom the comment belongs. - :type post_id: int - - :param creation: Time when the comment was created. - :type creation: str - """ + id: int, + owner_id: int, + body: str, + post_id: int, + creation: str) -> "PostComment": + """Create a new PostComment.""" self._id = id self._owner_id = owner_id self._body = body @@ -57,56 +43,25 @@ def __init__(self, @property def id(self) -> int: - """ - Comments unique id. - - :return: Comments unique id. - :rtype: int - """ + """The comment's unique numeric ID.""" return self._id @property def author_id(self) -> int: - """ - Id of the comments author. - - :return: Id of comment author. - :rtype: int - """ - + """The comment author's unique user ID.""" return self._owner_id @property def body(self) -> str: - """ - Content of the comment. - - :return: Content of the comment. - :rtype: str - """ + """The comment's body text.""" return self._body @property def post_id(self) -> int: - """ - Id of post, to whom the comment belongs. - - :return: Id of parent post. - :rtype: int - """ - + """The numeric ID of the attached post.""" return self._post_id @property def creation(self) -> str: - """ - Timestamp of when the comment was created. - - **Important: currently rule34.xxx api returns the time *when your - api request was made* and _not_ the time when the comment was created.** - - :return: Timestamp when comment was created. - :rtype: str - """ - + """Timestamp string of when the comment was created.""" return self._creation diff --git a/rule34Py/rule34.py b/rule34Py/rule34.py index d9b0680..95f76b8 100644 --- a/rule34Py/rule34.py +++ b/rule34Py/rule34.py @@ -108,6 +108,9 @@ def get_comments(self, post_id: int) -> list[PostComment]: Args: post_id: The Post's ID number. + Error: + Due to a bug in the rule34 site API, the creation timestamp in returned comments are erroneously set to the time that the comment API request is received, not the comments' true creation times. + Returns: List of comments returned from the request. If the post has no comments, an empty list will be returned. From f2f1ebcd653bcaf68c392c430deff0694be568ee Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 17 May 2025 09:52:07 -0400 Subject: [PATCH 19/31] rule34Py: document the toptag module Signed-off-by: Riparian Commit --- docs/api/rule34Py/toptag.rst | 2 - rule34Py/toptag.py | 99 +++++++++++++----------------------- 2 files changed, 35 insertions(+), 66 deletions(-) diff --git a/docs/api/rule34Py/toptag.rst b/docs/api/rule34Py/toptag.rst index c1fab06..5d4ac38 100644 --- a/docs/api/rule34Py/toptag.rst +++ b/docs/api/rule34Py/toptag.rst @@ -3,5 +3,3 @@ rule34Py.toptag .. automodule:: rule34Py.toptag :members: - :show-inheritance: - :undoc-members: diff --git a/rule34Py/toptag.py b/rule34Py/toptag.py index a3bdcf6..3614adc 100644 --- a/rule34Py/toptag.py +++ b/rule34Py/toptag.py @@ -1,84 +1,55 @@ -"""""" +# rule34Py - Python api wrapper for rule34.xxx +# +# Copyright (C) 2022-2024 b3yc0d3 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""A module for interacting with the Rule34 TopTag map. + +https://rule34.xxx/index.php?page=toptags """ -rule34Py - Python api wrapper for rule34.xxx -Copyright (C) 2022-2024 b3yc0d3 - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" class TopTag: - """ - TopTag class + """A TopTag entry. + + Parameters: + rank: The popularity rank of the tag. + tagname: The tag name. + percentage: The percentage of global posts that use the tag. """ def __init__(self, rank: int, tagname: str, percentage: int): - """ - TopTag class. - - :param rank: Rank of the tag. - :type rank: int - - :param tagname: Name of the tag. - :type tagname: str - - :param percentage: Percentage of how often tag is used. - :type percentage: int - """ - + """Create a new TopTag class.""" self._rank = rank self._tagname = tagname self._percentage = percentage - - def __from_dict(json: dict): - """ - Create TopTag class from JSON data. - :return: TopTag object. - :rtype: TopTag - """ + def __from_dict(json: dict): + """Create a TopTag object from JSON data.""" return TopTag(json["rank"], json["tagname"], json["percentage"] * 100) - + @property def rank(self) -> int: - """ - Get tags rank. - - :return: Get rank of the tag. - :rtype: int - """ - + """The popularity rank of the tag.""" return self._rank - + @property def tagname(self) -> str: - """ - Get tags name. - - :return: Get name of the tag. - :rytpe: str - """ - + """The tag name.""" return self._tagname - + @property def percentage(self) -> int: - """ - Get tags percentage in use. - - :return: Tags usage as percentage value. - :rtype: int - """ - - return self._percentage \ No newline at end of file + """The percentage of global posts that use the tag.""" + return self._percentage From e1d93b4e9c27a4bc13a8e1740663b0b627445c09 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 17 May 2025 10:48:16 -0400 Subject: [PATCH 20/31] rule34Py: update module docstrings Signed-off-by: Riparian Commit --- rule34Py/__init__.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/rule34Py/__init__.py b/rule34Py/__init__.py index 55ec858..ab30ca0 100644 --- a/rule34Py/__init__.py +++ b/rule34Py/__init__.py @@ -1,22 +1,21 @@ -"""Python api wrapper for rule34.xxx""" -""" -rule34Py - Python api wrapper for rule34.xxx +# rule34Py - Python api wrapper for rule34.xxx +# +# Copyright (C) 2022-2024 b3yc0d3 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Python api wrapper for rule34.xxx.""" -Copyright (C) 2022-2024 b3yc0d3 - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" from rule34Py.rule34 import rule34Py from rule34Py.icame import ICame From 379034667322a6f77362d46f8e74932a819540fb Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 17 May 2025 10:50:39 -0400 Subject: [PATCH 21/31] rule34Py: update __vars__ docstrings Signed-off-by: Riparian Commit --- rule34Py/__vars__.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/rule34Py/__vars__.py b/rule34Py/__vars__.py index 852b2de..d232804 100644 --- a/rule34Py/__vars__.py +++ b/rule34Py/__vars__.py @@ -1,22 +1,21 @@ -"""""" -""" -rule34Py - Python api wrapper for rule34.xxx +# rule34Py - Python api wrapper for rule34.xxx +# +# Copyright (C) 2022-2024 b3yc0d3 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Define module-scoped, hidden constants and variables.""" -Copyright (C) 2022-2024 b3yc0d3 - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" __version__tuple__ = ("2", "1", "0") __author__ = ("b3yc0d3") From b865bcd688cd32393d1e804b9e725be432e32f9e Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 17 May 2025 11:57:32 -0400 Subject: [PATCH 22/31] Use ruff to lint the project Add a python linter to the Makefile and a new target - lint - that lints the python files for quality. Currently, it only lints for docstring-related issues. Signed-off-by: Riparian Commit --- Makefile | 7 +++++++ README.md | 11 ++++++----- pyproject.toml | 25 +++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index cc7a6d9..57fe352 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ VERSION = $(shell git describe --tags) PYTHON3 ?= python3 PYTHON_BUILD = $(PYTHON3) -m build +RUFF = $(PYTHON3) -m ruff SPHINX_BUILD = $(PYTHON3) -m sphinx PYTEST = $(PYTHON3) -m pytest @@ -39,6 +40,7 @@ check : clean : mostlyclean find ./ -depth -path '**/.pytest_cache*' -print -delete find ./ -depth -path '**/__pycache__*' -print -delete + $(RUFF) clean .PHONY : clean @@ -56,6 +58,11 @@ html : .PHONY : html +lint : + $(RUFF) check $(srcdir) +.PHONY : lint + + mostlyclean : rm -rf $(builddir) find ./ -depth -path '**/rule34Py.egg-info*' -print -delete diff --git a/README.md b/README.md index 2fd2841..8afd64b 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ random_id = r34Py.random_post_id() ## Development Follow these steps to setup everything needed to develop on rule34Py. -Currently this setup guide only shows how it is done on unix-like systems. +Currently this setup guide only shows how it is done on UNIX-like systems. ### Clone This Repository ``` @@ -75,7 +75,7 @@ python -m venv venv source venv/bin/activate ``` -To deactivate the virtual environment type the following in your terminal +To deactivate the virtual environment type the following in your terminal. ``` deactivate ``` @@ -84,7 +84,7 @@ deactivate ``` python3 -m build -pip install -e . +pip install -e .[dev] ``` @@ -96,12 +96,13 @@ See the [`tests/README.md`](./tests/README.md) file for instructions on how to r ### Committing your Changes +- Before committing your changes, run the project **linter** by calling `make lint`. - Branch name should be prefixed with - `fix-` when fixing an bug/error - `feat-` when a feature got added - `chore-` everything else that doesn't fall in the above categories -- The title must be descriptive, what your pull request changes/does. -- Write a breve description of what the pull request does/solves in the commit. +- The title must describe what your pull request changes/does. +- Write a brief description of what the pull request does/solves in the commit. - If your pull request fixes an issue, please mention that issue in the commit title. Example structure of a commit message diff --git a/pyproject.toml b/pyproject.toml index f91f20f..a698c30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" [project.optional-dependencies] +dev = [ + "ruff >= 0.11.10", +] test = [ "pytest >= 8.3.2", "responses >= 0.25.3", @@ -55,3 +58,25 @@ docs = [ "sphinx >= 8.2.3", "sphinx-rtd-theme >= 3.0.2", ] + +[tool.ruff.lint] +preview = true # necessary to enable the pydoclint rules +select = [ + "D", # pydocstyle + "DOC", # pydoclint +# "I", # isort +# "F", # Pyflakes +# "E", # pycodestyle - error +# "N", # pep8-naming +# "PL", # pylint - all +# "RUF", # Ruff-specific rules +] +ignore = [ + # Ignore line lengths; mostly bc. there is no way to ignore only docstring linelengths. + "E501", + # Ignore PEP 8 bad-module-name. 'rule34Py' does not comply, but will not be renamed. + "N999", +] + +[tool.ruff.lint.pydocstyle] +convention = "google" # Use Google-style docstrings. From a4ad6b46dcc4cc454e62dceaf3936abcb6d284ee Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 17 May 2025 11:59:01 -0400 Subject: [PATCH 23/31] rule34Py: lint and fixup docstrings The ruff linter reports several issues with the project docstrings and type hinting. Fix them. Signed-off-by: Riparian Commit --- rule34Py/html.py | 33 ++++++++++++++------------------- rule34Py/post.py | 5 +---- rule34Py/rule34.py | 43 ++++++++++++++++++++----------------------- rule34Py/toptag.py | 8 ++++++-- 4 files changed, 41 insertions(+), 48 deletions(-) diff --git a/rule34Py/html.py b/rule34Py/html.py index 1059821..7a44b9e 100644 --- a/rule34Py/html.py +++ b/rule34Py/html.py @@ -19,21 +19,17 @@ class ICamePage(): This class can be instantiated as an object that automatically parses the useful information from the page's html, or used as a static class to parse the page's html directly. + + Args: + html: The page HTML to parse. """ #: The top icame results, in descending order. #: ie. element 0 is the most popular tag. top_chart: list[ICame] = [] - def __init__(self, html: str) -> "ICamePage": - """Create a new ICamePage object. - - Args: - html: The page HTML to parse. - - Returns: - An ICamePage object containing information parsed from the page. - """ + def __init__(self, html: str): + """Create a new ICamePage object.""" self.top_chart = ICamePage.top_chart_from_html(html) @staticmethod @@ -148,6 +144,9 @@ class TagMapPage(): This class can be instantiated as an object that automatically parses the useful information from the page's html, or used as a static class to parse the page's html directly. + + Args: + html: The Tag Map page HTML, as a string. """ #: Regex matcher for the TagMap page's plotly plot @@ -161,15 +160,8 @@ class TagMapPage(): #: Formatted as ``[location_code, top_tag]``. map_points: dict[str, str] = {} - def __init__(self, html: str) -> "TagMapPage": - """Create a new TagMapPage from the page's HTML. - - Args: - html: The Tag Map page HTML, as a string. - - Returns: - The parsed TagMapPage, with ``map_points`` extracted. - """ + def __init__(self, html: str): + """Create a new TagMapPage from the page's HTML.""" self.map_points = TagMapPage.map_points_from_html(html) @staticmethod @@ -209,12 +201,15 @@ class TopTagsPage(): This class can be instantiated as an object that automatically parses the useful information from the page's html, or used as a static class to parse the page's html directly. + + Args: + html: The Top Tags page HTML, as a string. """ #: The top-tags ranking list on this page. top_tags: list[TopTag] = [] - def __init__(self, html: str) -> "TopTagsPage": + def __init__(self, html: str): """Create a new TopTagsPage from the page's HTML.""" self.top_tags = TopTagsPage.top_tags_from_html(html) diff --git a/rule34Py/post.py b/rule34Py/post.py index 1e32873..6455fcb 100644 --- a/rule34Py/post.py +++ b/rule34Py/post.py @@ -52,7 +52,7 @@ def from_json(json: str) -> "Post": return Post(pId, pHash, pScore, pSize, pFileUrl, preview, sample, pOwner, pTags, img_type, directory, change) - def __init__(self, id: int, hash: str, score: int, size: list, image: str, preview: str, sample: str, owner: str, tags: list, file_type: str, directory: int, change: int) -> "Post": + def __init__(self, id: int, hash: str, score: int, size: list, image: str, preview: str, sample: str, owner: str, tags: list, file_type: str, directory: int, change: int): """Create a new Post object. Args: @@ -68,9 +68,6 @@ def __init__(self, id: int, hash: str, score: int, size: list, image: str, previ file_type: The Post's image file type. One of ["image", "gif", "video"]. directory: The Post's image directory on the Rule34 image server. change: The Post's change ID. - - Returns: - Post: A reference to the new Post object. """ self._file_type = file_type self._video = "" diff --git a/rule34Py/rule34.py b/rule34Py/rule34.py index 95f76b8..af442ca 100644 --- a/rule34Py/rule34.py +++ b/rule34Py/rule34.py @@ -75,11 +75,7 @@ class rule34Py(): user_agent: str = os.environ.get("R34_USER_AGENT", DEFAULT_USER_AGENT) def __init__(self): - """Initialize a new rule34 API client instance. - - Returns: - A new rule34 API client instance. - """ + """Initialize a new rule34 API client instance.""" self.session = requests.session() self.session.mount(__base_url__, self._base_site_rate_limiter) @@ -117,8 +113,7 @@ def get_comments(self, post_id: int) -> list[PostComment]: Raises: requests.HTTPError: The backing HTTP GET operation failed. - """ - + """ # noqa: DOC502 params = [ ["POST_ID", str(post_id)] ] @@ -154,8 +149,7 @@ def get_pool(self, pool_id: int) -> Pool: Raises: requests.HTTPError: The backing HTTP GET operation failed. - """ - + """ # noqa: DOC502 params = [ ["POOL_ID", str(pool_id)] ] @@ -174,7 +168,7 @@ def get_post(self, post_id: int) -> Post | None: Raises: requests.HTTPError: The backing HTTP GET operation failed. - """ + """ # noqa: DOC502 params = [ ["POST_ID", str(post_id)] ] @@ -200,7 +194,7 @@ def icame(self) -> list["ICame"]: Raises: requests.HTTPError: The backing HTTP GET operation failed. - """ + """ # noqa: DOC502 response = self._get(API_URLS.ICAME.value) response.raise_for_status() return ICamePage.top_chart_from_html(response.text) @@ -226,7 +220,7 @@ def iter_search( Raises: requests.HTTPError: The backing HTTP GET operation failed. - """ + """ # noqa: DOC502 page_id = 0 # what page of the search results we're on results_count = 0 # accumulator of how many results have been returned @@ -282,7 +276,7 @@ def random_post(self) -> Post: Raises: requests.HTTPError: The backing HTTP GET operation failed. - """ + """ # noqa: DOC502 return self.get_post(self.random_post_id()) def random_post_id(self) -> int: @@ -294,10 +288,12 @@ def random_post_id(self) -> int: Note: This method uses the interactive website and is rate-limited. + Returns: + A random post ID. + Raises: requests.HTTPError: The backing HTTP GET operation failed. - """ - + """ # noqa: DOC502 response = self._get(API_URLS.RANDOM_POST.value) response.raise_for_status() parsed = urlparse.urlparse(response.url) @@ -320,12 +316,13 @@ def search(self, Returns: A list of Post objects, representing the search results. - Raises: + Raises: requests.HTTPError: The backing HTTP GET operation failed. + ValueError: An invalid ``limit`` value was requested. References: - `rule34.xxx API Documentation `_ - """ + """ # noqa: DOC502 if limit < 0 or limit > SEARCH_RESULT_MAX: raise ValueError(f"Search limit must be between 0 and {SEARCH_RESULT_MAX}.") @@ -371,9 +368,9 @@ def tag_map(self) -> dict[str, str]: A mapping of country and district codes to their top tag. 3-letter keys are ISO-3 character country codes, 2-letter keys are US-state codes. - Raises: + Raises: requests.HTTPError: The backing HTTP GET operation failed. - """ + """ # noqa: DOC502 resp = self._get(__base_url__ + "static/tagmap.html") resp.raise_for_status() return TagMapPage.map_points_from_html(resp.text) @@ -390,9 +387,9 @@ def tagmap(self) -> list[TopTag]: Returns: A list of the current top 100 tags, globally. - Raises: + Raises: requests.HTTPError: The backing HTTP GET operation failed. - """ + """ # noqa: DOC502 warnings.warn( "The rule34Py.tagmap() method is scheduled for deprecation in a future release. If you want to retrieve the Global Top-100 tags list, use the rule34Py.top_tags() method. If you want to retrieve the tag map data points, use the rule34Py.tag_map() method (with an underscore.). See `https://github.com/b3yc0d3/rule34Py/tree/master/docs#functions` for more information.", DeprecationWarning, @@ -405,9 +402,9 @@ def top_tags(self) -> list[TopTag]: Returns: A list of the current top 100 tags, globally. - Raises: + Raises: requests.HTTPError: The backing HTTP GET operation failed. - """ + """ # noqa: DOC502 response = self._get(API_URLS.TOPMAP.value) response.raise_for_status() return TopTagsPage.top_tags_from_html(response.text) diff --git a/rule34Py/toptag.py b/rule34Py/toptag.py index 3614adc..43fe450 100644 --- a/rule34Py/toptag.py +++ b/rule34Py/toptag.py @@ -35,8 +35,12 @@ def __init__(self, rank: int, tagname: str, percentage: int): self._tagname = tagname self._percentage = percentage - def __from_dict(json: dict): - """Create a TopTag object from JSON data.""" + def __from_dict(json: dict) -> "TopTag": + """Create a TopTag object from JSON data. + + Returns: + A new TopTag object, populated with values from the ``json`` dictionary. + """ return TopTag(json["rank"], json["tagname"], json["percentage"] * 100) @property From 6eab0cd2fa82530699f09c709120e76a4e170bb3 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 17 May 2025 11:59:56 -0400 Subject: [PATCH 24/31] decs/index: Split the TOC into sections Signed-off-by: Riparian Commit --- docs/index.rst | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 06a098a..0a49a9d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,8 +5,13 @@ rule34Py Welcome to the rule34Py module documentation! .. toctree:: - :maxdepth: 1 - :caption: Sections: + :maxdepth: 2 + :caption: Tutorials - api/index tutorials/index + +.. toctree:: + :maxdepth: 2 + :caption: API Reference + + api/index From ac54f340ab17bf79d703c62156160003a65c0af6 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 17 May 2025 12:03:51 -0400 Subject: [PATCH 25/31] .github: lint PRs during pr-checks During the PR checks, run the project linter. Signed-off-by: Riparian Commit --- .github/workflows/pr-checks.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 88eb727..78540d9 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -25,6 +25,13 @@ jobs: path: ./build retention-days: 14 + - name: Lint the project + shell: bash + run: | + python -m pip install .[dev] + make lint + + build-windows: runs-on: windows-latest steps: From 499bc5610ad8ea966c17c67558d5e37118828176 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 17 May 2025 14:38:22 -0400 Subject: [PATCH 26/31] Move README content into docs/ * Use the README markdown as the basis for the main documentation index. * Move most of the information that is of interest to developers, to an independent developer-guide document in the docs. * Add section descriptions to some of the sections. Signed-off-by: Riparian Commit --- NOTICE.md | 4 +- README.md | 67 ++++--------------- docs/conf.py | 3 +- docs/dev/developer-guide.rst | 120 +++++++++++++++++++++++++++++++++++ docs/dev/index.rst | 11 ++++ docs/dev/license.rst | 9 +++ docs/index.rst | 14 +--- docs/tutorials/index.rst | 2 + pyproject.toml | 1 + 9 files changed, 165 insertions(+), 66 deletions(-) create mode 100644 docs/dev/developer-guide.rst create mode 100644 docs/dev/index.rst create mode 100644 docs/dev/license.rst diff --git a/NOTICE.md b/NOTICE.md index 6fbf269..50fca89 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,8 @@ +# NOTICE + The following third-party software is used in this project: -# Responses +## Responses * Licensed under the Apache License, Version 2.0 * Copyright (c) David Cramer diff --git a/README.md b/README.md index 8afd64b..7d5cc1d 100644 --- a/README.md +++ b/README.md @@ -4,31 +4,25 @@ ![GPL-3.0](https://img.shields.io/github/license/b3yc0d3/rule34Py) [![](https://img.shields.io/pypi/v/rule34Py)](https://pypi.org/project/rule34Py/) [![](https://img.shields.io/pypi/dm/rule34py?color=blue)](https://pypi.org/project/rule34Py/) -Python api wrapper for [rule34.xxx](https://rule34.xxx/). +Python API wrapper for [rule34.xxx](https://rule34.xxx/). -## Getting Started -#### Install it using pip -``` -pip install rule34py -``` +## Installation -#### Building it from Source -``` -git clone https://github.com/b3yc0d3/rule34Py.git -cd rule34Py -python3 -m build +[rule34Py](https://pypi.org/project/rule34Py/) is available directly from the Python Package Index and can be installed via `pip`. + +```bash +pip install rule34Py ``` -## Documentation -You can find the documentation [here](https://github.com/b3yc0d3/rule34Py/tree/master/docs). +Or you can build it from source using this project. +See the [Developer Guide](https://b3yc0d3.github.io/rule34Py/dev/developer-guide.html) for more information. + -> [!NOTE] -> The documentation might move in the future. +## Quickstart -## Code Snippet -```py +```python from rule34Py import rule34Py r34Py = rule34Py() @@ -54,45 +48,12 @@ random = r34Py.random_post() random_id = r34Py.random_post_id() ``` -## Development -Follow these steps to setup everything needed to develop on rule34Py. - -Currently this setup guide only shows how it is done on UNIX-like systems. -### Clone This Repository -``` -git clone https://github.com/b3yc0d3/rule34Py.git - -cd rule34Py - -git checkout develop -``` - -### Setting Up Virtual Python Environment -``` -python -m venv venv - -source venv/bin/activate -``` - -To deactivate the virtual environment type the following in your terminal. -``` -deactivate -``` - -### Install and Build rule34Py in the Virtual Environment -``` -python3 -m build - -pip install -e .[dev] -``` - - -### Running the Test Suite +## Documentation -This project is tested by an organic `pytest` suite, stored under the `:tests/` directory. +This project has extensive [documentation]((https://b3yc0d3.github.io/rule34Py/), hosted on the upstream Github Pages. -See the [`tests/README.md`](./tests/README.md) file for instructions on how to run the test suite. +The documentation includes additional **Tutorials**, **User Guides**, **API Documentation**, and more. ### Committing your Changes diff --git a/docs/conf.py b/docs/conf.py index f73cb48..52ff878 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,11 +27,12 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ + "sphinx_mdinclude", "sphinx.ext.autodoc", "sphinx.ext.autosummary", "sphinx.ext.duration", - "sphinx.ext.napoleon", "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", ] templates_path = ['_templates'] diff --git a/docs/dev/developer-guide.rst b/docs/dev/developer-guide.rst new file mode 100644 index 0000000..ca6cb51 --- /dev/null +++ b/docs/dev/developer-guide.rst @@ -0,0 +1,120 @@ +=============== +Developer Guide +=============== + + +tl;dr +===== + +.. code-block:: bash + + pip install . .[docs] .[dev] .[test] + make all # build wheel + make check # run unit tests + make dist # build sdist + make html # build documentation + make lint # run project linter + + +Project Layout +============== + +* ``.github/`` contains GitHub specific workflows, actions, and templates. + +* ``docs/`` contains project documentation, including the source for the sphinx-generated documentation. + +* ``rule34Py/`` contains the source code for the ``rule34Py`` package. + +* ``tests/`` contains the project test suite. + + * ``tests/unit/`` contains the project unit-tests, which can be run without building or installing the project. + + +Building the project from source +================================ + +This project can be built directly from source using a few host tools. +Supported development environments include: + +- Ubuntu 24.04 +- Windows + +The following instructions are written on the assumption that you are building in a Linux environment. + +#. Start by cloning the repository with ``git``. + + .. code-block:: bash + + git clone https://github.com/b3yc0d3/rule34Py.git + cd rule34Py + +#. (Optional. Recommended.) Setup a python virtual environment. + + .. code-block:: bash + + python -m venv .venv + echo "/.venv" >>.git/info/exclude # excludes your venv from git tracking + source .venv/bin/activate + +#. Install project python dependencies from the ``pyproject.toml`` file. Use ``GNU Make`` to build the project. The ``all`` make target (the default) builds the project's wheels. + + .. code-block:: bash + + pip install . .[dev] + make all + + Build output will be placed in the ``:build/`` directory in your workspace. + +Other ```make`` targets are supported to ``clean`` the project and build other artifacts. +Generally, the project ``Makefile`` honors the `GNU Standard Targets `_ specification. + + +Running the project test suite +============================== + +#. Setup your build environment as in the "Building the project from source" section. + +#. Install the optional test dependencies and invoke their ``make`` target. + + .. code-block:: bash + + pip install .[test] + make test + +For more information, reference the ``:tests/README.md`` file. + + +Building the project documentation +================================== + +#. Setup your build environment as in the "Building the project from source" section. + +#. Install the optional docs dependencies and invoke their ``make`` target. + + .. code-block:: bash + + pip install .[docs] + make html + + Build output will be placed in the ``:build/html/`` directory. + +#. (Optional.) Host the build output locally to test changes. + + .. code-block:: bash + + cd build/html + python -m http.server 8080 + + Python will host the docs site at http://localhost:8080. + + +Integrating this project +======================== + +This project is `licensed <./license.html#license>`_ under the GPLv3 license. +Ensure that your project's licensing strategy is compatible with the GPL. +For more information, reference the GNU reference guide for GPLv3 `here `_. + +All direct dependencies of this project are either GPL licensed, or are licensed more permissively. +But testing code does call the ``reponses`` module, which is licensed under the Apache 2.0 license. +Reference the `:NOTICE.md <./license.html#notice>`_ file for more information. diff --git a/docs/dev/index.rst b/docs/dev/index.rst new file mode 100644 index 0000000..0482e78 --- /dev/null +++ b/docs/dev/index.rst @@ -0,0 +1,11 @@ + +Developer Documentation +======================= + +This section contains documentation primarily useful to developers who would like to integrate or contribute-to this project. + +.. toctree:: + :maxdepth: 1 + + developer-guide + license diff --git a/docs/dev/license.rst b/docs/dev/license.rst new file mode 100644 index 0000000..8911537 --- /dev/null +++ b/docs/dev/license.rst @@ -0,0 +1,9 @@ + +LICENSE +======= + +.. literalinclude:: ../../LICENSE + + +.. NOTICE.md already includes an H1 header. +.. mdinclude:: ../../NOTICE.md diff --git a/docs/index.rst b/docs/index.rst index 0a49a9d..06634e9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,17 +1,9 @@ -======== -rule34Py -======== - -Welcome to the rule34Py module documentation! +.. mdinclude:: ../README.md .. toctree:: :maxdepth: 2 - :caption: Tutorials + :caption: Sections tutorials/index - -.. toctree:: - :maxdepth: 2 - :caption: API Reference - api/index + dev/index diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst index 3ceed14..a9b98a0 100644 --- a/docs/tutorials/index.rst +++ b/docs/tutorials/index.rst @@ -1,6 +1,8 @@ Tutorials ========= +This section contains tutorials for how to use this project. + .. toctree:: :maxdepth: 1 diff --git a/pyproject.toml b/pyproject.toml index a698c30..8f124bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ test = [ ] docs = [ "sphinx >= 8.2.3", + "sphinx-mdinclude >= 0.6.2", "sphinx-rtd-theme >= 3.0.2", ] From 4f57a1ed707a54729244a773f24219ce168eaea0 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 17 May 2025 15:01:39 -0400 Subject: [PATCH 27/31] Move contributing info to documentation Create a new contributing guide within the documentation, and move the contributing information from the README to it. Signed-off-by: Riparian Commit --- README.md | 25 ++--------------------- docs/dev/contributing.rst | 39 ++++++++++++++++++++++++++++++++++++ docs/dev/developer-guide.rst | 2 +- docs/dev/index.rst | 1 + 4 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 docs/dev/contributing.rst diff --git a/README.md b/README.md index 7d5cc1d..11e57b2 100644 --- a/README.md +++ b/README.md @@ -51,27 +51,6 @@ random_id = r34Py.random_post_id() ## Documentation -This project has extensive [documentation]((https://b3yc0d3.github.io/rule34Py/), hosted on the upstream Github Pages. +This project has extensive [documentation]((https://b3yc0d3.github.io/rule34Py/), hosted on the upstream Github Pages. It includes additional **Tutorials**, **User Guides**, **API Documentation**, and more. -The documentation includes additional **Tutorials**, **User Guides**, **API Documentation**, and more. - - -### Committing your Changes -- Before committing your changes, run the project **linter** by calling `make lint`. -- Branch name should be prefixed with - - `fix-` when fixing an bug/error - - `feat-` when a feature got added - - `chore-` everything else that doesn't fall in the above categories -- The title must describe what your pull request changes/does. -- Write a brief description of what the pull request does/solves in the commit. -- If your pull request fixes an issue, please mention that issue in the commit title. - -Example structure of a commit message -``` -here goes the title of the commit - -Here goes the description -``` - -The title shall not be longer then 50 characters. -**Select the `develop` branch for pull requests.** +See the [Contributing Guide](https://b3yc0d3.github.io/rule34Py/dev/contributing.html) for information about how to contribute to this project and file bugs. diff --git a/docs/dev/contributing.rst b/docs/dev/contributing.rst new file mode 100644 index 0000000..9f81802 --- /dev/null +++ b/docs/dev/contributing.rst @@ -0,0 +1,39 @@ +============ +Contributing +============ + +Thanks for taking an interest in contributing to the rule34Py project! + +* This project's **canonical upstream** is at https://github.com/b3yc0d3/rule34Py. +* File **bugs**, **enhancement requests**, and other **issues** to the GH issue tracker at https://github.com/b3yc0d3/rule34Py/issues. +* See the `Developer Guide <./developer-guide.html>`_ for information about how to **build** this project from source and run tests. + + +Submitting Changes +================== + +#. Base your development branch off of the `upstream `_ ``develop`` reference. + +#. Before committing your changes, run the **project linter** using ``make``. It will use the ``ruff`` to lint all the project sources. + + .. code-block:: bash + + pip install .[dev] + make lint + + Fix or respond to any findings in the linter. + +#. Run the project's test suite against your changes. Ensure that all tests pass. + + .. code-block:: bash + + pip install .[test] + make check + +#. Write a good commit message. If you are unsure of how, `this cbeams article `_ gives reasonable suggestions. Commit your changes. + +#. Fork the canonical upstream repository on github. [`GitHub Docs `_] + +#. Push your development branch to your own fork. `Open a new Pull Request `_ against the upstream `develop` ref. + +#. Submit your PR. Respond to any PR build failures or feedback from the maintainers. diff --git a/docs/dev/developer-guide.rst b/docs/dev/developer-guide.rst index ca6cb51..d12d522 100644 --- a/docs/dev/developer-guide.rst +++ b/docs/dev/developer-guide.rst @@ -65,7 +65,7 @@ The following instructions are written on the assumption that you are building i Build output will be placed in the ``:build/`` directory in your workspace. -Other ```make`` targets are supported to ``clean`` the project and build other artifacts. +Other ``make`` targets are supported to ``clean`` the project and build other artifacts. Generally, the project ``Makefile`` honors the `GNU Standard Targets `_ specification. diff --git a/docs/dev/index.rst b/docs/dev/index.rst index 0482e78..cc38018 100644 --- a/docs/dev/index.rst +++ b/docs/dev/index.rst @@ -8,4 +8,5 @@ This section contains documentation primarily useful to developers who would lik :maxdepth: 1 developer-guide + contributing license From 433f3104773fbbff32e3ea462ea29f5227b38112 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 17 May 2025 15:05:11 -0400 Subject: [PATCH 28/31] rule34Py: fixup __init__ docs in Post Since the autodocs config statement to document __init__() methods was removed, parameter documentation for __init__ must now go under the class docstring. Signed-off-by: Riparian Commit --- rule34Py/post.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/rule34Py/post.py b/rule34Py/post.py index 6455fcb..2248a72 100644 --- a/rule34Py/post.py +++ b/rule34Py/post.py @@ -24,6 +24,20 @@ class Post: """A Rule34 Post object. This class is mostly a pythonic representation of the Rule34.xxx JSON API post object. + + Parameters: + id: The Post's Rule34 ID number. + hash: The Post's Rule34 object hash. + score: The Post's voted user score. + size: A two-element list of image dimensions. [width, height] + image: URL to the Post's rule34 image server location. + preview: A URL to the Post's preview image. + sample: A URL to the Post's sample image. + owner: The user who owns the Post. + tags: A list of tags to assign to the Post. + file_type: The Post's image file type. One of ["image", "gif", "video"]. + directory: The Post's image directory on the Rule34 image server. + change: The Post's change ID. """ @staticmethod @@ -53,22 +67,7 @@ def from_json(json: str) -> "Post": return Post(pId, pHash, pScore, pSize, pFileUrl, preview, sample, pOwner, pTags, img_type, directory, change) def __init__(self, id: int, hash: str, score: int, size: list, image: str, preview: str, sample: str, owner: str, tags: list, file_type: str, directory: int, change: int): - """Create a new Post object. - - Args: - id: The Post's Rule34 ID number. - hash: The Post's Rule34 object hash. - score: The Post's voted user score. - size: A two-element list of image dimensions. [width, height] - image: URL to the Post's rule34 image server location. - preview: A URL to the Post's preview image. - sample: A URL to the Post's sample image. - owner: The user who owns the Post. - tags: A list of tags to assign to the Post. - file_type: The Post's image file type. One of ["image", "gif", "video"]. - directory: The Post's image directory on the Rule34 image server. - change: The Post's change ID. - """ + """Create a new Post object.""" self._file_type = file_type self._video = "" self._image = "" From e85acda600001093d4f6919be4d5ea895bd3f732 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 17 May 2025 16:38:42 -0400 Subject: [PATCH 29/31] docs: add captcha-clearance guide Users will probably be understandably confused as to how to use the captcha-clearance feature of the client class. Add a user guide on how to use it. Signed-off-by: Riparian Commit --- docs/_static/cf_clearance_example.webp | Bin 0 -> 48402 bytes docs/_static/user-agent_example.webp | Bin 0 -> 34970 bytes docs/guides/captcha-clearance.rst | 69 +++++++++++++++++++++++++ docs/guides/index.rst | 9 ++++ docs/index.rst | 1 + 5 files changed, 79 insertions(+) create mode 100644 docs/_static/cf_clearance_example.webp create mode 100644 docs/_static/user-agent_example.webp create mode 100644 docs/guides/captcha-clearance.rst create mode 100644 docs/guides/index.rst diff --git a/docs/_static/cf_clearance_example.webp b/docs/_static/cf_clearance_example.webp new file mode 100644 index 0000000000000000000000000000000000000000..bd403d46cd1eec587aa2bad5ec91217e3b4bbe9b GIT binary patch literal 48402 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 literal 0 HcmV?d00001 diff --git a/docs/_static/user-agent_example.webp b/docs/_static/user-agent_example.webp new file mode 100644 index 0000000000000000000000000000000000000000..3dbc5a4504235e8a3684b1c8f19e2fff97fdceb9 GIT binary patch literal 34970 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�o@{eUk#e>;0Lm!v%~lj|2S@Kyk3$pa?n|v{ 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 interactive (PHP) site occasionally enables Cloudflare captcha defenses, to limit non-human access to the interactive site. +As of writing (2025-05-17), captcha defenses are enabled. + +The `api.rule34.xxx `_ REST API endpoint does not have captcha defenses. +Workflows in the rule34Py module prefer to use the REST API endpoint wherever possible. +But not all features of the interactive site are supported by the REST endpoint, and so the module defaults to parsing the interactive site's HTML. + +In order to enable these interactive workflows, while providing at least some respect to the site's captcha defense, rule34Py allows the user to: complete the captcha defense manually in their browser, and then give their captcha clearance to the python module to use in subsequent interactions. + +Note that the module will rate-limit requests to the interactive site to 1 request per second, as a further courtesy to the site owners. +We also recommend avoiding these workflows entirely in your application design, out of respect. + +Reference the `rule34Py.rule34Py <../../api/rule34Py/rule34.html#rule34Py.rule34.rule34Py>`_ class documentation for an indication of which workflows have these limits. + + +Clearing the defenses +===================== + +#. Use any reasonable browser with javascript enabled to open any URL to the https://rule34.xxx site. + +#. Open your browser's Network Inspector feature (or equivalent). + + .. hint:: + + F12 in Firefox and Chrome + +#. With the inspector open and capturing transactions, complete the captcha test. + +#. Use the network inspector to find the document transaction where the rule34.xxx homepage (or really any page) is returned to you. In the transaction's ``Headers > Request Headers > Cookie`` key. It should contain ``cf_clearance=...`` in its value. Copy and store the total value of the cf_clearance (it should be ~450 characters). + + .. image:: /_static/cf_clearance_example.webp + +#. Use the network inspector to find your browser's ``Headers > Request Headers > User-Agent`` value. Copy and store the total value of this header. + + .. image:: /_static/user-agent_example.webp + +#. Issue your clearance and user-agent to the rule34Py client class using one of the following methods. + + .. important:: + + It is necessary to capture and set both your ``user_agent`` and ``cf_clearance`` value. When Cloudflare issues you a cf_clearance, it is only valid for the User-Agent (of your browser) that completed the captcha test. + + a. In your python module, set the value of ``rule34Py.cf_clearance`` to your token value. + + .. code-block:: python + + import rule34Py as r34 + + client = r34.rule34Py() + client.cf_clearance = # ${token_value} + client.user_agent = # ${user_agent} + + # Some code that uses the rule34Py client. + + b. Set your execution environment's ``R34_CAPTCHA_CLEARANCE`` variable to your token value. + + .. code-block:: bash + + export R34_CAPTCHA_CLEARANCE=${token_value} + export R34_USER_AGENT=${user_agent} + # some script or commands that use rule34Py + +.. note:: + + Consider that the lifetime of your captcha clearance is the same as in the browser. Do not rely on this feature for multi-day operations or code that you are sharing with others. diff --git a/docs/guides/index.rst b/docs/guides/index.rst new file mode 100644 index 0000000..73ee676 --- /dev/null +++ b/docs/guides/index.rst @@ -0,0 +1,9 @@ +User Guides +=========== + +This section contains how-to guides and special topics of interest to module users and developers. + +.. toctree:: + :maxdepth: 1 + + captcha-clearance diff --git a/docs/index.rst b/docs/index.rst index 06634e9..67a026f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,4 +6,5 @@ tutorials/index api/index + guides/index dev/index From b17fd7606a0d2db35b4e8c8412607379b2b6d411 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 17 May 2025 16:46:13 -0400 Subject: [PATCH 30/31] .github: remove dev/docs trigger from gh-pages The dev/docs trigger was added to the gh-pages workflow for testing. Remove it, now that the patchset is ready. Pages should only be published from the master ref. Signed-off-by: Riparian Commit --- .github/workflows/gh-pages.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 03d6f10..b479959 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -4,7 +4,6 @@ on: push: branches: - master - - dev/docs permissions: contents: write From a418c8cd5d8cd99a6617888d201ed64499b67e44 Mon Sep 17 00:00:00 2001 From: Riparian Commit Date: Sat, 17 May 2025 17:10:08 -0400 Subject: [PATCH 31/31] README: fixup documentation link Signed-off-by: Riparian Commit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 11e57b2..0d41f1b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,6 @@ random_id = r34Py.random_post_id() ## Documentation -This project has extensive [documentation]((https://b3yc0d3.github.io/rule34Py/), hosted on the upstream Github Pages. It includes additional **Tutorials**, **User Guides**, **API Documentation**, and more. +This project has extensive [documentation](https://b3yc0d3.github.io/rule34Py/), hosted on the upstream Github Pages. It includes additional **Tutorials**, **User Guides**, **API Documentation**, and more. See the [Contributing Guide](https://b3yc0d3.github.io/rule34Py/dev/contributing.html) for information about how to contribute to this project and file bugs.