Skip to content

Commit c076f8a

Browse files
regebroudifuchs
andauthored
Type annotations. (#108)
* Added type annotations to svg.path. All source code and tests pass the `mypy --strict` checks. * Fix tests for python 3.8 and 3.11. * Update after merge --------- Co-authored-by: udifuchs <udifuchs@gmail.com>
1 parent de85b7c commit c076f8a

18 files changed

+356
-207
lines changed

.github/workflows/integration.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ jobs:
2525
- name: Upgrade pip
2626
run: python -m pip install --upgrade pip
2727
- name: Install tools
28-
run: pip install flake8 black
28+
run: pip install flake8 black mypy
2929
- name: Install package
3030
run: pip install -e ".[test]"
3131
- name: Run black
3232
run: black --quiet --check .
3333
- name: Run flake8
3434
run: flake8 .
35+
- name: Run mypy
36+
run: mypy
3537
- name: Run tests
3638
run: pytest

CHANGES.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Changelog
77

88
- Improved test reliability [bdrung]
99

10+
- Added type annotations. [udifuchs]
11+
1012

1113
6.3 (2023-04-29)
1214
----------------

CONTRIBUTORS.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,5 @@ the relative setting when parsing.
2626
Taewoong Jang [twjang] implemented boundingbox functions.
2727

2828
Benjamin Drung [bdrung] added a font to make tests non-flakey.
29+
30+
Udi Fuchs [udifuchs] added type annotations.

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
include *.rst
22
include *.txt
3+
include src/svg/path/py.typed
34

45
exclude Makefile .flake8
56
recursive-exclude tests *

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ $(bin_dir)fullrelease: $(bin_dir)
2323
check: devenv
2424
$(bin_dir)black src tests
2525
$(bin_dir)flake8 src tests
26+
$(bin_dir)mypy
2627
$(bin_dir)pyroma -d .
2728
$(bin_dir)check-manifest
2829

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ test = [
3838
"flake8",
3939
"pyroma",
4040
"check-manifest",
41+
"mypy",
4142
"zest.releaser[recommended]",
4243
]
4344

@@ -58,3 +59,8 @@ universal = 1
5859

5960
[tool.pytest.ini_options]
6061
testpaths = ["tests"]
62+
63+
[tool.mypy]
64+
files = ["src", "tests"]
65+
66+
strict = true

src/svg/path/__init__.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
1-
from .path import Path, Move, Line, Arc, Close # noqa: 401
2-
from .path import CubicBezier, QuadraticBezier # noqa: 401
3-
from .path import PathSegment, Linear, NonLinear # noqa: 401
4-
from .parser import parse_path # noqa: 401
1+
from .path import Path, Move, Line, Arc, Close
2+
from .path import CubicBezier, QuadraticBezier
3+
from .path import PathSegment, Linear, NonLinear
4+
from .parser import parse_path
5+
6+
__all__ = (
7+
"Path",
8+
"Move",
9+
"Line",
10+
"Arc",
11+
"Close",
12+
"CubicBezier",
13+
"QuadraticBezier",
14+
"PathSegment",
15+
"Linear",
16+
"NonLinear",
17+
"parse_path",
18+
)

src/svg/path/parser.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# SVG Path specification parser
22

3+
from typing import Generator, Tuple, Union
34
import re
45
from svg.path import path
56

@@ -33,14 +34,14 @@ class InvalidPathError(ValueError):
3334
}
3435

3536

36-
def strip_array(arg_array):
37+
def strip_array(arg_array: bytearray) -> None:
3738
"""Strips whitespace and commas"""
3839
# EBNF wsp:(#x20 | #x9 | #xD | #xA) + comma: 0x2C
3940
while arg_array and arg_array[0] in (0x20, 0x9, 0xD, 0xA, 0x2C):
4041
arg_array[0:1] = b""
4142

4243

43-
def pop_number(arg_array):
44+
def pop_number(arg_array: bytearray) -> float:
4445
res = FLOAT_RE.search(arg_array)
4546
if not res or not res.group():
4647
raise InvalidPathError(f"Expected a number, got '{arg_array}'.")
@@ -53,27 +54,28 @@ def pop_number(arg_array):
5354
return number
5455

5556

56-
def pop_unsigned_number(arg_array):
57+
def pop_unsigned_number(arg_array: bytearray) -> float:
5758
number = pop_number(arg_array)
5859
if number < 0:
5960
raise InvalidPathError(f"Expected a non-negative number, got '{number}'.")
6061
return number
6162

6263

63-
def pop_coordinate_pair(arg_array):
64+
def pop_coordinate_pair(arg_array: bytearray) -> complex:
6465
x = pop_number(arg_array)
6566
y = pop_number(arg_array)
6667
return complex(x, y)
6768

6869

69-
def pop_flag(arg_array):
70+
def pop_flag(arg_array: bytearray) -> Union[bool, None]:
7071
flag = arg_array[0]
7172
arg_array[0:1] = b""
7273
strip_array(arg_array)
7374
if flag == 48: # ASCII 0
7475
return False
7576
if flag == 49: # ASCII 1
7677
return True
78+
return None
7779

7880

7981
FIELD_POPPERS = {
@@ -84,9 +86,9 @@ def pop_flag(arg_array):
8486
}
8587

8688

87-
def _commandify_path(pathdef):
89+
def _commandify_path(pathdef: str) -> Generator[Tuple[str, ...], None, None]:
8890
"""Splits path into commands and arguments"""
89-
token = None
91+
token: Union[Tuple[str, ...], None] = None
9092
for x in COMMAND_RE.split(pathdef):
9193
x = x.strip()
9294
if x in COMMANDS:
@@ -101,10 +103,14 @@ def _commandify_path(pathdef):
101103
if token is None:
102104
raise InvalidPathError(f"Path does not start with a command: {pathdef}")
103105
token += (x,)
104-
yield token
106+
# Logically token cannot be None, but mypy cannot deduce this.
107+
if token is not None:
108+
yield token
105109

106110

107-
def _tokenize_path(pathdef):
111+
def _tokenize_path(
112+
pathdef: str,
113+
) -> Generator[Tuple[Union[str, complex, float, bool, None], ...], None, None]:
108114
for command, args in _commandify_path(pathdef):
109115
# Shortcut this for the close command, that doesn't have arguments:
110116
if command in ("z", "Z"):
@@ -138,18 +144,20 @@ def _tokenize_path(pathdef):
138144
command = "L"
139145

140146

141-
def parse_path(pathdef):
147+
def parse_path(pathdef: str) -> path.Path:
142148
segments = path.Path()
143149
start_pos = None
144-
last_command = None
145-
current_pos = 0
150+
last_command = "No last command"
151+
current_pos = 0j
146152

147153
for token in _tokenize_path(pathdef):
148154
command = token[0]
155+
assert isinstance(command, str)
149156
relative = command.islower()
150157
command = command.upper()
151158
if command == "M":
152159
pos = token[1]
160+
assert isinstance(pos, complex)
153161
if relative:
154162
current_pos += pos
155163
else:
@@ -160,18 +168,21 @@ def parse_path(pathdef):
160168
elif command == "Z":
161169
# For Close commands the "relative" argument just preserves case,
162170
# it has no different in behavior.
171+
assert isinstance(start_pos, complex)
163172
segments.append(path.Close(current_pos, start_pos, relative=relative))
164173
current_pos = start_pos
165174

166175
elif command == "L":
167176
pos = token[1]
177+
assert isinstance(pos, complex)
168178
if relative:
169179
pos += current_pos
170180
segments.append(path.Line(current_pos, pos, relative=relative))
171181
current_pos = pos
172182

173183
elif command == "H":
174184
hpos = token[1]
185+
assert isinstance(hpos, float)
175186
if relative:
176187
hpos += current_pos.real
177188
pos = complex(hpos, current_pos.imag)
@@ -182,6 +193,7 @@ def parse_path(pathdef):
182193

183194
elif command == "V":
184195
vpos = token[1]
196+
assert isinstance(vpos, float)
185197
if relative:
186198
vpos += current_pos.imag
187199
pos = complex(current_pos.real, vpos)
@@ -192,8 +204,11 @@ def parse_path(pathdef):
192204

193205
elif command == "C":
194206
control1 = token[1]
207+
assert isinstance(control1, complex)
195208
control2 = token[2]
209+
assert isinstance(control2, complex)
196210
end = token[3]
211+
assert isinstance(end, complex)
197212

198213
if relative:
199214
control1 += current_pos
@@ -211,7 +226,9 @@ def parse_path(pathdef):
211226
# Smooth curve. First control point is the "reflection" of
212227
# the second control point in the previous path.
213228
control2 = token[1]
229+
assert isinstance(control2, complex)
214230
end = token[2]
231+
assert isinstance(end, complex)
215232

216233
if relative:
217234
control2 += current_pos
@@ -221,6 +238,7 @@ def parse_path(pathdef):
221238
# The first control point is assumed to be the reflection of
222239
# the second control point on the previous command relative
223240
# to the current point.
241+
assert isinstance(segments[-1], path.CubicBezier)
224242
control1 = current_pos + current_pos - segments[-1].control2
225243
else:
226244
# If there is no previous command or if the previous command
@@ -237,7 +255,9 @@ def parse_path(pathdef):
237255

238256
elif command == "Q":
239257
control = token[1]
258+
assert isinstance(control, complex)
240259
end = token[2]
260+
assert isinstance(end, complex)
241261

242262
if relative:
243263
control += current_pos
@@ -252,6 +272,7 @@ def parse_path(pathdef):
252272
# Smooth curve. Control point is the "reflection" of
253273
# the second control point in the previous path.
254274
end = token[1]
275+
assert isinstance(end, complex)
255276

256277
if relative:
257278
end += current_pos
@@ -260,6 +281,7 @@ def parse_path(pathdef):
260281
# The control point is assumed to be the reflection of
261282
# the control point on the previous command relative
262283
# to the current point.
284+
assert isinstance(segments[-1], path.QuadraticBezier)
263285
control = current_pos + current_pos - segments[-1].control
264286
else:
265287
# If there is no previous command or if the previous command
@@ -277,11 +299,17 @@ def parse_path(pathdef):
277299
elif command == "A":
278300
# For some reason I implemented the Arc with a complex radius.
279301
# That doesn't really make much sense, but... *shrugs*
302+
assert isinstance(token[1], float)
303+
assert isinstance(token[2], float)
280304
radius = complex(token[1], token[2])
281305
rotation = token[3]
306+
assert isinstance(rotation, float)
282307
arc = token[4]
308+
assert isinstance(arc, (bool, int))
283309
sweep = token[5]
310+
assert isinstance(sweep, (bool, int))
284311
end = token[6]
312+
assert isinstance(end, complex)
285313

286314
if relative:
287315
end += current_pos

0 commit comments

Comments
 (0)