diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2d647c4..1864771 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,6 +52,7 @@ repos: additional_dependencies: - "flake8-toml-config==1.0.0" - "flake8-bugbear==25.11.29" + - "flake8-typing-as-t==1.1.0" - repo: "https://github.com/editorconfig-checker/editorconfig-checker" rev: "6fa6539948897d1dceee75b6680d7e9688b5c9e9" # frozen: v3.8.0 diff --git a/pyproject.toml b/pyproject.toml index bc6ac7b..20e3823 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,21 @@ profile = "black" force_single_line = true +# mypy +# ---- + +[tool.mypy] +packages = "feedparser.sgmllib" +strict = true +sqlite_cache = true +enable_error_code = [ + "ignore-without-code", + "redundant-expr", + "truthy-bool", +] +warn_unreachable = true + + # pytest # ------ diff --git a/requirements/mypy/pyproject.toml b/requirements/mypy/pyproject.toml new file mode 100644 index 0000000..baee4b3 --- /dev/null +++ b/requirements/mypy/pyproject.toml @@ -0,0 +1,9 @@ +[tool.poetry] +package-mode = false + +[project] +name = "dependencies" +requires-python = ">=3.10" +dependencies = [ + "mypy", +] diff --git a/requirements/mypy/requirements.txt b/requirements/mypy/requirements.txt new file mode 100644 index 0000000..a16ce4b --- /dev/null +++ b/requirements/mypy/requirements.txt @@ -0,0 +1,7 @@ +ast-serialize==0.6.0 ; python_version >= "3.10" +librt==0.13.0 ; python_version >= "3.10" and platform_python_implementation != "PyPy" +mypy-extensions==1.1.0 ; python_version >= "3.10" +mypy==2.3.0 ; python_version >= "3.10" +pathspec==1.1.1 ; python_version >= "3.10" +tomli==2.4.1 ; python_version == "3.10" +typing-extensions==4.16.0 ; python_version >= "3.10" diff --git a/src/feedparser/sgmllib/__init__.py b/src/feedparser/sgmllib/__init__.py index ade89ab..e4988ac 100644 --- a/src/feedparser/sgmllib/__init__.py +++ b/src/feedparser/sgmllib/__init__.py @@ -11,6 +11,7 @@ import _markupbase import re +import typing as t __all__ = ["SGMLParser", "SGMLParseError"] @@ -61,36 +62,36 @@ class SGMLParser(_markupbase.ParserBase): # Definition of entities -- derived classes may override entity_or_charref = re.compile("&(?:([a-zA-Z][-.a-zA-Z0-9]*)|#([0-9]+))(;?)") - def __init__(self, verbose=0) -> None: + def __init__(self, verbose: bool = False) -> None: """Initialize and reset this instance.""" self.verbose = verbose self.reset() - def reset(self): + def reset(self) -> None: """Reset this instance. Loses all unprocessed data.""" - self.__starttag_text = None + self.__starttag_text: str | None = None self.rawdata = "" - self.stack = [] + self.stack: list[str] = [] self.lasttag = "???" self.nomoretags = 0 self.literal = 0 - _markupbase.ParserBase.reset(self) + super().reset() - def setnomoretags(self): + def setnomoretags(self) -> None: """Enter literal mode (CDATA) till EOF. Intended for derived classes only. """ self.nomoretags = self.literal = 1 - def setliteral(self, *args): + def setliteral(self, *args: t.Any) -> None: """Enter literal mode (CDATA). Intended for derived classes only. """ self.literal = 1 - def feed(self, data): + def feed(self, data: str) -> None: """Feed some data to the parser. Call this as often as you want, with as little or as much text @@ -101,17 +102,17 @@ def feed(self, data): self.rawdata = self.rawdata + data self.goahead(0) - def close(self): + def close(self) -> None: """Handle the remaining data.""" self.goahead(1) - def error(self, message): + def error(self, message: str) -> t.NoReturn: raise SGMLParseError(message) # Internal -- handle data as far as reasonable. May leave state # and data to be processed by a subsequent call. If 'end' is # true, force handling all data as if followed by EOF marker. - def goahead(self, end): + def goahead(self, end: int) -> None: rawdata = self.rawdata i = 0 n = len(rawdata) @@ -230,7 +231,7 @@ def goahead(self, end): _decl_otherchars = "=" # Internal -- parse processing instr, return length or -1 if not terminated - def parse_pi(self, i): + def parse_pi(self, i: int) -> int: rawdata = self.rawdata if rawdata[i : i + 2] != " str | None: return self.__starttag_text # Internal -- handle starttag, return length or -1 if not terminated - def parse_starttag(self, i): + def parse_starttag(self, i: int) -> int: self.__starttag_text = None start_pos = i rawdata = self.rawdata @@ -259,7 +260,7 @@ def parse_starttag(self, i): if not match: return -1 tag, data = match.group(1, 2) - self.__starttag_text = "<%s/" % tag + self.__starttag_text = f"<{tag}/" tag = tag.lower() k = match.end(0) self.finish_shorttag(tag, data) @@ -310,7 +311,7 @@ def parse_starttag(self, i): return j # Internal -- convert entity or character reference - def _convert_ref(self, match): + def _convert_ref(self, match: re.Match[str]) -> str: if match.group(2): return self.convert_charref(match.group(2)) or "&#%s%s" % match.groups()[1:] elif match.group(3): @@ -319,7 +320,7 @@ def _convert_ref(self, match): return "&%s" % match.group(1) # Internal -- parse endtag - def parse_endtag(self, i): + def parse_endtag(self, i: int) -> int: rawdata = self.rawdata match = endbracket.search(rawdata, i + 1) if not match: @@ -332,14 +333,16 @@ def parse_endtag(self, i): return j # Internal -- finish parsing of data) - def finish_shorttag(self, tag, data): + def finish_shorttag(self, tag: str, data: str) -> None: self.finish_starttag(tag, []) self.handle_data(data) self.finish_endtag(tag) # Internal -- finish processing of start tag # Return -1 for unknown tag, 0 for open-only tag, 1 for balanced tag - def finish_starttag(self, tag, attrs): + def finish_starttag( + self, tag: str, attrs: list[tuple[str, str]] + ) -> t.Literal[-1, 0, 1]: try: method = getattr(self, "start_" + tag) except AttributeError: @@ -357,7 +360,7 @@ def finish_starttag(self, tag, attrs): return 1 # Internal -- finish processing of end tag - def finish_endtag(self, tag): + def finish_endtag(self, tag: str) -> None: if not tag: found = len(self.stack) - 1 if found < 0: @@ -389,33 +392,35 @@ def finish_endtag(self, tag): del self.stack[-1] # Overridable -- handle start tag - def handle_starttag(self, tag, method, attrs): + def handle_starttag( + self, tag: str, method: t.Callable[[t.Any], t.Any], attrs: list[tuple[str, str]] + ) -> None: method(attrs) # Overridable -- handle end tag - def handle_endtag(self, tag, method): + def handle_endtag(self, tag: str, method: t.Callable[[], t.Any]) -> None: method() # Example -- report an unbalanced tag. - def report_unbalanced(self, tag): + def report_unbalanced(self, tag: str) -> None: if self.verbose: print("*** Unbalanced ") print("*** Stack:", self.stack) - def convert_charref(self, name): + def convert_charref(self, name: str) -> str | None: """Convert character reference, may be overridden.""" try: n = int(name) except ValueError: - return + return None if not 0 <= n <= 127: - return + return None return self.convert_codepoint(n) - def convert_codepoint(self, codepoint): + def convert_codepoint(self, codepoint: int) -> str: return chr(codepoint) - def handle_charref(self, name): + def handle_charref(self, name: str) -> None: """Handle character reference, no need to override.""" replacement = self.convert_charref(name) if replacement is None: @@ -426,7 +431,7 @@ def handle_charref(self, name): # Definition of entities -- derived classes may override entitydefs = {"lt": "<", "gt": ">", "amp": "&", "quot": '"', "apos": "'"} - def convert_entityref(self, name): + def convert_entityref(self, name: str) -> str | None: """Convert entity references. As an alternative to overriding this method; one can tailor the @@ -436,9 +441,9 @@ def convert_entityref(self, name): if name in table: return table[name] else: - return + return None - def handle_entityref(self, name): + def handle_entityref(self, name: str) -> None: """Handle entity references, no need to override.""" replacement = self.convert_entityref(name) if replacement is None: @@ -447,30 +452,30 @@ def handle_entityref(self, name): self.handle_data(replacement) # Example -- handle data, should be overridden - def handle_data(self, data): + def handle_data(self, data: str) -> t.Any: pass # Example -- handle comment, could be overridden - def handle_comment(self, data): + def handle_comment(self, data: str) -> t.Any: pass # Example -- handle declaration, could be overridden - def handle_decl(self, decl): + def handle_decl(self, decl: t.Any) -> t.Any: pass # Example -- handle processing instruction, could be overridden - def handle_pi(self, data): + def handle_pi(self, data: str) -> t.Any: pass # To be overridden -- handlers for unknown objects - def unknown_starttag(self, tag, attrs): + def unknown_starttag(self, tag: str, attrs: list[tuple[str, str]]) -> t.Any: pass - def unknown_endtag(self, tag): + def unknown_endtag(self, tag: str) -> t.Any: pass - def unknown_charref(self, ref): + def unknown_charref(self, ref: str) -> t.Any: pass - def unknown_entityref(self, ref): + def unknown_entityref(self, ref: str) -> t.Any: pass diff --git a/src/feedparser/sgmllib/py.typed b/src/feedparser/sgmllib/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tox.ini b/tox.ini index d2454ef..148be05 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist = coverage-report coverage-html build{, -minimum} + mypy-py{3.15, 3.14, 3.13, 3.12, 3.11, 3.10} docs labels = update = update-{pre_commit, requirements} @@ -22,6 +23,13 @@ depends = deps = -r requirements/test/requirements.txt commands = coverage run -m pytest {posargs} +[testenv:mypy-py{3.15, 3.14, 3.13, 3.12, 3.11, 3.10}] +description = Test type annotations on Python {py_dot_ver} +deps = -r requirements/mypy/requirements.txt +setenv = + MYPY_FORCE_COLOR=1 +commands = mypy {posargs} + [testenv:coverage_base] description = Base config for coverage-* environments deps = -r requirements/test/requirements-coverage.txt @@ -100,6 +108,8 @@ deps = commands = poetry update --directory="requirements/docs" --lock poetry export --directory="requirements/docs" --output="requirements.txt" --without-hashes + poetry update --directory="requirements/mypy" --lock + poetry export --directory="requirements/mypy" --output="requirements.txt" --without-hashes poetry update --directory="requirements/test" --lock poetry export --directory="requirements/test" --output="requirements.txt" --without-hashes poetry export --directory="requirements/test" --output="requirements-coverage.txt" --without-hashes --only="coverage"