Skip to content

Commit 66f55e3

Browse files
committed
Mypy, Python 3.10 - 3.12
1 parent 7acc68c commit 66f55e3

File tree

8 files changed

+169
-141
lines changed

8 files changed

+169
-141
lines changed

.github/workflows/release.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
- uses: actions/setup-python@v2
2323
name: Install Python
2424
with:
25-
python-version: 3.9
25+
python-version: 3.10
2626

2727
- run: |
2828
pip install packaging

.github/workflows/test.yaml

+7-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
lint:
1212
strategy:
1313
matrix:
14-
python-version: ['3.9', '3.10', '3.11']
14+
python-version: ['3.10', '3.11', '3.12']
1515
name: Lint ${{ matrix.python-version }}
1616
runs-on: 'ubuntu-20.04'
1717
container: python:${{ matrix.python-version }}
@@ -26,11 +26,16 @@ jobs:
2626
ruff format --check
2727
ruff check --select I
2828
29+
- name: Type check code
30+
run: |
31+
pip install mypy==1.10.1
32+
mypy
33+
2934
# Run tests
3035
test:
3136
strategy:
3237
matrix:
33-
python-version: ['3.9', '3.10', '3.11']
38+
python-version: ['3.10', '3.11', '3.12']
3439
# Do not cancel any jobs when a single job fails
3540
fail-fast: false
3641
name: Python ${{ matrix.python-version }}

pyproject.toml

+11
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,14 @@ max-branches = 16
5454

5555
[tool.ruff.lint.per-file-ignores]
5656
"tests/test_quotequail.py" = ["E501", "PT009"]
57+
58+
[tool.mypy]
59+
python_version = "3.10"
60+
ignore_missing_imports = true
61+
no_implicit_optional = true
62+
strict_equality = true
63+
follow_imports = "normal"
64+
warn_unreachable = true
65+
show_error_context = true
66+
pretty = true
67+
files = "quotequail"

quotequail/__init__.py

+30-26
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
__all__ = ["quote", "quote_html", "unwrap", "unwrap_html"]
88

99

10-
def quote(text, limit=1000):
10+
def quote(text: str, limit: int = 1000) -> list[tuple[bool, str]]:
1111
"""
1212
Take a plain text message as an argument, return a list of tuples. The
1313
first argument of the tuple denotes whether the text should be expanded by
@@ -33,7 +33,7 @@ def quote(text, limit=1000):
3333
return [(True, text)]
3434

3535

36-
def quote_html(html, limit=1000):
36+
def quote_html(html: str, limit: int = 1000) -> list[tuple[bool, str]]:
3737
"""
3838
Like quote(), but takes an HTML message as an argument. The limit param
3939
represents the maximum number of lines to traverse until quoting the rest
@@ -62,7 +62,7 @@ def quote_html(html, limit=1000):
6262
]
6363

6464

65-
def unwrap(text):
65+
def unwrap(text: str) -> dict[str, str] | None:
6666
"""
6767
If the passed text is the text body of a forwarded message, a reply, or
6868
contains quoted text, a dictionary with the following keys is returned:
@@ -78,31 +78,33 @@ def unwrap(text):
7878
"""
7979
lines = text.split("\n")
8080

81-
result = _internal.unwrap(
81+
unwrap_result = _internal.unwrap(
8282
lines,
8383
_patterns.MAX_WRAP_LINES,
8484
_patterns.MIN_HEADER_LINES,
8585
_patterns.MIN_QUOTED_LINES,
8686
)
87-
if not result:
87+
if not unwrap_result:
8888
return None
8989

90-
typ, top_range, hdrs, main_range, bottom_range, needs_unindent = result
90+
typ, top_range, hdrs, main_range, bottom_range, needs_unindent = (
91+
unwrap_result
92+
)
9193

92-
text_top = lines[slice(*top_range)] if top_range else ""
93-
text = lines[slice(*main_range)] if main_range else ""
94-
text_bottom = lines[slice(*bottom_range)] if bottom_range else ""
94+
text_top_lines = lines[slice(*top_range)] if top_range else []
95+
text_lines = lines[slice(*main_range)] if main_range else []
96+
text_bottom_lines = lines[slice(*bottom_range)] if bottom_range else []
9597

9698
if needs_unindent:
97-
text = _internal.unindent_lines(text)
99+
text_lines = _internal.unindent_lines(text_lines)
98100

99101
result = {
100102
"type": typ,
101103
}
102104

103-
text = "\n".join(text).strip()
104-
text_top = "\n".join(text_top).strip()
105-
text_bottom = "\n".join(text_bottom).strip()
105+
text = "\n".join(text_lines).strip()
106+
text_top = "\n".join(text_top_lines).strip()
107+
text_bottom = "\n".join(text_bottom_lines).strip()
106108

107109
if text:
108110
result["text"] = text
@@ -117,7 +119,7 @@ def unwrap(text):
117119
return result
118120

119121

120-
def unwrap_html(html):
122+
def unwrap_html(html: str) -> dict[str, str] | None:
121123
"""
122124
If the passed HTML is the HTML body of a forwarded message, a dictionary
123125
with the following keys is returned:
@@ -137,38 +139,40 @@ def unwrap_html(html):
137139

138140
start_refs, end_refs, lines = _html.get_line_info(tree)
139141

140-
result = _internal.unwrap(lines, 1, _patterns.MIN_HEADER_LINES, 1)
142+
unwrap_result = _internal.unwrap(lines, 1, _patterns.MIN_HEADER_LINES, 1)
141143

142-
if result:
143-
typ, top_range, hdrs, main_range, bottom_range, needs_unindent = result
144+
if unwrap_result:
145+
typ, top_range, hdrs, main_range, bottom_range, needs_unindent = (
146+
unwrap_result
147+
)
144148

145149
result = {
146150
"type": typ,
147151
}
148152

149-
top_range = _html.trim_slice(lines, top_range)
150-
main_range = _html.trim_slice(lines, main_range)
151-
bottom_range = _html.trim_slice(lines, bottom_range)
153+
top_range_slice = _html.trim_slice(lines, top_range)
154+
main_range_slice = _html.trim_slice(lines, main_range)
155+
bottom_range_slice = _html.trim_slice(lines, bottom_range)
152156

153-
if top_range:
157+
if top_range_slice:
154158
top_tree = _html.slice_tree(
155-
tree, start_refs, end_refs, top_range, html_copy=html
159+
tree, start_refs, end_refs, top_range_slice, html_copy=html
156160
)
157161
html_top = _html.render_html_tree(top_tree)
158162
if html_top:
159163
result["html_top"] = html_top
160164

161-
if bottom_range:
165+
if bottom_range_slice:
162166
bottom_tree = _html.slice_tree(
163-
tree, start_refs, end_refs, bottom_range, html_copy=html
167+
tree, start_refs, end_refs, bottom_range_slice, html_copy=html
164168
)
165169
html_bottom = _html.render_html_tree(bottom_tree)
166170
if html_bottom:
167171
result["html_bottom"] = html_bottom
168172

169-
if main_range:
173+
if main_range_slice:
170174
main_tree = _html.slice_tree(
171-
tree, start_refs, end_refs, main_range
175+
tree, start_refs, end_refs, main_range_slice
172176
)
173177
if needs_unindent:
174178
_html.unindent_tree(main_tree)

quotequail/_html.py

+50-20
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# HTML utils
2+
from collections.abc import Iterator
23

34
import lxml.etree
45
import lxml.html
56

67
from ._patterns import FORWARD_LINE, FORWARD_STYLES, MULTIPLE_WHITESPACE_RE
8+
from .types import Element, ElementRef
79

810
INLINE_TAGS = [
911
"a",
@@ -27,7 +29,7 @@
2729
END = "end"
2830

2931

30-
def trim_tree_after(element, include_element=True):
32+
def trim_tree_after(element: Element, include_element: bool = True):
3133
"""
3234
Remove the document tree following the given element. If include_element
3335
is True, the given element is kept in the tree, otherwise it is removed.
@@ -44,7 +46,9 @@ def trim_tree_after(element, include_element=True):
4446
el = parent_el
4547

4648

47-
def trim_tree_before(element, include_element=True, keep_head=True):
49+
def trim_tree_before(
50+
element: Element, include_element: bool = True, keep_head: bool = True
51+
) -> None:
4852
"""
4953
Remove the document tree preceding the given element. If include_element
5054
is True, the given element is kept in the tree, otherwise it is removed.
@@ -66,7 +70,9 @@ def trim_tree_before(element, include_element=True, keep_head=True):
6670
el = parent_el
6771

6872

69-
def trim_slice(lines, slice_tuple):
73+
def trim_slice(
74+
lines: list[str], slice_tuple: tuple[int | None, int | None] | None
75+
) -> tuple[int, int] | None:
7076
"""
7177
Trim a slice tuple (begin, end) so it starts at the first non-empty line
7278
(obtained via indented_tree_line_generator / get_line_info) and ends at the
@@ -97,7 +103,7 @@ def _empty(line):
97103
return (slice_start, slice_end)
98104

99105

100-
def unindent_tree(element):
106+
def unindent_tree(element: Element) -> None:
101107
"""
102108
Remove the outermost indent. For example, the tree
103109
"<div>A<blockqote>B<div>C<blockquote>D</blockquote>E</div>F</blockquote>G</div>"
@@ -111,7 +117,13 @@ def unindent_tree(element):
111117
return
112118

113119

114-
def slice_tree(tree, start_refs, end_refs, slice_tuple, html_copy=None):
120+
def slice_tree(
121+
tree: Element,
122+
start_refs: list[ElementRef | None],
123+
end_refs: list[ElementRef | None],
124+
slice_tuple: tuple[int | None, int | None] | None,
125+
html_copy: str | None = None,
126+
):
115127
"""
116128
Slice the HTML tree with the given start_refs and end_refs (obtained via
117129
get_line_info) at the given slice_tuple, a tuple (start, end) containing
@@ -190,27 +202,27 @@ def slice_tree(tree, start_refs, end_refs, slice_tuple, html_copy=None):
190202
return new_tree
191203

192204

193-
def get_html_tree(html):
205+
def get_html_tree(html: str) -> Element:
194206
"""
195207
Given the HTML string, returns a LXML tree object. The tree is wrapped in
196208
<div> elements if it doesn't have a top level tag or parsing would
197209
otherwise result in an error. The wrapping can be later removed with
198210
strip_wrapping().
199211
"""
200212
parser = lxml.html.HTMLParser(encoding="utf-8")
201-
html = html.encode("utf8")
213+
htmlb = html.encode("utf8")
202214

203215
try:
204-
tree = lxml.html.fromstring(html, parser=parser)
216+
tree = lxml.html.fromstring(htmlb, parser=parser)
205217
except lxml.etree.Error:
206218
# E.g. empty document. Use dummy <div>
207219
tree = lxml.html.fromstring("<div></div>")
208220

209221
# If the document doesn't start with a top level tag, wrap it with a <div>
210222
# that will be later stripped out for consistent behavior.
211223
if tree.tag not in lxml.html.defs.top_level_tags:
212-
html = b"<div>" + html + b"</div>"
213-
tree = lxml.html.fromstring(html, parser=parser)
224+
htmlb = b"<div>" + htmlb + b"</div>"
225+
tree = lxml.html.fromstring(htmlb, parser=parser)
214226

215227
# HACK for Outlook emails, where tags like <o:p> are rendered as <p>. We
216228
# can generally ignore these tags so we replace them with <span>, which
@@ -229,7 +241,7 @@ def get_html_tree(html):
229241
return tree
230242

231243

232-
def strip_wrapping(html):
244+
def strip_wrapping(html: str) -> str:
233245
"""
234246
Remove the wrapping that might have resulted when using get_html_tree().
235247
"""
@@ -238,7 +250,7 @@ def strip_wrapping(html):
238250
return html.strip()
239251

240252

241-
def render_html_tree(tree):
253+
def render_html_tree(tree: Element) -> str:
242254
"""
243255
Render the given HTML tree, and strip any wrapping that was applied in
244256
get_html_tree().
@@ -257,13 +269,15 @@ def render_html_tree(tree):
257269
return strip_wrapping(html)
258270

259271

260-
def is_indentation_element(element):
272+
def is_indentation_element(element: Element) -> bool:
261273
if isinstance(element.tag, str):
262274
return element.tag.lower() == "blockquote"
263275
return False
264276

265277

266-
def tree_token_generator(el, indentation_level=0):
278+
def tree_token_generator(
279+
el: Element, indentation_level: int = 0
280+
) -> Iterator[None | tuple[Element, str, int] | str]:
267281
"""
268282
Yield tokens for the given HTML element as follows:
269283
@@ -296,7 +310,13 @@ def tree_token_generator(el, indentation_level=0):
296310
yield el.tail
297311

298312

299-
def tree_line_generator(el, max_lines=None):
313+
def tree_line_generator(
314+
el: Element, max_lines: int | None = None
315+
) -> Iterator[
316+
tuple[
317+
tuple[ElementRef, str] | None, tuple[ElementRef, str] | None, int, str
318+
]
319+
]:
300320
"""
301321
Iterate through an LXML tree and yield a tuple per line.
302322
@@ -327,7 +347,7 @@ def tree_line_generator(el, max_lines=None):
327347
- ((<Element blockquote>, 'end'), (<Element div>, 'end'), 0, 'world')
328348
"""
329349

330-
def _trim_spaces(text):
350+
def _trim_spaces(text: str) -> str:
331351
return MULTIPLE_WHITESPACE_RE.sub(" ", text).strip()
332352

333353
counter = 1
@@ -341,7 +361,7 @@ def _trim_spaces(text):
341361
start_ref = None
342362

343363
# The indentation level at the start of the line.
344-
start_indentation_level = None
364+
start_indentation_level = 0
345365

346366
for token in tree_token_generator(el):
347367
if token is None:
@@ -393,12 +413,17 @@ def _trim_spaces(text):
393413
else:
394414
raise RuntimeError(f"invalid token: {token}")
395415

416+
"""
417+
TODO: wrong type, would trigger error if reached.
396418
line = _trim_spaces(line)
397419
if line:
398420
yield line
421+
"""
399422

400423

401-
def indented_tree_line_generator(el, max_lines=None):
424+
def indented_tree_line_generator(
425+
el: Element, max_lines: int | None = None
426+
) -> Iterator[tuple[ElementRef | None, ElementRef | None, str]]:
402427
r"""
403428
Like tree_line_generator, but yields tuples (start_ref, end_ref, line),
404429
where the line already takes the indentation into account by having "> "
@@ -413,14 +438,19 @@ def indented_tree_line_generator(el, max_lines=None):
413438
yield start_ref, end_ref, "> " * indentation_level + full_line
414439

415440

416-
def get_line_info(tree, max_lines=None):
441+
def get_line_info(
442+
tree: Element, max_lines: int | None = None
443+
) -> tuple[list[ElementRef | None], list[ElementRef | None], list[str]]:
417444
"""
418445
Shortcut for indented_tree_line_generator() that returns an array of
419446
start references, an array of corresponding end references (see
420447
tree_line_generator() docs), and an array of corresponding lines.
421448
"""
422449
line_gen = indented_tree_line_generator(tree, max_lines=max_lines)
423-
line_gen_result = list(zip(*line_gen))
450+
line_gen_result: (
451+
tuple[list[ElementRef | None], list[ElementRef | None], list[str]]
452+
| tuple[()]
453+
) = tuple(zip(*line_gen))
424454
if line_gen_result:
425455
return line_gen_result
426456
return [], [], []

0 commit comments

Comments
 (0)