diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 890060ec7..3e7f3bfa9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,6 @@ repos: hooks: - id: ruff name: ruff - files: ^(telegram|examples|tests)/.*\.py$ additional_dependencies: - httpx~=0.27.0 - tornado~=6.4 @@ -32,7 +31,7 @@ repos: rev: v3.0.3 hooks: - id: pylint - files: ^(telegram|examples)/.*\.py$ + files: ^(?!(tests|docs)).*\.py$ additional_dependencies: - httpx~=0.27.0 - tornado~=6.4 @@ -45,7 +44,7 @@ repos: hooks: - id: mypy name: mypy-ptb - files: ^telegram/.*\.py$ + files: ^(?!(tests|examples|docs)).*\.py$ additional_dependencies: - types-pytz - types-cryptography @@ -71,7 +70,6 @@ repos: rev: v3.15.0 hooks: - id: pyupgrade - files: ^(telegram|examples|tests|docs)/.*\.py$ args: - --py38-plus - repo: https://github.com/pycqa/isort diff --git a/README_RAW.rst b/README_RAW.rst index f3428cefb..d61101d7c 100644 --- a/README_RAW.rst +++ b/README_RAW.rst @@ -108,12 +108,12 @@ You can also install ``python-telegram-bot-raw`` from source, though this is usu $ git clone https://github.com/python-telegram-bot/python-telegram-bot $ cd python-telegram-bot - $ python setup-raw.py install + $ python setup_raw.py install Note ---- -Installing the ``.tar.gz`` archive available on PyPi directly via ``pip`` will *not* work as expected, as ``pip`` does not recognize that it should use ``setup-raw.py`` instead of ``setup.py``. +Installing the ``.tar.gz`` archive available on PyPi directly via ``pip`` will *not* work as expected, as ``pip`` does not recognize that it should use ``setup_raw.py`` instead of ``setup.py``. Verifying Releases ------------------ diff --git a/docs/__init__.py b/docs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docs/auxil/__init__.py b/docs/auxil/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docs/auxil/admonition_inserter.py b/docs/auxil/admonition_inserter.py index 9b1ddc98a..4227a8453 100644 --- a/docs/auxil/admonition_inserter.py +++ b/docs/auxil/admonition_inserter.py @@ -64,7 +64,7 @@ class AdmonitionInserter: ForwardRef('DefaultValue[DVValueType]') """ - METHOD_NAMES_FOR_BOT_AND_APPBUILDER: dict[type, str] = { + METHOD_NAMES_FOR_BOT_AND_APPBUILDER: typing.ClassVar[dict[type, str]] = { cls: tuple(m[0] for m in _iter_own_public_methods(cls)) # m[0] means we take only names for cls in (telegram.Bot, telegram.ext.ApplicationBuilder) } @@ -159,7 +159,7 @@ class AdmonitionInserter: telegram.ext, inspect.isclass ) - for class_name, inspected_class in classes_to_inspect: + for _class_name, inspected_class in classes_to_inspect: # We need to make "" into # "telegram.StickerSet" because that's the way the classes are mentioned in # docstrings. @@ -197,8 +197,8 @@ class AdmonitionInserter: "Error generating Sphinx 'Available in' admonition " f"(admonition_inserter.py). Class {name_of_class_in_attr} present in " f"attribute {target_attr} of class {name_of_inspected_class_in_docstr}" - f" could not be resolved. {str(e)}" - ) + f" could not be resolved. {e!s}" + ) from e # Properties need to be parsed separately because they act like attributes but not # listed as attributes. @@ -240,8 +240,8 @@ class AdmonitionInserter: "Error generating Sphinx 'Available in' admonition " f"(admonition_inserter.py). Class {name_of_class_in_prop} present in " f"property {prop_name} of class {name_of_inspected_class_in_docstr}" - f" could not be resolved. {str(e)}" - ) + f" could not be resolved. {e!s}" + ) from e return self._generate_admonitions(attrs_for_class, admonition_type="available_in") @@ -271,8 +271,8 @@ class AdmonitionInserter: raise NotImplementedError( "Error generating Sphinx 'Returned in' admonition " f"(admonition_inserter.py). {cls}, method {method_name}. " - f"Couldn't resolve type hint in return annotation {ret_annot}. {str(e)}" - ) + f"Couldn't resolve type hint in return annotation {ret_annot}. {e!s}" + ) from e return self._generate_admonitions(methods_for_class, admonition_type="returned_in") @@ -297,7 +297,7 @@ class AdmonitionInserter: # inspect methods of all telegram classes for return statements that indicate # that this given method is a shortcut for a Bot method - for class_name, cls in inspect.getmembers(telegram, predicate=inspect.isclass): + for _class_name, cls in inspect.getmembers(telegram, predicate=inspect.isclass): # no need to inspect Bot's own methods, as Bot can't have shortcuts in Bot if cls is telegram.Bot: continue @@ -344,8 +344,8 @@ class AdmonitionInserter: raise NotImplementedError( "Error generating Sphinx 'Use in' admonition " f"(admonition_inserter.py). {cls}, method {method_name}, parameter " - f"{param}: Couldn't resolve type hint {param.annotation}. {str(e)}" - ) + f"{param}: Couldn't resolve type hint {param.annotation}. {e!s}" + ) from e return self._generate_admonitions(methods_for_class, admonition_type="use_in") @@ -359,17 +359,19 @@ class AdmonitionInserter: If no key phrases are found, the admonition will be inserted at the very end. """ for idx, value in list(enumerate(lines)): - if ( - value.startswith(".. seealso:") - # The docstring contains heading "Examples:", but Sphinx will have it converted - # to ".. admonition: Examples": - or value.startswith(".. admonition:: Examples") - or value.startswith(".. version") - # The space after ":param" is important because docstring can contain ":paramref:" - # in its plain text in the beginning of a line (e.g. ExtBot): - or value.startswith(":param ") - # some classes (like "Credentials") have no params, so insert before attrs: - or value.startswith(".. attribute::") + if value.startswith( + ( + ".. seealso:", + # The docstring contains heading "Examples:", but Sphinx will have it converted + # to ".. admonition: Examples": + ".. admonition:: Examples", + ".. version", + # The space after ":param" is important because docstring can contain + # ":paramref:" in its plain text in the beginning of a line (e.g. ExtBot): + ":param ", + # some classes (like "Credentials") have no params, so insert before attrs: + ".. attribute::", + ) ): return idx return len(lines) - 1 @@ -411,7 +413,7 @@ class AdmonitionInserter: # so its page needs no admonitions. continue - attrs = sorted(attrs) + sorted_attrs = sorted(attrs) # e.g. for admonition type "use_in" the title will be "Use in" and CSS class "use-in". admonition = f""" @@ -419,11 +421,11 @@ class AdmonitionInserter: .. admonition:: {admonition_type.title().replace("_", " ")} :class: {admonition_type.replace("_", "-")} """ - if len(attrs) > 1: - for target_attr in attrs: + if len(sorted_attrs) > 1: + for target_attr in sorted_attrs: admonition += "\n * " + target_attr else: - admonition += f"\n {attrs[0]}" + admonition += f"\n {sorted_attrs[0]}" admonition += "\n " # otherwise an unexpected unindent warning will be issued admonition_for_class[cls] = admonition @@ -516,12 +518,12 @@ class AdmonitionInserter: # If it isn't resolved, we'll have the program throw an exception to be sure. try: cls = self._resolve_class(m.group("class_name")) - except AttributeError: + except AttributeError as exc: # skip known ForwardRef's that need not be resolved to a Telegram class if self.FORWARD_REF_SKIP_PATTERN.match(str(arg)): pass else: - raise NotImplementedError(f"Could not process ForwardRef: {arg}") + raise NotImplementedError(f"Could not process ForwardRef: {arg}") from exc else: yield cls @@ -587,6 +589,7 @@ class AdmonitionInserter: # If neither option works, this is not a PTB class. except (NameError, AttributeError): continue + return None if __name__ == "__main__": diff --git a/docs/auxil/kwargs_insertion.py b/docs/auxil/kwargs_insertion.py index a67d542f4..ffb2ada13 100644 --- a/docs/auxil/kwargs_insertion.py +++ b/docs/auxil/kwargs_insertion.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import inspect +from typing import List keyword_args = [ "Keyword Arguments:", @@ -84,13 +85,12 @@ get_updates_read_timeout_addition = [ ] -def find_insert_pos_for_kwargs(lines: list[str]) -> int: +def find_insert_pos_for_kwargs(lines: List[str]) -> int: """Finds the correct position to insert the keyword arguments and returns the index.""" for idx, value in reversed(list(enumerate(lines))): # reversed since :returns: is at the end if value.startswith("Returns"): return idx - else: - return False + return False def check_timeout_and_api_kwargs_presence(obj: object) -> int: diff --git a/docs/auxil/link_code.py b/docs/auxil/link_code.py index f54479e01..8c20f34b4 100644 --- a/docs/auxil/link_code.py +++ b/docs/auxil/link_code.py @@ -20,6 +20,8 @@ to link to the correct files & lines on github. Can be simplified once https://github.com/sphinx-doc/sphinx/issues/1556 is closed """ import subprocess +from pathlib import Path +from typing import Dict, Tuple from sphinx.util import logging @@ -30,7 +32,7 @@ sphinx_logger = logging.getLogger(__name__) # must be a module-level variable so that it can be written to by the `autodoc-process-docstring` # event handler in `sphinx_hooks.py` -LINE_NUMBERS = {} +LINE_NUMBERS: Dict[str, Tuple[Path, int, int]] = {} def _git_branch() -> str: @@ -52,7 +54,7 @@ git_branch = _git_branch() base_url = "https://github.com/python-telegram-bot/python-telegram-bot/blob/" -def linkcode_resolve(_, info): +def linkcode_resolve(_, info) -> str: """See www.sphinx-doc.org/en/master/usage/extensions/linkcode.html""" combined = ".".join((info["module"], info["fullname"])) # special casing for ExtBot which is due to the special structure of extbot.rst @@ -71,7 +73,7 @@ def linkcode_resolve(_, info): line_info = LINE_NUMBERS.get(info["module"]) if not line_info: - return + return None file, start_line, end_line = line_info return f"{base_url}{git_branch}/{file}#L{start_line}-L{end_line}" diff --git a/docs/auxil/sphinx_hooks.py b/docs/auxil/sphinx_hooks.py index c9bd8bbe4..3074ac7af 100644 --- a/docs/auxil/sphinx_hooks.py +++ b/docs/auxil/sphinx_hooks.py @@ -67,9 +67,9 @@ def autodoc_skip_member(app, what, name, obj, skip, options): return True break - if name == "filter" and obj.__module__ == "telegram.ext.filters": - if not included_in_obj: - return True # return True to exclude from docs. + if name == "filter" and obj.__module__ == "telegram.ext.filters" and not included_in_obj: + return True # return True to exclude from docs. + return None def autodoc_process_docstring( @@ -118,7 +118,7 @@ def autodoc_process_docstring( ): effective_insert: list[str] = media_write_timeout_deprecation elif get_updates and to_insert.lstrip().startswith("read_timeout"): - effective_insert = [to_insert] + get_updates_read_timeout_addition + effective_insert = [to_insert, *get_updates_read_timeout_addition] else: effective_insert = [to_insert] @@ -166,11 +166,11 @@ def autodoc_process_docstring( autodoc_process_docstring(app, "method", f"{name}.__init__", obj.__init__, options, lines) -def autodoc_process_bases(app, name, obj, option, bases: list): +def autodoc_process_bases(app, name, obj, option, bases: list) -> None: """Here we fine tune how the base class's classes are displayed.""" - for idx, base in enumerate(bases): + for idx, raw_base in enumerate(bases): # let's use a string representation of the object - base = str(base) + base = str(raw_base) # Special case for abstract context managers which are wrongly resoled for some reason if base.startswith("typing.AbstractAsyncContextManager"): diff --git a/docs/auxil/tg_const_role.py b/docs/auxil/tg_const_role.py index 35c8bf3b1..d4d5961ad 100644 --- a/docs/auxil/tg_const_role.py +++ b/docs/auxil/tg_const_role.py @@ -81,11 +81,12 @@ class TGConstXRefRole(PyXRefRole): ): return repr(value), target sphinx_logger.warning( - f"%s:%d: WARNING: Did not convert reference %s. :{CONSTANTS_ROLE}: is not supposed" + "%s:%d: WARNING: Did not convert reference %s. :%s: is not supposed" " to be used with this type of target.", refnode.source, refnode.line, refnode.rawsource, + CONSTANTS_ROLE, ) return title, target except Exception as exc: diff --git a/docs/source/conf.py b/docs/source/conf.py index 5d3f35567..c890a4af4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,4 +1,3 @@ -import os import re import sys from pathlib import Path @@ -8,7 +7,7 @@ from pathlib import Path # documentation root, use os.path.abspath to make it absolute, like shown here. from sphinx.application import Sphinx -sys.path.insert(0, os.path.abspath("../..")) +sys.path.insert(0, str(Path("../..").resolve().absolute())) # -- General configuration ------------------------------------------------ # General information about the project. @@ -310,13 +309,13 @@ texinfo_documents = [ # Due to Sphinx behaviour, these imports only work when imported here, not at top of module. # Not used but must be imported for the linkcode extension to find it -from docs.auxil.link_code import linkcode_resolve -from docs.auxil.sphinx_hooks import ( +from docs.auxil.link_code import linkcode_resolve # noqa: E402, F401 +from docs.auxil.sphinx_hooks import ( # noqa: E402 autodoc_process_bases, autodoc_process_docstring, autodoc_skip_member, ) -from docs.auxil.tg_const_role import CONSTANTS_ROLE, TGConstXRefRole +from docs.auxil.tg_const_role import CONSTANTS_ROLE, TGConstXRefRole # noqa: E402 def setup(app: Sphinx): diff --git a/pyproject.toml b/pyproject.toml index 233af8fec..b941a2444 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ select = ["E", "F", "I", "PL", "UP", "RUF", "PTH", "C4", "B", "PIE", "SIM", "RET [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["B018"] "tests/**.py" = ["RUF012", "ASYNC101"] +"docs/**.py" = ["INP001"] # PYLINT: [tool.pylint."messages control"] diff --git a/setup.cfg b/setup.cfg index 2067f25af..278056b06 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,4 +5,4 @@ license_files = LICENSE, LICENSE.dual, LICENSE.lesser max-line-length = 99 ignore = W503, W605 extend-ignore = E203, E704 -exclude = setup.py, setup-raw.py docs/source/conf.py +exclude = setup.py, setup_raw.py docs/source/conf.py diff --git a/setup.py b/setup.py index 01ec10e84..ef619d2ef 100644 --- a/setup.py +++ b/setup.py @@ -4,15 +4,16 @@ import subprocess import sys from collections import defaultdict from pathlib import Path +from typing import Any, Dict, List, Tuple from setuptools import find_packages, setup -def get_requirements(): +def get_requirements() -> List[str]: """Build the requirements list for this project""" requirements_list = [] - with Path("requirements.txt").open() as reqs: + with Path("requirements.txt").open(encoding="utf-8") as reqs: for install in reqs: if install.startswith("#"): continue @@ -21,7 +22,7 @@ def get_requirements(): return requirements_list -def get_packages_requirements(raw=False): +def get_packages_requirements(raw: bool = False) -> Tuple[List[str], List[str]]: """Build the package & requirements list for this project""" reqs = get_requirements() @@ -34,68 +35,69 @@ def get_packages_requirements(raw=False): return packs, reqs -def get_optional_requirements(raw=False): +def get_optional_requirements(raw: bool = False) -> Dict[str, List[str]]: """Build the optional dependencies""" requirements = defaultdict(list) - with Path("requirements-opts.txt").open() as reqs: + with Path("requirements-opts.txt").open(encoding="utf-8") as reqs: for line in reqs: - line = line.strip() - if not line or line.startswith("#"): + effective_line = line.strip() + if not effective_line or effective_line.startswith("#"): continue - dependency, names = line.split("#") + dependency, names = effective_line.split("#") dependency = dependency.strip() for name in names.split(","): - name = name.strip() - if name.endswith("!ext"): + effective_name = name.strip() + if effective_name.endswith("!ext"): if raw: continue - else: - name = name[:-4] - requirements["ext"].append(dependency) - requirements[name].append(dependency) + effective_name = effective_name[:-4] + requirements["ext"].append(dependency) + requirements[effective_name].append(dependency) requirements["all"].append(dependency) return requirements -def get_setup_kwargs(raw=False): +def get_setup_kwargs(raw: bool = False) -> Dict[str, Any]: """Builds a dictionary of kwargs for the setup function""" packages, requirements = get_packages_requirements(raw=raw) raw_ext = "-raw" if raw else "" readme = Path(f'README{"_RAW" if raw else ""}.rst') - version_file = Path("telegram/_version.py").read_text() + version_file = Path("telegram/_version.py").read_text(encoding="utf-8") first_part = version_file.split("# SETUP.PY MARKER")[0] - exec(first_part) + exec(first_part) # pylint: disable=exec-used - kwargs = dict( - script_name=f"setup{raw_ext}.py", - name=f"python-telegram-bot{raw_ext}", - version=locals()["__version__"], - author="Leandro Toledo", - author_email="devs@python-telegram-bot.org", - license="LGPLv3", - url="https://python-telegram-bot.org/", - # Keywords supported by PyPI can be found at https://github.com/pypa/warehouse/blob/aafc5185e57e67d43487ce4faa95913dd4573e14/warehouse/templates/packaging/detail.html#L20-L58 - project_urls={ + return { + "script_name": f"setup{raw_ext}.py", + "name": f"python-telegram-bot{raw_ext}", + "version": locals()["__version__"], + "author": "Leandro Toledo", + "author_email": "devs@python-telegram-bot.org", + "license": "LGPLv3", + "url": "https://python-telegram-bot.org/", + # Keywords supported by PyPI can be found at + # https://github.com/pypa/warehouse/blob/aafc5185e57e67d43487ce4faa95913dd4573e14/ + # warehouse/templates/packaging/detail.html#L20-L58 + "project_urls": { "Documentation": "https://docs.python-telegram-bot.org", "Bug Tracker": "https://github.com/python-telegram-bot/python-telegram-bot/issues", "Source Code": "https://github.com/python-telegram-bot/python-telegram-bot", "News": "https://t.me/pythontelegrambotchannel", "Changelog": "https://docs.python-telegram-bot.org/en/stable/changelog.html", }, - download_url=f"https://pypi.org/project/python-telegram-bot{raw_ext}/", - keywords="python telegram bot api wrapper", - description="We have made you a wrapper you can't refuse", - long_description=readme.read_text(), - long_description_content_type="text/x-rst", - packages=packages, - install_requires=requirements, - extras_require=get_optional_requirements(raw=raw), - include_package_data=True, - classifiers=[ + "download_url": f"https://pypi.org/project/python-telegram-bot{raw_ext}/", + "keywords": "python telegram bot api wrapper", + "description": "We have made you a wrapper you can't refuse", + "long_description": readme.read_text(encoding="utf-8"), + "long_description_content_type": "text/x-rst", + "packages": packages, + "install_requires": requirements, + "extras_require": get_optional_requirements(raw=raw), + "include_package_data": True, + "classifiers": [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", @@ -111,16 +113,14 @@ def get_setup_kwargs(raw=False): "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ], - python_requires=">=3.8", - ) - - return kwargs + "python_requires": ">=3.8", + } -def main(): +def main() -> None: # If we're building, build ptb-raw as well if set(sys.argv[1:]) in [{"bdist_wheel"}, {"sdist"}, {"sdist", "bdist_wheel"}]: - args = ["python", "setup-raw.py"] + args = ["python", "setup_raw.py"] args.extend(sys.argv[1:]) subprocess.run(args, check=True, capture_output=True) diff --git a/setup-raw.py b/setup_raw.py similarity index 100% rename from setup-raw.py rename to setup_raw.py diff --git a/telegram/_utils/enum.py b/telegram/_utils/enum.py index 20a045c02..aa370cbd4 100644 --- a/telegram/_utils/enum.py +++ b/telegram/_utils/enum.py @@ -60,7 +60,7 @@ class StringEnum(str, _enum.Enum): # Apply the __repr__ modification and __str__ fix to IntEnum -class IntEnum(_enum.IntEnum): # pylint: disable=invalid-slots +class IntEnum(_enum.IntEnum): """Helper class for int enums where ``str(member)`` prints the value, but ``repr(member)`` gives ``EnumName.MEMBER_NAME``. """ diff --git a/telegram/constants.py b/telegram/constants.py index d78dd8280..01cab88d0 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -29,7 +29,7 @@ those classes. * Most of the constants in this module are grouped into enums. """ # TODO: Remove this when https://github.com/PyCQA/pylint/issues/6887 is resolved. -# pylint: disable=invalid-enum-extension,invalid-slots +# pylint: disable=invalid-enum-extension __all__ = [ "BOT_API_VERSION", diff --git a/tests/test_meta.py b/tests/test_meta.py index 077f9ae05..fd698585d 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -40,4 +40,4 @@ def test_build(): @skip_disabled def test_build_raw(): - assert os.system("python setup-raw.py bdist_dumb") == 0 # pragma: no cover + assert os.system("python setup_raw.py bdist_dumb") == 0 # pragma: no cover