Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------

Expand Down
9 changes: 9 additions & 0 deletions requirements/mypy/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[tool.poetry]
package-mode = false

[project]
name = "dependencies"
requires-python = ">=3.10"
dependencies = [
"mypy",
]
7 changes: 7 additions & 0 deletions requirements/mypy/requirements.txt
Original file line number Diff line number Diff line change
@@ -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"
83 changes: 44 additions & 39 deletions src/feedparser/sgmllib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import _markupbase
import re
import typing as t

__all__ = ["SGMLParser", "SGMLParseError"]

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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] != "<?":
self.error("unexpected call to parse_pi()")
Expand All @@ -242,11 +243,11 @@ def parse_pi(self, i):
j = match.end(0)
return j - i

def get_starttag_text(self):
def get_starttag_text(self) -> 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
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand All @@ -332,14 +333,16 @@ def parse_endtag(self, i):
return j

# Internal -- finish parsing of <tag/data/ (same as <tag>data</tag>)
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:
Expand All @@ -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:
Expand Down Expand Up @@ -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 </" + tag + ">")
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:
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
Empty file added src/feedparser/sgmllib/py.typed
Empty file.
10 changes: 10 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down Expand Up @@ -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"